lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests services kachelplan.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { |
| 3 | KACHEL_KANTE, |
| 4 | KARTENDIENSTE, |
| 5 | MAX_MOSAIK_KANTE, |
| 6 | baueKachelplan, |
| 7 | dienstMit, |
| 8 | type Kachelplan, |
| 9 | } from '@/services/kartendienste'; |
| 10 | import { ausschnittUm, kantenlaenge as kanteVon } from '@/domain/geometrie/projektion'; |
| 11 | |
| 12 | /** |
| 13 | * Kachelplan. |
| 14 | * |
| 15 | * Aus dem Mosaik werden Raeum- und Einfahrwege VERMESSEN. Die Laenge geht |
| 16 | * unmittelbar in die Zwischenzeit ein: tz = tue + (sr + Fahrzeuglaenge)/vr - |
| 17 | * se/ve. Ein Fehler von d Metern auf dem Raeumweg wirkt mit d/vr Sekunden - |
| 18 | * bei vr = 1,0 m/s (mobilitaetseingeschraenkter Fussgaenger) also fast eins zu |
| 19 | * eins. |
| 20 | * |
| 21 | * Daraus folgt, was hier geprueft wird, und warum es keine Kleinigkeiten sind: |
| 22 | * |
| 23 | * 1. LUECKENLOSIGKEIT. Fehlt zwischen zwei Kacheln ein Streifen oder ueberlappen |
| 24 | * sie einander, dann ist das Bild an dieser Stelle gestaucht oder gedehnt. |
| 25 | * Ein darueber hinweg abgegriffener Raeumweg waere zu kurz - und die |
| 26 | * Zwischenzeit zu knapp. Optisch faellt eine Naht von wenigen Metern in einem |
| 27 | * Luftbild niemandem auf. |
| 28 | * |
| 29 | * 2. GLEICHE BODENAUFLOESUNG. Waere eine Randkachel schmaler, haette das Bild |
| 30 | * einen ortsabhaengigen Massstab. Dieselbe Strecke ergaebe je nach Lage im |
| 31 | * Bild eine andere Laenge. |
| 32 | * |
| 33 | * 3. ZEILENRICHTUNG. Zeile 0 muss die oberste Bildzeile sein, also der |
| 34 | * noerdlichste Streifen. Waere die Richtung vertauscht, entstuende ein |
| 35 | * senkrecht gespiegeltes Bild - eine Fussgaengerfurt laege dann auf der |
| 36 | * falschen Seite des Knotenpunkts, ohne dass das Bild unplausibel aussaehe. |
| 37 | * |
| 38 | * 4. OBERGRENZEN. Keine Kachel groesser, als der Dienst zulaesst (sonst |
| 39 | * verweigert er den Abruf), und kein Mosaik groesser als MAX_MOSAIK_KANTE |
| 40 | * (sonst sprengt das eingebettete Bild die Projektdatei). |
| 41 | */ |
| 42 | |
| 43 | const KNOTEN = { breite: 50.9412784, laenge: 6.9582814 }; |
| 44 | |
| 45 | /** Ausschnitt einer Kachel, wieder aus ihrer Abrufadresse gelesen. */ |
| 46 | interface Kachelfeld { |
| 47 | readonly minOst: number; |
| 48 | readonly minNord: number; |
| 49 | readonly maxOst: number; |
| 50 | readonly maxNord: number; |
| 51 | readonly kante: number; |
| 52 | } |
| 53 | |
| 54 | function feldVon(plan: Kachelplan, spalte: number, zeile: number): Kachelfeld { |
| 55 | const kachel = plan.kacheln.find((k) => k.spalte === spalte && k.zeile === zeile); |
| 56 | if (kachel === undefined) throw new Error(`Kachel ${spalte}/${zeile} fehlt im Plan`); |
| 57 | const parameter = new URL(kachel.url).searchParams; |
| 58 | const bbox = (parameter.get('BBOX') ?? '').split(',').map(Number); |
| 59 | expect(bbox).toHaveLength(4); |
| 60 | return { |
| 61 | minOst: bbox[0] as number, |
| 62 | minNord: bbox[1] as number, |
| 63 | maxOst: bbox[2] as number, |
| 64 | maxNord: bbox[3] as number, |
| 65 | kante: Number(parameter.get('WIDTH')), |
| 66 | }; |
| 67 | } |
| 68 | |
| 69 | // Die BBOX wird auf Millimeter gerundet ausgegeben; auf zwei Stellen genau zu |
| 70 | // vergleichen ist damit die schaerfste sinnvolle Schranke. |
| 71 | const MILLIMETER = 2; |
| 72 | |
| 73 | describe('Kachelzahl und Kachelgroesse', () => { |
| 74 | const nrw = dienstMit('nw-dop'); // 10 cm, hoechstens 5000 Bildpunkte |
| 75 | const bayern = dienstMit('by-dop'); // 40 cm, hoechstens 6000 Bildpunkte |
| 76 | |
| 77 | it('kommt bei kleinen Ausschnitten mit einer einzigen Kachel aus', () => { |
| 78 | // 100 m aus einem 10-cm-Luftbild sind 1000 Bildpunkte - das schafft ein |
| 79 | // Dienst in einem Zug, ein Mosaik waere unnoetiger Aufwand. |
| 80 | const plan = baueKachelplan({ dienst: nrw, mitte: KNOTEN, kantenlaenge: 100 }); |
| 81 | expect(plan.spalten).toBe(1); |
| 82 | expect(plan.zeilen).toBe(1); |
| 83 | expect(plan.kacheln).toHaveLength(1); |
| 84 | expect(plan.bildkante).toBe(1000); |
| 85 | expect(plan.meterJeBildpunkt).toBeCloseTo(0.1, 9); |
| 86 | }); |
| 87 | |
| 88 | it('teilt auf, sobald ein Abruf die Kachelkante ueberschreitet', () => { |
| 89 | // 200 m mit 10 cm sind 2000 Bildpunkte, also zwei Kacheln je Richtung. |
| 90 | const plan = baueKachelplan({ dienst: nrw, mitte: KNOTEN, kantenlaenge: 200 }); |
| 91 | expect(plan.spalten).toBe(2); |
| 92 | expect(plan.kacheln).toHaveLength(4); |
| 93 | expect(plan.bildkante).toBe(2000); |
| 94 | expect(plan.kacheln.every((k) => k.kante === 1000)).toBe(true); |
| 95 | expect(plan.meterJeBildpunkt).toBeCloseTo(0.1, 9); |
| 96 | }); |
| 97 | |
| 98 | it('liefert bis 1000 m die volle Bodenaufloesung des Dienstes', () => { |
| 99 | // Das ist die Zusage hinter MAX_MOSAIK_KANTE: 1000 m mit 20 cm sind 5000 |
| 100 | // Bildpunkte und passen darunter. |
| 101 | const plan = baueKachelplan({ dienst: dienstMit('he-dop'), mitte: KNOTEN, kantenlaenge: 1000 }); |
| 102 | expect(plan.bildkante).toBe(5000); |
| 103 | expect(plan.meterJeBildpunkt).toBeCloseTo(0.2, 9); |
| 104 | }); |
| 105 | |
| 106 | it('fordert von Bayern nicht feiner an als die gelieferten 40 cm', () => { |
| 107 | // 2000 m mit 40 cm sind 5000 Bildpunkte. Mehr anzufordern waere leeres |
| 108 | // Vergroessern: Es sieht genauer aus, ohne genauer zu sein. |
| 109 | const plan = baueKachelplan({ dienst: bayern, mitte: KNOTEN, kantenlaenge: 2000 }); |
| 110 | expect(plan.bildkante).toBe(5000); |
| 111 | expect(plan.meterJeBildpunkt).toBeCloseTo(0.4, 9); |
| 112 | expect(plan.spalten).toBe(4); |
| 113 | expect(plan.kacheln).toHaveLength(16); |
| 114 | expect(plan.kacheln.every((k) => k.kante === 1250)).toBe(true); |
| 115 | }); |
| 116 | |
| 117 | it('wird bei 2000 m zum Uebersichtsbild mit 28,6 cm je Bildpunkt', () => { |
| 118 | // Hier liegt die fachliche Gefahr: Ein Bildpunkt Zeichenfehler kostet |
| 119 | // 0,029 s Zwischenzeit beim Kfz und 0,286 s beim mobilitaetseingeschraenkten |
| 120 | // Fussgaenger. Der Wert muss deshalb stimmen - die Oberflaeche warnt daran. |
| 121 | const plan = baueKachelplan({ dienst: nrw, mitte: KNOTEN, kantenlaenge: 2000 }); |
| 122 | expect(plan.bildkante).toBe(MAX_MOSAIK_KANTE); |
| 123 | expect(plan.meterJeBildpunkt).toBeCloseTo(0.2857, 4); |
| 124 | expect(plan.spalten).toBe(5); |
| 125 | expect(plan.kacheln).toHaveLength(25); |
| 126 | }); |
| 127 | |
| 128 | it('meldet einen Massstab, der zum Bild passt', () => { |
| 129 | for (const laenge of [50, 200, 750, 1200, 2000]) { |
| 130 | const plan = baueKachelplan({ dienst: nrw, mitte: KNOTEN, kantenlaenge: laenge }); |
| 131 | expect(plan.meterJeBildpunkt * plan.bildkante).toBeCloseTo(laenge, 9); |
| 132 | expect(plan.bildkante).toBe(plan.spalten * (plan.kacheln[0]?.kante ?? 0)); |
| 133 | } |
| 134 | }); |
| 135 | }); |
| 136 | |
| 137 | describe('Obergrenzen', () => { |
| 138 | it('haelt MAX_MOSAIK_KANTE und die Grenze jedes Dienstes ein', () => { |
| 139 | for (const dienst of KARTENDIENSTE) { |
| 140 | for (const laenge of [20, 100, 500, 1000, 1500, 2000, 9000]) { |
| 141 | const plan = baueKachelplan({ dienst, mitte: KNOTEN, kantenlaenge: laenge }); |
| 142 | const wo = `${dienst.id} bei ${laenge} m`; |
| 143 | |
| 144 | expect(plan.bildkante, wo).toBeLessThanOrEqual(MAX_MOSAIK_KANTE); |
| 145 | expect(plan.bildkante, wo).toBeGreaterThan(0); |
| 146 | for (const kachel of plan.kacheln) { |
| 147 | expect(kachel.kante, wo).toBeLessThanOrEqual(KACHEL_KANTE); |
| 148 | expect(kachel.kante, wo).toBeLessThanOrEqual(dienst.maxKante); |
| 149 | expect(kachel.kante, wo).toBeGreaterThan(0); |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | }); |
| 154 | |
| 155 | it('fordert nirgends feiner an als die Bodenaufloesung des Dienstes', () => { |
| 156 | // Unterhalb von 512 Bildpunkten greift die vorhandene Untergrenze der |
| 157 | // Anwendung und nicht die Aufloesung des Dienstes - ein winziges Bild waere |
| 158 | // zum Vermessen unbrauchbar. |
| 159 | const UNTERGRENZE = 512; |
| 160 | for (const dienst of KARTENDIENSTE) { |
| 161 | for (const laenge of [200, 500, 1000, 2000]) { |
| 162 | const plan = baueKachelplan({ dienst, mitte: KNOTEN, kantenlaenge: laenge }); |
| 163 | // Zulaessig ist ausserdem das Aufrunden auf gleich grosse Kacheln - |
| 164 | // hoechstens (spalten - 1) Bildpunkte. |
| 165 | expect(plan.bildkante, `${dienst.id} bei ${laenge} m`).toBeLessThanOrEqual( |
| 166 | Math.max(UNTERGRENZE, laenge / dienst.aufloesung + plan.spalten), |
| 167 | ); |
| 168 | } |
| 169 | } |
| 170 | }); |
| 171 | }); |
| 172 | |
| 173 | describe('Lueckenlosigkeit', () => { |
| 174 | const faelle = [ |
| 175 | { dienst: dienstMit('nw-dop'), kantenlaenge: 200 }, |
| 176 | { dienst: dienstMit('nw-dop'), kantenlaenge: 2000 }, |
| 177 | { dienst: dienstMit('by-dop'), kantenlaenge: 1200 }, |
| 178 | { dienst: dienstMit('sn-dop'), kantenlaenge: 900 }, |
| 179 | ]; |
| 180 | |
| 181 | for (const fall of faelle) { |
| 182 | const name = `${fall.dienst.id} bei ${fall.kantenlaenge} m`; |
| 183 | |
| 184 | it(`deckt den Gesamtausschnitt vollstaendig ab - ${name}`, () => { |
| 185 | const plan = baueKachelplan({ ...fall, mitte: KNOTEN }); |
| 186 | const ganz = ausschnittUm(KNOTEN, fall.kantenlaenge); |
| 187 | |
| 188 | const westrand = feldVon(plan, 0, 0); |
| 189 | const ostrand = feldVon(plan, plan.spalten - 1, plan.zeilen - 1); |
| 190 | expect(westrand.minOst).toBeCloseTo(ganz.minOst, MILLIMETER); |
| 191 | expect(westrand.maxNord).toBeCloseTo(ganz.maxNord, MILLIMETER); |
| 192 | expect(ostrand.maxOst).toBeCloseTo(ganz.maxOst, MILLIMETER); |
| 193 | expect(ostrand.minNord).toBeCloseTo(ganz.minNord, MILLIMETER); |
| 194 | }); |
| 195 | |
| 196 | it(`stoesst ohne Luecke und ohne Ueberlappung an - ${name}`, () => { |
| 197 | const plan = baueKachelplan({ ...fall, mitte: KNOTEN }); |
| 198 | const teilbreite = kanteVon(ausschnittUm(KNOTEN, fall.kantenlaenge)) / plan.spalten; |
| 199 | |
| 200 | for (let zeile = 0; zeile < plan.zeilen; zeile += 1) { |
| 201 | for (let spalte = 0; spalte < plan.spalten; spalte += 1) { |
| 202 | const feld = feldVon(plan, spalte, zeile); |
| 203 | if (spalte > 0) { |
| 204 | // Die Ostkante der linken Nachbarin ist die Westkante dieser Kachel - |
| 205 | // gleich, nicht ueberlappend und nicht auseinanderklaffend. |
| 206 | expect(feldVon(plan, spalte - 1, zeile).maxOst).toBeCloseTo(feld.minOst, MILLIMETER); |
| 207 | } |
| 208 | if (zeile > 0) { |
| 209 | expect(feldVon(plan, spalte, zeile - 1).minNord).toBeCloseTo(feld.maxNord, MILLIMETER); |
| 210 | } |
| 211 | expect(feld.maxOst - feld.minOst).toBeCloseTo(teilbreite, MILLIMETER); |
| 212 | expect(feld.maxNord - feld.minNord).toBeCloseTo(teilbreite, MILLIMETER); |
| 213 | } |
| 214 | } |
| 215 | }); |
| 216 | |
| 217 | it(`hat in jeder Kachel dieselbe Bodenaufloesung - ${name}`, () => { |
| 218 | const plan = baueKachelplan({ ...fall, mitte: KNOTEN }); |
| 219 | for (const kachel of plan.kacheln) { |
| 220 | const feld = feldVon(plan, kachel.spalte, kachel.zeile); |
| 221 | expect(feld.kante).toBe(kachel.kante); |
| 222 | expect((feld.maxOst - feld.minOst) / feld.kante).toBeCloseTo(plan.meterJeBildpunkt, 6); |
| 223 | expect((feld.maxNord - feld.minNord) / feld.kante).toBeCloseTo(plan.meterJeBildpunkt, 6); |
| 224 | } |
| 225 | }); |
| 226 | } |
| 227 | }); |
| 228 | |
| 229 | describe('Einsetzpunkte im Mosaik', () => { |
| 230 | const plan = baueKachelplan({ dienst: dienstMit('nw-dop'), mitte: KNOTEN, kantenlaenge: 2000 }); |
| 231 | |
| 232 | it('gibt jede Kachel genau einmal aus', () => { |
| 233 | const felder = plan.kacheln.map((k) => `${k.spalte}/${k.zeile}`); |
| 234 | expect(new Set(felder).size).toBe(plan.spalten * plan.zeilen); |
| 235 | expect(plan.kacheln).toHaveLength(plan.spalten * plan.zeilen); |
| 236 | }); |
| 237 | |
| 238 | it('fuellt das Mosaik luecken- und ueberlappungsfrei', () => { |
| 239 | for (const kachel of plan.kacheln) { |
| 240 | expect(kachel.x).toBe(kachel.spalte * kachel.kante); |
| 241 | expect(kachel.y).toBe(kachel.zeile * kachel.kante); |
| 242 | expect(kachel.x + kachel.kante).toBeLessThanOrEqual(plan.bildkante); |
| 243 | expect(kachel.y + kachel.kante).toBeLessThanOrEqual(plan.bildkante); |
| 244 | } |
| 245 | const flaeche = plan.kacheln.reduce((summe, k) => summe + k.kante * k.kante, 0); |
| 246 | expect(flaeche).toBe(plan.bildkante * plan.bildkante); |
| 247 | }); |
| 248 | |
| 249 | it('zaehlt die Zeilen von Norden nach Sueden', () => { |
| 250 | // Der Einsetzpunkt y waechst nach unten, der Nordwert nach oben. Waere das |
| 251 | // vertauscht, entstuende ein senkrecht gespiegeltes Luftbild. |
| 252 | const oben = feldVon(plan, 0, 0); |
| 253 | const unten = feldVon(plan, 0, plan.zeilen - 1); |
| 254 | expect(oben.maxNord).toBeGreaterThan(unten.maxNord); |
| 255 | expect(oben.minNord).toBeGreaterThan(unten.minNord); |
| 256 | }); |
| 257 | |
| 258 | it('zaehlt die Spalten von Westen nach Osten', () => { |
| 259 | const links = feldVon(plan, 0, 0); |
| 260 | const rechts = feldVon(plan, plan.spalten - 1, 0); |
| 261 | expect(rechts.minOst).toBeGreaterThan(links.minOst); |
| 262 | }); |
| 263 | }); |
| 264 | |
| 265 | describe('Abrufadresse jeder Kachel', () => { |
| 266 | it('nennt Dienst, Bezugssystem und Bildgroesse', () => { |
| 267 | const dienst = dienstMit('nw-dop'); |
| 268 | const plan = baueKachelplan({ dienst, mitte: KNOTEN, kantenlaenge: 600 }); |
| 269 | for (const kachel of plan.kacheln) { |
| 270 | const adresse = new URL(kachel.url); |
| 271 | const parameter = adresse.searchParams; |
| 272 | expect(kachel.url.startsWith(dienst.basisUrl)).toBe(true); |
| 273 | expect(parameter.get('VERSION')).toBe('1.3.0'); |
| 274 | expect(parameter.get('REQUEST')).toBe('GetMap'); |
| 275 | expect(parameter.get('LAYERS')).toBe(dienst.ebene); |
| 276 | expect(parameter.get('FORMAT')).toBe(dienst.bildformat); |
| 277 | // Alle Kacheln in derselben Zone - sonst liessen sie sich nicht |
| 278 | // zusammensetzen. |
| 279 | expect(parameter.get('CRS')).toBe('EPSG:25832'); |
| 280 | expect(parameter.get('WIDTH')).toBe(String(kachel.kante)); |
| 281 | expect(parameter.get('HEIGHT')).toBe(String(kachel.kante)); |
| 282 | |
| 283 | // Achsfolge Ost/Nord: Ost liegt in Zone 32 nahe 500 000, Nord bei ueber |
| 284 | // 5 Millionen. Vertauscht liefe der Abruf ins Leere. |
| 285 | const bbox = (parameter.get('BBOX') ?? '').split(',').map(Number); |
| 286 | expect(bbox[0] as number).toBeLessThan(bbox[1] as number); |
| 287 | expect(bbox[0] as number).toBeLessThan(bbox[2] as number); |
| 288 | expect(bbox[1] as number).toBeLessThan(bbox[3] as number); |
| 289 | } |
| 290 | }); |
| 291 | |
| 292 | it('gibt die Quellenangabe des Ausschnitts mit', () => { |
| 293 | const plan = baueKachelplan({ |
| 294 | dienst: dienstMit('nw-dop'), |
| 295 | mitte: KNOTEN, |
| 296 | kantenlaenge: 1500, |
| 297 | }); |
| 298 | expect(plan.herkunft).toContain('Geobasis NRW'); |
| 299 | expect(plan.herkunft).toContain('1500 m'); |
| 300 | }); |
| 301 | }); |