import './styles/app.css'; import { ProjectStore } from './app/store'; import { applyTheme, beiErscheinungsbildwechsel, nextTheme, themeLabel } from './app/theme'; import { createEmptyProject, createStandardIntersectionProject, istUnberuehrt, } from './domain/model/factory'; import type { Project } from './domain/model/project'; import type { IntergreenComparisonRow, MigrationResult } from './domain/model/migrate'; import { intergreenKey } from './domain/plan/signalPlan'; import { AutoSave } from './services/autosave'; import * as fmt from './ui/format'; import { DEFAULT_APP_SETTINGS, MELDUNGSDAUER_STUFEN, loadAppSettings, ladeSitzung, openProjectFromFile, saveAppSettings, saveProjectToFile, type AppSettings, type Sitzungsbefund, } from './services/storage'; import { desktopBridge } from './platform/bridge'; import { Shell, confirmDiscard } from './ui/shell'; import { zeigeGlossar } from './ui/help/hilfe'; import { zeigeUeber } from './ui/ueber'; import { starteAssistent } from './ui/assistent'; import { Panel, confirmDialog, meldungsverlauf, notify, offeneFensterAnzahl, setzeMeldungsdauer, } from './ui/feedback'; import { beschrifteTabelle, button, el, emptyState, select } from './ui/dom'; import { compatibilityView } from './ui/views/compatibilityView'; import { lageplanView } from './ui/views/lageplanView'; import { conflictsView } from './ui/views/conflictsView'; import { exportView } from './ui/views/exportView'; import { phasesView } from './ui/views/phasesView'; import { vergleichView } from './ui/views/vergleichView'; import { koordinierungView } from './ui/views/koordinierungView'; import { planView } from './ui/views/planView'; import { projectView } from './ui/views/projectView'; import { reportView } from './ui/views/reportView'; import { settingsView } from './ui/views/settingsView'; import { signalGroupsView } from './ui/views/signalGroupsView'; import { simulationView } from './ui/views/simulationView'; /** * Programmstart. * * Reihenfolge: Einstellungen laden, Erscheinungsbild setzen, zuletzt * bearbeiteten Stand wiederherstellen, Oberflaeche aufbauen. Der Altbestand * legte beim Start zuerst ein leeres Projekt an und ueberschrieb damit den * gespeicherten Stand, bevor ueberhaupt geprueft wurde, ob etwas * wiederherzustellen war. */ /** * Programmversion. * * Kommt aus package.json und wird von Vite eingesetzt - dieselbe Zahl, aus der * electron-builder den Namen des Installationspakets bildet. Zuvor stand sie * hier ein zweites Mal als Zeichenkette: Wer die eine erhoehte und die andere * vergass, bekam einen Ausdruck, dessen Versionsangabe nicht zu dem Paket * passte, mit dem er erstellt wurde. Bei einer Unterlage, die in ein * Verwaltungsverfahren geht, ist die Versionsangabe kein Zierrat. * * Der Rueckfall greift nur im Testbetrieb, wo Vite nichts einsetzt. */ // Aufgeloest wird die Nummer in src/fassung.ts - dieselbe Stelle, aus der // die gedruckte Planunterlage sie nimmt. import { APP_VERSION } from './fassung'; /* * Der Start wartet auf den Sitzungsspeicher. * * Seit der Zwischenstand in IndexedDB liegt statt in localStorage, ist das * Einlesen asynchron. Gewartet wird bewusst, statt mit einem leeren Projekt zu * beginnen und den Stand nachzureichen: Der Projektspeicher traegt die * Rueckgaengig-Kette, und ein nachgereichter Austausch waere entweder ein * unwiderrufliches Ueberschreiben der ersten Eingaben oder ein * Rueckgaengig-Schritt, der den Anwender an einen Stand fuehrt, den er nie * bearbeitet hat. Ein Schluessel-Lesevorgang dauert wenige Millisekunden. */ /** * Die Kurzhilfe, solange sie offen ist (Befund M1). * * F1 ist ein Tastenkuerzel am Fenster und feuert weiter, waehrend die Kurzhilfe * schon offensteht. Ohne diesen Waechter stapelte jeder weitere Druck ein * zusaetzliches, wortgleiches Fenster - unsichtbar fuer den, der die * Ueberlagerung nicht sieht, und jedes davon war einzeln zu schliessen. Die * Parallelfunktion `zeigeHilfe` in ui/help/hilfe.ts hatte einen solchen * Waechter von Anfang an; hier fehlte er. * * Auf Modulebene und nicht in `boot()`: Der F1-Empfaenger haengt am Fenster und * kann feuern, sobald er gebunden ist. Eine Variable im Rumpf von `boot()` waere * bis zu ihrer eigenen Anweisung in der zeitlichen Totzone - dasselbe Muster * wie `offenesFenster` in hilfe.ts. */ let kurzhilfe: Panel | null = null; async function boot(): Promise { const root = document.getElementById('anwendung'); if (!root) throw new Error('Das Wurzelelement der Anwendung fehlt.'); let settings: AppSettings = loadAppSettings(); applyTheme(settings.theme); // Vor der ersten Meldung: Die Anzeigedauer ist eine Einstellung des // Anwenders (Befund L8, WCAG 2.2.1), und `feedback.ts` liest den Speicher // nicht selbst - naeheres bei `setzeMeldungsdauer`. setzeMeldungsdauer(settings.meldungsdauerSekunden); const restored = await restoreSession(); const store = new ProjectStore(restored.project); if (restored.filePath !== null) store.setFilePath(restored.filePath); /** * Der wiederhergestellte Stand steht in keiner Datei. * * `ProjectStore` beginnt mit `dirty = false`, und der Wiederanlauf aendert * daran nichts: Ein aus dem Sitzungsspeicher geholter Stand galt damit sofort * als gespeichert, obwohl er nie in eine Datei geschrieben wurde. Die * Waechter vor dem Verwerfen haengen allein an diesem Merker und griffen * deshalb nicht - ein Strg+N nach dem Wiederanlauf loeschte die ganze * Handarbeit eines Knotenpunkts ohne ein Wort, und einen Rueckweg gibt es * nicht. * * Der Merker sitzt im Zustandsspeicher und nicht daneben: Kopfzeile, * Fenstertitel und Statusleiste lesen `store.getState()`, und eine zweite * Buchfuehrung liess sie etwas anderes sagen als die Waechter. Er erlischt, * sobald der Anwender selbst bestimmt, was gilt - `store.replace(...)` bei * "Neu" und "Öffnen", `store.markSaved(...)` beim Speichern in eine Datei. * * VOR `autoSave.start()`: Der Sitzungsspeicher haelt genau diesen Stand * bereits; ihn wegen des Merkers gleich noch einmal zu schreiben, waere bei * einem eingebetteten Luftbild ein Schreibvorgang ueber zwanzig Megabyte * ohne jeden Zugewinn. */ /* * NUR, wenn dort auch etwas steht. Ein unberuehrter Stand ist von einem eben * angelegten nicht zu unterscheiden, und beim Verlassen schreibt * `AutoSave.beimVerlassen` ihn in den Sitzungsspeicher: Wer das Programm * oeffnet, nichts eingibt und schliesst, bekaeme sonst bei JEDEM zweiten * Start den Aufzaehlungspunkt im Titel und beim Schliessen die Rueckfrage * "Ungespeicherte Änderungen" - ueber eine Arbeit, die es nie gab. Am * gebauten Stand gemessen; der Rauchtest lief in die Rueckfrage. * * Eine Rueckfrage, die immer kommt, schuetzt nichts: Wer sie taeglich * wegklickt, klickt auch die eine weg, die einen Knotenpunkt haelt. */ if (restored.art === 'geladen' && !istUnberuehrt(restored.project)) store.markDirty(); // Die Art wird durchgereicht und nicht mehr auf "info" eingeebnet: Eine // Warnung bleibt im Meldungsbereich stehen, eine Kurzmeldung verschwindet // nach fuenf Sekunden. const autoSave = new AutoSave(store, settings, (message, kind) => { notify(message, kind); }); autoSave.start(); /* * Solange unter dem Sitzungsschluessel noch etwas Rettbares liegt, wird * nichts geschrieben. * * Zwei Ausgaenge fuehren dorthin, und beide sagen dem Anwender dasselbe zu: * * - 'nicht-lesbar': Der Sitzungsspeicher laesst den Satz bewusst unangetastet * (services/storage.ts, ladeSitzung: "Der Datensatz bleibt unangetastet"), * und der Anwender bekommt die Zusage "Er ist nicht verloren ... Speichern * Sie jetzt nichts". * - 'beschaedigt' OHNE Beiseitelegen: Der Satz liess sich nicht in den * geschuetzten Schluessel umhaengen - vermutlich ist der Speicher voll - * und bleibt deshalb liegen; der Quelltext sagt daneben zu, er werde "beim * naechsten Start erneut geprueft". * * Ohne diese Sperre bricht das Programm beide Zusagen: Der erste Sichtwechsel * - Minimieren oder Schliessen, also genau der empfohlene Neustart - schriebe * das leere Startprojekt darueber, und die anschliessende * Grossdatenaufraeumung naehme das Luftbild mit. Freigegeben wird erst, wenn * der Anwender ueber "Neu" oder "Öffnen" selbst bestimmt, was im * Sitzungsspeicher stehen soll. * * Ein beschaedigter Satz, der beiseitegelegt WERDEN KONNTE, ist damit nicht * gemeint: Er steht unter dem geschuetzten Schluessel, den auch die * Grossdatenaufraeumung mitliest, und der Platz ist frei. */ if (restored.art === 'nicht-lesbar' || !restored.beiseitegelegt) { autoSave.sperreSitzungsspeicherung(); } const shell = new Shell(root, store, APP_VERSION); // --- Aktionen der Kopfzeile --------------------------------------------- const newProject = async (): Promise => { // Waechter wie bei der Kurzhilfe (Befund M1): Steht schon ein modales // Fenster offen, baut ein weiterer Aufruf ein zweites, wortgleiches darauf. // Hier und nicht nur im Tastenempfaenger, weil das Anwendungsmenue von der // Stilllegung des Hintergrunds nicht erfasst wird. if (offeneFensterAnzahl() > 0) return; if (!(await confirmDiscard(store.getState(), 'Verwerfen und neu beginnen'))) return; const choice = await chooseTemplate(); if (choice === null) return; // `store.replace` loescht die Aenderungsmarke mit: Der Anwender hat selbst // bestimmt, was von jetzt an gilt. store.replace(choice); // Aus demselben Grund faellt eine Sperre aus dem Befund 'nicht-lesbar' weg. autoSave.gibSitzungsspeicherungFrei(); // Sofort sichern: sonst zeigt ein Neustart vor der ersten Aenderung wieder // den vorherigen Stand. // void: Das Versprechen wird bewusst nicht abgewartet - gesichert wird // im Hintergrund. Ohne das Schluesselwort verschwaende eine Ablehnung // (etwa ein Fehler der IndexedDB) lautlos als unbehandelte Ablehnung. void autoSave.flush(); // Die Ansicht neu aufbauen, nicht nur nachzeichnen. Ansichten, die eigenen // Zustand ueber das Projekt hinweg halten - die Simulation etwa fuehrt // Warteschlangen, Verkehrsstaerken und Bewertungsskala des Projekts mit, // aus dem sie gebaut wurde - zeigten sonst weiter die Zahlen des VORIGEN // Projekts, waehrend die Leinwand daneben schon das neue zeichnete. // Rueckgaengig, Wiederherstellen und die Vorgaben tun das laengst. shell.navigate(currentViewId()); notify('Neues Projekt angelegt.', 'erfolg'); }; const openProject = async (): Promise => { // Derselbe Waechter wie beim Neuanlegen - siehe dort. if (offeneFensterAnzahl() > 0) return; if (!(await confirmDiscard(store.getState(), 'Verwerfen und öffnen'))) return; const result = await openProjectFromFile(); if (result.canceled) return; if (!result.ok || result.result === null) { notify(result.message, 'fehler'); return; } store.replace(result.result.project, result.filePath); // Wie beim Neuanlegen: bewusst gesetzter Stand, also darf wieder // geschrieben werden. autoSave.gibSitzungsspeicherungFrei(); // void: Das Versprechen wird bewusst nicht abgewartet - gesichert wird // im Hintergrund. Ohne das Schluesselwort verschwaende eine Ablehnung // (etwa ein Fehler der IndexedDB) lautlos als unbehandelte Ablehnung. void autoSave.flush(); // Wie beim Neuanlegen: Ansichten mit eigenem Zustand muessen neu gebaut // werden, sonst stehen zwei Projekte in einem Bild. shell.navigate(currentViewId()); notify(result.message, 'erfolg'); // Unbedingt: Ob die Aufstellung ueberhaupt erscheint, entscheidet // `showImportIssues` selbst - fuer beide Wege dieselbe Bedingung. showImportIssues( result.result.issues, result.result.migrated, result.result.intergreenComparison, ); }; const speichereEinmal = async (forceDialog: boolean): Promise => { const state = store.getState(); // Genau dieses Objekt geht in die Datei: `serializeProject` laeuft // synchron vor dem Warten, der Dateiinhalt steht also mit dem Aufruf fest. const geschrieben = state.project; const result = await saveProjectToFile(geschrieben, forceDialog ? null : state.filePath); if (result.canceled) return; if (!result.ok) { notify(result.message, 'fehler'); return; } /* * Ohne Nachweis keine Aenderungsmarke loeschen. * * Ohne Desktop-Bruecke gibt es nur das Herunterladen im Browser, und ob * dabei wirklich etwas abgelegt wurde, erfaehrt die Anwendung nicht * (services/storage.ts, `unbestaetigt`). Ein `markSaved()` machte daraus * dieselbe unbelegte Aussage "gespeichert" wie beim Wettlauf darunter - mit * derselben Folge: "Neu" und "Öffnen" verwerfen danach ohne Rueckfrage. */ if (result.unbestaetigt === true) { notify( `${result.message} Ob die Datei abgelegt wurde, lässt sich hier nicht feststellen – der Stand gilt weiter als ungespeichert.`, 'warnung', ); void autoSave.flush(); return; } /* * Die Aenderungsmarke nur loeschen, wenn das Geschriebene noch gilt. * * Waehrend `saveProjectToFile` laeuft - IPC und Plattenschreibvorgang, bei * einem eingebetteten Luftbild auf einem Netzlaufwerk Sekunden - bleibt die * Oberflaeche bedienbar. Jede Eingabe in dieser Zeit setzt `dirty`. Ein * bedingungsloses `markSaved()` machte daraus die Aussage "gespeichert", * obwohl die Aenderung in keiner Datei steht: "Neu" und "Öffnen" verwerfen * sie danach ohne Rueckfrage. Denselben Wettlauf fuehrt der * Sitzungsspeicher laengst nach (services/autosave.ts, `nachholen`). * * Ein ZWEITER Speichervorgang kann daneben nicht mehr laufen - siehe den * Riegel bei `saveProject`. */ if (store.getProject() !== geschrieben) { store.setFilePath(result.filePath); notify( `${result.message} Was Sie während des Speicherns geändert haben, steht noch nicht in der Datei – bitte noch einmal speichern.`, 'warnung', ); // Der geaenderte Stand gehoert trotzdem sofort in den Sitzungsspeicher - // dort ist er nach einem Absturz noch da. void: wie unten. void autoSave.flush(); return; } store.markSaved(result.filePath); // void: Das Versprechen wird bewusst nicht abgewartet - gesichert wird // im Hintergrund. Ohne das Schluesselwort verschwaende eine Ablehnung // (etwa ein Fehler der IndexedDB) lautlos als unbehandelte Ablehnung. void autoSave.flush(); notify(result.message, 'erfolg'); }; /** * Der laufende Speichervorgang, solange einer laeuft. * * WARUM EIN RIEGEL UM DEN GANZEN ABLAUF * * Ein zweiter Lauf war ueber vier Wege ausloesbar - Strg+S, die * Kopfzeilenschaltflaeche (sie traegt kein `enabled` und ist damit nie * gesperrt), der Menuebefehl und "Speichern und schliessen" aus der * Rueckfrage -, und keiner davon fragte, ob schon einer laeuft. Der Waechter * in `speichereEinmal` vergleicht nur den EIGENEN Stand mit dem jetzigen und * weiss von einem zweiten Lauf nichts: Endete der juengere Lauf zuerst, * loeschte er die Aenderungsmarke, und der aeltere legte danach den AELTEREN * Stand auf die Platte. Der Anwender hatte eine Datei mit dem alten Stand, * ein Programm, das "gespeichert" sagt, und keinen Waechter mehr vor "Neu", * "Öffnen" oder dem Schliessen. Der Sitzungsspeicher fuehrt denselben Riegel * laengst (services/autosave.ts: "Es laeuft nie mehr als ein Schreibvorgang * gleichzeitig"). * * Ein zweiter Aufruf startet deshalb keinen zweiten Lauf, sondern bekommt die * Zusage des laufenden zurueck. Er wartet damit auf dessen Ergebnis - das * braucht "Speichern und schliessen", das gleich danach `dirty` liest -, ohne * eine zweite Nutzlast in denselben Dateiweg zu schicken. Gilt der * geschriebene Stand danach nicht mehr, bleibt die Aenderungsmarke stehen und * die Warnung "bitte noch einmal speichern" sagt es. * * Auch "Speichern unter" waehrend eines laufenden Speicherns bekommt den * laufenden Vorgang und keinen Dialog: Zwei Ziele gleichzeitig zu beschreiben * ist nicht das, was der Anwender meint, wenn er waehrend des Wartens noch * einmal drueckt. */ let laufenderSpeichervorgang: Promise | null = null; const saveProject = (forceDialog: boolean): Promise => { if (laufenderSpeichervorgang !== null) return laufenderSpeichervorgang; const lauf = speichereEinmal(forceDialog).finally(() => { laufenderSpeichervorgang = null; }); laufenderSpeichervorgang = lauf; return lauf; }; shell.addHeaderAction({ label: 'Neu', title: 'Neues Projekt anlegen (Strg+N)', run: () => newProject(), }); shell.addHeaderAction({ label: 'Öffnen', title: 'Projektdatei öffnen (Strg+O)', run: () => openProject(), }); shell.addHeaderAction({ label: 'Speichern', title: 'Projekt speichern (Strg+S)', variant: 'primaer', run: () => saveProject(false), }); shell.addHeaderAction({ label: 'Speichern unter', title: 'Projekt unter neuem Namen speichern (Strg+Umschalt+S)', run: () => saveProject(true), }); shell.addHeaderAction({ label: 'Rückgängig', title: 'Letzte Änderung rückgängig machen (Strg+Z)', enabled: (state) => state.canUndo, run: () => { const label = store.getState().undoLabel; store.undo(); // Neu aufbauen, NICHT den Ort wechseln: Eine Ruecknahme bewegt den // Anwender nicht von der Stelle. `shell.navigate` endete mit `focus()` // auf der Ueberschrift, und danach war die Schaltflaeche unter dem // Finger fort - ein zweiter Schritt zurueck nur noch ueber die ganze // Navigation erreichbar (WCAG 2.4.3). `baueSichtbareAnsichtNeu` leistet // denselben vollstaendigen Ab- und Wiederaufbau, den die Ansichten mit // eigenem Zustand brauchen, und bewahrt dabei den Fokus. shell.baueSichtbareAnsichtNeu(); if (label !== null) notify(`Rückgängig: ${label}`, 'info', 2500); }, }); shell.addHeaderAction({ label: 'Wiederherstellen', title: 'Änderung wiederherstellen (Strg+Y)', enabled: (state) => state.canRedo, run: () => { const label = store.getState().redoLabel; store.redo(); // Wie bei "Rückgängig" - siehe dort. shell.baueSichtbareAnsichtNeu(); if (label !== null) notify(`Wiederhergestellt: ${label}`, 'info', 2500); }, }); shell.addHeaderAction({ label: `Ansicht: ${themeLabel(settings.theme)}`, title: 'Zwischen hellem, dunklem und Systemerscheinungsbild wechseln', variant: 'schlicht', run: () => { // Frisch lesen und nur das eigene Feld setzen: `saveAppSettings` schreibt // das GANZE Objekt und mischt nichts. Eine hier gehaltene Startkopie // truege die Fundstellen von vor Stunden zurueck in den Speicher und // loeschte damit alles, was der Anwender seither unter "Vorgaben" // eingetragen hat - eine Angabe, die in die Planunterlage geht. const gespeichert = loadAppSettings(); settings = { ...gespeichert, theme: nextTheme(gespeichert.theme) }; saveAppSettings(settings); // Das Neuzeichnen der Leinwand haengt nicht hier, sondern am Wechsel // selbst (`beiErscheinungsbildwechsel` weiter unten): Er kommt auch aus // dem Betriebssystem, und beide Wege gehoeren an dieselbe Stelle. applyTheme(settings.theme); // Die Beschriftung nennt die EINSTELLUNG und wird deshalb hier // mitgefuehrt - sie wechselt auch dann, wenn sich an den Farben nichts // aendert (etwa von "Dunkel" auf "System" bei dunklem System). const node = [...root.querySelectorAll('button')].find((b) => // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- textContent fuehrt lib.dom als string, die Spezifikation als string | null b.textContent?.startsWith('Ansicht:'), ); if (node) node.textContent = `Ansicht: ${themeLabel(settings.theme)}`; }, }); shell.addHeaderAction({ label: 'Glossar', title: 'Alle Fachbegriffe nachschlagen', variant: 'schlicht', run: () => { zeigeGlossar(); }, }); shell.addHeaderAction({ label: 'Meldungen', title: 'Die Kurzmeldungen dieser Sitzung noch einmal ansehen', variant: 'schlicht', run: () => { zeigeMeldungen(); }, }); shell.addHeaderAction({ label: 'Hilfe', title: 'Kurzhilfe und Tastenkürzel anzeigen (F1)', variant: 'schlicht', run: () => { showHelp(); }, }); // --- Ansichten ----------------------------------------------------------- // Reihenfolge = Bearbeitungsreihenfolge. Die Vertraeglichkeit steht bewusst // VOR den Phasen: Ohne sie kann das Programm nicht warnen, wenn zwei // feindliche Signalgruppen in dieselbe Phase geraten. Das Vermassen der Wege // folgt danach, weil es Detailarbeit ist und die Struktur nicht beeinflusst. for (const view of [ projectView, // Der Lageplan steht vor den Signalgruppen: Wer zeichnet, laesst sich die // Signalgruppen daraus vorschlagen, statt sie von Hand anzulegen. lageplanView, signalGroupsView, compatibilityView, phasesView, conflictsView, planView, simulationView, // Der Vergleich steht hinter dem Ergebnis und vor dem Pruefbericht: Er // beurteilt einen fertigen Plan gegen einen zweiten und aendert nichts. // Die Koordinierung steht neben dem Vergleich: Beide beurteilen einen // fertigen Plan - der eine gegen einen zweiten Planfall, die andere gegen // die Nachbaranlagen. koordinierungView, vergleichView, reportView, exportView, settingsView, ]) { shell.addView(view); } /** * Welche Ansicht gerade sichtbar ist. * * Zuvor wurde `shell.navigate` hier durch eine Huelle ersetzt, die den Namen * nebenher in einer eigenen Variablen mitfuehrte. Zwei Buchfuehrungen ueber * dieselbe Sache laufen auseinander: Wechselte die Shell aus eigenem Antrieb * - ueber den Fuehrungshinweis oder ueber `context.navigate` aus einer * Ansicht heraus -, zeigte die Variable weiter auf die vorherige Ansicht, und * Strg+Z zeichnete danach die falsche neu. Die Shell weiss es selbst. */ const currentViewId = (): string => shell.aktiveAnsichtId; shell.start('projekt'); /* * Wechselt das Erscheinungsbild, wird die sichtbare Ansicht neu gezeichnet. * * `applyTheme` setzt das Attribut am Wurzelelement; die Farben der * Oberflaeche haengen an CSS-Variablen und folgen von selbst. Die * Zeichenflaechen tun das nicht: Signalzeitenplan, Simulation und Lageplan * lesen das Attribut im Augenblick des Zeichnens und behielten sonst die * alten Farben, bis irgendetwas anderes ein Neuzeichnen ausloeste - eine * weisse Zeichnung mitten in einer dunklen Oberflaeche. * * Hier und nur hier, weil es mehrere Zeichenflaechen gibt und der Wechsel aus * zwei Richtungen kommt: ueber die Kopfzeile und aus dem Betriebssystem * (Systemerscheinungsbild, Kontrastdesign). Jede Ansicht fuer sich nachziehen * zu lassen, waeren so viele Beschreibungen desselben Vorgangs, wie es * Ansichten gibt. * * Neu aufbauen, NICHT den Ort wechseln: Aus dem Betriebssystem kommt der * Wechsel ohne Zutun des Anwenders, und `shell.navigate` warf den Fokus * dabei mitten in der Eingabe auf die Ueberschrift der Ansicht. Was der * Neuaufbau nicht rettet - den angefangenen Streckenzug im Lageplan und die * laufende Simulation -, ist bei `Shell.baueSichtbareAnsichtNeu` vermerkt. */ beiErscheinungsbildwechsel(() => { shell.baueSichtbareAnsichtNeu(); }); /* * Was beim Einlesen des Sitzungsstands auffiel, in derselben Aufstellung wie * beim Oeffnen einer Datei (Befund 12). * * Erst hier, nicht in `restoreSession`: Das Fenster braucht die aufgebaute * Oberflaeche. Zuvor nannte der Wiederanlauf nur die Anzahl der Meldungen - * eine Zahl, aus der niemand ersieht, dass etwa ein Raeumweg auf den * Hoechstwert der Anlagenart begrenzt wurde und die Zwischenzeiten deshalb * andere sind als eingetragen. */ const einlesebefund = restored.einlesebefund; // Wie beim Oeffnen unbedingt gerufen: Ob etwas zu zeigen ist, entscheidet // `showImportIssues`. Der Wiederanlauf hatte hier zuvor eine eigene, aermere // Bedingung - und danach eine wortgleiche Abschrift der anderen. if (einlesebefund !== null) { showImportIssues( einlesebefund.issues, einlesebefund.migrated, einlesebefund.intergreenComparison, ); } // --- Tastenkuerzel ------------------------------------------------------- window.addEventListener('keydown', (event) => { const ctrl = event.ctrlKey || event.metaKey; if (event.key === 'F1') { event.preventDefault(); showHelp(); return; } if (!ctrl) return; /* * Nichts auswerten, solange ein modales Fenster offensteht. * * Der Stapelempfaenger in ui/feedback.ts faengt nur Escape und Tab ab; jede * andere Taste blubbert bis hierher, obwohl der ganze Hintergrund * stillgelegt ist. Strg+N und Strg+O bauten so ein weiteres Fenster auf den * Stapel - genau das Muster, das fuer F1 als Befund M1 behoben ist -, und * Strg+Z/Strg+Y bauten die stillgelegte Ansicht unter dem Dialog neu auf. * F1 steht bewusst davor: Ein zweiter Druck holt die schon offene Kurzhilfe * nach vorn, statt gar nichts zu tun. */ if (offeneFensterAnzahl() > 0) return; const key = event.key.toLowerCase(); if (key === 's') { event.preventDefault(); void saveProject(event.shiftKey); } else if (key === 'o') { event.preventDefault(); void openProject(); } else if (key === 'n') { event.preventDefault(); void newProject(); } else if (key === 'z' && !event.shiftKey) { if (isTextEntry(event.target)) return; event.preventDefault(); store.undo(); // Neu aufbauen statt den Ort zu wechseln - Begruendung bei der // Kopfzeilenaktion "Rückgängig". shell.baueSichtbareAnsichtNeu(); } else if (key === 'y' || (key === 'z' && event.shiftKey)) { if (isTextEntry(event.target)) return; event.preventDefault(); store.redo(); shell.baueSichtbareAnsichtNeu(); } }); // --- Fehlerbehandlung ---------------------------------------------------- /** * Unbehandelte Fehler sichtbar machen und protokollieren. * * Zuvor verschwand ein solcher Fehler in der Konsole, die im * Auslieferungsstand niemand sieht: Die Oberflaeche blieb einfach stehen, * ohne dass erkennbar war, warum. Jetzt erscheint eine Meldung, der Stand * wird sofort gesichert, und der Fehler wird zusammen mit dem Zustand der * Anwendung protokolliert - genau die Angaben, die man zur Nachstellung * braucht. */ const meldeFehler = (quelle: string, fehler: unknown): void => { const nachricht = fehler instanceof Error ? fehler.message : String(fehler); const stapel = fehler instanceof Error ? (fehler.stack ?? '') : ''; const state = store.getState(); // Hier NICHT try/catch: flush() liefert ein Versprechen, und ein // try/catch um einen nicht abgewarteten Aufruf faengt ausschliesslich // synchrone Ausnahmen ab. writeSession() in services/autoSave.ts hat ein // finally, aber kein catch - eine Ablehnung waere also ausgerechnet // waehrend der Fehlerbehandlung als unbehandelte Ablehnung entkommen. autoSave.flush().catch(() => { /* Sichern darf die Fehlerbehandlung nicht unterbrechen. */ }); desktopBridge()?.reportError({ quelle, nachricht, ...(stapel !== '' ? { stapel } : {}), zustand: { ansicht: currentViewId(), signalgruppen: state.project.signalGroups.length, phasen: state.project.phases.length, konflikte: state.project.conflicts.length, verkehrsstaerken: state.project.demands.length, umlaufzeit: state.plan.cycleTime, verfahren: state.project.program.method, fehler: state.report.errorCount, warnungen: state.report.warningCount, ungespeichert: state.dirty, }, }); console.error(`${quelle}:`, fehler); notify( `Es ist ein unerwarteter Fehler aufgetreten: ${nachricht}\n\n` + 'Ihr Arbeitsstand wurde gesichert. Bitte melden Sie den Vorgang zusammen mit dem, was Sie zuletzt getan haben.', 'fehler', ); }; /** * Meldungen, die zwar als Fehler gemeldet werden, aber keine sind. * * "ResizeObserver loop completed with undelivered notifications" ist der * einzige Fall: Die Anzeige teilt damit mit, dass sie eine Groessenmeldung * auf den naechsten Bilddurchlauf verschoben hat. Die Darstellung ist dabei * einen Durchlauf spaeter richtig; nichts geht verloren. Der Anwender mit * einer Fehlermeldung samt Bitte um Meldung zu behelligen waere irrefuehrend. * * Es bleibt bei genau dieser einen Ausnahme - eine Liste, die mit der Zeit * waechst, wuerde die Fehlerbehandlung wieder aushoehlen. */ const istHarmlos = (nachricht: string): boolean => nachricht.startsWith('ResizeObserver loop'); window.addEventListener('error', (event) => { // `message` fuehrt lib.dom als Zeichenkette, der Linter beanstandet den // Rueckfall deshalb. Er bleibt: Diese Stelle ist die letzte vor der // Fehlermeldung an den Anwender, und sie bekommt auch von Hand erzeugte // Ereignisse. Ein fehlendes Feld darf die Fehlerbehandlung nicht selbst // zum Absturz bringen. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- letzte Fehlerbehandlung vor der Meldung; siehe darueber if (istHarmlos(String(event.message ?? ''))) return; meldeFehler('Ausnahme', event.error ?? event.message); }); window.addEventListener('unhandledrejection', (event) => { meldeFehler('Zusage', event.reason); }); // --- Desktop-Bruecke ----------------------------------------------------- const bridge = desktopBridge(); if (bridge) { const meldeAenderungsstand = (): void => { bridge.setDirty(store.getState().dirty); }; store.subscribe(meldeAenderungsstand); // Einmal sofort, nicht erst bei der naechsten Aenderung: Der Hauptprozess // fragt vor dem Schliessen nur nach, solange er den Stand fuer ungespeichert // haelt (electron/main.ts, `rendererIsDirty`). Ein wiederhergestellter Stand // ist es von der ersten Sekunde an. meldeAenderungsstand(); bridge.onMenuCommand((command) => { switch (command) { case 'neu': void newProject(); break; case 'oeffnen': void openProject(); break; case 'speichern': /* * Derselbe Waechter wie bei "Neu" und "Öffnen" darueber, und aus * demselben Grund: Das Anwendungsmenue wird von der Stilllegung des * Hintergrunds nicht erfasst. Der Tastenweg bricht bei offenem * modalem Fenster ab; ohne den Waechter hier liesse sich mitten in * der Vorlagenauswahl oder in der Rueckfrage "Ungespeicherte * Änderungen" ein Speichervorgang ausloesen. * * "Speichern und schliessen" aus jener Rueckfrage kommt nicht hier * durch, sondern ruft `saveProject` unmittelbar - dort ist das * Fenster bereits vom Stapel. */ if (offeneFensterAnzahl() > 0) break; void saveProject(false); break; case 'speichern-unter': if (offeneFensterAnzahl() > 0) break; void saveProject(true); break; case 'export': /* * Derselbe Waechter, und er traegt auch hier: Ein Ortswechsel ist kein harmloserer * Vorgang als ein Speichervorgang. `shell.navigate` raeumt die * Ansicht ab, aus der das offene Fenster stammt, und endet mit dem * Fokus auf der Ueberschrift der neuen - also IN dem Teilbaum, den * `legeHintergrundStill` gerade stillgelegt hat. Der Tastenempfaenger * bricht aus demselben Grund schon bei Strg+Z ab. */ if (offeneFensterAnzahl() > 0) break; shell.navigate('export'); break; case 'hilfe': showHelp(); break; case 'ueber': /* * Ohne Riegel an dieser Stelle: `zeigeUeber` bringt beide Waechter * selbst mit und in der Reihenfolge, auf die es ankommt - erst die * Rueckkehr in ein schon offenes "Über"-Fenster (Befund M1), dann * der Abbruch bei jedem anderen offenen Fenster. Umgekehrt * geschachtelt bliebe ein zweiter Menuebefehl bei offenem * "Über"-Fenster ohne jede Rueckmeldung. * * Die Fassung wird uebergeben und nicht dort ermittelt: Sie steht * einmal, in package.json, und kommt ueber `__APP_VERSION__` hier * an. */ zeigeUeber(APP_VERSION); break; default: break; } }); bridge.onBeforeClose(async () => { const state = store.getState(); if (!state.dirty) return true; const answer = await confirmDialog({ title: 'Ungespeicherte Änderungen', message: `Am Projekt "${state.project.meta.name}" bestehen ungespeicherte Änderungen.`, confirmLabel: 'Ohne Speichern schließen', cancelLabel: 'Abbrechen', extraLabel: 'Speichern und schließen', danger: true, }); if (answer === 'abgebrochen') return false; if (answer === 'zusatz') { await saveProject(false); return !store.getState().dirty; } return true; }); } else { // Im Browser bleibt nur die Standardabfrage des Fensters. window.addEventListener('beforeunload', (event) => { if (!store.getState().dirty) return; event.preventDefault(); event.returnValue = ''; }); } // --- Hilfsfunktionen ----------------------------------------------------- function chooseTemplate(): Promise { return new Promise((resolve) => { const panel = new Panel({ title: 'Neues Projekt', width: 520 }); let settled = false; const finish = (value: Project | null): void => { if (settled) return; settled = true; panel.close(); resolve(value); }; panel.onClose(() => { if (!settled) { settled = true; resolve(null); } }); panel.setContent( el('p', { class: 'feld-hinweis', text: 'Womit möchten Sie beginnen?' }), el( 'div', { style: 'display:flex;flex-direction:column;gap:8px' }, templateOption( 'Geführter Einstieg', // Nicht "Vier Fragen": Seit der Anlagenart fuehrt der Assistent bei // Fussgaengerschutzanlage und einstreifiger Verkehrsfuehrung nur // drei Schritte (assistent.ts, schritteFuer). Eine feste Zahl an // dieser Stelle ist in zwei von drei Faellen falsch. 'Empfohlen, wenn Sie zum ersten Mal einen Signalzeitenplan aufstellen. Drei bis vier Fragen zur Örtlichkeit – daraus entstehen Signalgruppen, Konfliktbeziehungen und Phasen.', () => { // Wichtig: erst als erledigt markieren, dann schliessen. Sonst // loest der Schliessen-Empfaenger die Zusage sofort mit "abgebrochen" // auf, und das Ergebnis des Assistenten kommt nie an. settled = true; panel.close(); void starteAssistent().then((projekt) => { if (projekt === null) { // Abgebrochen - zurueck zur Auswahl. void chooseTemplate().then(resolve); return; } resolve(projekt); }); }, ), templateOption( 'Beispielprojekt', 'Fertiger vierarmiger Knotenpunkt mit vier Kfz-Signalgruppen, zwei Fußgängerfurten, zwei Phasen und Verkehrsstärken – zum Ansehen und Ausprobieren.', () => { finish(createStandardIntersectionProject()); }, ), templateOption( 'Leeres Projekt', 'Ohne Signalgruppen und Phasen. Nur sinnvoll, wenn Sie den Ablauf bereits kennen.', () => { finish(createEmptyProject()); }, ), ), ); panel.setActions(button({ label: 'Abbrechen', onClick: () => finish(null) })); }); } function templateOption(title: string, description: string, onClick: () => void): HTMLElement { const node = button({ label: '', onClick }); node.style.textAlign = 'left'; node.style.display = 'block'; node.style.padding = '12px'; // Ohne ausdruecklichen Namen liest ein Bildschirmleser Ueberschrift und // Beschreibung als eine lange Zeichenkette vor. node.setAttribute('aria-label', title); node.replaceChildren( el('strong', { text: title }), el('span', { class: 'feld-hinweis', style: 'display:block', text: description }), ); return node; } /** * Zeigt, was beim Einlesen auffiel - und entscheidet selbst, ob es etwas zu * zeigen gibt. * * Die Bedingung steht hier und nicht an den Aufrufstellen: Beide Wege in ein * Projekt hinein - "Öffnen" und der Wiederanlauf aus dem Sitzungsspeicher - * fuehren dieselbe Aufstellung, und eine wortgleiche Abschrift der Bedingung * an zwei Stellen laeuft auseinander, sobald einer sie erweitert. * * Auch ohne Hinweise wird gezeigt, sobald eine Gegenueberstellung vorliegt: * Bei einem uebernommenen 4.x-Projekt ist gerade sie die wichtigste Auskunft. */ function showImportIssues( issues: readonly { path: string; severity: string; message: string }[], migrated: boolean, vergleich: readonly IntergreenComparisonRow[] = [], ): void { if (issues.length === 0 && vergleich.length === 0) return; const panel = new Panel({ title: migrated ? 'Projekt übernommen' : 'Hinweise zum Import', width: 720, }); panel.setContent( el('p', { text: migrated ? 'Das Projekt stammt aus einer älteren Programmversion. Die folgenden Punkte sind zu beachten:' : 'Beim Einlesen sind folgende Punkte aufgefallen:', }), issues.length === 0 ? null : // Wie jede andere Tabelle der Anwendung durch `beschrifteTabelle`: // Sie setzt die Beschriftung und den Spaltenbezug, die die // Zusage zur Barrierefreiheit unter EK 1.3.1 fuer das // GESAMTE Erzeugnis zusagt. Die vier Tabellen dieser Datei liefen als // einzige daran vorbei; tests/ui/barrierefreiheit.test.ts erreicht // sie nicht, weil er nur ueber die zwoelf Ansichten laeuft. beschrifteTabelle( el( 'div', { class: 'tabelle-rahmen' }, el( 'table', {}, el( 'thead', {}, el( 'tr', {}, el('th', { text: 'Art' }), el('th', { text: 'Bereich' }), el('th', { text: 'Hinweis' }), ), ), el( 'tbody', {}, ...issues.map((issue) => el( 'tr', {}, el('td', { text: issue.severity }), el('td', { text: issue.path }), el('td', { text: issue.message }), ), ), ), ), ), { beschriftung: 'Hinweise zum Einlesen der Datei' }, ), ...zwischenzeitVergleich(vergleich), ); panel.setActions( button({ label: 'Verstanden', variant: 'primaer', onClick: () => panel.close() }), ); } /** * Gegenueberstellung der Zwischenzeiten aus 4.x mit den neu ermittelten. * * Der Kopf von migrate.ts sagt diese Gegenueberstellung ausdruecklich zu - * "damit die Aenderung nachvollziehbar ist" -, und sie wurde auch aufgebaut, * erreichte den Anwender aber nie: Niemand las das Feld. Uebrig blieb ein * Sammelhinweis, dass die Zwischenzeiten neu gerechnet wurden. Um wie viel * sie sich aendern, ist die eigentliche Frage: Der Altbestand rechnete mit * 50 km/h Raeumgeschwindigkeit statt der 10 m/s des Regelwerks, seine Werte * sind also zu kurz - und der Planer muss sehen, wie viel zu kurz. */ function zwischenzeitVergleich(vergleich: readonly IntergreenComparisonRow[]): (Node | null)[] { if (vergleich.length === 0) return []; const plan = store.getPlan(); const gruppe = (name: string): string | undefined => store.getProject().signalGroups.find((g) => g.name === name)?.id; const zeilen = vergleich.map((z) => { const vonId = gruppe(z.from); const nachId = gruppe(z.to); const neu = vonId === undefined || nachId === undefined ? null : (plan.intergreens.get(intergreenKey(vonId, nachId))?.value ?? null); return { ...z, neu }; }); return [ el('h3', { text: 'Zwischenzeiten: bisher und jetzt', style: 'margin-top:18px' }), el('p', { class: 'feld-hinweis', text: 'Die gespeicherte Zwischenzeitenmatrix wurde nicht übernommen. Sie war mit 50 km/h ' + 'Räumgeschwindigkeit gerechnet, das Regelwerk sieht 10 m/s vor – die alten Werte sind ' + 'zu kurz. Neu ermittelt wurde aus Räum- und Einfahrweg.', }), beschrifteTabelle( el( 'div', { class: 'tabelle-rahmen' }, el( 'table', {}, el( 'thead', {}, el( 'tr', {}, el('th', { text: 'Beziehung' }), el('th', { text: 'bisher' }), el('th', { text: 'jetzt' }), el('th', { text: 'Unterschied' }), ), ), el( 'tbody', {}, ...zeilen.map((z) => el( 'tr', {}, el('td', { text: `${z.from} nach ${z.to}` }), el('td', { class: 'zahl', text: `${fmt.numShort(z.legacyValue, 0)} s` }), el('td', { class: 'zahl', text: z.neu === null ? '–' : `${fmt.numShort(z.neu, 0)} s`, }), el('td', { class: 'zahl', text: z.neu === null ? '–' : `${z.neu > z.legacyValue ? '+' : ''}${fmt.numShort(z.neu - z.legacyValue, 0)} s`, }), ), ), ), ), ), { beschriftung: 'Zwischenzeiten: bisher und jetzt' }, ), ]; } /** * Der Meldungsverlauf dieser Sitzung. * * Eine Kurzmeldung ist fluechtig - das ist ihr Zweck und zugleich ihr Mangel. * Wer mit einer Bildschirmlupe arbeitet, hat den Balken unten rechts oft noch * nicht gefunden, wenn er schon wieder verschwunden ist; und wer beim Speichern * kurz wegsieht, hatte bisher keinen Weg zurueck zu dem, was gemeldet wurde. * Ausgerechnet die abgelehnten Zahleneingaben stehen dort. * * Hier steht auch die ANZEIGEDAUER (Befund L8, WCAG 2.2.1). Sie gehoert * hierher und nicht unter "Vorgaben": Diese Ansicht fuehrt die Kennwerte der * RiLSA, und eine Bildschirmeinstellung zwischen Raeumgeschwindigkeit und * Gelbzeitstaffel waere dort nicht zu vermuten. Wer eine Meldung verpasst * hat, kommt genau hierher - und findet an derselben Stelle den Schalter, mit * dem es nicht wieder vorkommt. */ function zeigeMeldungen(): void { const panel = new Panel({ title: 'Meldungen dieser Sitzung', width: 720 }); const eintraege = meldungsverlauf(); const uhrzeit = new Intl.DateTimeFormat('de-DE', { timeStyle: 'medium' }); const bezeichnung: Record = { info: 'Hinweis', erfolg: 'Erfolg', warnung: 'Warnung', fehler: 'Fehler', }; const dauerbeschriftung = (sekunden: number): string => { if (sekunden === 0) return 'stehen lassen, bis ich sie schließe'; const standard = sekunden === DEFAULT_APP_SETTINGS.meldungsdauerSekunden; return `${sekunden} Sekunden${standard ? ' (Standard)' : ''}`; }; const dauerfeld = select({ label: 'Anzeigedauer für Hinweise und Erfolgsmeldungen', value: String(settings.meldungsdauerSekunden), options: MELDUNGSDAUER_STUFEN.map((s) => ({ value: String(s), label: dauerbeschriftung(s), })), hint: 'Gilt für die kurzen Meldungen am Bildschirmrand. Warnungen und Fehler bleiben ohnehin ' + 'stehen, bis sie geschlossen werden. Die Einstellung wird gesichert und gilt auch nach ' + 'einem Neustart.', onChange: (wert) => { const sekunden = Number(wert); if (!MELDUNGSDAUER_STUFEN.includes(sekunden)) return; // Frisch lesen und nur das eigene Feld setzen - Begruendung bei der // Kopfzeilenschaltflaeche "Ansicht:". settings = { ...loadAppSettings(), meldungsdauerSekunden: sekunden }; saveAppSettings(settings); setzeMeldungsdauer(sekunden); // Die Meldung ist zugleich die Probe: Sie erscheint mit der eben // gewaehlten Dauer. notify( sekunden === 0 ? 'Meldungen bleiben jetzt stehen, bis Sie sie schließen.' : `Meldungen werden jetzt ${sekunden} Sekunden lang angezeigt.`, 'erfolg', ); }, }); panel.setContent( dauerfeld, eintraege.length === 0 ? emptyState('In dieser Sitzung wurde noch nichts gemeldet.') : el( 'ul', { class: 'meldungsverlauf' }, ...eintraege.map((eintrag) => el( 'li', { class: `meldung meldung-${eintrag.art}` }, // Die Art steht als Wort da, nicht nur als Randfarbe: Sonst // laesst sich eine abgelehnte Eingabe nicht von einer // Erfolgsmeldung unterscheiden, sobald man Farben schlecht // trennt - und im Verlauf fehlt der zeitliche Zusammenhang, der // im Augenblick der Meldung noch half. el('strong', { text: `${uhrzeit.format(eintrag.zeitpunkt)} · ${bezeichnung[eintrag.art] ?? eintrag.art}`, }), el('span', { text: eintrag.text }), ), ), ), ); panel.setActions( button({ label: 'Schließen', variant: 'primaer', onClick: () => panel.close() }), ); } /** Waechter gegen gestapelte Kurzhilfen - siehe `kurzhilfe` (Befund M1). */ function showHelp(): void { // Schon offen: kein zweites Fenster, sondern zurueck in das vorhandene. // Ein Schliessen und Neuoeffnen waere fuer eine Sprachausgabe ein // Ortswechsel und wuerde den Lesestand des Anwenders verwerfen. if (kurzhilfe !== null) { kurzhilfe.fokussiere(); return; } const panel = new Panel({ title: 'Kurzhilfe', width: 760 }); kurzhilfe = panel; panel.onClose(() => { kurzhilfe = null; }); panel.setContent( el('h3', { text: 'Vorgehen' }), el( 'ol', {}, el('li', { text: 'Projektdaten und Knotenpunkt erfassen.' }), el('li', { text: 'Signalgruppen anlegen und Verkehrsstaerken eintragen.' }), el('li', { text: 'Konfliktbeziehungen in der Zwischenzeitenmatrix erfassen und Räum- sowie Einfahrwege aus dem Lageplan vermaßen.', }), el('li', { text: 'Phasen bilden und die Phasenfolge festlegen.' }), el('li', { text: 'Signalzeitenplan prüfen, Prüfbericht abarbeiten.' }), el('li', { text: 'Planunterlagen ausgeben.' }), ), el('h3', { text: 'Rechengrundlagen' }), el( 'ul', {}, el('li', { text: 'Zwischenzeit: tz = tü + tr − te, aufgerundet auf ganze Sekunden.' }), el('li', { text: 'Räumzeit tr = sr / vr mit sr einschließlich Fahrzeuglänge; Einfahrzeit te = se / ve.', }), el('li', { text: 'Gelbzeit: bis 50 km/h 3 s, bis 60 km/h 4 s, darüber 5 s. Rot-Gelb 1 s.', }), el('li', { text: 'Übergangszeit zwischen zwei Phasen: die größte Zwischenzeit zwischen endenden und beginnenden Signalgruppen.', }), el('li', { text: 'Umlaufzeit wahlweise nach Webster, Akçelik, HBS oder HCM - oder als feste Vorgabe.', }), ), el('h3', { text: 'Tastenkürzel' }), // Kopfzeile und Beschriftung wie bei jeder anderen Tabelle: Beide // Tafeln hatten weder das eine noch das andere und standen fuer eine // Sprachausgabe als namenloses Feld aus zwei Spalten da - entgegen der // Zusage zur Barrierefreiheit unter EK 1.3.1. tastenkuerzelTafel('Tastenkürzel der Anwendung', [ shortcutRow('Strg + N', 'Neues Projekt'), shortcutRow('Strg + O', 'Projekt öffnen'), shortcutRow('Strg + S', 'Speichern'), shortcutRow('Strg + Umschalt + S', 'Speichern unter'), shortcutRow('Strg + Z', 'Rückgängig'), shortcutRow('Strg + Y', 'Wiederherstellen'), shortcutRow('F1', 'Diese Hilfe'), shortcutRow('Esc', 'Dialog schließen'), ]), el('h3', { text: 'Zeichenfläche des Lageplans' }), el('p', { text: 'Gilt, solange die Zeichenfläche den Fokus hat. Dieselbe Belegung steht aufklappbar ' + 'unter der Zeichenfläche selbst.', }), tastenkuerzelTafel('Tastenkürzel der Zeichenfläche des Lageplans', [ shortcutRow('Pfeiltasten', 'Zeiger bewegen (Umschalt fein, Strg grob)'), shortcutRow('Leertaste', 'Punkt setzen'), shortcutRow('Eingabetaste', 'Linie abschliessen'), shortcutRow('Rückschritt', 'Letzten Punkt zurücknehmen'), shortcutRow('+ / -', 'Vergrößern und verkleinern'), shortcutRow('0', 'Einpassen'), shortcutRow('Bild auf / ab, Pos1 / Ende', 'Ausschnitt verschieben'), shortcutRow('Esc', 'Zeichnen abbrechen'), ]), el('h3', { text: 'Grenzen der Prüfung' }), el('p', { text: 'Die Prüfung deckt die rechnerisch prüfbaren Anforderungen ab. Sie ersetzt weder die fachliche ' + 'Verantwortung des Planers noch die verkehrsbehördliche Anordnung nach Paragraf 45 StVO. ' + 'Örtliche Besonderheiten, Sichtverhältnisse und die bauliche Ausbildung sind gesondert zu würdigen.', }), el('h3', { text: 'Bei Störungen' }), (() => { /* * ERST DER MENUEWEG, DANN DER PFAD. Bis 5.42.1 schickte dieser Absatz * den Anwender allein auf den Pfad darunter. Je nach Installationsart * findet er die Datei dort im Explorer nicht unbedingt: Windows leitet * neue Dateien unter %APPDATA% in einen eigenen Bereich um, sofern * %APPDATA%\lsa-planer-professional vorher nicht bestand, und der Pfad, * den der Hauptprozess nennt, ist der unumgeleitete (Kopf von * electron/protokoll.ts). Bestand der Ordner schon - vom Setup oder vom * tragbaren Programm -, landen auch neue Dateien dort, und der Pfad * stimmt (gemessen am 17.09.2026 unter Windows 11). Der Satz unter dem * Menueweg nennt deshalb die Ausnahme, fuer Windows 11 - so, wie es * docs/datenschutz.md seit ihrer ersten Fassung 5.43.0 tut. * "Fehlerprotokoll speichern …" liest die Datei dort, wo das Programm * sie kennt, und fuehrt in jeder Auslieferung zu einer Kopie. Die * Beschriftung steht woertlich wie in electron/menu.ts. * * "Sie enthält nur technische Angaben, keine Projektinhalte" stand hier * und war staerker als der Beleg: Das Protokoll ist darauf angelegt, * keine Projektinhalte aufzunehmen, filtert die Meldungstexte selbst * aber nicht, und der Aufrufstapel kann den Windows-Benutzernamen tragen * (docs/datenschutz.md, Abschnitt "Fehlerprotokoll"). Deshalb der * Hinweis, dass sich die Kopie vor dem Versenden lesen laesst. * * Der Pfad bleibt als Auskunft stehen - meist stimmt er. Im * Browserbetrieb (npm run dev) gibt es weder Protokoll noch * Anwendungsmenue; dort steht allein der Satz, der das sagt, statt eines * Menuewegs, der ins Leere fuehrt. */ const bruecke = desktopBridge(); if (!bruecke) { return el('p', { class: 'feld-hinweis', text: 'Im Browserbetrieb steht kein Protokoll zur Verfügung; Fehler erscheinen in der Entwicklerkonsole.', }); } const absatz = el('p', { text: 'Die Anwendung schreibt unerwartete Fehler in ein Protokoll. Über ' + '„Hilfe → Fehlerprotokoll speichern …“ ' + 'legen Sie eine Kopie an einem Ort Ihrer Wahl an; bitte legen Sie diese Kopie einer ' + 'Fehlermeldung bei. Das Protokoll ist darauf angelegt, keine Projektinhalte ' + 'aufzunehmen, und die Kopie ist eine gewöhnliche Textdatei, die Sie vor dem Versenden ' + 'lesen können.', }); const auskunft = el('p', { class: 'feld-hinweis', text: 'Das Protokoll selbst liegt unter dem folgenden Pfad. Bei manchen Installationsarten ' + 'zeigt der Explorer die Datei dort nicht unbedingt an, weil Windows die ' + 'Programmdaten gesondert führt. Unter Windows 11 gilt das nicht, wenn das Programm ' + 'aus dem Setup oder das tragbare Programm ihren Ordner schon vorher angelegt hatte.', }); const pfad = el('p', { class: 'feld-hinweis', text: 'Pfad wird ermittelt …' }); void bruecke .logPath() .then((wert) => { pfad.textContent = wert; pfad.style.userSelect = 'text'; }) .catch(() => { pfad.textContent = 'Der Pfad konnte nicht ermittelt werden.'; }); return el('div', {}, absatz, auskunft, pfad); })(), ); panel.setActions( button({ label: 'Schließen', variant: 'primaer', onClick: () => panel.close() }), ); } function shortcutRow(keys: string, description: string): HTMLElement { return el( 'tr', {}, el('td', { class: 'eng' }, el('span', { class: 'tastenkuerzel', text: keys })), el('td', { text: description }), ); } /** * Eine Tafel mit Tastenkuerzeln - mit Kopfzeile und Beschriftung. * * Beide Tafeln der Kurzhilfe standen zuvor ohne `thead` und ohne `caption` * da. Eine Sprachausgabe nannte damit weder, wovon die Tafel handelt, noch * welche Spalte die Taste und welche die Wirkung fuehrt - obwohl die Zusage * zur Barrierefreiheit unter EK 1.3.1 fuer das gesamte Erzeugnis zusagt, dass * Tabellen `scope` an jedem Spaltenkopf und eine Beschriftung tragen. Den * Spaltenbezug setzt `beschrifteTabelle` nach; die Kopfzeile muss es geben, * damit es einen gibt. */ function tastenkuerzelTafel(beschriftung: string, zeilen: HTMLElement[]): HTMLElement { return beschrifteTabelle( el( 'div', { class: 'tabelle-rahmen' }, el( 'table', {}, el('thead', {}, el('tr', {}, el('th', { text: 'Taste' }), el('th', { text: 'Wirkung' }))), el('tbody', {}, ...zeilen), ), ), { beschriftung }, ); } } /** * Stellt den zuletzt bearbeiteten Stand her - und sagt, wenn das misslingt. * * Zuvor lieferte loadSession() fuer drei voellig verschiedene Ausgaenge * dasselbe null: nie etwas gespeichert, Datenbank nicht lesbar, Satz * beschaedigt. Der Anwender bekam in jedem Fall ein leeres Projekt, ohne ein * Wort - auch dann, wenn sein Arbeitsstand noch da, aber unlesbar war. Der * catch-Zweig darunter griff dabei praktisch nie, weil ladeSitzung() seine * Fehler selbst abfaengt. */ interface Wiederanlauf { readonly project: Project; readonly filePath: string | null; /** * Ausgang des Ladeversuchs. * * Wird mit herausgereicht, weil der Start ihn braucht: Nach 'nicht-lesbar' * darf kein Sitzungsstand geschrieben werden, und ein 'geladen' * wiederhergestellter Stand steht in keiner Datei. Ohne diese Angabe konnte * `boot()` beides nicht auseinanderhalten. */ readonly art: Sitzungsbefund['art']; /** * Konnte ein beschaedigter Satz beiseitegelegt werden? * * Nur bei `art === 'beschaedigt'` von Belang, sonst immer `true`. Wird * mitgereicht, weil der Start es braucht: Misslang das Beiseitelegen, steht * der beschaedigte Satz weiterhin unter dem Sitzungsschluessel, und der * Speicher sagt daneben zu, er werde "beim naechsten Start erneut geprueft". * Ohne diese Angabe kam der Wert nie bei `boot()` an - er ging allein in den * Meldungstext -, die Zwischenspeicherung lief ungesperrt weiter, und der * erste Sichtwechsel schrieb das leere Startprojekt darueber. */ readonly beiseitegelegt: boolean; /** * Was beim Einlesen des Sitzungsstands auffiel - oder null. * * Wird mit herausgereicht, damit `boot()` dieselbe Aufstellung zeigen kann * wie beim Oeffnen einer Datei. Zuvor blieb hier nur die ANZAHL der * Meldungen uebrig, und die eigentliche Auskunft ging verloren: dass etwa ein * Raeumweg auf den Hoechstwert der Anlagenart begrenzt wurde und die * Zwischenzeiten deshalb andere sind als eingetragen. */ readonly einlesebefund: MigrationResult | null; } async function restoreSession(): Promise { // Meldungen erscheinen erst nach dem Aufbau der Oberflaeche, damit der Start // nicht blockiert. const spaeter = (text: string, art: 'warnung' | 'fehler'): void => { setTimeout(() => { notify(text, art); }, 400); }; try { const befund = await ladeSitzung(); switch (befund.art) { case 'leer': return { project: createEmptyProject(), filePath: null, art: befund.art, beiseitegelegt: true, einlesebefund: null, }; case 'nicht-lesbar': spaeter( 'Der zuletzt bearbeitete Stand konnte nicht gelesen werden. Er ist nicht verloren – ' + 'die Datenbank ließ sich nur nicht öffnen. Speichern Sie jetzt nichts, sondern ' + 'starten Sie das Programm neu; bleibt es dabei, öffnen Sie Ihre zuletzt gespeicherte ' + 'Projektdatei.', 'fehler', ); return { project: createEmptyProject(), filePath: null, art: befund.art, beiseitegelegt: true, einlesebefund: null, }; case 'beschaedigt': spaeter( befund.beiseitegelegt ? 'Der zuletzt bearbeitete Stand war beschädigt und ließ sich nicht einlesen. Er wurde ' + 'beiseitegelegt und nicht gelöscht. Öffnen Sie Ihre zuletzt gespeicherte Projektdatei.' : 'Der zuletzt bearbeitete Stand war beschädigt und ließ sich nicht einlesen. Er konnte ' + 'auch nicht beiseitegelegt werden – vermutlich ist der Speicher voll. Öffnen Sie ' + 'Ihre zuletzt gespeicherte Projektdatei.', 'fehler', ); return { project: createEmptyProject(), filePath: null, art: befund.art, beiseitegelegt: befund.beiseitegelegt, einlesebefund: null, }; case 'geladen': // Die Meldungen selbst zeigt `boot()`, sobald die Oberflaeche steht - // in derselben Aufstellung wie beim Oeffnen einer Datei. Hier stand // zuvor nur ihre Anzahl als Kurzmeldung. return { project: befund.stand.project, filePath: null, art: befund.art, beiseitegelegt: true, einlesebefund: befund.stand, }; } } catch (error) { console.error('Der zuletzt bearbeitete Stand konnte nicht wiederhergestellt werden:', error); spaeter( 'Der zuletzt bearbeitete Stand konnte nicht wiederhergestellt werden. Öffnen Sie Ihre ' + 'zuletzt gespeicherte Projektdatei.', 'fehler', ); // Wie 'nicht-lesbar' behandeln: Was der Speicher enthaelt, ist unbekannt, // und ueberschrieben werden darf er deshalb nicht. return { project: createEmptyProject(), filePath: null, art: 'nicht-lesbar', beiseitegelegt: true, einlesebefund: null, }; } } function isTextEntry(target: EventTarget | null): boolean { return ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable) ); } // Ein Startfehler darf nicht in einer weissen Seite enden - auch dann nicht, // wenn er erst nach dem ersten `await` auftritt. Ein try/catch um einen // asynchronen Aufruf faengt genau das NICHT, deshalb der Fehlerpfad an der // Zusage selbst. boot().catch((error: unknown) => { console.error(error); const root = document.getElementById('anwendung') ?? document.body; root.replaceChildren( el( 'div', { style: 'padding:32px;max-width:70ch;margin:0 auto' }, el('h1', { text: 'Die Anwendung konnte nicht gestartet werden.' }), el('p', { text: error instanceof Error ? error.message : String(error) }), el('p', { text: 'Bitte starten Sie das Programm neu. Bleibt der Fehler bestehen, senden Sie diese Meldung an den Hersteller.', }), ), ); });