lsa-planer

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

/ src app store.ts

14,7 KB Rohdatei
src/app/store.ts — 382 Zeilen
1 import { buildSignalPlan, type SignalPlan } from '@/domain/plan/signalPlan';
2 import { validateProject, type ValidationReport } from '@/domain/validation';
3 import { createEmptyProject, istUnberuehrt } from '@/domain/model/factory';
4 import type { Project } from '@/domain/model/project';
5
6 /**
7 * Zentraler Anwendungszustand.
8 *
9 * Ein einziger Speicherort fuer das Projekt. Der Altbestand hielt dasselbe
10 * Projekt gleichzeitig im DataManager und im ProjectManager; Aenderungen liefen
11 * in das eine Objekt, gespeichert wurde das andere. Der Quelltext trug an der
12 * Stelle den Kommentar "WICHTIG: ProjectManager NICHT verwenden!".
13 *
14 * Ableitungen (Signalzeitenplan, Pruefbericht) werden bei jeder Aenderung genau
15 * einmal neu berechnet und zwischengespeichert, damit Ansichten nie
16 * unterschiedliche Staende zeigen.
17 */
18
19 export interface AppState {
20 readonly project: Project;
21 readonly plan: SignalPlan;
22 readonly report: ValidationReport;
23 /** Gibt es Aenderungen seit dem letzten Speichern? */
24 readonly dirty: boolean;
25 /** Dateipfad des zuletzt gespeicherten oder geoeffneten Projekts. */
26 readonly filePath: string | null;
27 readonly canUndo: boolean;
28 readonly canRedo: boolean;
29 /** Beschreibung des naechsten rueckgaengig zu machenden Schritts. */
30 readonly undoLabel: string | null;
31 readonly redoLabel: string | null;
32 }
33
34 export type Listener = (state: AppState) => void;
35
36 interface HistoryEntry {
37 readonly project: Project;
38 readonly label: string;
39 /**
40 * Die grossen Zeichenketten dieses Standes, jede genau einmal, mit ihrer
41 * Laenge. Grundlage der Schranke `HISTORY_ZEICHEN_LIMIT`; einmal beim
42 * Ablegen ermittelt, damit die Schranke danach ohne erneuten Durchlauf
43 * durch die Projektbaeume auskommt.
44 */
45 readonly grossdaten: ReadonlyMap<string, number>;
46 }
47
48 export interface UpdateOptions {
49 /** Beschreibung der Aenderung fuer die Rueckgaengig-Anzeige. */
50 readonly label: string;
51 /**
52 * Aenderungen mit demselben Schluessel werden zu einem Schritt zusammengefasst,
53 * solange sie unmittelbar aufeinander folgen. So erzeugt das Tippen in einem
54 * Textfeld nicht je Zeichen einen Rueckgaengig-Schritt.
55 */
56 readonly coalesceKey?: string;
57 /** Aenderung nicht in die Historie aufnehmen (z. B. Laden einer Datei). */
58 readonly skipHistory?: boolean;
59 /** Aenderung markiert das Projekt nicht als ungespeichert. */
60 readonly keepClean?: boolean;
61 }
62
63 const HISTORY_LIMIT = 100;
64
65 /**
66 * Ab welcher Laenge eine Zeichenkette fuer den Umfang der Historie zaehlt.
67 *
68 * Alles darunter - Bezeichnungen, Kennungen, Bemerkungen - faellt gegen ein
69 * Luftbild nicht ins Gewicht und wuerde den Durchlauf nur verlangsamen. Die
70 * Speicherschicht setzt dieselbe Zahl an (`GROSSDATEN_GRENZE` in
71 * src/services/storage.ts), beantwortet damit aber eine andere Frage: dort
72 * geht es darum, welche Zeichenkette im Sitzungsspeicher in ein eigenes Lager
73 * ausgelagert wird, hier darum, welche zum Umfang der Historie zaehlt. Die
74 * Zahl steht deshalb ein zweites Mal: `GROSSDATEN_GRENZE` ist modulintern und
75 * nicht ausgefuehrt, also gar nicht einbindbar, und die beiden Schranken
76 * bleiben unabhaengig voneinander aenderbar. Nicht zusammenlegen.
77 */
78 const GROSSZEICHEN_GRENZE = 32_768;
79
80 /**
81 * Wieviel an grossen Zeichenketten die Historie insgesamt halten darf.
82 *
83 * `HISTORY_LIMIT` zaehlt SCHRITTE und traegt fuer gewoehnliche Aenderungen:
84 * Was sich nicht aendert, wird zwischen den Staenden geteilt, ein Schritt
85 * kostet dann fast nichts. Beim Luftbild traegt es nicht. Jedes neu geholte
86 * oder eingelesene Bild ist eine EIGENE Zeichenkette von bis zu rund 32 MiB
87 * (MAX_BILD_ZEICHEN in src/domain/model/schema.ts), und der vorige Stand samt
88 * seinem Bild blieb im Stapel liegen. Gemessen: hundert Durchgaenge
89 * "Amtliches Luftbild holen" - beim Zurechtruecken des Ausschnitts eine ganz
90 * gewoehnliche Zahl - banden 2,0 GB im Renderer, bis er abstuerzte.
91 *
92 * 64 MiB lassen etwa zwei Bilder in Hoechstgroesse oder ein Dutzend
93 * gewoehnliche zurueckgehen und halten den Renderer zugleich weit von seiner
94 * Grenze fort. Dieselbe Abwaegung fuehrt die Speicherschicht fuer die
95 * Wiederherstellungspunkte ("Fuenf VOLLKOPIEN eines 20-MB-Projekts waeren
96 * aber 100 MB", src/services/storage.ts); dort wandern die grossen
97 * Zeichenketten in ein eigenes Lager, hier faellt der aelteste Schritt fort.
98 *
99 * Ein Schritt bleibt immer stehen, auch wenn er die Schranke allein
100 * ueberschreitet: Ein Bildwechsel muss rueckgaengig zu machen sein.
101 */
102 const HISTORY_ZEICHEN_LIMIT = 64 * 1024 * 1024;
103
104 /**
105 * Sammelt die grossen Zeichenketten eines Standes, jede genau einmal.
106 *
107 * Nach WERT abgelegt und nicht nach Verweis: Zwei wertgleiche Zeichenketten
108 * sind fuer die Historie dasselbe Bild, und einen Verweisvergleich fuer
109 * Zeichenketten gibt es in JavaScript ohnehin nicht. Wertgleiche Bilder
110 * entstehen im Betrieb nicht - `istWertgleich` liesse eine solche Aenderung
111 * gar nicht erst durch.
112 */
113 function grosseZeichenketten(wert: unknown, gefunden: Map<string, number>): Map<string, number> {
114 if (typeof wert === 'string') {
115 if (wert.length >= GROSSZEICHEN_GRENZE) gefunden.set(wert, wert.length);
116 return gefunden;
117 }
118 if (typeof wert !== 'object' || wert === null) return gefunden;
119 if (Array.isArray(wert)) {
120 for (const eintrag of wert) grosseZeichenketten(eintrag, gefunden);
121 return gefunden;
122 }
123 for (const eintrag of Object.values(wert as Record<string, unknown>)) {
124 grosseZeichenketten(eintrag, gefunden);
125 }
126 return gefunden;
127 }
128
129 /**
130 * Sind zwei Staende wertgleich?
131 *
132 * Der Waechter gegen wirkungslose Aenderungen in `update()` verglich nur die
133 * Verweise. Die Aenderungsfunktionen in `actions.ts` geben aber immer ein neues
134 * Objekt zurueck, auch wenn kein Feld anders ausfaellt - `updateSignalGroup`
135 * etwa baut Projekt und Signalgruppenliste in jedem Fall neu auf. Kommt aus der
136 * Oberflaeche ein bereits eingetragener Wert an, was bei jeder auf den
137 * vorhandenen Wert begrenzten Zahleneingabe geschieht, entstand daraus ein
138 * Rueckgaengig-Schritt, der nichts rueckgaengig macht, dazu der
139 * Aenderungsmerker und ein neues `meta.modifiedAt` - eine Angabe, die als
140 * "Zuletzt geändert" in die Planunterlage gedruckt wird (Befund 59).
141 *
142 * Verglichen wird von oben nach unten, mit Abbruch beim ersten Unterschied.
143 * Gleiche Verweise gelten sofort als gleich, ohne hineinzusehen: Ein
144 * unveraendert weitergereichter Teilbaum - der Lageplan mit einem eingebetteten
145 * Luftbild ueber zwanzig Megabyte etwa - wird deshalb nie durchlaufen.
146 *
147 * Unterscheiden sich zwei Staende nur darin, dass der eine ein Feld mit dem
148 * Wert `undefined` fuehrt und der andere es gar nicht hat, gelten sie als
149 * ungleich. Das ist die vorsichtige Richtung: Die Aenderung laeuft dann durch
150 * wie bisher.
151 */
152 function istWertgleich(a: unknown, b: unknown): boolean {
153 if (a === b) return true;
154 if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
155 if (Array.isArray(a) || Array.isArray(b)) {
156 if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
157 return a.every((wert, index) => istWertgleich(wert, b[index]));
158 }
159 const links = a as Record<string, unknown>;
160 const rechts = b as Record<string, unknown>;
161 const namen = Object.keys(links);
162 if (namen.length !== Object.keys(rechts).length) return false;
163 return namen.every(
164 (name) =>
165 Object.prototype.hasOwnProperty.call(rechts, name) &&
166 istWertgleich(links[name], rechts[name]),
167 );
168 }
169
170 export class ProjectStore {
171 private project: Project;
172 private plan: SignalPlan;
173 private report: ValidationReport;
174 private dirty = false;
175 private filePath: string | null = null;
176
177 private past: HistoryEntry[] = [];
178 private future: HistoryEntry[] = [];
179 private lastCoalesceKey: string | null = null;
180
181 private readonly listeners = new Set<Listener>();
182
183 constructor(project: Project = createEmptyProject()) {
184 this.project = project;
185 this.plan = buildSignalPlan(project);
186 this.report = validateProject(project, this.plan);
187 }
188
189 getState(): AppState {
190 return {
191 project: this.project,
192 plan: this.plan,
193 report: this.report,
194 dirty: this.dirty,
195 filePath: this.filePath,
196 canUndo: this.past.length > 0,
197 canRedo: this.future.length > 0,
198 undoLabel: this.past[this.past.length - 1]?.label ?? null,
199 redoLabel: this.future[this.future.length - 1]?.label ?? null,
200 };
201 }
202
203 getProject(): Project {
204 return this.project;
205 }
206
207 getPlan(): SignalPlan {
208 return this.plan;
209 }
210
211 getReport(): ValidationReport {
212 return this.report;
213 }
214
215 subscribe(listener: Listener): () => void {
216 this.listeners.add(listener);
217 return () => {
218 this.listeners.delete(listener);
219 };
220 }
221
222 /** Wendet eine Aenderung auf das Projekt an. */
223 update(mutator: (project: Project) => Project, options: UpdateOptions): void {
224 const next = mutator(this.project);
225 // Nicht nur derselbe Verweis: Die Aenderungsfunktionen bauen das Projekt in
226 // jedem Fall neu auf, auch wenn kein Feld anders ausfaellt. Begruendung bei
227 // `istWertgleich`.
228 if (istWertgleich(next, this.project)) return;
229
230 if (options.skipHistory !== true) {
231 const coalesce =
232 options.coalesceKey !== undefined && options.coalesceKey === this.lastCoalesceKey;
233 if (!coalesce) {
234 this.past.push(this.historienEintrag(this.project, options.label));
235 this.begrenzeHistorie();
236 }
237 // Ein neuer Schritt verwirft die Wiederherstellen-Kette. Der Altbestand
238 // liess sie stehen, sodass "Wiederherstellen" nach einer neuen Aenderung
239 // einen Zustand aus einem anderen Bearbeitungszweig einspielte.
240 this.future = [];
241 this.lastCoalesceKey = options.coalesceKey ?? null;
242 }
243
244 this.applyProject(this.touch(next), options.keepClean !== true);
245 }
246
247 /**
248 * Ersetzt das Projekt vollstaendig, etwa beim Oeffnen einer Datei.
249 *
250 * Gespeichert ist nur, was in einer Datei steht. Kommt ein Dateipfad mit
251 * ("Öffnen"), ist der Stand gesichert. Kommt keiner ("Neu"), gilt derselbe
252 * Massstab wie beim Wiederanlauf aus dem Sitzungsspeicher (siehe
253 * `markDirty`): Das Ergebnis des gefuehrten Einstiegs - drei bis vier
254 * beantwortete Fragen samt Signalgruppen, Konflikten und Phasen - steht in
255 * keiner Datei und ist damit ungespeichert. Fuer das Beispielprojekt gilt
256 * dasselbe: Es ist ein vollstaendiger Knotenpunkt und kein leeres Blatt.
257 *
258 * Vorher loeschte `replace` den Merker bedingungslos. Die Kopfzeile meldete
259 * daraufhin "gespeichert", und die Waechter vor dem Verwerfen ("Neu",
260 * "Öffnen", Fenster schliessen) haengen alle an diesem Merker: Ein zweites
261 * Strg+N lief ohne Rueckfrage durch, leerte hier die Rueckgaengig-Kette, und
262 * der Sitzungssatz wurde gleich darauf ueberschrieben. Einen Rueckweg gibt es
263 * nicht (Befund 38).
264 *
265 * Ein unberuehrtes Projekt bleibt sauber - "Neu" > "Leeres Projekt" ist von
266 * einem eben angelegten Start nicht zu unterscheiden, und eine Rueckfrage,
267 * die immer kommt, schuetzt nichts.
268 */
269 replace(project: Project, filePath: string | null = null): void {
270 this.past = [];
271 this.future = [];
272 this.lastCoalesceKey = null;
273 this.filePath = filePath;
274 this.applyProject(project, filePath === null && !istUnberuehrt(project));
275 }
276
277 undo(): void {
278 const entry = this.past.pop();
279 if (!entry) return;
280 this.future.push(this.historienEintrag(this.project, entry.label));
281 this.lastCoalesceKey = null;
282 this.applyProject(entry.project, true);
283 }
284
285 redo(): void {
286 const entry = this.future.pop();
287 if (!entry) return;
288 this.past.push(this.historienEintrag(this.project, entry.label));
289 this.lastCoalesceKey = null;
290 this.applyProject(entry.project, true);
291 }
292
293 markSaved(filePath: string | null = this.filePath): void {
294 this.filePath = filePath;
295 this.dirty = false;
296 this.notify();
297 }
298
299 /**
300 * Merkt den vorhandenen Stand als ungespeichert, ohne ihn anzufassen.
301 *
302 * Fuer den Wiederanlauf aus dem Sitzungsspeicher: Der Stand ist da, er steht
303 * aber in keiner Datei, und die Waechter vor dem Verwerfen ("Neu", "Öffnen",
304 * Fenster schliessen) haengen alle an diesem Merker. Ohne ihn galt ein
305 * wiederhergestellter Knotenpunkt sofort als gespeichert.
306 *
307 * Bewusst nicht ueber `update()`: `touch()` setzte dabei `meta.modifiedAt`
308 * hoch und verfaelschte damit eine Projektangabe, die in die Planunterlage
309 * geht. Und bewusst hier statt neben `dirty` in der Oberflaeche: Kopfzeile,
310 * Fenstertitel und Statusleiste lesen `getState()`; ein zweiter Merker
311 * daneben liess sie drei verschiedene Auskuenfte ueber denselben Stand geben.
312 */
313 markDirty(): void {
314 if (this.dirty) return;
315 this.dirty = true;
316 this.notify();
317 }
318
319 setFilePath(filePath: string | null): void {
320 this.filePath = filePath;
321 this.notify();
322 }
323
324 private historienEintrag(project: Project, label: string): HistoryEntry {
325 return { project, label, grossdaten: grosseZeichenketten(project, new Map()) };
326 }
327
328 /**
329 * Nimmt die aeltesten Schritte fort, bis Zahl und Umfang wieder passen.
330 *
331 * Gezaehlt werden `past` UND `future`: Beide halten ganze Projektstaende am
332 * Leben, und `undo` verschiebt nur zwischen ihnen. Fortgenommen wird
333 * ausschliesslich am unteren Ende von `past` - das ist der aelteste Schritt
334 * und damit der, den am ehesten niemand mehr braucht.
335 */
336 private begrenzeHistorie(): void {
337 if (this.past.length > HISTORY_LIMIT) this.past.shift();
338 while (this.past.length > 1 && this.gehalteneGrossdaten() > HISTORY_ZEICHEN_LIMIT) {
339 this.past.shift();
340 }
341 }
342
343 /** Umfang der grossen Zeichenketten, die die Historie noch festhaelt. */
344 private gehalteneGrossdaten(): number {
345 const gezaehlt = new Set<string>();
346 let summe = 0;
347 for (const eintrag of [...this.past, ...this.future]) {
348 for (const [text, laenge] of eintrag.grossdaten) {
349 if (gezaehlt.has(text)) continue;
350 gezaehlt.add(text);
351 summe += laenge;
352 }
353 }
354 return summe;
355 }
356
357 private applyProject(project: Project, dirty: boolean): void {
358 this.project = project;
359 // Der Plan wird genau einmal je Aenderung aufgebaut und an den Pruefbericht
360 // weitergereicht, statt in jeder Ansicht erneut.
361 this.plan = buildSignalPlan(project);
362 this.report = validateProject(project, this.plan);
363 this.dirty = dirty;
364 this.notify();
365 }
366
367 private touch(project: Project): Project {
368 return { ...project, meta: { ...project.meta, modifiedAt: new Date().toISOString() } };
369 }
370
371 private notify(): void {
372 const state = this.getState();
373 for (const listener of [...this.listeners]) {
374 try {
375 listener(state);
376 } catch (error) {
377 // Ein Fehler in einer Ansicht darf die uebrigen nicht mitreissen.
378 console.error('Fehler in einem Zustandsempfaenger:', error);
379 }
380 }
381 }
382 }