import { describe, expect, it } from 'vitest'; import { KACHEL_KANTE, KARTENDIENSTE, MAX_MOSAIK_KANTE, baueKachelplan, dienstMit, type Kachelplan, } from '@/services/kartendienste'; import { ausschnittUm, kantenlaenge as kanteVon } from '@/domain/geometrie/projektion'; /** * Kachelplan. * * Aus dem Mosaik werden Raeum- und Einfahrwege VERMESSEN. Die Laenge geht * unmittelbar in die Zwischenzeit ein: tz = tue + (sr + Fahrzeuglaenge)/vr - * se/ve. Ein Fehler von d Metern auf dem Raeumweg wirkt mit d/vr Sekunden - * bei vr = 1,0 m/s (mobilitaetseingeschraenkter Fussgaenger) also fast eins zu * eins. * * Daraus folgt, was hier geprueft wird, und warum es keine Kleinigkeiten sind: * * 1. LUECKENLOSIGKEIT. Fehlt zwischen zwei Kacheln ein Streifen oder ueberlappen * sie einander, dann ist das Bild an dieser Stelle gestaucht oder gedehnt. * Ein darueber hinweg abgegriffener Raeumweg waere zu kurz - und die * Zwischenzeit zu knapp. Optisch faellt eine Naht von wenigen Metern in einem * Luftbild niemandem auf. * * 2. GLEICHE BODENAUFLOESUNG. Waere eine Randkachel schmaler, haette das Bild * einen ortsabhaengigen Massstab. Dieselbe Strecke ergaebe je nach Lage im * Bild eine andere Laenge. * * 3. ZEILENRICHTUNG. Zeile 0 muss die oberste Bildzeile sein, also der * noerdlichste Streifen. Waere die Richtung vertauscht, entstuende ein * senkrecht gespiegeltes Bild - eine Fussgaengerfurt laege dann auf der * falschen Seite des Knotenpunkts, ohne dass das Bild unplausibel aussaehe. * * 4. OBERGRENZEN. Keine Kachel groesser, als der Dienst zulaesst (sonst * verweigert er den Abruf), und kein Mosaik groesser als MAX_MOSAIK_KANTE * (sonst sprengt das eingebettete Bild die Projektdatei). */ const KNOTEN = { breite: 50.9412784, laenge: 6.9582814 }; /** Ausschnitt einer Kachel, wieder aus ihrer Abrufadresse gelesen. */ interface Kachelfeld { readonly minOst: number; readonly minNord: number; readonly maxOst: number; readonly maxNord: number; readonly kante: number; } function feldVon(plan: Kachelplan, spalte: number, zeile: number): Kachelfeld { const kachel = plan.kacheln.find((k) => k.spalte === spalte && k.zeile === zeile); if (kachel === undefined) throw new Error(`Kachel ${spalte}/${zeile} fehlt im Plan`); const parameter = new URL(kachel.url).searchParams; const bbox = (parameter.get('BBOX') ?? '').split(',').map(Number); expect(bbox).toHaveLength(4); return { minOst: bbox[0] as number, minNord: bbox[1] as number, maxOst: bbox[2] as number, maxNord: bbox[3] as number, kante: Number(parameter.get('WIDTH')), }; } // Die BBOX wird auf Millimeter gerundet ausgegeben; auf zwei Stellen genau zu // vergleichen ist damit die schaerfste sinnvolle Schranke. const MILLIMETER = 2; describe('Kachelzahl und Kachelgroesse', () => { const nrw = dienstMit('nw-dop'); // 10 cm, hoechstens 5000 Bildpunkte const bayern = dienstMit('by-dop'); // 40 cm, hoechstens 6000 Bildpunkte it('kommt bei kleinen Ausschnitten mit einer einzigen Kachel aus', () => { // 100 m aus einem 10-cm-Luftbild sind 1000 Bildpunkte - das schafft ein // Dienst in einem Zug, ein Mosaik waere unnoetiger Aufwand. const plan = baueKachelplan({ dienst: nrw, mitte: KNOTEN, kantenlaenge: 100 }); expect(plan.spalten).toBe(1); expect(plan.zeilen).toBe(1); expect(plan.kacheln).toHaveLength(1); expect(plan.bildkante).toBe(1000); expect(plan.meterJeBildpunkt).toBeCloseTo(0.1, 9); }); it('teilt auf, sobald ein Abruf die Kachelkante ueberschreitet', () => { // 200 m mit 10 cm sind 2000 Bildpunkte, also zwei Kacheln je Richtung. const plan = baueKachelplan({ dienst: nrw, mitte: KNOTEN, kantenlaenge: 200 }); expect(plan.spalten).toBe(2); expect(plan.kacheln).toHaveLength(4); expect(plan.bildkante).toBe(2000); expect(plan.kacheln.every((k) => k.kante === 1000)).toBe(true); expect(plan.meterJeBildpunkt).toBeCloseTo(0.1, 9); }); it('liefert bis 1000 m die volle Bodenaufloesung des Dienstes', () => { // Das ist die Zusage hinter MAX_MOSAIK_KANTE: 1000 m mit 20 cm sind 5000 // Bildpunkte und passen darunter. const plan = baueKachelplan({ dienst: dienstMit('he-dop'), mitte: KNOTEN, kantenlaenge: 1000 }); expect(plan.bildkante).toBe(5000); expect(plan.meterJeBildpunkt).toBeCloseTo(0.2, 9); }); it('fordert von Bayern nicht feiner an als die gelieferten 40 cm', () => { // 2000 m mit 40 cm sind 5000 Bildpunkte. Mehr anzufordern waere leeres // Vergroessern: Es sieht genauer aus, ohne genauer zu sein. const plan = baueKachelplan({ dienst: bayern, mitte: KNOTEN, kantenlaenge: 2000 }); expect(plan.bildkante).toBe(5000); expect(plan.meterJeBildpunkt).toBeCloseTo(0.4, 9); expect(plan.spalten).toBe(4); expect(plan.kacheln).toHaveLength(16); expect(plan.kacheln.every((k) => k.kante === 1250)).toBe(true); }); it('wird bei 2000 m zum Uebersichtsbild mit 28,6 cm je Bildpunkt', () => { // Hier liegt die fachliche Gefahr: Ein Bildpunkt Zeichenfehler kostet // 0,029 s Zwischenzeit beim Kfz und 0,286 s beim mobilitaetseingeschraenkten // Fussgaenger. Der Wert muss deshalb stimmen - die Oberflaeche warnt daran. const plan = baueKachelplan({ dienst: nrw, mitte: KNOTEN, kantenlaenge: 2000 }); expect(plan.bildkante).toBe(MAX_MOSAIK_KANTE); expect(plan.meterJeBildpunkt).toBeCloseTo(0.2857, 4); expect(plan.spalten).toBe(5); expect(plan.kacheln).toHaveLength(25); }); it('meldet einen Massstab, der zum Bild passt', () => { for (const laenge of [50, 200, 750, 1200, 2000]) { const plan = baueKachelplan({ dienst: nrw, mitte: KNOTEN, kantenlaenge: laenge }); expect(plan.meterJeBildpunkt * plan.bildkante).toBeCloseTo(laenge, 9); expect(plan.bildkante).toBe(plan.spalten * (plan.kacheln[0]?.kante ?? 0)); } }); }); describe('Obergrenzen', () => { it('haelt MAX_MOSAIK_KANTE und die Grenze jedes Dienstes ein', () => { for (const dienst of KARTENDIENSTE) { for (const laenge of [20, 100, 500, 1000, 1500, 2000, 9000]) { const plan = baueKachelplan({ dienst, mitte: KNOTEN, kantenlaenge: laenge }); const wo = `${dienst.id} bei ${laenge} m`; expect(plan.bildkante, wo).toBeLessThanOrEqual(MAX_MOSAIK_KANTE); expect(plan.bildkante, wo).toBeGreaterThan(0); for (const kachel of plan.kacheln) { expect(kachel.kante, wo).toBeLessThanOrEqual(KACHEL_KANTE); expect(kachel.kante, wo).toBeLessThanOrEqual(dienst.maxKante); expect(kachel.kante, wo).toBeGreaterThan(0); } } } }); it('fordert nirgends feiner an als die Bodenaufloesung des Dienstes', () => { // Unterhalb von 512 Bildpunkten greift die vorhandene Untergrenze der // Anwendung und nicht die Aufloesung des Dienstes - ein winziges Bild waere // zum Vermessen unbrauchbar. const UNTERGRENZE = 512; for (const dienst of KARTENDIENSTE) { for (const laenge of [200, 500, 1000, 2000]) { const plan = baueKachelplan({ dienst, mitte: KNOTEN, kantenlaenge: laenge }); // Zulaessig ist ausserdem das Aufrunden auf gleich grosse Kacheln - // hoechstens (spalten - 1) Bildpunkte. expect(plan.bildkante, `${dienst.id} bei ${laenge} m`).toBeLessThanOrEqual( Math.max(UNTERGRENZE, laenge / dienst.aufloesung + plan.spalten), ); } } }); }); describe('Lueckenlosigkeit', () => { const faelle = [ { dienst: dienstMit('nw-dop'), kantenlaenge: 200 }, { dienst: dienstMit('nw-dop'), kantenlaenge: 2000 }, { dienst: dienstMit('by-dop'), kantenlaenge: 1200 }, { dienst: dienstMit('sn-dop'), kantenlaenge: 900 }, ]; for (const fall of faelle) { const name = `${fall.dienst.id} bei ${fall.kantenlaenge} m`; it(`deckt den Gesamtausschnitt vollstaendig ab - ${name}`, () => { const plan = baueKachelplan({ ...fall, mitte: KNOTEN }); const ganz = ausschnittUm(KNOTEN, fall.kantenlaenge); const westrand = feldVon(plan, 0, 0); const ostrand = feldVon(plan, plan.spalten - 1, plan.zeilen - 1); expect(westrand.minOst).toBeCloseTo(ganz.minOst, MILLIMETER); expect(westrand.maxNord).toBeCloseTo(ganz.maxNord, MILLIMETER); expect(ostrand.maxOst).toBeCloseTo(ganz.maxOst, MILLIMETER); expect(ostrand.minNord).toBeCloseTo(ganz.minNord, MILLIMETER); }); it(`stoesst ohne Luecke und ohne Ueberlappung an - ${name}`, () => { const plan = baueKachelplan({ ...fall, mitte: KNOTEN }); const teilbreite = kanteVon(ausschnittUm(KNOTEN, fall.kantenlaenge)) / plan.spalten; for (let zeile = 0; zeile < plan.zeilen; zeile += 1) { for (let spalte = 0; spalte < plan.spalten; spalte += 1) { const feld = feldVon(plan, spalte, zeile); if (spalte > 0) { // Die Ostkante der linken Nachbarin ist die Westkante dieser Kachel - // gleich, nicht ueberlappend und nicht auseinanderklaffend. expect(feldVon(plan, spalte - 1, zeile).maxOst).toBeCloseTo(feld.minOst, MILLIMETER); } if (zeile > 0) { expect(feldVon(plan, spalte, zeile - 1).minNord).toBeCloseTo(feld.maxNord, MILLIMETER); } expect(feld.maxOst - feld.minOst).toBeCloseTo(teilbreite, MILLIMETER); expect(feld.maxNord - feld.minNord).toBeCloseTo(teilbreite, MILLIMETER); } } }); it(`hat in jeder Kachel dieselbe Bodenaufloesung - ${name}`, () => { const plan = baueKachelplan({ ...fall, mitte: KNOTEN }); for (const kachel of plan.kacheln) { const feld = feldVon(plan, kachel.spalte, kachel.zeile); expect(feld.kante).toBe(kachel.kante); expect((feld.maxOst - feld.minOst) / feld.kante).toBeCloseTo(plan.meterJeBildpunkt, 6); expect((feld.maxNord - feld.minNord) / feld.kante).toBeCloseTo(plan.meterJeBildpunkt, 6); } }); } }); describe('Einsetzpunkte im Mosaik', () => { const plan = baueKachelplan({ dienst: dienstMit('nw-dop'), mitte: KNOTEN, kantenlaenge: 2000 }); it('gibt jede Kachel genau einmal aus', () => { const felder = plan.kacheln.map((k) => `${k.spalte}/${k.zeile}`); expect(new Set(felder).size).toBe(plan.spalten * plan.zeilen); expect(plan.kacheln).toHaveLength(plan.spalten * plan.zeilen); }); it('fuellt das Mosaik luecken- und ueberlappungsfrei', () => { for (const kachel of plan.kacheln) { expect(kachel.x).toBe(kachel.spalte * kachel.kante); expect(kachel.y).toBe(kachel.zeile * kachel.kante); expect(kachel.x + kachel.kante).toBeLessThanOrEqual(plan.bildkante); expect(kachel.y + kachel.kante).toBeLessThanOrEqual(plan.bildkante); } const flaeche = plan.kacheln.reduce((summe, k) => summe + k.kante * k.kante, 0); expect(flaeche).toBe(plan.bildkante * plan.bildkante); }); it('zaehlt die Zeilen von Norden nach Sueden', () => { // Der Einsetzpunkt y waechst nach unten, der Nordwert nach oben. Waere das // vertauscht, entstuende ein senkrecht gespiegeltes Luftbild. const oben = feldVon(plan, 0, 0); const unten = feldVon(plan, 0, plan.zeilen - 1); expect(oben.maxNord).toBeGreaterThan(unten.maxNord); expect(oben.minNord).toBeGreaterThan(unten.minNord); }); it('zaehlt die Spalten von Westen nach Osten', () => { const links = feldVon(plan, 0, 0); const rechts = feldVon(plan, plan.spalten - 1, 0); expect(rechts.minOst).toBeGreaterThan(links.minOst); }); }); describe('Abrufadresse jeder Kachel', () => { it('nennt Dienst, Bezugssystem und Bildgroesse', () => { const dienst = dienstMit('nw-dop'); const plan = baueKachelplan({ dienst, mitte: KNOTEN, kantenlaenge: 600 }); for (const kachel of plan.kacheln) { const adresse = new URL(kachel.url); const parameter = adresse.searchParams; expect(kachel.url.startsWith(dienst.basisUrl)).toBe(true); expect(parameter.get('VERSION')).toBe('1.3.0'); expect(parameter.get('REQUEST')).toBe('GetMap'); expect(parameter.get('LAYERS')).toBe(dienst.ebene); expect(parameter.get('FORMAT')).toBe(dienst.bildformat); // Alle Kacheln in derselben Zone - sonst liessen sie sich nicht // zusammensetzen. expect(parameter.get('CRS')).toBe('EPSG:25832'); expect(parameter.get('WIDTH')).toBe(String(kachel.kante)); expect(parameter.get('HEIGHT')).toBe(String(kachel.kante)); // Achsfolge Ost/Nord: Ost liegt in Zone 32 nahe 500 000, Nord bei ueber // 5 Millionen. Vertauscht liefe der Abruf ins Leere. const bbox = (parameter.get('BBOX') ?? '').split(',').map(Number); expect(bbox[0] as number).toBeLessThan(bbox[1] as number); expect(bbox[0] as number).toBeLessThan(bbox[2] as number); expect(bbox[1] as number).toBeLessThan(bbox[3] as number); } }); it('gibt die Quellenangabe des Ausschnitts mit', () => { const plan = baueKachelplan({ dienst: dienstMit('nw-dop'), mitte: KNOTEN, kantenlaenge: 1500, }); expect(plan.herkunft).toContain('Geobasis NRW'); expect(plan.herkunft).toContain('1500 m'); }); });