lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
| 1 | import type { AppSettings } from '@/services/storage'; |
| 2 | |
| 3 | /** |
| 4 | * Helles und dunkles Erscheinungsbild. |
| 5 | * |
| 6 | * Genau ein Schalter: das Attribut `data-erscheinungsbild` am Wurzelelement. |
| 7 | * Alle Farben haengen an CSS-Variablen. Der Altbestand setzte teils Klassen, |
| 8 | * teils Inline-Farben und teils ein zweites Stylesheet; einzelne Bereiche |
| 9 | * blieben dadurch im dunklen Erscheinungsbild hell und unlesbar. |
| 10 | */ |
| 11 | |
| 12 | export type ThemeSetting = AppSettings['theme']; |
| 13 | |
| 14 | /** Das Erscheinungsbild, das tatsaechlich gilt. */ |
| 15 | export type Erscheinungsbild = 'hell' | 'dunkel'; |
| 16 | |
| 17 | type Abmeldung = () => void; |
| 18 | |
| 19 | let abmeldungen: Abmeldung[] = []; |
| 20 | |
| 21 | /** |
| 22 | * Empfaenger fuer den Wechsel des geltenden Erscheinungsbilds. |
| 23 | * |
| 24 | * Das Attribut allein genuegt nicht: Alles, was in CSS-Variablen haengt, folgt |
| 25 | * ihm von selbst - eine Zeichenflaeche nicht. Sie liest die Farben beim |
| 26 | * Zeichnen und behaelt sie bis zum naechsten Mal. Wechselt das Erscheinungsbild |
| 27 | * im Betrieb, stand die Zeichnung deshalb in den alten Farben in einer schon |
| 28 | * umgestellten Oberflaeche. |
| 29 | */ |
| 30 | const wechselempfaenger = new Set<(erscheinungsbild: Erscheinungsbild) => void>(); |
| 31 | |
| 32 | /** Wendet die Einstellung an und folgt Systemvorgabe und Kontrastdesign. */ |
| 33 | export function applyTheme(setting: ThemeSetting): void { |
| 34 | loeseEmpfaenger(); |
| 35 | |
| 36 | const kontrastdesign = medienabfrage('(forced-colors: active)'); |
| 37 | const systemDunkel = medienabfrage('(prefers-color-scheme: dark)'); |
| 38 | |
| 39 | const anwenden = (): void => { |
| 40 | setAttribute( |
| 41 | effektivesErscheinungsbild( |
| 42 | setting, |
| 43 | kontrastdesign?.matches === true, |
| 44 | systemDunkel?.matches === true, |
| 45 | ), |
| 46 | ); |
| 47 | }; |
| 48 | |
| 49 | anwenden(); |
| 50 | |
| 51 | // Beide Abfragen koennen sich im Betrieb aendern: das Erscheinungsbild des |
| 52 | // Systems ohnehin, das Kontrastdesign beim Umschalten in den |
| 53 | // Windows-Einstellungen. Beobachtet werden sie deshalb unabhaengig von der |
| 54 | // gewaehlten Einstellung - bei "Hell" und "Dunkel" wirkt sich nur der Fall |
| 55 | // Kontrastdesign aus, und genau der ist der Grund fuer diesen Umbau. |
| 56 | beobachte(kontrastdesign, anwenden); |
| 57 | beobachte(systemDunkel, anwenden); |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * Meldet einen Empfaenger an, der bei jedem Wechsel gerufen wird. |
| 62 | * |
| 63 | * Gerufen wird nur, wenn sich das geltende Erscheinungsbild wirklich aendert - |
| 64 | * ein Rundlauf von "Dunkel" auf "System" bei dunklem System aendert die |
| 65 | * Einstellung, aber keine Farbe, und ein Neuzeichnen waere dort nur ein |
| 66 | * unnoetiger Fokussprung. |
| 67 | * |
| 68 | * Die Rueckgabe meldet wieder ab. |
| 69 | */ |
| 70 | export function beiErscheinungsbildwechsel( |
| 71 | empfaenger: (erscheinungsbild: Erscheinungsbild) => void, |
| 72 | ): () => void { |
| 73 | wechselempfaenger.add(empfaenger); |
| 74 | return () => { |
| 75 | wechselempfaenger.delete(empfaenger); |
| 76 | }; |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Welches Erscheinungsbild tatsaechlich gilt. |
| 81 | * |
| 82 | * Befund L14 (EN 301 549 Kap. 11.7): Die einzige Eingangsgroesse fuer die |
| 83 | * Farbwahl der Zeichenflaechen ist dieses Attribut (`planView`, |
| 84 | * `simulationView`, `lageplanView` lesen `dataset['erscheinungsbild']`). Ein |
| 85 | * `canvas` wird von `forced-colors: active` nicht erfasst - das Betriebssystem |
| 86 | * ersetzt dort keine Farbe. Wer also ein dunkles Kontrastdesign benutzt und in |
| 87 | * den Vorgaben ausdruecklich "Hell" gewaehlt hat, bekam eine weisse Zeichnung |
| 88 | * mitten in einer schwarzen Oberflaeche. Umgekehrt ebenso. |
| 89 | * |
| 90 | * Bei aktivem Kontrastdesign uebersteuert das System deshalb die eigene Wahl: |
| 91 | * Die Oberflaeche wird ohnehin vollstaendig in den Systemfarben gezeichnet, die |
| 92 | * Wahl "Hell"/"Dunkel" ist dort ohne sichtbare Wirkung, und die Zeichenflaeche |
| 93 | * soll zu dem passen, was daneben steht. Ob das Kontrastdesign hell oder dunkel |
| 94 | * ist, sagt `prefers-color-scheme`: Bei aktivem Kontrastdesign richtet sich die |
| 95 | * Abfrage nach der Hintergrundfarbe der Systempalette (CSS Color Adjust 1). |
| 96 | * |
| 97 | * Traefe diese Annahme einmal nicht zu, bliebe es beim heutigen Verhalten - die |
| 98 | * Zeichnung waere dann so falsch wie zuvor, nicht falscher. |
| 99 | */ |
| 100 | export function effektivesErscheinungsbild( |
| 101 | setting: ThemeSetting, |
| 102 | kontrastdesign: boolean, |
| 103 | systemDunkel: boolean, |
| 104 | ): Erscheinungsbild { |
| 105 | if (kontrastdesign) return systemDunkel ? 'dunkel' : 'hell'; |
| 106 | if (setting === 'system') return systemDunkel ? 'dunkel' : 'hell'; |
| 107 | return setting; |
| 108 | } |
| 109 | |
| 110 | /** Naechste Einstellung im Rundlauf hell -> dunkel -> System. */ |
| 111 | export function nextTheme(current: ThemeSetting): ThemeSetting { |
| 112 | return current === 'hell' ? 'dunkel' : current === 'dunkel' ? 'system' : 'hell'; |
| 113 | } |
| 114 | |
| 115 | export function themeLabel(setting: ThemeSetting): string { |
| 116 | return setting === 'hell' ? 'Hell' : setting === 'dunkel' ? 'Dunkel' : 'System'; |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * Setzt das Attribut und sagt Bescheid, wenn sich dabei etwas geaendert hat. |
| 121 | * |
| 122 | * Der Vorher-Wert wird am Attribut selbst abgelesen und nicht daneben |
| 123 | * mitgeschrieben: Zwei Buchfuehrungen ueber dasselbe laufen auseinander, und |
| 124 | * das Attribut ist die Quelle, aus der auch die Zeichenflaechen lesen. |
| 125 | */ |
| 126 | function setAttribute(value: Erscheinungsbild): void { |
| 127 | const wurzel = document.documentElement; |
| 128 | const vorher = wurzel.dataset['erscheinungsbild']; |
| 129 | wurzel.dataset['erscheinungsbild'] = value; |
| 130 | if (vorher === value) return; |
| 131 | for (const empfaenger of [...wechselempfaenger]) { |
| 132 | try { |
| 133 | empfaenger(value); |
| 134 | } catch (error) { |
| 135 | // Ein Fehler in einer Ansicht darf die uebrigen nicht mitreissen - und |
| 136 | // schon gar nicht den Programmstart, in dem `applyTheme` als eine der |
| 137 | // ersten Anweisungen laeuft. |
| 138 | console.error('Fehler beim Wechsel des Erscheinungsbilds:', error); |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | /** |
| 144 | * Medienabfrage, sofern die Umgebung sie kennt. |
| 145 | * |
| 146 | * In der Testumgebung und in aelteren Anzeigen fehlt `matchMedia`. Ohne diese |
| 147 | * Pruefung braeche das Setzen des Erscheinungsbilds dort mit einem Fehler ab - |
| 148 | * und mit ihm der ganze Programmstart, denn `applyTheme` laeuft als eine der |
| 149 | * ersten Anweisungen. |
| 150 | */ |
| 151 | function medienabfrage(abfrage: string): MediaQueryList | null { |
| 152 | if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return null; |
| 153 | try { |
| 154 | return window.matchMedia(abfrage); |
| 155 | } catch { |
| 156 | return null; |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | function beobachte(liste: MediaQueryList | null, anwenden: () => void): void { |
| 161 | if (liste === null || typeof liste.addEventListener !== 'function') return; |
| 162 | const empfaenger = (): void => { |
| 163 | anwenden(); |
| 164 | }; |
| 165 | liste.addEventListener('change', empfaenger); |
| 166 | abmeldungen.push(() => { |
| 167 | liste.removeEventListener('change', empfaenger); |
| 168 | }); |
| 169 | } |
| 170 | |
| 171 | function loeseEmpfaenger(): void { |
| 172 | for (const abmelden of abmeldungen) abmelden(); |
| 173 | abmeldungen = []; |
| 174 | } |