import type { Meters } from '@/domain/units'; import { ausschnittUm, bboxParameter, epsgFuerZone, unterAusschnitt, type Ausschnitt, type GeoPunkt, } from '@/domain/geometrie/projektion'; /** * Amtliche Kartendienste als Grundlage des Lageplans. * * WARUM AMTLICHE DIENSTE UND KEIN BEKANNTER KARTENANBIETER * * Die Kacheln der grossen Kartenanbieter duerfen ausserhalb ihrer eigenen * kostenpflichtigen Schnittstellen nicht verwendet, nicht zwischengespeichert * und erst recht nicht in eine fremde Datei eingebettet werden. Genau das waere * hier aber noetig: Ein Signalzeitenplan muss Jahre spaeter noch aus der * Projektdatei allein nachvollziehbar sein. * * Die Landesvermessungsverwaltungen stellen dieselbe Grundlage in besserer * Qualitaet und mit klarer Rechtslage bereit: digitale Orthophotos, also * entzerrte und lagerichtige Luftbilder mit 20 cm Bodenaufloesung oder besser. * "Lagerichtig" ist der entscheidende Punkt - in einem Orthophoto darf gemessen * werden, in einer Schraegaufnahme nicht. * * PRUEFSTAND DER EINTRAEGE * * Jeder Eintrag wurde mit einem echten Bildabruf ueber der jeweiligen * Landeshauptstadt geprueft. Die Angaben zu Nutzungsrecht und Quellenvermerk * stammen aus der Selbstauskunft des Dienstes (GetCapabilities, Felder Fees und * AccessConstraints). Ein Quellenvermerk ist nur dort eingetragen, wo der * Dienst ihn woertlich vorgibt; sonst bleibt das Feld leer, und es wird der * Herausgeber genannt. * * BEZUGSSYSTEM * * Angefordert wird durchweg in ETRS89/UTM (EPSG:258xx) - dem Bezugssystem der * deutschen Landesvermessung. Dessen Karteneinheiten sind Meter; ein daraus * abgegriffener Raeumweg ist damit unmittelbar der Raeumweg in der Wirklichkeit. * Die ausfuehrliche Begruendung steht in domain/geometrie/projektion.ts. */ export type Kartenart = 'luftbild' | 'karte'; export interface Kartendienst { readonly id: string; /** Bezeichnung in der Auswahlliste. */ readonly name: string; readonly art: Kartenart; /** Herausgeber - erscheint als Quellenangabe im Plan und im Ausdruck. */ readonly herausgeber: string; /** Gebiet, fuer das der Dienst Daten liefert. */ readonly gebiet: string; readonly basisUrl: string; readonly ebene: string; readonly bildformat: 'image/jpeg' | 'image/png'; /** Bodenaufloesung in Metern je Bildpunkt. */ readonly aufloesung: Meters; /** Groesste vom Dienst zugelassene Bildkante. */ readonly maxKante: number; /** Nutzungsrecht laut Selbstauskunft des Dienstes. */ readonly nutzungsrecht: string; /** Woertlich vorgegebener Quellenvermerk, sofern der Dienst einen nennt. */ readonly namensnennung: string | null; } /** * Bodenaufloesung: 0,1 m bedeutet, dass ein Bildpunkt 10 cm am Boden * entspricht. Feiner anzufordern bringt nichts - das Bild wird dadurch nur * groesser, nicht schaerfer. * * Die Liste ist nach Gebiet geordnet; der Bundesdienst steht vorn, weil er * ueberall gilt. */ export const KARTENDIENSTE: readonly Kartendienst[] = [ { id: 'de-topplus', name: 'Deutschland · Amtliche Stadtkarte (TopPlusOpen)', art: 'karte', herausgeber: 'Bundesamt für Kartographie und Geodäsie', gebiet: 'Deutschland und Europa', basisUrl: 'https://sgx.geodatenzentrum.de/wms_topplus_open', ebene: 'web', bildformat: 'image/png', aufloesung: 0.2, maxKante: 6000, nutzungsrecht: 'Datenlizenz Deutschland Namensnennung 2.0, geldleistungsfrei', namensnennung: null, }, { id: 'bw-dop', name: 'Baden-Württemberg · Luftbild 20 cm', art: 'luftbild', herausgeber: 'Landesamt für Geoinformation und Landentwicklung Baden-Württemberg', gebiet: 'Baden-Württemberg', basisUrl: 'https://owsproxy.lgl-bw.de/owsproxy/ows/WMS_LGL-BW_ATKIS_DOP_20_C', ebene: 'IMAGES_DOP_20_RGB', bildformat: 'image/jpeg', aufloesung: 0.2, maxKante: 15000, nutzungsrecht: 'Datenlizenz Deutschland Namensnennung 2.0, unentgeltlich', namensnennung: 'LGL-BW (Jahr des Datenbezugs), dl-de/by-2-0, www.lgl-bw.de', }, { id: 'by-dop', name: 'Bayern · Luftbild 40 cm', art: 'luftbild', herausgeber: 'Bayerische Vermessungsverwaltung', gebiet: 'Bayern', basisUrl: 'https://geoservices.bayern.de/od/wms/dop/v1/dop40', ebene: 'by_dop40c', bildformat: 'image/jpeg', aufloesung: 0.4, maxKante: 6000, nutzungsrecht: 'Creative Commons Namensnennung 4.0 (CC BY 4.0), kostenfrei', namensnennung: null, }, { id: 'bebb-dop', name: 'Berlin und Brandenburg · Luftbild 20 cm', art: 'luftbild', herausgeber: 'Landesvermessung und Geobasisinformation Brandenburg', gebiet: 'Berlin und Brandenburg', basisUrl: 'https://isk.geobasis-bb.de/mapproxy/dop20c/service/wms', ebene: 'bebb_dop20c', bildformat: 'image/jpeg', aufloesung: 0.2, maxKante: 4000, nutzungsrecht: 'Datenlizenz Deutschland Namensnennung 2.0, kostenfrei', namensnennung: '© GeoBasis-DE/LGB, dl-de/by-2-0; © Geoportal Berlin, dl-de/by-2-0', }, { id: 'he-dop', name: 'Hessen · Luftbild 20 cm', art: 'luftbild', herausgeber: 'Hessische Verwaltung für Bodenmanagement und Geoinformation', gebiet: 'Hessen', basisUrl: 'https://www.gds-srv.hessen.de/cgi-bin/lika-services/ogc-free-images.ows', ebene: 'he_dop20_rgb', bildformat: 'image/jpeg', aufloesung: 0.2, maxKante: 3000, nutzungsrecht: 'Ohne Einschränkung nutzbar, kostenfrei (§ 24 HVGG)', namensnennung: null, }, { id: 'mv-dop', name: 'Mecklenburg-Vorpommern · Luftbild 20 cm', art: 'luftbild', herausgeber: 'Amt für Geoinformation, Vermessungs- und Katasterwesen M-V', gebiet: 'Mecklenburg-Vorpommern', basisUrl: 'https://www.geodaten-mv.de/dienste/adv_dop', ebene: 'mv_dop', bildformat: 'image/jpeg', aufloesung: 0.2, maxKante: 4192, nutzungsrecht: 'Es gelten keine Bedingungen; sichtbarer Quellenvermerk verlangt', namensnennung: '© GeoBasis-DE/M-V (Jahr der Datenlieferung)', }, { id: 'ni-dop', name: 'Niedersachsen · Luftbild 20 cm', art: 'luftbild', herausgeber: 'Landesamt für Geoinformation und Landesvermessung Niedersachsen', gebiet: 'Niedersachsen', basisUrl: 'https://opendata.lgln.niedersachsen.de/doorman/noauth/dop_wms', ebene: 'ni_dop20', bildformat: 'image/jpeg', aufloesung: 0.2, maxKante: 15000, nutzungsrecht: 'Creative Commons Namensnennung 4.0 (CC BY 4.0)', namensnennung: 'LGLN (Jahr des Datenbezugs), CC BY 4.0', }, { id: 'nw-dop', name: 'Nordrhein-Westfalen · Luftbild 10 cm', art: 'luftbild', herausgeber: 'Land NRW, Bezirksregierung Köln - Geobasis NRW', gebiet: 'Nordrhein-Westfalen', basisUrl: 'https://www.wms.nrw.de/geobasis/wms_nw_dop', ebene: 'nw_dop_rgb', bildformat: 'image/jpeg', aufloesung: 0.1, maxKante: 5000, nutzungsrecht: 'Datenlizenz Deutschland Zero 2.0 - Nutzung ohne Einschränkungen', namensnennung: null, }, { id: 'rp-dop', name: 'Rheinland-Pfalz · Luftbild 20 cm', art: 'luftbild', herausgeber: 'Vermessungs- und Katasterverwaltung Rheinland-Pfalz', gebiet: 'Rheinland-Pfalz', basisUrl: 'https://geo4.service24.rlp.de/wms/rp_dop20.fcgi', ebene: 'rp_dop20', bildformat: 'image/jpeg', aufloesung: 0.2, maxKante: 10000, nutzungsrecht: 'Datenlizenz Deutschland Namensnennung 2.0, geldleistungsfrei', namensnennung: '© GeoBasis-DE / LVermGeoRP (Jahr des Datenbezugs), dl-de/by-2-0', }, { id: 'sn-dop', name: 'Sachsen · Luftbild 20 cm', art: 'luftbild', herausgeber: 'Staatsbetrieb Geobasisinformation und Vermessung Sachsen', gebiet: 'Sachsen', basisUrl: 'https://geodienste.sachsen.de/wms_geosn_dop-rgb/guest', ebene: 'sn_dop_020', bildformat: 'image/jpeg', aufloesung: 0.2, maxKante: 4096, nutzungsrecht: 'Kostenfrei nach den Nutzungsbedingungen des Geoportals Sachsen', namensnennung: null, }, { id: 'st-dop', name: 'Sachsen-Anhalt · Luftbild 20 cm', art: 'luftbild', herausgeber: 'Landesamt für Vermessung und Geoinformation Sachsen-Anhalt', gebiet: 'Sachsen-Anhalt', basisUrl: 'https://www.geodatenportal.sachsen-anhalt.de/wss/service/ST_LVermGeo_DOP_WMS_OpenData/guest', ebene: 'lsa_lvermgeo_dop20_2', bildformat: 'image/jpeg', aufloesung: 0.2, maxKante: 4096, nutzungsrecht: 'Offene Daten des Landes Sachsen-Anhalt', namensnennung: null, }, { id: 'sh-dop', name: 'Schleswig-Holstein · Luftbild 20 cm', art: 'luftbild', herausgeber: 'Landesamt für Vermessung und Geoinformation Schleswig-Holstein', gebiet: 'Schleswig-Holstein', basisUrl: 'https://dienste.gdi-sh.de/WMS_SH_DOP20col_OpenGBD', ebene: 'sh_dop20_rgb', bildformat: 'image/jpeg', aufloesung: 0.2, maxKante: 5000, nutzungsrecht: 'Creative Commons Namensnennung 4.0 (CC BY 4.0)', namensnennung: '© GeoBasis-DE/LVermGeo SH/CC BY 4.0', }, ]; /** Vorbelegung der Auswahl. */ export const STANDARD_DIENST = 'nw-dop'; /** Dienst zu einer Kennung; ohne Treffer der erste Eintrag. */ export function dienstMit(id: string): Kartendienst { return KARTENDIENSTE.find((d) => d.id === id) ?? (KARTENDIENSTE[0] as Kartendienst); } /** Rechnernamen aller eingetragenen Dienste - fuer die Freigabeliste im Hauptprozess. */ export function rechnernamen(): readonly string[] { return [...new Set(KARTENDIENSTE.map((d) => new URL(d.basisUrl).hostname))]; } // --- Bildanforderung --------------------------------------------------------- /* * Hier stand ein zweiter Weg: baueAbruf() holte den Ausschnitt in EINEM Bild, * begrenzt auf 1600 Bildpunkte (MAX_ANGEFORDERTE_KANTE). Seit der Kachelung * ging ihn niemand mehr - der Luftbilddialog ruft ausschliesslich * baueKachelplan -, nur die Pruefungen hielten ihn noch am Leben. Ein Weg, den * das Programm nicht geht, driftet unbemerkt von dem ab, der benutzt wird: * Beide bauten dieselbe Adresse, rechneten dieselbe Bodenaufloesung und * schrieben denselben Herkunftstext, aber nur einer davon wurde beim naechsten * Fehler mitgeaendert. Entfernt, mitsamt seinen Pruefungen. */ /** Kleinste sinnvolle Bildkante. */ const MIN_KANTE = 512; export interface Kartenausschnitt { readonly dienst: Kartendienst; readonly mitte: GeoPunkt; /** Kantenlaenge des quadratischen Ausschnitts am Boden. */ readonly kantenlaenge: Meters; } /** * Baut die Adresse EINES Bildabrufs. * * Einziger Aufrufer ist der Kachelplan. Bis 5.3.0 ging auch ein zweiter Weg * hier durch - ein Einzelabruf des ganzen Ausschnitts -, und die gemeinsame * Stelle war der Grund: Wichen die beiden Wege voneinander ab, saehe man das * dem Bild nicht an, aber jede daraus abgegriffene Laenge waere falsch. Der * zweite Weg ist entfallen (siehe oben); die Funktion bleibt getrennt, weil * sie die Achsfolge und die Zonenwahl an EINER Stelle festhaelt. * * Die Zone wird dem Ausschnitt entnommen und nicht erneut aus der geografischen * Laenge bestimmt: Eine Kachel liegt in derselben Zone wie das Gesamtbild, auch * wenn ihr Rand rechnerisch knapp in die Nachbarzone reichte. Zwei Kacheln in * verschiedenen Zonen liessen sich nicht zusammensetzen. */ function abrufAdresse(dienst: Kartendienst, ausschnitt: Ausschnitt, kante: number): string { // WMS 1.3.0: Bei den UTM-Systemen ist die Achsfolge Ost/Nord, die BBOX also // minOst,minNord,maxOst,maxNord. (Nur bei EPSG:4326 waere sie vertauscht - // ein haeufiger Grund fuer scheinbar leere Bilder.) const parameter = new URLSearchParams({ SERVICE: 'WMS', VERSION: '1.3.0', REQUEST: 'GetMap', LAYERS: dienst.ebene, STYLES: '', CRS: `EPSG:${epsgFuerZone(ausschnitt.zone)}`, BBOX: bboxParameter(ausschnitt), WIDTH: String(kante), HEIGHT: String(kante), FORMAT: dienst.bildformat, TRANSPARENT: 'FALSE', }); return `${dienst.basisUrl}?${parameter.toString()}`; } /** * Quellenangabe, die im Plan und im Ausdruck erscheint. * * Sie enthaelt Herausgeber, Ausschnittsmitte und Kantenlaenge - damit laesst * sich der Ausschnitt spaeter Punkt fuer Punkt nachstellen. Ein Pruefer kann * die Grundlage der Vermassung dadurch selbst nachvollziehen. */ export function herkunftstext(dienst: Kartendienst, mitte: GeoPunkt, kantenlaenge: Meters): string { const koordinate = `${mitte.breite.toFixed(6)}, ${mitte.laenge.toFixed(6)}`; const vermerk = dienst.namensnennung === null ? '' : ` · ${dienst.namensnennung}`; return `${dienst.name} · ${dienst.herausgeber} · Mitte ${koordinate} · Kante ${Math.round(kantenlaenge)} m${vermerk}`; } // --- Kachelplan -------------------------------------------------------------- /** * Groesste Kante EINER Kachel. * * Begrenzt, was einem Dienst in einem Zug zugemutet wird: Der Kartendienst * rechnet das Bild erst aus, und der Aufwand waechst mit der Flaeche. Gemessen * am Dienst Nordrhein-Westfalens dauerte ein Bild mit 2000 Bildpunkten ueber * 40 Sekunden, eines mit 1600 rund zwei Drittel davon. * * Fachlich kostet die Grenze nichts, weil groessere Ausschnitte aus mehreren * Kacheln entstehen: Die Bodenaufloesung des fertigen Bildes haengt an * MAX_MOSAIK_KANTE, nicht hieran. */ export const KACHEL_KANTE = 1600; /** * Groesste Kante des zusammengesetzten Bildes. * * Daran haengt die Genauigkeit, mit der spaeter gemessen wird: 7000 Bildpunkte * reichen, damit ein 20-cm-Dienst bis 1400 m Kantenlaenge seine volle * Bodenaufloesung liefert. Der feinste eingetragene Dienst (Nordrhein-Westfalen, * 10 cm) schoepft sie nur bis 700 m aus; bei 1000 m liefert er 14,3 cm je * Bildpunkt statt 10. Bei 2000 m sind es 28,6 cm - ein Uebersichtsbild. * * Hier stand "damit JEDER Dienst bis 1000 m seine volle Bodenaufloesung * liefert". Das trifft ausgerechnet auf den voreingestellten Dienst nicht zu. * Die Kappung ist trotzdem richtig: Ein Bild mit 10000 Bildpunkten Kante * sprengt die Projektdatei, und die gewonnene Genauigkeit braucht bei einem * 1000-m-Ausschnitt niemand. Ein Bildpunkt Zeichenfehler kostet dort bereits 0,029 s * Zwischenzeit beim Kfz und 0,286 s beim mobilitaetseingeschraenkten * Fussgaenger. Die Bodenaufloesung muss der Anwendung deshalb angezeigt werden, * bevor er auf einem solchen Bild eine Furt abgreift. * * Nach oben begrenzt sie ausserdem die Projektdatei - das Mosaik wird * eingebettet, damit der Plan Jahre spaeter aus der Datei allein * nachvollziehbar bleibt. */ export const MAX_MOSAIK_KANTE = 7000; /** Ein einzelner Abruf innerhalb des Mosaiks. */ export interface Kachel { readonly url: string; /** Spalte im Mosaik, 0 ist die westlichste. */ readonly spalte: number; /** Zeile im Mosaik, 0 ist die oberste Bildzeile - also die noerdlichste. */ readonly zeile: number; /** Einsetzpunkt im Mosaik in Bildpunkten, von links. */ readonly x: number; /** Einsetzpunkt im Mosaik in Bildpunkten, von oben. */ readonly y: number; /** Kantenlaenge der Kachel in Bildpunkten - fuer jede Kachel dieselbe. */ readonly kante: number; } export interface Kachelplan { /** Kantenlaenge des fertigen Mosaiks in Bildpunkten. */ readonly bildkante: number; readonly spalten: number; readonly zeilen: number; /** Bodenaufloesung des Mosaiks - in jeder Kachel dieselbe. */ readonly meterJeBildpunkt: Meters; /** Quellenangabe fuer den Plan. */ readonly herkunft: string; readonly kacheln: readonly Kachel[]; } /** * Zerlegt einen Ausschnitt in Kacheln, die einzeln abgerufen und zu einem Bild * zusammengesetzt werden. * * WARUM UEBERHAUPT KACHELN * * Eine Ortsdurchfahrt mit mehreren Knotenpunkten braucht bis zu 2000 m * Kantenlaenge. Als ein Abruf waeren das 7000 Bildpunkte - das rechnet ein * Kartendienst entweder gar nicht oder so lange, dass der Abruf in eine * Zeitgrenze laeuft. Mehrere kleine Abrufe liefern dasselbe Bild und lassen * sich nebenbei einzeln wiederholen und im Fortschritt anzeigen. * * WARUM ALLE KACHELN GLEICH GROSS SIND * * Die Bildkante wird nach oben auf ein Vielfaches der Kachelkante angeglichen. * Die Alternative - eine schmalere Randkachel - erzeugt zwei Fehler: eine * sichtbare Nahtstelle und, ungleich schlimmer, einen ortsabhaengigen Massstab. * Ein Raeumweg, der ueber eine solche Naht hinweg abgegriffen wird, waere je * nach Lage im Bild zu lang oder zu kurz - und die Zwischenzeit damit falsch, * ohne dass irgendetwas darauf hindeutete. Der Preis sind hoechstens * (spalten - 1) Bildpunkte mehr als angefordert; das sind bei 7000 vier. */ export function baueKachelplan(anfrage: Kartenausschnitt): Kachelplan { const { dienst, mitte } = anfrage; const kantenlaenge = Math.max(20, Math.min(2000, anfrage.kantenlaenge)); // Die Bodenaufloesung des Dienstes ist die Untergrenze: Bayern liefert 40 cm, // ein feiner angefordertes Bild waere nur leeres Vergroessern - es wiegt eine // Genauigkeit vor, die in den Daten nicht steckt, und blaeht die Projektdatei. const gewuenscht = Math.round( Math.max(MIN_KANTE, Math.min(MAX_MOSAIK_KANTE, kantenlaenge / dienst.aufloesung)), ); const kachelKante = Math.min(KACHEL_KANTE, dienst.maxKante); const spalten = Math.max(1, Math.ceil(gewuenscht / kachelKante)); const zeilen = spalten; // Der Ausschnitt ist quadratisch. const kachelPixel = Math.min( Math.ceil(gewuenscht / spalten), // Das Aufrunden darf die Obergrenze des Mosaiks nicht ueberschreiten ... Math.floor(MAX_MOSAIK_KANTE / spalten), // ... und keine Kachel darf groesser werden, als der Dienst zulaesst. kachelKante, ); const bildkante = kachelPixel * spalten; const ausschnitt = ausschnittUm(mitte, kantenlaenge); const kacheln: Kachel[] = []; for (let zeile = 0; zeile < zeilen; zeile += 1) { for (let spalte = 0; spalte < spalten; spalte += 1) { const teil = unterAusschnitt(ausschnitt, spalte, zeile, spalten, zeilen); kacheln.push({ url: abrufAdresse(dienst, teil, kachelPixel), spalte, zeile, x: spalte * kachelPixel, y: zeile * kachelPixel, kante: kachelPixel, }); } } return { bildkante, spalten, zeilen, meterJeBildpunkt: kantenlaenge / bildkante, herkunft: herkunftstext(dienst, mitte, kantenlaenge), kacheln, }; }