lsa-planer

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

/ src render zeitWegDrawing.ts

10,4 KB Rohdatei
src/render/zeitWegDrawing.ts — 284 Zeilen
1 import { fitText, type Surface } from './surface';
2 import type { Koordinierungslage } from '@/domain/plan/koordinierung';
3 import { modulo } from '@/domain/rilsa/coordination';
4
5 /**
6 * Das Zeit-Weg-Diagramm einer Gruenen Welle.
7 *
8 * WAS ES ZEIGT: waagerecht die Zeit ueber zwei Umlaeufe, senkrecht den Weg
9 * entlang des Strassenzugs. Je Anlage eine Linie mit ihren Freigabefenstern,
10 * darueber das Band - die Menge der Startzeiten, mit denen ein Fahrzeug ohne
11 * Halt durchkommt. Das Band ist eine FLAECHE und keine Linie: Seine Ausdehnung
12 * in der Zeit IST die Aussage.
13 *
14 * ZWEI UMLAEUFE UND NICHT EINER: Ein Band, das ueber das Umlaufende
15 * hinausreicht, waere sonst in zwei Stuecke zerschnitten, und der Betrachter
16 * saehe zwei schmale statt eines breiten.
17 *
18 * WAS AUS FRUEHEREN UMLAEUFEN HEREINREICHT, STEHT AUCH IM BILD
19 * (Fassung 5.43.0): ein Freigabefenster, das ueber das Umlaufende laeuft,
20 * am Anfang der Zeitachse, und ein Band, das mehr als einen Umlauf vor dem Bild
21 * losgefahren ist, dort, wo es ankommt. Frueher fehlte das Stueck des
22 * Fensters immer, das des Bandes, sobald Beginn, Breite und Reisezeit zusammen
23 * zwei Umlaeufe ueberstiegen - auch bei einer Reisezeit unter einem Umlauf;
24 * am Beispielknoten zeigte der Signalzeitenplan fuer K1 "91–44",
25 * und das Diagramm zwischen 0 und 44 s keine Freigabe. Umgekehrt bleibt das
26 * Band auf der Zeichenflaeche: `Surface` kennt kein Zuschneiden, deshalb
27 * schneidet `zeichneBand` selbst.
28 *
29 * WIE DER SIGNALZEITENPLAN wird auch dieses Bild genau EINMAL beschrieben und
30 * ueber `Surface` auf Bildschirm und Papier ausgegeben. Der Altbestand hatte
31 * fuer den Signalzeitenplan zwei Zeichenroutinen, und sie liefen auseinander.
32 */
33
34 const RAND = { links: 96, rechts: 16, oben: 26, unten: 30 } as const;
35
36 const FARBEN = {
37 achse: '#8a93a5',
38 raster: '#e3e7ee',
39 linie: '#1b2230',
40 freigabe: '#2f9e5a',
41 band: '#2f9e5a44',
42 gegenband: '#0b5c8a44',
43 text: '#1b2230',
44 leise: '#5a6478',
45 } as const;
46
47 /** Wie viele Umlaeufe die Zeitachse zeigt. */
48 export const UMLAEUFE_IM_BILD = 2;
49
50 export interface ZeitWegOptionen {
51 /** Die Gegenrichtung mitzeichnen, wo sie erfasst ist. */
52 readonly mitGegenrichtung?: boolean;
53 }
54
55 export function drawZeitWeg(
56 surface: Surface,
57 lage: Koordinierungslage,
58 optionen: ZeitWegOptionen = {},
59 ): void {
60 const ergebnis = lage.ergebnis;
61 if (ergebnis === null || lage.kette.length < 2) return;
62 const tU = ergebnis.cycleTime;
63 const gesamtweg =
64 ergebnis.intersections[ergebnis.intersections.length - 1]?.cumulativeDistance ?? 0;
65 if (tU <= 0 || gesamtweg <= 0) return;
66
67 const flaeche = {
68 x: RAND.links,
69 y: RAND.oben,
70 breite: surface.width - RAND.links - RAND.rechts,
71 hoehe: surface.height - RAND.oben - RAND.unten,
72 };
73 if (flaeche.breite <= 0 || flaeche.hoehe <= 0) return;
74
75 const zeitspanne = tU * UMLAEUFE_IM_BILD;
76 const x = (t: number): number => flaeche.x + (t / zeitspanne) * flaeche.breite;
77 // Der erste Knotenpunkt liegt UNTEN: Der Weg waechst nach oben, wie in jeder
78 // Darstellung eines Strassenzugs.
79 const y = (weg: number): number => flaeche.y + flaeche.hoehe - (weg / gesamtweg) * flaeche.hoehe;
80
81 surface.rect(flaeche.x, flaeche.y, flaeche.breite, flaeche.hoehe, '#ffffff');
82 surface.strokeRect(flaeche.x, flaeche.y, flaeche.breite, flaeche.hoehe, FARBEN.achse, 1);
83
84 // Umlaufgrenzen als senkrechte Hilfslinien, jede mit ihrer Sekunde.
85 for (let k = 0; k <= UMLAEUFE_IM_BILD; k += 1) {
86 const t = k * tU;
87 surface.dashedLine(x(t), flaeche.y, x(t), flaeche.y + flaeche.hoehe, FARBEN.raster, 1);
88 surface.text(`${String(Math.round(t))} s`, x(t), flaeche.y + flaeche.hoehe + 12, {
89 size: 8,
90 color: FARBEN.leise,
91 align: 'mitte',
92 });
93 }
94
95 const reisezeit = ergebnis.intersections[ergebnis.intersections.length - 1]?.travelTime ?? 0;
96
97 // Das Band zuerst: Die Freigabebalken der Anlagen liegen darueber und
98 // bleiben lesbar.
99 if (ergebnis.hinrichtung.breite > 0) {
100 zeichneBand(surface, {
101 x,
102 y,
103 gesamtweg,
104 tU,
105 zeitspanne,
106 beginn: ergebnis.hinrichtung.beginn,
107 breite: ergebnis.hinrichtung.breite,
108 reisezeit,
109 rueckwaerts: false,
110 farbe: FARBEN.band,
111 });
112 }
113 const gegen = ergebnis.rueckrichtung;
114 if (optionen.mitGegenrichtung === true && gegen !== null && gegen.breite > 0) {
115 zeichneBand(surface, {
116 x,
117 y,
118 gesamtweg,
119 tU,
120 zeitspanne,
121 beginn: gegen.beginn,
122 breite: gegen.breite,
123 reisezeit,
124 rueckwaerts: true,
125 farbe: FARBEN.gegenband,
126 });
127 }
128
129 for (const [index, glied] of lage.kette.entries()) {
130 const weg = ergebnis.intersections[index]?.cumulativeDistance ?? 0;
131 const linie = y(weg);
132 surface.line(flaeche.x, linie, flaeche.x + flaeche.breite, linie, FARBEN.raster, 1);
133
134 /*
135 * Freigabefenster, in jedem sichtbaren Umlauf einmal - ab dem Umlauf VOR
136 * dem Bild, denn ein Fenster, das ueber das Umlaufende laeuft, reicht mit
137 * seinem Schluss an den Anfang der Zeitachse.
138 *
139 * DER BEGINN WIRD ZUERST IN DEN UMLAUF GEFALTET, mit demselben `modulo`,
140 * mit dem die Koordinierung rechnet: "Freigabe ab" einer Nachbaranlage darf
141 * die Umlaufzeit uebersteigen, und ohne die Faltung laege ein Beginn von
142 * 130 s bei 60 s Umlaufzeit nur einmal im Bild, einer von 300 s gar nicht -
143 * waehrend die Bandbreite mit dem gefalteten Fenster gerechnet ist.
144 */
145 const beginnImUmlauf = modulo(glied.freigabe.start, tU);
146 for (let k = -1; k <= UMLAEUFE_IM_BILD; k += 1) {
147 const start = beginnImUmlauf + k * tU;
148 const ende = Math.min(start + glied.freigabe.duration, zeitspanne);
149 if (start >= zeitspanne || ende <= 0) continue;
150 const von = Math.max(0, start);
151 surface.rect(x(von), linie - 3, Math.max(1, x(ende) - x(von)), 6, FARBEN.freigabe);
152 }
153
154 const beschriftung = fitText(surface, glied.name, RAND.links - 8, { size: 8 });
155 surface.text(beschriftung, flaeche.x - 6, linie, {
156 size: 8,
157 color: glied.eigener ? FARBEN.text : FARBEN.leise,
158 align: 'rechts',
159 baseline: 'mitte',
160 });
161 surface.text(`${String(Math.round(weg))} m`, flaeche.x + flaeche.breite + 2, linie, {
162 size: 7,
163 color: FARBEN.leise,
164 baseline: 'mitte',
165 });
166 }
167
168 surface.text('Zeit', flaeche.x + flaeche.breite / 2, surface.height - 6, {
169 size: 8,
170 color: FARBEN.leise,
171 align: 'mitte',
172 });
173 surface.text('Weg entlang des Straßenzugs', flaeche.x, flaeche.y - 10, {
174 size: 8,
175 color: FARBEN.leise,
176 });
177 }
178
179 interface Bandwerte {
180 readonly x: (t: number) => number;
181 readonly y: (weg: number) => number;
182 readonly gesamtweg: number;
183 readonly tU: number;
184 /** Laenge der Zeitachse; das Band wird auf [0, zeitspanne] zugeschnitten. */
185 readonly zeitspanne: number;
186 readonly beginn: number;
187 readonly breite: number;
188 readonly reisezeit: number;
189 readonly rueckwaerts: boolean;
190 readonly farbe: string;
191 }
192
193 /**
194 * Das Band als Parallelogramm, in jedem sichtbaren Umlauf einmal.
195 *
196 * Die Hinrichtung laeuft vom ersten zum letzten Knotenpunkt (unten nach oben),
197 * die Gegenrichtung umgekehrt. Ihre Schraege ist deshalb gespiegelt - und
198 * genau daran sieht ein Betrachter den Zielkonflikt: Zwei Baender, die sich
199 * kreuzen, lassen sich nicht beide breit machen.
200 *
201 * AB WELCHEM UMLAUF: Das Stueck des Umlaufs k faehrt am Bezugspunkt bei
202 * Beginn + k·tU los und ist am Ende der Kette bei Beginn + Breite + Reisezeit
203 * + k·tU vorbei. Ob es noch im Bild ankommt, haengt an diesen drei ZUSAMMEN,
204 * nicht an der Reisezeit allein. Die Schleife beginnt bei
205 * k = -1 - ceil(Reisezeit / tU) - ohne Reisezeit der Umlauf vor dem Bild, je
206 * angefangenem Umlauf Reisezeit einer frueher. Das reicht, solange Beginn und
207 * Breite zusammen zwei Umlaeufe nicht uebersteigen: Die Rechnung faltet den
208 * Beginn in den Umlauf und begrenzt die Breite auf einen (`bandAus` in
209 * rilsa/coordination.ts). Was aus diesen Umlaeufen doch nicht ins Bild
210 * faellt, laesst der Zuschnitt fort.
211 *
212 * ZUGESCHNITTEN auf die Zeitachse [0, zeitspanne], als Vieleck in Zeit und Weg,
213 * bevor es in Bildlage umgerechnet wird. Am Rand wird aus dem Parallelogramm
214 * ein Drei- bis Sechseck, denn jede der beiden Kanten fuegt hoechstens eine
215 * Ecke hinzu; was auf die Zeitachse faellt, bleibt stehen.
216 */
217 function zeichneBand(surface: Surface, w: Bandwerte): void {
218 const vonWeg = w.rueckwaerts ? w.gesamtweg : 0;
219 const bisWeg = w.rueckwaerts ? 0 : w.gesamtweg;
220 const umlaeufeUnterwegs =
221 Number.isFinite(w.reisezeit) && w.reisezeit > 0 ? Math.ceil(w.reisezeit / w.tU) : 0;
222 for (let k = -1 - umlaeufeUnterwegs; k <= UMLAEUFE_IM_BILD; k += 1) {
223 const t0 = w.beginn + k * w.tU;
224 const ecken = aufZeitachse(
225 [
226 { t: t0, weg: vonWeg },
227 { t: t0 + w.breite, weg: vonWeg },
228 { t: t0 + w.breite + w.reisezeit, weg: bisWeg },
229 { t: t0 + w.reisezeit, weg: bisWeg },
230 ],
231 w.zeitspanne,
232 );
233 if (ecken.length < 3 || flaecheninhalt(ecken) <= 1e-9) continue;
234 surface.polygon(
235 ecken.map((p) => ({ x: w.x(p.t), y: w.y(p.weg) })),
236 w.farbe,
237 );
238 }
239 }
240
241 interface Eckpunkt {
242 readonly t: number;
243 readonly weg: number;
244 }
245
246 /**
247 * Ein Vieleck, beschnitten an den senkrechten Kanten t = 0 und t = zeitspanne
248 * (Sutherland-Hodgman, je Kante ein Durchgang). Ein konvexes Vieleck bleibt
249 * dabei konvex und in seiner Umlaufrichtung.
250 */
251 function aufZeitachse(ecken: readonly Eckpunkt[], zeitspanne: number): Eckpunkt[] {
252 const links = anKante(ecken, 0, (t) => t >= 0);
253 return anKante(links, zeitspanne, (t) => t <= zeitspanne);
254 }
255
256 function anKante(
257 ecken: readonly Eckpunkt[],
258 kante: number,
259 innen: (t: number) => boolean,
260 ): Eckpunkt[] {
261 const ergebnis: Eckpunkt[] = [];
262 for (const [index, aktuell] of ecken.entries()) {
263 const vorher = ecken[(index + ecken.length - 1) % ecken.length];
264 if (vorher === undefined) continue;
265 const aktuellInnen = innen(aktuell.t);
266 if (aktuellInnen !== innen(vorher.t)) {
267 const anteil = (kante - vorher.t) / (aktuell.t - vorher.t);
268 ergebnis.push({ t: kante, weg: vorher.weg + anteil * (aktuell.weg - vorher.weg) });
269 }
270 if (aktuellInnen) ergebnis.push(aktuell);
271 }
272 return ergebnis;
273 }
274
275 /** Betrag des Flaecheninhalts in Sekunden mal Meter (Gausssche Trapezformel). */
276 function flaecheninhalt(ecken: readonly Eckpunkt[]): number {
277 let summe = 0;
278 for (const [index, a] of ecken.entries()) {
279 const b = ecken[(index + 1) % ecken.length];
280 if (b === undefined) continue;
281 summe += a.t * b.weg - b.t * a.weg;
282 }
283 return Math.abs(summe) / 2;
284 }