lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ src render zeitWegDrawing.ts
| 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 | } |