import { buildSignalPlan, type SignalPlan } from '@/domain/plan/signalPlan'; import { validateProject, type ValidationReport } from '@/domain/validation'; import { createEmptyProject, istUnberuehrt } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; /** * Zentraler Anwendungszustand. * * Ein einziger Speicherort fuer das Projekt. Der Altbestand hielt dasselbe * Projekt gleichzeitig im DataManager und im ProjectManager; Aenderungen liefen * in das eine Objekt, gespeichert wurde das andere. Der Quelltext trug an der * Stelle den Kommentar "WICHTIG: ProjectManager NICHT verwenden!". * * Ableitungen (Signalzeitenplan, Pruefbericht) werden bei jeder Aenderung genau * einmal neu berechnet und zwischengespeichert, damit Ansichten nie * unterschiedliche Staende zeigen. */ export interface AppState { readonly project: Project; readonly plan: SignalPlan; readonly report: ValidationReport; /** Gibt es Aenderungen seit dem letzten Speichern? */ readonly dirty: boolean; /** Dateipfad des zuletzt gespeicherten oder geoeffneten Projekts. */ readonly filePath: string | null; readonly canUndo: boolean; readonly canRedo: boolean; /** Beschreibung des naechsten rueckgaengig zu machenden Schritts. */ readonly undoLabel: string | null; readonly redoLabel: string | null; } export type Listener = (state: AppState) => void; interface HistoryEntry { readonly project: Project; readonly label: string; /** * Die grossen Zeichenketten dieses Standes, jede genau einmal, mit ihrer * Laenge. Grundlage der Schranke `HISTORY_ZEICHEN_LIMIT`; einmal beim * Ablegen ermittelt, damit die Schranke danach ohne erneuten Durchlauf * durch die Projektbaeume auskommt. */ readonly grossdaten: ReadonlyMap; } export interface UpdateOptions { /** Beschreibung der Aenderung fuer die Rueckgaengig-Anzeige. */ readonly label: string; /** * Aenderungen mit demselben Schluessel werden zu einem Schritt zusammengefasst, * solange sie unmittelbar aufeinander folgen. So erzeugt das Tippen in einem * Textfeld nicht je Zeichen einen Rueckgaengig-Schritt. */ readonly coalesceKey?: string; /** Aenderung nicht in die Historie aufnehmen (z. B. Laden einer Datei). */ readonly skipHistory?: boolean; /** Aenderung markiert das Projekt nicht als ungespeichert. */ readonly keepClean?: boolean; } const HISTORY_LIMIT = 100; /** * Ab welcher Laenge eine Zeichenkette fuer den Umfang der Historie zaehlt. * * Alles darunter - Bezeichnungen, Kennungen, Bemerkungen - faellt gegen ein * Luftbild nicht ins Gewicht und wuerde den Durchlauf nur verlangsamen. Die * Speicherschicht setzt dieselbe Zahl an (`GROSSDATEN_GRENZE` in * src/services/storage.ts), beantwortet damit aber eine andere Frage: dort * geht es darum, welche Zeichenkette im Sitzungsspeicher in ein eigenes Lager * ausgelagert wird, hier darum, welche zum Umfang der Historie zaehlt. Die * Zahl steht deshalb ein zweites Mal: `GROSSDATEN_GRENZE` ist modulintern und * nicht ausgefuehrt, also gar nicht einbindbar, und die beiden Schranken * bleiben unabhaengig voneinander aenderbar. Nicht zusammenlegen. */ const GROSSZEICHEN_GRENZE = 32_768; /** * Wieviel an grossen Zeichenketten die Historie insgesamt halten darf. * * `HISTORY_LIMIT` zaehlt SCHRITTE und traegt fuer gewoehnliche Aenderungen: * Was sich nicht aendert, wird zwischen den Staenden geteilt, ein Schritt * kostet dann fast nichts. Beim Luftbild traegt es nicht. Jedes neu geholte * oder eingelesene Bild ist eine EIGENE Zeichenkette von bis zu rund 32 MiB * (MAX_BILD_ZEICHEN in src/domain/model/schema.ts), und der vorige Stand samt * seinem Bild blieb im Stapel liegen. Gemessen: hundert Durchgaenge * "Amtliches Luftbild holen" - beim Zurechtruecken des Ausschnitts eine ganz * gewoehnliche Zahl - banden 2,0 GB im Renderer, bis er abstuerzte. * * 64 MiB lassen etwa zwei Bilder in Hoechstgroesse oder ein Dutzend * gewoehnliche zurueckgehen und halten den Renderer zugleich weit von seiner * Grenze fort. Dieselbe Abwaegung fuehrt die Speicherschicht fuer die * Wiederherstellungspunkte ("Fuenf VOLLKOPIEN eines 20-MB-Projekts waeren * aber 100 MB", src/services/storage.ts); dort wandern die grossen * Zeichenketten in ein eigenes Lager, hier faellt der aelteste Schritt fort. * * Ein Schritt bleibt immer stehen, auch wenn er die Schranke allein * ueberschreitet: Ein Bildwechsel muss rueckgaengig zu machen sein. */ const HISTORY_ZEICHEN_LIMIT = 64 * 1024 * 1024; /** * Sammelt die grossen Zeichenketten eines Standes, jede genau einmal. * * Nach WERT abgelegt und nicht nach Verweis: Zwei wertgleiche Zeichenketten * sind fuer die Historie dasselbe Bild, und einen Verweisvergleich fuer * Zeichenketten gibt es in JavaScript ohnehin nicht. Wertgleiche Bilder * entstehen im Betrieb nicht - `istWertgleich` liesse eine solche Aenderung * gar nicht erst durch. */ function grosseZeichenketten(wert: unknown, gefunden: Map): Map { if (typeof wert === 'string') { if (wert.length >= GROSSZEICHEN_GRENZE) gefunden.set(wert, wert.length); return gefunden; } if (typeof wert !== 'object' || wert === null) return gefunden; if (Array.isArray(wert)) { for (const eintrag of wert) grosseZeichenketten(eintrag, gefunden); return gefunden; } for (const eintrag of Object.values(wert as Record)) { grosseZeichenketten(eintrag, gefunden); } return gefunden; } /** * Sind zwei Staende wertgleich? * * Der Waechter gegen wirkungslose Aenderungen in `update()` verglich nur die * Verweise. Die Aenderungsfunktionen in `actions.ts` geben aber immer ein neues * Objekt zurueck, auch wenn kein Feld anders ausfaellt - `updateSignalGroup` * etwa baut Projekt und Signalgruppenliste in jedem Fall neu auf. Kommt aus der * Oberflaeche ein bereits eingetragener Wert an, was bei jeder auf den * vorhandenen Wert begrenzten Zahleneingabe geschieht, entstand daraus ein * Rueckgaengig-Schritt, der nichts rueckgaengig macht, dazu der * Aenderungsmerker und ein neues `meta.modifiedAt` - eine Angabe, die als * "Zuletzt geändert" in die Planunterlage gedruckt wird (Befund 59). * * Verglichen wird von oben nach unten, mit Abbruch beim ersten Unterschied. * Gleiche Verweise gelten sofort als gleich, ohne hineinzusehen: Ein * unveraendert weitergereichter Teilbaum - der Lageplan mit einem eingebetteten * Luftbild ueber zwanzig Megabyte etwa - wird deshalb nie durchlaufen. * * Unterscheiden sich zwei Staende nur darin, dass der eine ein Feld mit dem * Wert `undefined` fuehrt und der andere es gar nicht hat, gelten sie als * ungleich. Das ist die vorsichtige Richtung: Die Aenderung laeuft dann durch * wie bisher. */ function istWertgleich(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; if (Array.isArray(a) || Array.isArray(b)) { if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; return a.every((wert, index) => istWertgleich(wert, b[index])); } const links = a as Record; const rechts = b as Record; const namen = Object.keys(links); if (namen.length !== Object.keys(rechts).length) return false; return namen.every( (name) => Object.prototype.hasOwnProperty.call(rechts, name) && istWertgleich(links[name], rechts[name]), ); } export class ProjectStore { private project: Project; private plan: SignalPlan; private report: ValidationReport; private dirty = false; private filePath: string | null = null; private past: HistoryEntry[] = []; private future: HistoryEntry[] = []; private lastCoalesceKey: string | null = null; private readonly listeners = new Set(); constructor(project: Project = createEmptyProject()) { this.project = project; this.plan = buildSignalPlan(project); this.report = validateProject(project, this.plan); } getState(): AppState { return { project: this.project, plan: this.plan, report: this.report, dirty: this.dirty, filePath: this.filePath, canUndo: this.past.length > 0, canRedo: this.future.length > 0, undoLabel: this.past[this.past.length - 1]?.label ?? null, redoLabel: this.future[this.future.length - 1]?.label ?? null, }; } getProject(): Project { return this.project; } getPlan(): SignalPlan { return this.plan; } getReport(): ValidationReport { return this.report; } subscribe(listener: Listener): () => void { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } /** Wendet eine Aenderung auf das Projekt an. */ update(mutator: (project: Project) => Project, options: UpdateOptions): void { const next = mutator(this.project); // Nicht nur derselbe Verweis: Die Aenderungsfunktionen bauen das Projekt in // jedem Fall neu auf, auch wenn kein Feld anders ausfaellt. Begruendung bei // `istWertgleich`. if (istWertgleich(next, this.project)) return; if (options.skipHistory !== true) { const coalesce = options.coalesceKey !== undefined && options.coalesceKey === this.lastCoalesceKey; if (!coalesce) { this.past.push(this.historienEintrag(this.project, options.label)); this.begrenzeHistorie(); } // Ein neuer Schritt verwirft die Wiederherstellen-Kette. Der Altbestand // liess sie stehen, sodass "Wiederherstellen" nach einer neuen Aenderung // einen Zustand aus einem anderen Bearbeitungszweig einspielte. this.future = []; this.lastCoalesceKey = options.coalesceKey ?? null; } this.applyProject(this.touch(next), options.keepClean !== true); } /** * Ersetzt das Projekt vollstaendig, etwa beim Oeffnen einer Datei. * * Gespeichert ist nur, was in einer Datei steht. Kommt ein Dateipfad mit * ("Öffnen"), ist der Stand gesichert. Kommt keiner ("Neu"), gilt derselbe * Massstab wie beim Wiederanlauf aus dem Sitzungsspeicher (siehe * `markDirty`): Das Ergebnis des gefuehrten Einstiegs - drei bis vier * beantwortete Fragen samt Signalgruppen, Konflikten und Phasen - steht in * keiner Datei und ist damit ungespeichert. Fuer das Beispielprojekt gilt * dasselbe: Es ist ein vollstaendiger Knotenpunkt und kein leeres Blatt. * * Vorher loeschte `replace` den Merker bedingungslos. Die Kopfzeile meldete * daraufhin "gespeichert", und die Waechter vor dem Verwerfen ("Neu", * "Öffnen", Fenster schliessen) haengen alle an diesem Merker: Ein zweites * Strg+N lief ohne Rueckfrage durch, leerte hier die Rueckgaengig-Kette, und * der Sitzungssatz wurde gleich darauf ueberschrieben. Einen Rueckweg gibt es * nicht (Befund 38). * * Ein unberuehrtes Projekt bleibt sauber - "Neu" > "Leeres Projekt" ist von * einem eben angelegten Start nicht zu unterscheiden, und eine Rueckfrage, * die immer kommt, schuetzt nichts. */ replace(project: Project, filePath: string | null = null): void { this.past = []; this.future = []; this.lastCoalesceKey = null; this.filePath = filePath; this.applyProject(project, filePath === null && !istUnberuehrt(project)); } undo(): void { const entry = this.past.pop(); if (!entry) return; this.future.push(this.historienEintrag(this.project, entry.label)); this.lastCoalesceKey = null; this.applyProject(entry.project, true); } redo(): void { const entry = this.future.pop(); if (!entry) return; this.past.push(this.historienEintrag(this.project, entry.label)); this.lastCoalesceKey = null; this.applyProject(entry.project, true); } markSaved(filePath: string | null = this.filePath): void { this.filePath = filePath; this.dirty = false; this.notify(); } /** * Merkt den vorhandenen Stand als ungespeichert, ohne ihn anzufassen. * * Fuer den Wiederanlauf aus dem Sitzungsspeicher: Der Stand ist da, er steht * aber in keiner Datei, und die Waechter vor dem Verwerfen ("Neu", "Öffnen", * Fenster schliessen) haengen alle an diesem Merker. Ohne ihn galt ein * wiederhergestellter Knotenpunkt sofort als gespeichert. * * Bewusst nicht ueber `update()`: `touch()` setzte dabei `meta.modifiedAt` * hoch und verfaelschte damit eine Projektangabe, die in die Planunterlage * geht. Und bewusst hier statt neben `dirty` in der Oberflaeche: Kopfzeile, * Fenstertitel und Statusleiste lesen `getState()`; ein zweiter Merker * daneben liess sie drei verschiedene Auskuenfte ueber denselben Stand geben. */ markDirty(): void { if (this.dirty) return; this.dirty = true; this.notify(); } setFilePath(filePath: string | null): void { this.filePath = filePath; this.notify(); } private historienEintrag(project: Project, label: string): HistoryEntry { return { project, label, grossdaten: grosseZeichenketten(project, new Map()) }; } /** * Nimmt die aeltesten Schritte fort, bis Zahl und Umfang wieder passen. * * Gezaehlt werden `past` UND `future`: Beide halten ganze Projektstaende am * Leben, und `undo` verschiebt nur zwischen ihnen. Fortgenommen wird * ausschliesslich am unteren Ende von `past` - das ist der aelteste Schritt * und damit der, den am ehesten niemand mehr braucht. */ private begrenzeHistorie(): void { if (this.past.length > HISTORY_LIMIT) this.past.shift(); while (this.past.length > 1 && this.gehalteneGrossdaten() > HISTORY_ZEICHEN_LIMIT) { this.past.shift(); } } /** Umfang der grossen Zeichenketten, die die Historie noch festhaelt. */ private gehalteneGrossdaten(): number { const gezaehlt = new Set(); let summe = 0; for (const eintrag of [...this.past, ...this.future]) { for (const [text, laenge] of eintrag.grossdaten) { if (gezaehlt.has(text)) continue; gezaehlt.add(text); summe += laenge; } } return summe; } private applyProject(project: Project, dirty: boolean): void { this.project = project; // Der Plan wird genau einmal je Aenderung aufgebaut und an den Pruefbericht // weitergereicht, statt in jeder Ansicht erneut. this.plan = buildSignalPlan(project); this.report = validateProject(project, this.plan); this.dirty = dirty; this.notify(); } private touch(project: Project): Project { return { ...project, meta: { ...project.meta, modifiedAt: new Date().toISOString() } }; } private notify(): void { const state = this.getState(); for (const listener of [...this.listeners]) { try { listener(state); } catch (error) { // Ein Fehler in einer Ansicht darf die uebrigen nicht mitreissen. console.error('Fehler in einem Zustandsempfaenger:', error); } } } }