lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
| 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 | } |