lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tools vierarmigerKnotenpunkt.ts

28,6 KB Rohdatei
tools/vierarmigerKnotenpunkt.ts — 761 Zeilen
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}`);