lsa-planer

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

/ src app theme.ts

6,3 KB Rohdatei
src/app/theme.ts — 174 Zeilen
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 }