import { fitText, type Surface } from './surface'; import type { Koordinierungslage } from '@/domain/plan/koordinierung'; import { modulo } from '@/domain/rilsa/coordination'; /** * Das Zeit-Weg-Diagramm einer Gruenen Welle. * * WAS ES ZEIGT: waagerecht die Zeit ueber zwei Umlaeufe, senkrecht den Weg * entlang des Strassenzugs. Je Anlage eine Linie mit ihren Freigabefenstern, * darueber das Band - die Menge der Startzeiten, mit denen ein Fahrzeug ohne * Halt durchkommt. Das Band ist eine FLAECHE und keine Linie: Seine Ausdehnung * in der Zeit IST die Aussage. * * ZWEI UMLAEUFE UND NICHT EINER: Ein Band, das ueber das Umlaufende * hinausreicht, waere sonst in zwei Stuecke zerschnitten, und der Betrachter * saehe zwei schmale statt eines breiten. * * WAS AUS FRUEHEREN UMLAEUFEN HEREINREICHT, STEHT AUCH IM BILD * (Fassung 5.43.0): ein Freigabefenster, das ueber das Umlaufende laeuft, * am Anfang der Zeitachse, und ein Band, das mehr als einen Umlauf vor dem Bild * losgefahren ist, dort, wo es ankommt. Frueher fehlte das Stueck des * Fensters immer, das des Bandes, sobald Beginn, Breite und Reisezeit zusammen * zwei Umlaeufe ueberstiegen - auch bei einer Reisezeit unter einem Umlauf; * am Beispielknoten zeigte der Signalzeitenplan fuer K1 "91–44", * und das Diagramm zwischen 0 und 44 s keine Freigabe. Umgekehrt bleibt das * Band auf der Zeichenflaeche: `Surface` kennt kein Zuschneiden, deshalb * schneidet `zeichneBand` selbst. * * WIE DER SIGNALZEITENPLAN wird auch dieses Bild genau EINMAL beschrieben und * ueber `Surface` auf Bildschirm und Papier ausgegeben. Der Altbestand hatte * fuer den Signalzeitenplan zwei Zeichenroutinen, und sie liefen auseinander. */ const RAND = { links: 96, rechts: 16, oben: 26, unten: 30 } as const; const FARBEN = { achse: '#8a93a5', raster: '#e3e7ee', linie: '#1b2230', freigabe: '#2f9e5a', band: '#2f9e5a44', gegenband: '#0b5c8a44', text: '#1b2230', leise: '#5a6478', } as const; /** Wie viele Umlaeufe die Zeitachse zeigt. */ export const UMLAEUFE_IM_BILD = 2; export interface ZeitWegOptionen { /** Die Gegenrichtung mitzeichnen, wo sie erfasst ist. */ readonly mitGegenrichtung?: boolean; } export function drawZeitWeg( surface: Surface, lage: Koordinierungslage, optionen: ZeitWegOptionen = {}, ): void { const ergebnis = lage.ergebnis; if (ergebnis === null || lage.kette.length < 2) return; const tU = ergebnis.cycleTime; const gesamtweg = ergebnis.intersections[ergebnis.intersections.length - 1]?.cumulativeDistance ?? 0; if (tU <= 0 || gesamtweg <= 0) return; const flaeche = { x: RAND.links, y: RAND.oben, breite: surface.width - RAND.links - RAND.rechts, hoehe: surface.height - RAND.oben - RAND.unten, }; if (flaeche.breite <= 0 || flaeche.hoehe <= 0) return; const zeitspanne = tU * UMLAEUFE_IM_BILD; const x = (t: number): number => flaeche.x + (t / zeitspanne) * flaeche.breite; // Der erste Knotenpunkt liegt UNTEN: Der Weg waechst nach oben, wie in jeder // Darstellung eines Strassenzugs. const y = (weg: number): number => flaeche.y + flaeche.hoehe - (weg / gesamtweg) * flaeche.hoehe; surface.rect(flaeche.x, flaeche.y, flaeche.breite, flaeche.hoehe, '#ffffff'); surface.strokeRect(flaeche.x, flaeche.y, flaeche.breite, flaeche.hoehe, FARBEN.achse, 1); // Umlaufgrenzen als senkrechte Hilfslinien, jede mit ihrer Sekunde. for (let k = 0; k <= UMLAEUFE_IM_BILD; k += 1) { const t = k * tU; surface.dashedLine(x(t), flaeche.y, x(t), flaeche.y + flaeche.hoehe, FARBEN.raster, 1); surface.text(`${String(Math.round(t))} s`, x(t), flaeche.y + flaeche.hoehe + 12, { size: 8, color: FARBEN.leise, align: 'mitte', }); } const reisezeit = ergebnis.intersections[ergebnis.intersections.length - 1]?.travelTime ?? 0; // Das Band zuerst: Die Freigabebalken der Anlagen liegen darueber und // bleiben lesbar. if (ergebnis.hinrichtung.breite > 0) { zeichneBand(surface, { x, y, gesamtweg, tU, zeitspanne, beginn: ergebnis.hinrichtung.beginn, breite: ergebnis.hinrichtung.breite, reisezeit, rueckwaerts: false, farbe: FARBEN.band, }); } const gegen = ergebnis.rueckrichtung; if (optionen.mitGegenrichtung === true && gegen !== null && gegen.breite > 0) { zeichneBand(surface, { x, y, gesamtweg, tU, zeitspanne, beginn: gegen.beginn, breite: gegen.breite, reisezeit, rueckwaerts: true, farbe: FARBEN.gegenband, }); } for (const [index, glied] of lage.kette.entries()) { const weg = ergebnis.intersections[index]?.cumulativeDistance ?? 0; const linie = y(weg); surface.line(flaeche.x, linie, flaeche.x + flaeche.breite, linie, FARBEN.raster, 1); /* * Freigabefenster, in jedem sichtbaren Umlauf einmal - ab dem Umlauf VOR * dem Bild, denn ein Fenster, das ueber das Umlaufende laeuft, reicht mit * seinem Schluss an den Anfang der Zeitachse. * * DER BEGINN WIRD ZUERST IN DEN UMLAUF GEFALTET, mit demselben `modulo`, * mit dem die Koordinierung rechnet: "Freigabe ab" einer Nachbaranlage darf * die Umlaufzeit uebersteigen, und ohne die Faltung laege ein Beginn von * 130 s bei 60 s Umlaufzeit nur einmal im Bild, einer von 300 s gar nicht - * waehrend die Bandbreite mit dem gefalteten Fenster gerechnet ist. */ const beginnImUmlauf = modulo(glied.freigabe.start, tU); for (let k = -1; k <= UMLAEUFE_IM_BILD; k += 1) { const start = beginnImUmlauf + k * tU; const ende = Math.min(start + glied.freigabe.duration, zeitspanne); if (start >= zeitspanne || ende <= 0) continue; const von = Math.max(0, start); surface.rect(x(von), linie - 3, Math.max(1, x(ende) - x(von)), 6, FARBEN.freigabe); } const beschriftung = fitText(surface, glied.name, RAND.links - 8, { size: 8 }); surface.text(beschriftung, flaeche.x - 6, linie, { size: 8, color: glied.eigener ? FARBEN.text : FARBEN.leise, align: 'rechts', baseline: 'mitte', }); surface.text(`${String(Math.round(weg))} m`, flaeche.x + flaeche.breite + 2, linie, { size: 7, color: FARBEN.leise, baseline: 'mitte', }); } surface.text('Zeit', flaeche.x + flaeche.breite / 2, surface.height - 6, { size: 8, color: FARBEN.leise, align: 'mitte', }); surface.text('Weg entlang des Straßenzugs', flaeche.x, flaeche.y - 10, { size: 8, color: FARBEN.leise, }); } interface Bandwerte { readonly x: (t: number) => number; readonly y: (weg: number) => number; readonly gesamtweg: number; readonly tU: number; /** Laenge der Zeitachse; das Band wird auf [0, zeitspanne] zugeschnitten. */ readonly zeitspanne: number; readonly beginn: number; readonly breite: number; readonly reisezeit: number; readonly rueckwaerts: boolean; readonly farbe: string; } /** * Das Band als Parallelogramm, in jedem sichtbaren Umlauf einmal. * * Die Hinrichtung laeuft vom ersten zum letzten Knotenpunkt (unten nach oben), * die Gegenrichtung umgekehrt. Ihre Schraege ist deshalb gespiegelt - und * genau daran sieht ein Betrachter den Zielkonflikt: Zwei Baender, die sich * kreuzen, lassen sich nicht beide breit machen. * * AB WELCHEM UMLAUF: Das Stueck des Umlaufs k faehrt am Bezugspunkt bei * Beginn + k·tU los und ist am Ende der Kette bei Beginn + Breite + Reisezeit * + k·tU vorbei. Ob es noch im Bild ankommt, haengt an diesen drei ZUSAMMEN, * nicht an der Reisezeit allein. Die Schleife beginnt bei * k = -1 - ceil(Reisezeit / tU) - ohne Reisezeit der Umlauf vor dem Bild, je * angefangenem Umlauf Reisezeit einer frueher. Das reicht, solange Beginn und * Breite zusammen zwei Umlaeufe nicht uebersteigen: Die Rechnung faltet den * Beginn in den Umlauf und begrenzt die Breite auf einen (`bandAus` in * rilsa/coordination.ts). Was aus diesen Umlaeufen doch nicht ins Bild * faellt, laesst der Zuschnitt fort. * * ZUGESCHNITTEN auf die Zeitachse [0, zeitspanne], als Vieleck in Zeit und Weg, * bevor es in Bildlage umgerechnet wird. Am Rand wird aus dem Parallelogramm * ein Drei- bis Sechseck, denn jede der beiden Kanten fuegt hoechstens eine * Ecke hinzu; was auf die Zeitachse faellt, bleibt stehen. */ function zeichneBand(surface: Surface, w: Bandwerte): void { const vonWeg = w.rueckwaerts ? w.gesamtweg : 0; const bisWeg = w.rueckwaerts ? 0 : w.gesamtweg; const umlaeufeUnterwegs = Number.isFinite(w.reisezeit) && w.reisezeit > 0 ? Math.ceil(w.reisezeit / w.tU) : 0; for (let k = -1 - umlaeufeUnterwegs; k <= UMLAEUFE_IM_BILD; k += 1) { const t0 = w.beginn + k * w.tU; const ecken = aufZeitachse( [ { t: t0, weg: vonWeg }, { t: t0 + w.breite, weg: vonWeg }, { t: t0 + w.breite + w.reisezeit, weg: bisWeg }, { t: t0 + w.reisezeit, weg: bisWeg }, ], w.zeitspanne, ); if (ecken.length < 3 || flaecheninhalt(ecken) <= 1e-9) continue; surface.polygon( ecken.map((p) => ({ x: w.x(p.t), y: w.y(p.weg) })), w.farbe, ); } } interface Eckpunkt { readonly t: number; readonly weg: number; } /** * Ein Vieleck, beschnitten an den senkrechten Kanten t = 0 und t = zeitspanne * (Sutherland-Hodgman, je Kante ein Durchgang). Ein konvexes Vieleck bleibt * dabei konvex und in seiner Umlaufrichtung. */ function aufZeitachse(ecken: readonly Eckpunkt[], zeitspanne: number): Eckpunkt[] { const links = anKante(ecken, 0, (t) => t >= 0); return anKante(links, zeitspanne, (t) => t <= zeitspanne); } function anKante( ecken: readonly Eckpunkt[], kante: number, innen: (t: number) => boolean, ): Eckpunkt[] { const ergebnis: Eckpunkt[] = []; for (const [index, aktuell] of ecken.entries()) { const vorher = ecken[(index + ecken.length - 1) % ecken.length]; if (vorher === undefined) continue; const aktuellInnen = innen(aktuell.t); if (aktuellInnen !== innen(vorher.t)) { const anteil = (kante - vorher.t) / (aktuell.t - vorher.t); ergebnis.push({ t: kante, weg: vorher.weg + anteil * (aktuell.weg - vorher.weg) }); } if (aktuellInnen) ergebnis.push(aktuell); } return ergebnis; } /** Betrag des Flaecheninhalts in Sekunden mal Meter (Gausssche Trapezformel). */ function flaecheninhalt(ecken: readonly Eckpunkt[]): number { let summe = 0; for (const [index, a] of ecken.entries()) { const b = ecken[(index + 1) % ecken.length]; if (b === undefined) continue; summe += a.t * b.weg - b.t * a.weg; } return Math.abs(summe) / 2; }