import type { Seconds } from '../units'; import { roundTo } from '../units'; import type { SignalAspect } from '../rilsa/types'; import type { GreenInterval, PlannedSignalGroup } from './signalPlan'; /** * Ableitung der Signalbilder aus den Freigabezeitfenstern. * * Diese Datei ist die einzige Quelle dafuer, welches Signalbild eine * Signalgruppe zu einem Zeitpunkt zeigt. Darstellung des Signalzeitenplans, * Simulation und Ausdruck greifen alle darauf zu. Im Altbestand zeichnete die * Plandarstellung die Rot-Gelb-Phase VOR den Umlaufbeginn (also ausserhalb des * Diagramms, wo sie unsichtbar blieb), waehrend die Simulation sie ans Ende der * Rotzeit setzte - die beiden Ansichten widersprachen sich. * * Zeitliche Anordnung eines Freigabezeitfensters nach RiLSA: * * ... Rot ... | Rot-Gelb | ####### Freigabezeit ####### | Gelb | ... Rot ... * ^ start - tRotGelb ^ start + Dauer * * Die Zwischenzeit reicht vom Ende der Freigabezeit der raeumenden Gruppe bis * zum Beginn der Freigabezeit der einfahrenden Gruppe; Gelb der einen und * Rot-Gelb der anderen Gruppe liegen darin. */ export interface AspectSegment { readonly aspect: SignalAspect; readonly start: Seconds; readonly duration: Seconds; } /** Positiver Rest, auch fuer negative Eingaben. */ export function modulo(value: number, m: number): number { if (m <= 0) return 0; return ((value % m) + m) % m; } /** * Zerlegt den Umlauf einer Signalgruppe in Abschnitte gleichen Signalbilds. * Die Abschnitte sind nach Beginn sortiert, ueberschneiden sich nicht und * ergeben in Summe genau die Umlaufzeit. * * VORRANG BEI UEBERSCHNEIDUNG: Freigabe vor Gelb vor Rot-Gelb. * * Die Freigabezeitfenster sind die Aussage des Plans; Gelb und Rot-Gelb werden * daraus abgeleitet. Liegen zwei Fenster derselben Signalgruppe naeher * beieinander, als Gelb und Rot-Gelb lang sind, wird deshalb das Gelb bzw. das * Rot-Gelb gekuerzt - nie die Freigabe. Vorher wurden die Marken allein nach * Beginn geordnet und am fortlaufenden Cursor abgeschnitten; das kuerzte die * FREIGABE des zweiten Fensters um die Ueberschneidung (Befund F33). Die * Signalbilder wichen damit von denselben Fenstern ab, aus denen sie stammen: * `aspectTotals(...).gruen` war kleiner als `totalGreen` des Plans, waehrend * `isGreenAt` - und mit ihm Simulation und Gleichzeitigkeitspruefung - die * volle Freigabe meldete. */ export function aspectSegments( group: Pick, cycleTime: Seconds, ): AspectSegment[] { if (cycleTime <= 0) return []; // Dauerfreigabe. if (group.greens.length === 1 && (group.greens[0]?.duration ?? 0) >= cycleTime) { return [{ aspect: 'gruen', start: 0, duration: cycleTime }]; } if (group.greens.length === 0) { return [{ aspect: 'rot', start: 0, duration: cycleTime }]; } // Jedes Fenster liefert Rot-Gelb, Gruen und Gelb; die Luecken sind Rot. const gruen: Mark[] = []; const gelb: Mark[] = []; const rotgelb: Mark[] = []; for (const green of group.greens) { if (group.times.redYellow > 0) { rotgelb.push({ start: modulo(green.start - group.times.redYellow, cycleTime), duration: group.times.redYellow, aspect: 'rotgelb', }); } gruen.push({ start: modulo(green.start, cycleTime), duration: green.duration, aspect: 'gruen', }); if (group.times.yellow > 0) { gelb.push({ start: modulo(green.start + green.duration, cycleTime), duration: group.times.yellow, aspect: 'gelb', }); } } // In der Reihenfolge des Vorrangs belegen; was belegt ist, wird dem // nachrangigen Signalbild weggeschnitten. const belegt: AspectSegment[] = []; for (const stufe of [gruen, gelb, rotgelb]) { for (const segment of splitAtCycleEnd(stufe, cycleTime)) { belegt.push(...ohneUeberschneidung(segment, belegt)); } } return fillWithRed( belegt.sort((a, b) => a.start - b.start), cycleTime, ); } interface Mark { readonly start: Seconds; readonly duration: Seconds; readonly aspect: SignalAspect; } /** Was von `segment` uebrig bleibt, wenn die bereits belegten Zeiten entfallen. */ function ohneUeberschneidung( segment: AspectSegment, belegt: readonly AspectSegment[], ): AspectSegment[] { let stuecke: { start: Seconds; end: Seconds }[] = [ { start: segment.start, end: segment.start + segment.duration }, ]; for (const anderer of belegt) { const von = anderer.start; const bis = anderer.start + anderer.duration; const rest: { start: Seconds; end: Seconds }[] = []; for (const stueck of stuecke) { if (bis <= stueck.start || von >= stueck.end) { rest.push(stueck); continue; } if (von > stueck.start) rest.push({ start: stueck.start, end: von }); if (bis < stueck.end) rest.push({ start: bis, end: stueck.end }); } stuecke = rest; } return stuecke .filter((stueck) => stueck.end - stueck.start > 1e-6) .map((stueck) => ({ aspect: segment.aspect, start: stueck.start, duration: stueck.end - stueck.start, })); } /** Zerteilt Abschnitte, die ueber das Umlaufende hinausreichen. */ function splitAtCycleEnd(marks: readonly Mark[], cycleTime: Seconds): AspectSegment[] { const result: AspectSegment[] = []; for (const mark of marks) { if (mark.duration <= 0) continue; const end = mark.start + mark.duration; if (end <= cycleTime) { result.push({ aspect: mark.aspect, start: mark.start, duration: mark.duration }); } else { result.push({ aspect: mark.aspect, start: mark.start, duration: cycleTime - mark.start }); result.push({ aspect: mark.aspect, start: 0, duration: end - cycleTime }); } } return result.sort((a, b) => a.start - b.start); } /** * Fuellt die Luecken zwischen den Abschnitten mit Rot auf. * * Die Abschnitte kommen ueberschneidungsfrei herein (der Vorrang ist in * `aspectSegments` bereits aufgeloest); das Abschneiden am Cursor ist nur noch * die Absicherung der Zusage "ueberschneiden sich nicht". */ function fillWithRed(segments: readonly AspectSegment[], cycleTime: Seconds): AspectSegment[] { const result: AspectSegment[] = []; let cursor = 0; for (const segment of segments) { if (segment.duration <= 0) continue; if (segment.start > cursor + 1e-6) { result.push({ aspect: 'rot', start: roundTo(cursor, 3), duration: roundTo(segment.start - cursor, 3), }); } const start = Math.max(cursor, segment.start); const end = Math.min(cycleTime, segment.start + segment.duration); if (end > start) { result.push({ aspect: segment.aspect, start: roundTo(start, 3), duration: roundTo(end - start, 3), }); cursor = end; } } if (cursor < cycleTime - 1e-6) { result.push({ aspect: 'rot', start: roundTo(cursor, 3), duration: roundTo(cycleTime - cursor, 3), }); } return result; } /** Signalbild zu einem Zeitpunkt innerhalb des Umlaufs. */ export function aspectAt( group: Pick, time: Seconds, cycleTime: Seconds, ): SignalAspect { if (cycleTime <= 0) return 'dunkel'; const t = modulo(time, cycleTime); for (const segment of aspectSegments(group, cycleTime)) { if (t >= segment.start && t < segment.start + segment.duration) return segment.aspect; } return 'rot'; } /** Zeigt die Signalgruppe zu diesem Zeitpunkt Freigabe? */ export function isGreenAt( group: Pick, time: Seconds, cycleTime: Seconds, ): boolean { if (cycleTime <= 0) return false; const t = modulo(time, cycleTime); for (const green of group.greens) { const start = modulo(green.start, cycleTime); const end = start + green.duration; if (end <= cycleTime) { if (t >= start && t < end) return true; } else if (t >= start || t < end - cycleTime) { return true; } } return false; } /** * Farbwerte der Signalbilder fuer Darstellung und Ausdruck. * * BEWUSST UNVERAENDERT (Befund M5): Die Nachbarpaare bleiben untereinander * unter den 3:1 der WCAG 2.1, Ziffer 1.4.11 * - Rot/Gruen 1,40:1, Rot-Gelb/Gruen 1,29:1, Gruen/Gelb 1,86:1 -, und Gelb * kommt gegen die weisse Zeichenflaeche nur auf 1,84:1. Es sind die * Signalfarben, an denen ein Pruefer den Plan gegen die RiLSA liest; sie sind * nicht frei waehlbar, und die Ausnahme "wesentlich" der WCAG greift dafuer. * * Der Kontrast wird deshalb NEBEN der Farbe hergestellt: durch das Kuerzel im * Balken (ASPECT_KUERZEL, gleich darunter), durch die Trennlinie an jeder * Abschnittsgrenze (ABSCHNITTSGRENZE in src/render/signalPlanDrawing.ts) und * durch die Schriftfarbe darauf (TEXT_AUF_SIGNALBILD ebenda). Wer hier Werte * aendert, muss alle drei neu nachrechnen. */ export const ASPECT_COLORS: Readonly> = { rot: '#d13b2e', rotgelb: '#e08b1f', gruen: '#2e9e4f', gelb: '#e8b923', dunkel: '#555a63', 'gelb-blinkend': '#e8b923', }; /** * Kuerzel der Signalbilder fuer die Zeichnung. * * DER GRUND: Im Zeitdiagramm trug das Signalbild seine Aussage bisher * ausschliesslich ueber die Farbe. Der einzige Text im Balken war die Dauer - * also in jedem Abschnitt derselbe Zahlentyp. Wer Rot und Gruen nicht * auseinanderhalten kann, konnte den Plan nicht pruefen; im Schwarzweissdruck * konnte es niemand. * * Die Kuerzel sind die in der Signalplanung ueblichen und stehen zusaetzlich zur * Dauer im Balken. */ export const ASPECT_KUERZEL: Readonly> = { rot: 'R', rotgelb: 'RG', gruen: 'F', gelb: 'GE', dunkel: 'D', 'gelb-blinkend': 'GB', }; /** Beschriftungen der Signalbilder. */ export const ASPECT_LABELS: Readonly> = { rot: 'Rot', rotgelb: 'Rot-Gelb', gruen: 'Freigabe', gelb: 'Gelb', dunkel: 'Dunkel', 'gelb-blinkend': 'Gelb blinkend', }; /** Gesamtdauer eines Signalbilds im Umlauf - fuer die Plantabelle. */ export function aspectTotals( group: Pick, cycleTime: Seconds, ): Readonly> { const totals: Record = { rot: 0, rotgelb: 0, gruen: 0, gelb: 0, dunkel: 0, 'gelb-blinkend': 0, }; for (const segment of aspectSegments(group, cycleTime)) { totals[segment.aspect] = roundTo(totals[segment.aspect] + segment.duration, 3); } return totals; } /** Freigabezeitfenster, absteigend nach Dauer - fuer Kurzangaben im Bericht. */ export function primaryGreen(greens: readonly GreenInterval[]): GreenInterval | undefined { return [...greens].sort((a, b) => b.duration - a.duration)[0]; }