lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tools vierarmigerKnotenpunkt.ts
| 1 | import { deflateSync } from 'node:zlib'; |
| 2 | import { mkdirSync, writeFileSync } from 'node:fs'; |
| 3 | import { dirname, resolve } from 'node:path'; |
| 4 | import { createEmptyProject } from '../src/domain/model/factory'; |
| 5 | import { leiteAb, uebernimmWege, wegSchluessel } from '../src/domain/geometrie/ableitung'; |
| 6 | import { buildSignalPlan } from '../src/domain/plan/signalPlan'; |
| 7 | import { validateProject } from '../src/domain/validation/engine'; |
| 8 | import { REGELBREITE } from '../src/domain/geometrie/vermessung'; |
| 9 | import type { WegeVorschlag } from '../src/domain/geometrie/ableitung'; |
| 10 | import type { Haltlinie, Lageplan, Planlinie } from '../src/domain/geometrie/lageplan'; |
| 11 | import type { Movement } from '../src/domain/rilsa/types'; |
| 12 | import type { IntersectionArm, Project } from '../src/domain/model/project'; |
| 13 | |
| 14 | /** |
| 15 | * Erzeugt einen vollstaendigen vierarmigen Knotenpunkt als Pruefstueck. |
| 16 | * |
| 17 | * WARUM ERZEUGT UND NICHT GEKLICKT |
| 18 | * |
| 19 | * Sechzehn Fahrlinien und acht Haltlinien von Hand in die Oberflaeche zu |
| 20 | * klicken waere weder wiederholbar noch nachrechenbar. Hier steht die Geometrie |
| 21 | * dagegen als Formel: Jede Zufahrt entsteht aus derselben Vorschrift, nur |
| 22 | * gedreht. Damit laesst sich jede Zahl des Ergebnisses von Hand nachpruefen - |
| 23 | * und das ist der Sinn einer Kontrolle. |
| 24 | * |
| 25 | * MASSE |
| 26 | * |
| 27 | * Rechtsverkehr, 90 m im Quadrat, 1 Bildpunkt = 0,1 m. |
| 28 | * Fahrbahn 14 m breit, je Richtung 7 m mit zwei Fahrstreifen zu 3,5 m. |
| 29 | * Haltlinie 12 m vor Knotenpunktmitte, Furt 9 m davor - Fahrzeuge halten vor der Furt. |
| 30 | */ |
| 31 | |
| 32 | // --- Masse in Metern -------------------------------------------------------- |
| 33 | |
| 34 | const KANTE = 90; |
| 35 | const PX_JE_METER = 10; |
| 36 | const MITTE = { x: 45, y: 45 }; |
| 37 | /** Halbe Fahrbahnbreite - so weit reicht eine Fahrtrichtung von der Achse. */ |
| 38 | const HALB = 7; |
| 39 | /** |
| 40 | * Fahrstreifenachsen je Richtung: zwei Fahrstreifen zu 3,5 m. |
| 41 | * Der innere fuehrt links ab, der aeussere geradeaus und rechts - der Regelfall. |
| 42 | */ |
| 43 | const ACHSE_INNEN = 1.75; |
| 44 | const ACHSE_AUSSEN = 5.25; |
| 45 | /** |
| 46 | * Reihenfolge von aussen nach innen: Haltlinie, dann Furt, dann Knotenpunkt. |
| 47 | * Fahrzeuge halten VOR der Furt - genau das macht die Furt zum Konflikt. |
| 48 | */ |
| 49 | /** Halbmesser der Abbiegeboegen. */ |
| 50 | const R_LINKS = 12; |
| 51 | const R_RECHTS = 6; |
| 52 | const HALTLINIE_VOR = 12; |
| 53 | const FURT_VOR = 9; |
| 54 | const AUSLAUF = 40; |
| 55 | |
| 56 | interface Arm { |
| 57 | readonly id: string; |
| 58 | readonly name: string; |
| 59 | /** Fahrtrichtung der einfahrenden Stroeme, Bildkoordinaten (y nach unten). */ |
| 60 | readonly f: { x: number; y: number }; |
| 61 | } |
| 62 | |
| 63 | /** Rechts des Fahrers - bei y nach unten ist das (-fy, fx). */ |
| 64 | function rechts(v: { x: number; y: number }): { x: number; y: number } { |
| 65 | return { x: -v.y, y: v.x }; |
| 66 | } |
| 67 | |
| 68 | function plus( |
| 69 | a: { x: number; y: number }, |
| 70 | ...teile: readonly { v: { x: number; y: number }; s: number }[] |
| 71 | ): { x: number; y: number } { |
| 72 | return teile.reduce((p, t) => ({ x: p.x + t.v.x * t.s, y: p.y + t.v.y * t.s }), a); |
| 73 | } |
| 74 | |
| 75 | /** |
| 76 | * Kreisbogen zwischen zwei Punkten um einen Mittelpunkt. |
| 77 | * |
| 78 | * Abbiegevorgaenge als Eckzug zu zeichnen erzeugt Schnittpunkte, die es in |
| 79 | * Wirklichkeit nicht gibt: Zwei gegenueberliegende Linksabbieger fahren |
| 80 | * hintereinander um die Knotenpunktmitte herum, ihre Sehnen aber kreuzen sich. |
| 81 | * Deshalb wird der Bogen abgetastet - hier ist Naeherung angebracht, denn die |
| 82 | * Fahrlinie selbst ist ein Linienzug. |
| 83 | */ |
| 84 | function bogen( |
| 85 | mittelpunkt: { x: number; y: number }, |
| 86 | von: { x: number; y: number }, |
| 87 | bis: { x: number; y: number }, |
| 88 | stuecke = 8, |
| 89 | ): { x: number; y: number }[] { |
| 90 | const radius = Math.hypot(von.x - mittelpunkt.x, von.y - mittelpunkt.y); |
| 91 | const a0 = Math.atan2(von.y - mittelpunkt.y, von.x - mittelpunkt.x); |
| 92 | let a1 = Math.atan2(bis.y - mittelpunkt.y, bis.x - mittelpunkt.x); |
| 93 | // Immer den kurzen Weg nehmen - ein Abbiegevorgang ist hoechstens ein |
| 94 | // Viertelkreis. |
| 95 | while (a1 - a0 > Math.PI) a1 -= 2 * Math.PI; |
| 96 | while (a0 - a1 > Math.PI) a1 += 2 * Math.PI; |
| 97 | |
| 98 | const punkte: { x: number; y: number }[] = []; |
| 99 | for (let i = 0; i <= stuecke; i += 1) { |
| 100 | const a = a0 + ((a1 - a0) * i) / stuecke; |
| 101 | punkte.push({ |
| 102 | x: mittelpunkt.x + radius * Math.cos(a), |
| 103 | y: mittelpunkt.y + radius * Math.sin(a), |
| 104 | }); |
| 105 | } |
| 106 | return punkte; |
| 107 | } |
| 108 | |
| 109 | /** Meter in Bildpunkte. */ |
| 110 | function px(p: { x: number; y: number }): { x: number; y: number } { |
| 111 | return { x: p.x * PX_JE_METER, y: p.y * PX_JE_METER }; |
| 112 | } |
| 113 | |
| 114 | const ARME: readonly Arm[] = [ |
| 115 | { id: 'arm-nord', name: 'Nord', f: { x: 0, y: 1 } }, |
| 116 | { id: 'arm-ost', name: 'Ost', f: { x: -1, y: 0 } }, |
| 117 | { id: 'arm-sued', name: 'Sued', f: { x: 0, y: -1 } }, |
| 118 | { id: 'arm-west', name: 'West', f: { x: 1, y: 0 } }, |
| 119 | ]; |
| 120 | |
| 121 | // --- Hintergrundbild -------------------------------------------------------- |
| 122 | |
| 123 | /** |
| 124 | * Zeichnet einen schematischen Knotenpunkt als PNG. |
| 125 | * |
| 126 | * Ohne Fremdbibliothek: Die Bildpunkte werden unmittelbar gesetzt und mit der |
| 127 | * in Node enthaltenen Deflate-Kodierung verpackt. Das Bild dient nur als |
| 128 | * Unterlage - gerechnet wird ausschliesslich mit der Geometrie. |
| 129 | */ |
| 130 | function baueBild(): string { |
| 131 | const groesse = KANTE * PX_JE_METER; |
| 132 | const daten = Buffer.alloc(groesse * (groesse * 3 + 1)); |
| 133 | |
| 134 | const setze = (x: number, y: number, r: number, g: number, b: number): void => { |
| 135 | const zeile = y * (groesse * 3 + 1); |
| 136 | daten[zeile] = 0; |
| 137 | daten[zeile + 1 + x * 3] = r; |
| 138 | daten[zeile + 2 + x * 3] = g; |
| 139 | daten[zeile + 3 + x * 3] = b; |
| 140 | }; |
| 141 | |
| 142 | const halbPx = HALB * PX_JE_METER; |
| 143 | const mittePx = px(MITTE); |
| 144 | |
| 145 | for (let y = 0; y < groesse; y += 1) { |
| 146 | for (let x = 0; x < groesse; x += 1) { |
| 147 | const dx = Math.abs(x - mittePx.x); |
| 148 | const dy = Math.abs(y - mittePx.y); |
| 149 | const aufFahrbahn = dx <= halbPx || dy <= halbPx; |
| 150 | |
| 151 | if (!aufFahrbahn) { |
| 152 | // Umfeld |
| 153 | setze(x, y, 214, 220, 210); |
| 154 | continue; |
| 155 | } |
| 156 | |
| 157 | // Asphalt |
| 158 | let r = 96; |
| 159 | let g = 99; |
| 160 | let b = 104; |
| 161 | |
| 162 | // Fahrbahnachse als unterbrochene Linie, nur ausserhalb des Knotens. |
| 163 | const imKnoten = dx <= halbPx && dy <= halbPx; |
| 164 | if (!imKnoten) { |
| 165 | const aufAchseX = dx <= 1 && Math.floor(y / 20) % 2 === 0; |
| 166 | const aufAchseY = dy <= 1 && Math.floor(x / 20) % 2 === 0; |
| 167 | // Fahrstreifenbegrenzung zwischen den beiden Fahrstreifen je Richtung. |
| 168 | const trennerX = |
| 169 | Math.abs(dx - ACHSE_AUSSEN * PX_JE_METER) <= 1 && Math.floor(y / 16) % 2 === 0; |
| 170 | const trennerY = |
| 171 | Math.abs(dy - ACHSE_AUSSEN * PX_JE_METER) <= 1 && Math.floor(x / 16) % 2 === 0; |
| 172 | if (aufAchseX || aufAchseY || trennerX || trennerY) { |
| 173 | r = 236; |
| 174 | g = 236; |
| 175 | b = 232; |
| 176 | } |
| 177 | } |
| 178 | setze(x, y, r, g, b); |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | return `data:image/png;base64,${kodierePng(groesse, groesse, daten).toString('base64')}`; |
| 183 | } |
| 184 | |
| 185 | function kodierePng(breite: number, hoehe: number, roh: Buffer): Buffer { |
| 186 | const teil = (typ: string, inhalt: Buffer): Buffer => { |
| 187 | const laenge = Buffer.alloc(4); |
| 188 | laenge.writeUInt32BE(inhalt.length, 0); |
| 189 | const koerper = Buffer.concat([Buffer.from(typ, 'ascii'), inhalt]); |
| 190 | const pruef = Buffer.alloc(4); |
| 191 | pruef.writeUInt32BE(crc32(koerper), 0); |
| 192 | return Buffer.concat([laenge, koerper, pruef]); |
| 193 | }; |
| 194 | |
| 195 | const kopf = Buffer.alloc(13); |
| 196 | kopf.writeUInt32BE(breite, 0); |
| 197 | kopf.writeUInt32BE(hoehe, 4); |
| 198 | kopf[8] = 8; // Bittiefe |
| 199 | kopf[9] = 2; // Echtfarbe |
| 200 | kopf[10] = 0; |
| 201 | kopf[11] = 0; |
| 202 | kopf[12] = 0; |
| 203 | |
| 204 | return Buffer.concat([ |
| 205 | Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), |
| 206 | teil('IHDR', kopf), |
| 207 | teil('IDAT', deflateSync(roh, { level: 9 })), |
| 208 | teil('IEND', Buffer.alloc(0)), |
| 209 | ]); |
| 210 | } |
| 211 | |
| 212 | const CRC_TABELLE = (() => { |
| 213 | const t = new Uint32Array(256); |
| 214 | for (let n = 0; n < 256; n += 1) { |
| 215 | let c = n; |
| 216 | for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; |
| 217 | t[n] = c >>> 0; |
| 218 | } |
| 219 | return t; |
| 220 | })(); |
| 221 | |
| 222 | function crc32(daten: Buffer): number { |
| 223 | let c = 0xffffffff; |
| 224 | for (const wert of daten) c = (CRC_TABELLE[(c ^ wert) & 0xff] as number) ^ (c >>> 8); |
| 225 | return (c ^ 0xffffffff) >>> 0; |
| 226 | } |
| 227 | |
| 228 | // --- Knotenpunkt ------------------------------------------------------------ |
| 229 | |
| 230 | function baueLageplan(): Lageplan { |
| 231 | const haltlinien: Haltlinie[] = []; |
| 232 | const linien: Planlinie[] = []; |
| 233 | |
| 234 | for (const arm of ARME) { |
| 235 | const f = arm.f; |
| 236 | const r = rechts(f); |
| 237 | const rr = rechts(r); |
| 238 | |
| 239 | // Haltlinie der Zufahrt: von der Fahrbahnachse bis zum Bordstein. |
| 240 | const aufAchse = plus(MITTE, { v: f, s: -HALTLINIE_VOR }); |
| 241 | const haltlinie: Haltlinie = { |
| 242 | id: `hl-${arm.id}`, |
| 243 | name: arm.name, |
| 244 | art: 'zufahrt', |
| 245 | armId: arm.id, |
| 246 | von: px(aufAchse), |
| 247 | bis: px(plus(aufAchse, { v: r, s: HALB })), |
| 248 | }; |
| 249 | haltlinien.push(haltlinie); |
| 250 | |
| 251 | // Zwei Fahrstreifen: innen der Linksabbieger, aussen geradeaus und rechts. |
| 252 | const startAussen = plus(aufAchse, { v: r, s: ACHSE_AUSSEN }); |
| 253 | const startInnen = plus(aufAchse, { v: r, s: ACHSE_INNEN }); |
| 254 | |
| 255 | const stroeme: readonly { |
| 256 | movement: Movement; |
| 257 | start: { x: number; y: number }; |
| 258 | startT: number; |
| 259 | punkte: readonly { x: number; y: number }[]; |
| 260 | }[] = [ |
| 261 | { |
| 262 | // Geradeaus: bleibt auf dem aeusseren Fahrstreifen. |
| 263 | movement: 'geradeaus', |
| 264 | start: startAussen, |
| 265 | startT: ACHSE_AUSSEN / HALB, |
| 266 | punkte: [ |
| 267 | startAussen, |
| 268 | plus(MITTE, { v: f, s: FURT_VOR }, { v: r, s: ACHSE_AUSSEN }), |
| 269 | plus(MITTE, { v: f, s: AUSLAUF }, { v: r, s: ACHSE_AUSSEN }), |
| 270 | ], |
| 271 | }, |
| 272 | { |
| 273 | // Rechts: vom selben Fahrstreifen, enger Bogen in die Ausfahrt. |
| 274 | // Bogenmitte rechts der Zufahrt, Halbmesser R_RECHTS. |
| 275 | movement: 'rechts', |
| 276 | start: startAussen, |
| 277 | startT: ACHSE_AUSSEN / HALB, |
| 278 | punkte: [ |
| 279 | startAussen, |
| 280 | ...bogen( |
| 281 | plus( |
| 282 | MITTE, |
| 283 | { v: f, s: -(ACHSE_AUSSEN + R_RECHTS) }, |
| 284 | { v: r, s: ACHSE_AUSSEN + R_RECHTS }, |
| 285 | ), |
| 286 | plus(MITTE, { v: f, s: -(ACHSE_AUSSEN + R_RECHTS) }, { v: r, s: ACHSE_AUSSEN }), |
| 287 | plus(MITTE, { v: r, s: ACHSE_AUSSEN + R_RECHTS }, { v: rr, s: ACHSE_AUSSEN }), |
| 288 | ), |
| 289 | plus(MITTE, { v: r, s: AUSLAUF }, { v: rr, s: ACHSE_AUSSEN }), |
| 290 | ], |
| 291 | }, |
| 292 | { |
| 293 | // Links: eigener Fahrstreifen, weiter Bogen um die Knotenpunktmitte. |
| 294 | // |
| 295 | // Bogenmitte auf der Seite, zu der abgebogen wird. Dadurch fahren die |
| 296 | // gegenueberliegenden Linksabbieger hintereinander umeinander herum - |
| 297 | // als Eckzug gezeichnet schnitten ihre Sehnen sich, und die Vermassung |
| 298 | // meldete das dann voellig zu Recht als Konflikt. |
| 299 | movement: 'links', |
| 300 | start: startInnen, |
| 301 | startT: ACHSE_INNEN / HALB, |
| 302 | punkte: [ |
| 303 | startInnen, |
| 304 | ...bogen( |
| 305 | plus(MITTE, { v: f, s: ACHSE_INNEN - R_LINKS }, { v: r, s: ACHSE_INNEN - R_LINKS }), |
| 306 | plus(MITTE, { v: f, s: ACHSE_INNEN - R_LINKS }, { v: r, s: ACHSE_INNEN }), |
| 307 | plus(MITTE, { v: f, s: ACHSE_INNEN }, { v: r, s: ACHSE_INNEN - R_LINKS }), |
| 308 | ), |
| 309 | plus(MITTE, { v: r, s: -AUSLAUF }, { v: rr, s: -ACHSE_INNEN }), |
| 310 | ], |
| 311 | }, |
| 312 | ]; |
| 313 | |
| 314 | for (const strom of stroeme) { |
| 315 | linien.push({ |
| 316 | id: `${arm.id}-${strom.movement}`, |
| 317 | name: `${arm.name} ${strom.movement}`, |
| 318 | mode: 'kfz', |
| 319 | movement: strom.movement, |
| 320 | breiteMeter: REGELBREITE.kfz, |
| 321 | punkte: strom.punkte.map(px), |
| 322 | haltlinieId: haltlinie.id, |
| 323 | startT: strom.startT, |
| 324 | signalGroupId: null, |
| 325 | }); |
| 326 | } |
| 327 | |
| 328 | // Fussgaengerfurt ueber diesen Arm, vor dem Knotenpunkt. |
| 329 | const furtAchse = plus(MITTE, { v: f, s: -FURT_VOR }); |
| 330 | const bordA = plus(furtAchse, { v: r, s: HALB }); |
| 331 | const bordB = plus(furtAchse, { v: r, s: -HALB }); |
| 332 | const furtHalt: Haltlinie = { |
| 333 | id: `hl-furt-${arm.id}`, |
| 334 | name: `Furt ${arm.name}`, |
| 335 | art: 'querung', |
| 336 | armId: arm.id, |
| 337 | von: px(plus(bordA, { v: f, s: -2 })), |
| 338 | bis: px(plus(bordA, { v: f, s: 2 })), |
| 339 | }; |
| 340 | haltlinien.push(furtHalt); |
| 341 | |
| 342 | linien.push({ |
| 343 | id: `${arm.id}-furt`, |
| 344 | name: `Furt ${arm.name}`, |
| 345 | mode: 'fuss', |
| 346 | movement: 'querung', |
| 347 | breiteMeter: REGELBREITE.fuss, |
| 348 | punkte: [px(bordA), px(bordB)], |
| 349 | haltlinieId: furtHalt.id, |
| 350 | startT: 0.5, |
| 351 | signalGroupId: null, |
| 352 | }); |
| 353 | } |
| 354 | |
| 355 | return { |
| 356 | arbeitsbereiche: [], |
| 357 | signalgeber: [], |
| 358 | bild: { |
| 359 | datenUrl: baueBild(), |
| 360 | breite: KANTE * PX_JE_METER, |
| 361 | hoehe: KANTE * PX_JE_METER, |
| 362 | herkunft: 'Schematischer vierarmiger Knotenpunkt (Pruefstueck)', |
| 363 | geladenAm: new Date(0).toISOString(), |
| 364 | }, |
| 365 | /* |
| 366 | * GEMESSEN, NICHT GEOREFERENZIERT (Fassung 5.11.0, Befund 34). |
| 367 | * |
| 368 | * 'georeferenziert' heisst: Das Programm hat den Ausschnitt selbst beim |
| 369 | * Kartendienst angefordert und kennt seine Kantenlaenge deshalb exakt |
| 370 | * (`Kalibrierung` in src/domain/geometrie/lageplan.ts). Hier zeichnet |
| 371 | * `baueBild()` das Bild selbst; ein Kartendienst ist nicht beteiligt. Der |
| 372 | * Ausdruck druckte aus dem alten Wert die Zeile "Herkunft des Maszstabs: |
| 373 | * aus dem Kartendienst übernommen (georeferenzierter Ausschnitt, nicht |
| 374 | * nachgemessen)" - zwei Zeilen unter der wahren Bildherkunft in derselben |
| 375 | * Tabelle -, und die Seitenleiste des Lageplans sagte "Er muss nicht |
| 376 | * nachgemessen werden". Beides nahm dem Pruefer genau den Arbeitsschritt |
| 377 | * ab, der bei einem nicht georeferenzierten Bild die einzige Absicherung |
| 378 | * des Maszstabs ist. |
| 379 | * |
| 380 | * Mit 'gemessen' druckt der Ausdruck stattdessen "an einer Strecke |
| 381 | * bekannter Länge abgegriffen: 90,0 m auf 900 Bildpunkten" - beide Zahlen |
| 382 | * stehen unten und sind nachpruefbar. Der Maszstab selbst aendert sich |
| 383 | * nicht. |
| 384 | */ |
| 385 | kalibrierung: { |
| 386 | von: { x: 0, y: 0 }, |
| 387 | bis: { x: KANTE * PX_JE_METER, y: 0 }, |
| 388 | laengeMeter: KANTE, |
| 389 | herkunft: 'gemessen', |
| 390 | }, |
| 391 | haltlinien, |
| 392 | linien, |
| 393 | }; |
| 394 | } |
| 395 | |
| 396 | function baueProjekt(): Project { |
| 397 | const leer = createEmptyProject('Vierarmiger Knotenpunkt (Kontrolle)', new Date(0)); |
| 398 | const arms: IntersectionArm[] = ARME.map((a) => ({ |
| 399 | id: a.id, |
| 400 | name: a.name, |
| 401 | direction: a.name, |
| 402 | lanes: 2, |
| 403 | vZul: 50, |
| 404 | })); |
| 405 | |
| 406 | return { |
| 407 | ...leer, |
| 408 | meta: { |
| 409 | ...leer.meta, |
| 410 | location: 'Musterkreuzung', |
| 411 | variant: 'Kontrolle', |
| 412 | // AUS DEN KONSTANTEN GEBILDET, nicht abgeschrieben (Fassung 5.11.0, |
| 413 | // Befund 33). Hier stand "Haltlinie 9 m vor Knotenpunktmitte" - das ist |
| 414 | // FURT_VOR, also die Lage der Furt. Der Satz steht woertlich in der |
| 415 | // ausgelieferten .lsap und wird von src/services/export/pdf.ts unter |
| 416 | // "Bemerkungen" in die Planunterlage gedruckt; wer die Vermassung anhand |
| 417 | // dieser Angabe nachprueft, misst am falschen Querschnitt. Mit der |
| 418 | // Schablone kann er bei einer Massaenderung nicht erneut |
| 419 | // auseinanderlaufen. |
| 420 | description: |
| 421 | `Erzeugtes Pruefstueck: vier Zufahrten mit je geradeaus, links und rechts, dazu vier ` + |
| 422 | `Fussgaengerfurten. Fahrbahn ${2 * HALB} m, Haltlinie ${HALTLINIE_VOR} m vor ` + |
| 423 | `Knotenpunktmitte, Furt ${FURT_VOR} m davor.`, |
| 424 | }, |
| 425 | intersection: { ...leer.intersection, name: 'Musterkreuzung', arms, vZul: 50 }, |
| 426 | lageplan: baueLageplan(), |
| 427 | }; |
| 428 | } |
| 429 | |
| 430 | // --- Auswertung ------------------------------------------------------------- |
| 431 | |
| 432 | const projekt = baueProjekt(); |
| 433 | const ableitung = leiteAb(projekt, projekt.lageplan); |
| 434 | |
| 435 | const zufahrtVon = (id: string): string => (id.startsWith('arm-') ? (id.split('-')[1] ?? '') : ''); |
| 436 | const istFurt = (id: string): boolean => id.endsWith('-furt'); |
| 437 | |
| 438 | const innerhalb = ableitung.vertraeglichkeit.filter( |
| 439 | (v) => |
| 440 | zufahrtVon(v.aLinieId) === zufahrtVon(v.bLinieId) && |
| 441 | !istFurt(v.aLinieId) && |
| 442 | !istFurt(v.bLinieId), |
| 443 | ); |
| 444 | const furtGegenEigenenArm = ableitung.vertraeglichkeit.filter( |
| 445 | (v) => |
| 446 | zufahrtVon(v.aLinieId) === zufahrtVon(v.bLinieId) && |
| 447 | (istFurt(v.aLinieId) || istFurt(v.bLinieId)), |
| 448 | ); |
| 449 | const zwischenZufahrten = ableitung.vertraeglichkeit.filter( |
| 450 | (v) => zufahrtVon(v.aLinieId) !== zufahrtVon(v.bLinieId), |
| 451 | ); |
| 452 | |
| 453 | console.log('=== Vierarmiger Knotenpunkt ==='); |
| 454 | console.log(`Haltlinien ${projekt.lageplan.haltlinien.length}`); |
| 455 | console.log(`Fahrlinien ${projekt.lageplan.linien.length}`); |
| 456 | console.log(`Paare insgesamt ${ableitung.vertraeglichkeit.length}`); |
| 457 | console.log(''); |
| 458 | console.log('--- Stroeme DERSELBEN Zufahrt (muessen vertraeglich sein) ---'); |
| 459 | console.log(` Paare ${innerhalb.length}`); |
| 460 | console.log(` davon feindlich ${innerhalb.filter((v) => v.feindlich).length}`); |
| 461 | const gruende = new Map<string, number>(); |
| 462 | for (const v of innerhalb) gruende.set(v.grund, (gruende.get(v.grund) ?? 0) + 1); |
| 463 | for (const [grund, anzahl] of gruende) console.log(` ${grund.padEnd(24)} ${anzahl}`); |
| 464 | console.log(''); |
| 465 | console.log('--- Furt gegen Stroeme ihres EIGENEN Arms (muessen feindlich sein) ---'); |
| 466 | console.log(` Paare ${furtGegenEigenenArm.length}`); |
| 467 | console.log(` davon feindlich ${furtGegenEigenenArm.filter((v) => v.feindlich).length}`); |
| 468 | console.log(''); |
| 469 | console.log('--- Zwischen verschiedenen Zufahrten ---'); |
| 470 | console.log(` Paare ${zwischenZufahrten.length}`); |
| 471 | console.log(` davon feindlich ${zwischenZufahrten.filter((v) => v.feindlich).length}`); |
| 472 | console.log(''); |
| 473 | console.log(`Unterdrueckte Beruehrungen ${ableitung.unterdrueckt.length}`); |
| 474 | console.log(`Vermessene Beziehungen ${ableitung.wege.length}`); |
| 475 | console.log(''); |
| 476 | |
| 477 | /** |
| 478 | * Eine Stichprobenzeile zum Nachrechnen - Namen, beide Wege, Schnittwinkel. |
| 479 | * |
| 480 | * Als eigene Funktion herausgezogen, damit der SATZ pruefbar ist und nicht nur |
| 481 | * seine Bausteine: Hier entsteht die Angabe, und hier hing der Fehler. |
| 482 | * |
| 483 | * Der Winkel 0 heisst "laengs" und nicht "null Grad": Der Konfliktbereich |
| 484 | * fuehrt den Winkel des Schnitts mit, der sein Ende bestimmt hat. Stammt das |
| 485 | * Ende aus einem gemeinsamen Stueck - zwei Stroeme laufen nebeneinander her, |
| 486 | * oder es gibt gar keinen Punktschnitt -, gehoert dazu kein Schnittwinkel, und |
| 487 | * `vermessung.ts` traegt 0 ein (siehe `Konfliktbereich.winkel`). "0.0 Grad" |
| 488 | * behauptete daraus einen Schnitt unter null Grad und damit, die Linien |
| 489 | * kreuzten sich nicht - neben Raeumweg und Einfahrweg derselben Beziehung. |
| 490 | * Dieselbe Unterscheidung trifft die Ansicht (lageplanView.ts); hier steht sie |
| 491 | * in Umschrift wie die uebrige Ausgabe dieses Werkzeugs. |
| 492 | * |
| 493 | * Der Text landet in keiner erzeugten Datei - er wird nur ausgegeben. |
| 494 | */ |
| 495 | export function stichprobenzeile(weg: WegeVorschlag): string { |
| 496 | const winkel = weg.winkelGrad === 0 ? 'laengs' : `${weg.winkelGrad.toFixed(1)} Grad`; |
| 497 | return ( |
| 498 | ` ${weg.vonName.padEnd(18)} raeumt vor ${weg.nachName.padEnd(18)} ` + |
| 499 | `Raeumweg ${weg.raeumweg.toFixed(2).padStart(6)} m ` + |
| 500 | `Einfahrweg ${weg.einfahrweg.toFixed(2).padStart(6)} m ` + |
| 501 | `Winkel ${winkel}` |
| 502 | ); |
| 503 | } |
| 504 | |
| 505 | console.log('--- Stichproben zum Nachrechnen ---'); |
| 506 | const stichproben = [ |
| 507 | ['arm-nord-geradeaus', 'arm-west-geradeaus'], |
| 508 | ['arm-west-geradeaus', 'arm-nord-geradeaus'], |
| 509 | ['arm-nord-links', 'arm-sued-geradeaus'], |
| 510 | ['arm-nord-geradeaus', 'arm-nord-furt'], |
| 511 | ]; |
| 512 | for (const [von, nach] of stichproben) { |
| 513 | const weg = ableitung.wege.find((w) => w.vonLinieId === von && w.nachLinieId === nach); |
| 514 | if (weg === undefined) { |
| 515 | console.log(` ${von} raeumt / ${nach} faehrt ein: keine Beziehung`); |
| 516 | continue; |
| 517 | } |
| 518 | console.log(stichprobenzeile(weg)); |
| 519 | } |
| 520 | |
| 521 | for (const hinweis of ableitung.hinweise) { |
| 522 | console.log(`HINWEIS [${hinweis.severity}] ${hinweis.message}`); |
| 523 | } |
| 524 | |
| 525 | // --- Vom Lageplan zum fertigen Signalzeitenplan ----------------------------- |
| 526 | |
| 527 | /** |
| 528 | * Buendelt die Stroeme zu Signalgruppen, uebernimmt die vermessenen Wege und |
| 529 | * bildet ein uebliches Vierphasenprogramm. |
| 530 | * |
| 531 | * Damit wird die ganze Kette geprueft, nicht nur die Geometrie: Zwoelf Stroeme |
| 532 | * werden zu acht Kfz-Signalgruppen gebuendelt - geradeaus und rechts teilen |
| 533 | * sich je Zufahrt einen Signalgeber, der Linksabbieger bekommt einen eigenen. |
| 534 | * Genau dieser Fall, mehrere Stroeme an einer Gruppe, war vorher nicht |
| 535 | * abbildbar. |
| 536 | */ |
| 537 | function baueSignalprogramm(basis: Project): Project { |
| 538 | let p = basis; |
| 539 | const gruppeVon = new Map<string, string>(); |
| 540 | |
| 541 | const lege = ( |
| 542 | name: string, |
| 543 | mode: 'kfz' | 'fuss', |
| 544 | armId: string, |
| 545 | linien: readonly string[], |
| 546 | ): void => { |
| 547 | const id = `sg-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; |
| 548 | p = { |
| 549 | ...p, |
| 550 | signalGroups: [ |
| 551 | ...p.signalGroups, |
| 552 | { |
| 553 | id, |
| 554 | name, |
| 555 | mode, |
| 556 | movement: |
| 557 | mode === 'fuss' |
| 558 | ? 'querung' |
| 559 | : linien.some((l) => l.endsWith('links')) |
| 560 | ? 'links' |
| 561 | : 'geradeaus', |
| 562 | armId, |
| 563 | vZul: 50, |
| 564 | // Geradeaus und rechts teilen sich EINEN Fahrstreifen - deshalb einer, |
| 565 | // nicht einer je Strom. Die Saettigungsverkehrsstaerke haengt am |
| 566 | // Fahrstreifen, nicht an der Zahl der Fahrbeziehungen. |
| 567 | lanes: 1, |
| 568 | // Fussgaenger haben keine Fahrzeuglaenge - dafuer steht 'keine'. |
| 569 | vehicleClass: mode === 'fuss' ? 'keine' : 'pkw', |
| 570 | minGreenOverride: null, |
| 571 | maxGreenOverride: null, |
| 572 | reducedMobility: false, |
| 573 | color: '#2f6fd0', |
| 574 | }, |
| 575 | ], |
| 576 | }; |
| 577 | for (const l of linien) gruppeVon.set(l, id); |
| 578 | }; |
| 579 | |
| 580 | for (const arm of ARME) { |
| 581 | // Ein Signalgeber fuer geradeaus UND rechts - sie teilen den Fahrstreifen. |
| 582 | lege(`K ${arm.name}`, 'kfz', arm.id, [`${arm.id}-geradeaus`, `${arm.id}-rechts`]); |
| 583 | lege(`K ${arm.name} links`, 'kfz', arm.id, [`${arm.id}-links`]); |
| 584 | lege(`F ${arm.name}`, 'fuss', arm.id, [`${arm.id}-furt`]); |
| 585 | } |
| 586 | |
| 587 | p = { |
| 588 | ...p, |
| 589 | lageplan: { |
| 590 | ...p.lageplan, |
| 591 | linien: p.lageplan.linien.map((l) => ({ ...l, signalGroupId: gruppeVon.get(l.id) ?? null })), |
| 592 | }, |
| 593 | }; |
| 594 | |
| 595 | // Wege uebernehmen - alle, die die Ableitung ermittelt hat. |
| 596 | const neu = leiteAb(p, p.lageplan); |
| 597 | const ergebnis = uebernimmWege(p, p.lageplan, neu, neu.wege.map(wegSchluessel)); |
| 598 | p = ergebnis.project; |
| 599 | |
| 600 | // Uebliches Vierphasenprogramm. |
| 601 | // Eigene Fussgaengerphase statt paralleler Freigabe zu den Rechtsabbiegern. |
| 602 | // |
| 603 | // Der Grund ist fachlich und wird von der Pruefung erzwungen: Geradeaus und |
| 604 | // rechts teilen sich hier einen Fahrstreifen und muessen deshalb denselben |
| 605 | // Signalgeber bekommen. Der Rechtsabbieger kreuzt aber die Furt des Arms, in |
| 606 | // den er einbiegt. Bei zweiwertiger Vertraeglichkeit - und mehr kann dieser |
| 607 | // Stand - bleibt nur, die Furten in eine eigene Phase zu legen. |
| 608 | // |
| 609 | // In der Praxis loest man das ueber die bedingte Vertraeglichkeit: Der |
| 610 | // Rechtsabbieger erhaelt gleichzeitig Freigabe und muss warten. Solange das |
| 611 | // Programm das nicht abbilden kann, waere es unredlich, so zu tun als ob. |
| 612 | const phasen = [ |
| 613 | { name: 'Phase 1 - Nord/Sued geradeaus und rechts', gruppen: ['sg-k-nord', 'sg-k-sued'] }, |
| 614 | { name: 'Phase 2 - Nord/Sued links', gruppen: ['sg-k-nord-links', 'sg-k-sued-links'] }, |
| 615 | { name: 'Phase 3 - Ost/West geradeaus und rechts', gruppen: ['sg-k-ost', 'sg-k-west'] }, |
| 616 | { name: 'Phase 4 - Ost/West links', gruppen: ['sg-k-ost-links', 'sg-k-west-links'] }, |
| 617 | { name: 'Phase 5 - Fussgaenger', gruppen: ['sg-f-nord', 'sg-f-ost', 'sg-f-sued', 'sg-f-west'] }, |
| 618 | ].map((ph, i) => ({ |
| 619 | id: `ph-${i + 1}`, |
| 620 | name: ph.name, |
| 621 | signalGroupIds: ph.gruppen, |
| 622 | manualGreen: null, |
| 623 | })); |
| 624 | |
| 625 | return { |
| 626 | ...p, |
| 627 | phases: phasen, |
| 628 | program: { ...p.program, phaseOrder: phasen.map((ph) => ph.id) }, |
| 629 | demands: p.signalGroups.map((g) => ({ |
| 630 | signalGroupId: g.id, |
| 631 | volume: g.mode === 'fuss' ? 120 : 160, |
| 632 | heavyVehicleShare: g.mode === 'fuss' ? 0 : 0.05, |
| 633 | })), |
| 634 | }; |
| 635 | } |
| 636 | |
| 637 | const fertig = baueSignalprogramm(projekt); |
| 638 | const plan = buildSignalPlan(fertig); |
| 639 | const bericht = validateProject(fertig, plan, new Date(0)); |
| 640 | |
| 641 | console.log(''); |
| 642 | console.log('=== Weiter bis zum Signalzeitenplan ==='); |
| 643 | console.log(`Signalgruppen ${fertig.signalGroups.length}`); |
| 644 | console.log(`Konfliktbeziehungen ${fertig.conflicts.length}`); |
| 645 | console.log(`Phasen ${fertig.phases.length}`); |
| 646 | console.log(`Umlaufzeit ${plan.cycleTime} s`); |
| 647 | console.log(`Fehler ${bericht.errorCount}`); |
| 648 | console.log(`Warnungen ${bericht.warningCount}`); |
| 649 | console.log(`Ausgabe gesperrt ${bericht.exportBlocked ? 'ja' : 'nein'}`); |
| 650 | for (const eintrag of bericht.findings.slice(0, 10)) { |
| 651 | console.log(` [${eintrag.severity}] ${eintrag.message}`); |
| 652 | } |
| 653 | if (bericht.findings.length > 10) console.log(` ... und ${bericht.findings.length - 10} weitere`); |
| 654 | |
| 655 | console.log(''); |
| 656 | console.log('--- Groesste Zwischenzeiten ---'); |
| 657 | const groesste = [...plan.intergreens.entries()] |
| 658 | .map(([schluessel, wert]) => { |
| 659 | const [vonId, nachId] = schluessel.split('|'); |
| 660 | return { |
| 661 | von: fertig.signalGroups.find((g) => g.id === vonId)?.name ?? vonId ?? '?', |
| 662 | nach: fertig.signalGroups.find((g) => g.id === nachId)?.name ?? nachId ?? '?', |
| 663 | wert: wert.value, |
| 664 | }; |
| 665 | }) |
| 666 | .sort((a, b) => b.wert - a.wert) |
| 667 | .slice(0, 6); |
| 668 | for (const z of groesste) { |
| 669 | console.log(` ${z.von.padEnd(16)} -> ${z.nach.padEnd(16)} ${String(z.wert).padStart(3)} s`); |
| 670 | } |
| 671 | |
| 672 | // --- Bild des Plans --------------------------------------------------------- |
| 673 | |
| 674 | /** |
| 675 | * Zeichnet den Plan als SVG, damit sich das Ergebnis ansehen laesst. |
| 676 | * |
| 677 | * Bewusst nicht die Leinwand der Anwendung: Die haengt am Browser. Hier geht es |
| 678 | * darum, das erzeugte Pruefstueck sichtbar zu machen - dieselben Punkte, nur |
| 679 | * anders ausgegeben. |
| 680 | * |
| 681 | * NUR DIE PUNKTE, KEINE AUSWERTUNG (Fassung 5.11.0, Befund 61): |
| 682 | * Hier stand unter der Ueberschrift "Feindliche Beruehrungen" eine Schleife |
| 683 | * ueber alle feindlichen Paare, die beide Planlinien heraussuchte und danach |
| 684 | * nichts erzeugte - kein `teile.push`. Das Bild zeigte also keine einzige |
| 685 | * Beruehrung, obwohl der Kommentar sie ankuendigte, und allein dieser toten |
| 686 | * Schleife wegen wurde `leiteAb` ein drittes Mal ueber den Knotenpunkt |
| 687 | * gerechnet. Die Ankuendigung ist gefallen, nicht die Zeichnung ergaenzt: Der |
| 688 | * Satz darueber sagt, wofuer dieses Bild da ist, und das sind die Punkte. |
| 689 | */ |
| 690 | function baueSvg(p: Project): string { |
| 691 | // Die Lageplankoordinaten stehen in Dezimetern (PX_JE_METER = 10, siehe |
| 692 | // `px`); das Teilen durch 10 fuehrt sie hier in Meter. Der Kommentar nannte |
| 693 | // vor der Fassung 5.11.0 (Befund 62) die Eingangs- statt der Ausgangseinheit; |
| 694 | // wer im Bild nachmisst und ihm folgt, liegt um den Faktor zehn daneben. Dass |
| 695 | // eine Einheit ein Meter ist, zeigt die viewBox: 90 Einheiten fuer 90 m |
| 696 | // Kantenlaenge - bei Dezimetern stuenden dort 900. Auf Meter ausgelegt sind |
| 697 | // auch die Schriftgroessen 2.4 und 2.6 und die Strichbreiten 0.7 und 1.6. |
| 698 | const s = (KANTE * PX_JE_METER) / 10; // 1 Einheit = 1 m -> handliche Zahlen |
| 699 | const m = (w: { x: number; y: number }) => `${(w.x / 10).toFixed(1)},${(w.y / 10).toFixed(1)}`; |
| 700 | const farbe: Record<string, string> = { |
| 701 | geradeaus: '#2f6fd0', |
| 702 | links: '#a05ec4', |
| 703 | rechts: '#4a9e6a', |
| 704 | querung: '#e07b39', |
| 705 | }; |
| 706 | |
| 707 | const teile: string[] = []; |
| 708 | teile.push(`<rect width="${s}" height="${s}" fill="#d6dcd2"/>`); |
| 709 | // Fahrbahnen |
| 710 | const halb = (HALB * PX_JE_METER) / 10; |
| 711 | const mitte = (MITTE.x * PX_JE_METER) / 10; |
| 712 | teile.push(`<rect x="0" y="${mitte - halb}" width="${s}" height="${halb * 2}" fill="#606368"/>`); |
| 713 | teile.push(`<rect x="${mitte - halb}" y="0" width="${halb * 2}" height="${s}" fill="#606368"/>`); |
| 714 | |
| 715 | // Korridore |
| 716 | for (const linie of p.lageplan.linien) { |
| 717 | const breite = (linie.breiteMeter * PX_JE_METER) / 10; |
| 718 | teile.push( |
| 719 | `<polyline points="${linie.punkte.map(m).join(' ')}" fill="none" ` + |
| 720 | `stroke="${farbe[linie.movement] ?? '#888'}" stroke-width="${breite}" ` + |
| 721 | 'stroke-opacity="0.22" stroke-linejoin="round" stroke-linecap="butt"/>', |
| 722 | ); |
| 723 | } |
| 724 | // Mittellinien |
| 725 | for (const linie of p.lageplan.linien) { |
| 726 | teile.push( |
| 727 | `<polyline points="${linie.punkte.map(m).join(' ')}" fill="none" ` + |
| 728 | `stroke="${farbe[linie.movement] ?? '#888'}" stroke-width="0.7" stroke-linejoin="round"/>`, |
| 729 | ); |
| 730 | } |
| 731 | // Haltlinien |
| 732 | for (const h of p.lageplan.haltlinien) { |
| 733 | teile.push( |
| 734 | `<line x1="${(h.von.x / 10).toFixed(1)}" y1="${(h.von.y / 10).toFixed(1)}" ` + |
| 735 | `x2="${(h.bis.x / 10).toFixed(1)}" y2="${(h.bis.y / 10).toFixed(1)}" ` + |
| 736 | `stroke="${h.art === 'querung' ? '#4a5568' : '#111827'}" stroke-width="1.6" ` + |
| 737 | `${h.art === 'querung' ? 'stroke-dasharray="3 2" ' : ''}stroke-linecap="round"/>`, |
| 738 | ); |
| 739 | teile.push( |
| 740 | `<text x="${(h.bis.x / 10 + 1).toFixed(1)}" y="${(h.bis.y / 10 - 1).toFixed(1)}" ` + |
| 741 | 'font-family="system-ui,sans-serif" font-size="2.4" fill="#111827">' + |
| 742 | `${h.name}</text>`, |
| 743 | ); |
| 744 | } |
| 745 | return ( |
| 746 | `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${s} ${s}" width="820" height="820">` + |
| 747 | teile.join('') + |
| 748 | `<text x="2" y="${s - 2}" font-family="system-ui,sans-serif" font-size="2.6" fill="#111827">` + |
| 749 | `${p.lageplan.linien.length} Stroeme an ${p.lageplan.haltlinien.length} Haltlinien</text>` + |
| 750 | '</svg>' |
| 751 | ); |
| 752 | } |
| 753 | |
| 754 | writeFileSync(resolve('beispiele/vierarmiger-knotenpunkt.svg'), baueSvg(fertig), 'utf-8'); |
| 755 | |
| 756 | // Projektdatei ablegen, damit sie sich in der Anwendung oeffnen laesst. |
| 757 | const ziel = resolve(process.argv[2] ?? 'beispiele/vierarmiger-knotenpunkt.lsap'); |
| 758 | mkdirSync(dirname(ziel), { recursive: true }); |
| 759 | writeFileSync(ziel, JSON.stringify(fertig, null, 2), 'utf-8'); |
| 760 | console.log(''); |
| 761 | console.log(`Projektdatei: ${ziel}`); |