lsa-planer

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

/ src main.ts

61,2 KB Rohdatei
src/main.ts — 1487 Zeilen
1 import './styles/app.css';
2
3 import { ProjectStore } from './app/store';
4 import { applyTheme, beiErscheinungsbildwechsel, nextTheme, themeLabel } from './app/theme';
5 import {
6 createEmptyProject,
7 createStandardIntersectionProject,
8 istUnberuehrt,
9 } from './domain/model/factory';
10 import type { Project } from './domain/model/project';
11 import type { IntergreenComparisonRow, MigrationResult } from './domain/model/migrate';
12 import { intergreenKey } from './domain/plan/signalPlan';
13 import { AutoSave } from './services/autosave';
14 import * as fmt from './ui/format';
15 import {
16 DEFAULT_APP_SETTINGS,
17 MELDUNGSDAUER_STUFEN,
18 loadAppSettings,
19 ladeSitzung,
20 openProjectFromFile,
21 saveAppSettings,
22 saveProjectToFile,
23 type AppSettings,
24 type Sitzungsbefund,
25 } from './services/storage';
26 import { desktopBridge } from './platform/bridge';
27 import { Shell, confirmDiscard } from './ui/shell';
28 import { zeigeGlossar } from './ui/help/hilfe';
29 import { zeigeUeber } from './ui/ueber';
30 import { starteAssistent } from './ui/assistent';
31 import {
32 Panel,
33 confirmDialog,
34 meldungsverlauf,
35 notify,
36 offeneFensterAnzahl,
37 setzeMeldungsdauer,
38 } from './ui/feedback';
39 import { beschrifteTabelle, button, el, emptyState, select } from './ui/dom';
40 import { compatibilityView } from './ui/views/compatibilityView';
41 import { lageplanView } from './ui/views/lageplanView';
42 import { conflictsView } from './ui/views/conflictsView';
43 import { exportView } from './ui/views/exportView';
44 import { phasesView } from './ui/views/phasesView';
45 import { vergleichView } from './ui/views/vergleichView';
46 import { koordinierungView } from './ui/views/koordinierungView';
47 import { planView } from './ui/views/planView';
48 import { projectView } from './ui/views/projectView';
49 import { reportView } from './ui/views/reportView';
50 import { settingsView } from './ui/views/settingsView';
51 import { signalGroupsView } from './ui/views/signalGroupsView';
52 import { simulationView } from './ui/views/simulationView';
53
54 /**
55 * Programmstart.
56 *
57 * Reihenfolge: Einstellungen laden, Erscheinungsbild setzen, zuletzt
58 * bearbeiteten Stand wiederherstellen, Oberflaeche aufbauen. Der Altbestand
59 * legte beim Start zuerst ein leeres Projekt an und ueberschrieb damit den
60 * gespeicherten Stand, bevor ueberhaupt geprueft wurde, ob etwas
61 * wiederherzustellen war.
62 */
63
64 /**
65 * Programmversion.
66 *
67 * Kommt aus package.json und wird von Vite eingesetzt - dieselbe Zahl, aus der
68 * electron-builder den Namen des Installationspakets bildet. Zuvor stand sie
69 * hier ein zweites Mal als Zeichenkette: Wer die eine erhoehte und die andere
70 * vergass, bekam einen Ausdruck, dessen Versionsangabe nicht zu dem Paket
71 * passte, mit dem er erstellt wurde. Bei einer Unterlage, die in ein
72 * Verwaltungsverfahren geht, ist die Versionsangabe kein Zierrat.
73 *
74 * Der Rueckfall greift nur im Testbetrieb, wo Vite nichts einsetzt.
75 */
76 // Aufgeloest wird die Nummer in src/fassung.ts - dieselbe Stelle, aus der
77 // die gedruckte Planunterlage sie nimmt.
78 import { APP_VERSION } from './fassung';
79
80 /*
81 * Der Start wartet auf den Sitzungsspeicher.
82 *
83 * Seit der Zwischenstand in IndexedDB liegt statt in localStorage, ist das
84 * Einlesen asynchron. Gewartet wird bewusst, statt mit einem leeren Projekt zu
85 * beginnen und den Stand nachzureichen: Der Projektspeicher traegt die
86 * Rueckgaengig-Kette, und ein nachgereichter Austausch waere entweder ein
87 * unwiderrufliches Ueberschreiben der ersten Eingaben oder ein
88 * Rueckgaengig-Schritt, der den Anwender an einen Stand fuehrt, den er nie
89 * bearbeitet hat. Ein Schluessel-Lesevorgang dauert wenige Millisekunden.
90 */
91 /**
92 * Die Kurzhilfe, solange sie offen ist (Befund M1).
93 *
94 * F1 ist ein Tastenkuerzel am Fenster und feuert weiter, waehrend die Kurzhilfe
95 * schon offensteht. Ohne diesen Waechter stapelte jeder weitere Druck ein
96 * zusaetzliches, wortgleiches Fenster - unsichtbar fuer den, der die
97 * Ueberlagerung nicht sieht, und jedes davon war einzeln zu schliessen. Die
98 * Parallelfunktion `zeigeHilfe` in ui/help/hilfe.ts hatte einen solchen
99 * Waechter von Anfang an; hier fehlte er.
100 *
101 * Auf Modulebene und nicht in `boot()`: Der F1-Empfaenger haengt am Fenster und
102 * kann feuern, sobald er gebunden ist. Eine Variable im Rumpf von `boot()` waere
103 * bis zu ihrer eigenen Anweisung in der zeitlichen Totzone - dasselbe Muster
104 * wie `offenesFenster` in hilfe.ts.
105 */
106 let kurzhilfe: Panel | null = null;
107
108 async function boot(): Promise<void> {
109 const root = document.getElementById('anwendung');
110 if (!root) throw new Error('Das Wurzelelement der Anwendung fehlt.');
111
112 let settings: AppSettings = loadAppSettings();
113 applyTheme(settings.theme);
114 // Vor der ersten Meldung: Die Anzeigedauer ist eine Einstellung des
115 // Anwenders (Befund L8, WCAG 2.2.1), und `feedback.ts` liest den Speicher
116 // nicht selbst - naeheres bei `setzeMeldungsdauer`.
117 setzeMeldungsdauer(settings.meldungsdauerSekunden);
118
119 const restored = await restoreSession();
120 const store = new ProjectStore(restored.project);
121 if (restored.filePath !== null) store.setFilePath(restored.filePath);
122
123 /**
124 * Der wiederhergestellte Stand steht in keiner Datei.
125 *
126 * `ProjectStore` beginnt mit `dirty = false`, und der Wiederanlauf aendert
127 * daran nichts: Ein aus dem Sitzungsspeicher geholter Stand galt damit sofort
128 * als gespeichert, obwohl er nie in eine Datei geschrieben wurde. Die
129 * Waechter vor dem Verwerfen haengen allein an diesem Merker und griffen
130 * deshalb nicht - ein Strg+N nach dem Wiederanlauf loeschte die ganze
131 * Handarbeit eines Knotenpunkts ohne ein Wort, und einen Rueckweg gibt es
132 * nicht.
133 *
134 * Der Merker sitzt im Zustandsspeicher und nicht daneben: Kopfzeile,
135 * Fenstertitel und Statusleiste lesen `store.getState()`, und eine zweite
136 * Buchfuehrung liess sie etwas anderes sagen als die Waechter. Er erlischt,
137 * sobald der Anwender selbst bestimmt, was gilt - `store.replace(...)` bei
138 * "Neu" und "Öffnen", `store.markSaved(...)` beim Speichern in eine Datei.
139 *
140 * VOR `autoSave.start()`: Der Sitzungsspeicher haelt genau diesen Stand
141 * bereits; ihn wegen des Merkers gleich noch einmal zu schreiben, waere bei
142 * einem eingebetteten Luftbild ein Schreibvorgang ueber zwanzig Megabyte
143 * ohne jeden Zugewinn.
144 */
145 /*
146 * NUR, wenn dort auch etwas steht. Ein unberuehrter Stand ist von einem eben
147 * angelegten nicht zu unterscheiden, und beim Verlassen schreibt
148 * `AutoSave.beimVerlassen` ihn in den Sitzungsspeicher: Wer das Programm
149 * oeffnet, nichts eingibt und schliesst, bekaeme sonst bei JEDEM zweiten
150 * Start den Aufzaehlungspunkt im Titel und beim Schliessen die Rueckfrage
151 * "Ungespeicherte Änderungen" - ueber eine Arbeit, die es nie gab. Am
152 * gebauten Stand gemessen; der Rauchtest lief in die Rueckfrage.
153 *
154 * Eine Rueckfrage, die immer kommt, schuetzt nichts: Wer sie taeglich
155 * wegklickt, klickt auch die eine weg, die einen Knotenpunkt haelt.
156 */
157 if (restored.art === 'geladen' && !istUnberuehrt(restored.project)) store.markDirty();
158
159 // Die Art wird durchgereicht und nicht mehr auf "info" eingeebnet: Eine
160 // Warnung bleibt im Meldungsbereich stehen, eine Kurzmeldung verschwindet
161 // nach fuenf Sekunden.
162 const autoSave = new AutoSave(store, settings, (message, kind) => {
163 notify(message, kind);
164 });
165 autoSave.start();
166 /*
167 * Solange unter dem Sitzungsschluessel noch etwas Rettbares liegt, wird
168 * nichts geschrieben.
169 *
170 * Zwei Ausgaenge fuehren dorthin, und beide sagen dem Anwender dasselbe zu:
171 *
172 * - 'nicht-lesbar': Der Sitzungsspeicher laesst den Satz bewusst unangetastet
173 * (services/storage.ts, ladeSitzung: "Der Datensatz bleibt unangetastet"),
174 * und der Anwender bekommt die Zusage "Er ist nicht verloren ... Speichern
175 * Sie jetzt nichts".
176 * - 'beschaedigt' OHNE Beiseitelegen: Der Satz liess sich nicht in den
177 * geschuetzten Schluessel umhaengen - vermutlich ist der Speicher voll -
178 * und bleibt deshalb liegen; der Quelltext sagt daneben zu, er werde "beim
179 * naechsten Start erneut geprueft".
180 *
181 * Ohne diese Sperre bricht das Programm beide Zusagen: Der erste Sichtwechsel
182 * - Minimieren oder Schliessen, also genau der empfohlene Neustart - schriebe
183 * das leere Startprojekt darueber, und die anschliessende
184 * Grossdatenaufraeumung naehme das Luftbild mit. Freigegeben wird erst, wenn
185 * der Anwender ueber "Neu" oder "Öffnen" selbst bestimmt, was im
186 * Sitzungsspeicher stehen soll.
187 *
188 * Ein beschaedigter Satz, der beiseitegelegt WERDEN KONNTE, ist damit nicht
189 * gemeint: Er steht unter dem geschuetzten Schluessel, den auch die
190 * Grossdatenaufraeumung mitliest, und der Platz ist frei.
191 */
192 if (restored.art === 'nicht-lesbar' || !restored.beiseitegelegt) {
193 autoSave.sperreSitzungsspeicherung();
194 }
195
196 const shell = new Shell(root, store, APP_VERSION);
197
198 // --- Aktionen der Kopfzeile ---------------------------------------------
199
200 const newProject = async (): Promise<void> => {
201 // Waechter wie bei der Kurzhilfe (Befund M1): Steht schon ein modales
202 // Fenster offen, baut ein weiterer Aufruf ein zweites, wortgleiches darauf.
203 // Hier und nicht nur im Tastenempfaenger, weil das Anwendungsmenue von der
204 // Stilllegung des Hintergrunds nicht erfasst wird.
205 if (offeneFensterAnzahl() > 0) return;
206 if (!(await confirmDiscard(store.getState(), 'Verwerfen und neu beginnen'))) return;
207 const choice = await chooseTemplate();
208 if (choice === null) return;
209 // `store.replace` loescht die Aenderungsmarke mit: Der Anwender hat selbst
210 // bestimmt, was von jetzt an gilt.
211 store.replace(choice);
212 // Aus demselben Grund faellt eine Sperre aus dem Befund 'nicht-lesbar' weg.
213 autoSave.gibSitzungsspeicherungFrei();
214 // Sofort sichern: sonst zeigt ein Neustart vor der ersten Aenderung wieder
215 // den vorherigen Stand.
216 // void: Das Versprechen wird bewusst nicht abgewartet - gesichert wird
217 // im Hintergrund. Ohne das Schluesselwort verschwaende eine Ablehnung
218 // (etwa ein Fehler der IndexedDB) lautlos als unbehandelte Ablehnung.
219 void autoSave.flush();
220 // Die Ansicht neu aufbauen, nicht nur nachzeichnen. Ansichten, die eigenen
221 // Zustand ueber das Projekt hinweg halten - die Simulation etwa fuehrt
222 // Warteschlangen, Verkehrsstaerken und Bewertungsskala des Projekts mit,
223 // aus dem sie gebaut wurde - zeigten sonst weiter die Zahlen des VORIGEN
224 // Projekts, waehrend die Leinwand daneben schon das neue zeichnete.
225 // Rueckgaengig, Wiederherstellen und die Vorgaben tun das laengst.
226 shell.navigate(currentViewId());
227 notify('Neues Projekt angelegt.', 'erfolg');
228 };
229
230 const openProject = async (): Promise<void> => {
231 // Derselbe Waechter wie beim Neuanlegen - siehe dort.
232 if (offeneFensterAnzahl() > 0) return;
233 if (!(await confirmDiscard(store.getState(), 'Verwerfen und öffnen'))) return;
234 const result = await openProjectFromFile();
235 if (result.canceled) return;
236 if (!result.ok || result.result === null) {
237 notify(result.message, 'fehler');
238 return;
239 }
240
241 store.replace(result.result.project, result.filePath);
242 // Wie beim Neuanlegen: bewusst gesetzter Stand, also darf wieder
243 // geschrieben werden.
244 autoSave.gibSitzungsspeicherungFrei();
245 // void: Das Versprechen wird bewusst nicht abgewartet - gesichert wird
246 // im Hintergrund. Ohne das Schluesselwort verschwaende eine Ablehnung
247 // (etwa ein Fehler der IndexedDB) lautlos als unbehandelte Ablehnung.
248 void autoSave.flush();
249 // Wie beim Neuanlegen: Ansichten mit eigenem Zustand muessen neu gebaut
250 // werden, sonst stehen zwei Projekte in einem Bild.
251 shell.navigate(currentViewId());
252 notify(result.message, 'erfolg');
253
254 // Unbedingt: Ob die Aufstellung ueberhaupt erscheint, entscheidet
255 // `showImportIssues` selbst - fuer beide Wege dieselbe Bedingung.
256 showImportIssues(
257 result.result.issues,
258 result.result.migrated,
259 result.result.intergreenComparison,
260 );
261 };
262
263 const speichereEinmal = async (forceDialog: boolean): Promise<void> => {
264 const state = store.getState();
265 // Genau dieses Objekt geht in die Datei: `serializeProject` laeuft
266 // synchron vor dem Warten, der Dateiinhalt steht also mit dem Aufruf fest.
267 const geschrieben = state.project;
268 const result = await saveProjectToFile(geschrieben, forceDialog ? null : state.filePath);
269 if (result.canceled) return;
270 if (!result.ok) {
271 notify(result.message, 'fehler');
272 return;
273 }
274 /*
275 * Ohne Nachweis keine Aenderungsmarke loeschen.
276 *
277 * Ohne Desktop-Bruecke gibt es nur das Herunterladen im Browser, und ob
278 * dabei wirklich etwas abgelegt wurde, erfaehrt die Anwendung nicht
279 * (services/storage.ts, `unbestaetigt`). Ein `markSaved()` machte daraus
280 * dieselbe unbelegte Aussage "gespeichert" wie beim Wettlauf darunter - mit
281 * derselben Folge: "Neu" und "Öffnen" verwerfen danach ohne Rueckfrage.
282 */
283 if (result.unbestaetigt === true) {
284 notify(
285 `${result.message} Ob die Datei abgelegt wurde, lässt sich hier nicht feststellen – der Stand gilt weiter als ungespeichert.`,
286 'warnung',
287 );
288 void autoSave.flush();
289 return;
290 }
291 /*
292 * Die Aenderungsmarke nur loeschen, wenn das Geschriebene noch gilt.
293 *
294 * Waehrend `saveProjectToFile` laeuft - IPC und Plattenschreibvorgang, bei
295 * einem eingebetteten Luftbild auf einem Netzlaufwerk Sekunden - bleibt die
296 * Oberflaeche bedienbar. Jede Eingabe in dieser Zeit setzt `dirty`. Ein
297 * bedingungsloses `markSaved()` machte daraus die Aussage "gespeichert",
298 * obwohl die Aenderung in keiner Datei steht: "Neu" und "Öffnen" verwerfen
299 * sie danach ohne Rueckfrage. Denselben Wettlauf fuehrt der
300 * Sitzungsspeicher laengst nach (services/autosave.ts, `nachholen`).
301 *
302 * Ein ZWEITER Speichervorgang kann daneben nicht mehr laufen - siehe den
303 * Riegel bei `saveProject`.
304 */
305 if (store.getProject() !== geschrieben) {
306 store.setFilePath(result.filePath);
307 notify(
308 `${result.message} Was Sie während des Speicherns geändert haben, steht noch nicht in der Datei – bitte noch einmal speichern.`,
309 'warnung',
310 );
311 // Der geaenderte Stand gehoert trotzdem sofort in den Sitzungsspeicher -
312 // dort ist er nach einem Absturz noch da. void: wie unten.
313 void autoSave.flush();
314 return;
315 }
316 store.markSaved(result.filePath);
317 // void: Das Versprechen wird bewusst nicht abgewartet - gesichert wird
318 // im Hintergrund. Ohne das Schluesselwort verschwaende eine Ablehnung
319 // (etwa ein Fehler der IndexedDB) lautlos als unbehandelte Ablehnung.
320 void autoSave.flush();
321 notify(result.message, 'erfolg');
322 };
323
324 /**
325 * Der laufende Speichervorgang, solange einer laeuft.
326 *
327 * WARUM EIN RIEGEL UM DEN GANZEN ABLAUF
328 *
329 * Ein zweiter Lauf war ueber vier Wege ausloesbar - Strg+S, die
330 * Kopfzeilenschaltflaeche (sie traegt kein `enabled` und ist damit nie
331 * gesperrt), der Menuebefehl und "Speichern und schliessen" aus der
332 * Rueckfrage -, und keiner davon fragte, ob schon einer laeuft. Der Waechter
333 * in `speichereEinmal` vergleicht nur den EIGENEN Stand mit dem jetzigen und
334 * weiss von einem zweiten Lauf nichts: Endete der juengere Lauf zuerst,
335 * loeschte er die Aenderungsmarke, und der aeltere legte danach den AELTEREN
336 * Stand auf die Platte. Der Anwender hatte eine Datei mit dem alten Stand,
337 * ein Programm, das "gespeichert" sagt, und keinen Waechter mehr vor "Neu",
338 * "Öffnen" oder dem Schliessen. Der Sitzungsspeicher fuehrt denselben Riegel
339 * laengst (services/autosave.ts: "Es laeuft nie mehr als ein Schreibvorgang
340 * gleichzeitig").
341 *
342 * Ein zweiter Aufruf startet deshalb keinen zweiten Lauf, sondern bekommt die
343 * Zusage des laufenden zurueck. Er wartet damit auf dessen Ergebnis - das
344 * braucht "Speichern und schliessen", das gleich danach `dirty` liest -, ohne
345 * eine zweite Nutzlast in denselben Dateiweg zu schicken. Gilt der
346 * geschriebene Stand danach nicht mehr, bleibt die Aenderungsmarke stehen und
347 * die Warnung "bitte noch einmal speichern" sagt es.
348 *
349 * Auch "Speichern unter" waehrend eines laufenden Speicherns bekommt den
350 * laufenden Vorgang und keinen Dialog: Zwei Ziele gleichzeitig zu beschreiben
351 * ist nicht das, was der Anwender meint, wenn er waehrend des Wartens noch
352 * einmal drueckt.
353 */
354 let laufenderSpeichervorgang: Promise<void> | null = null;
355
356 const saveProject = (forceDialog: boolean): Promise<void> => {
357 if (laufenderSpeichervorgang !== null) return laufenderSpeichervorgang;
358 const lauf = speichereEinmal(forceDialog).finally(() => {
359 laufenderSpeichervorgang = null;
360 });
361 laufenderSpeichervorgang = lauf;
362 return lauf;
363 };
364
365 shell.addHeaderAction({
366 label: 'Neu',
367 title: 'Neues Projekt anlegen (Strg+N)',
368 run: () => newProject(),
369 });
370 shell.addHeaderAction({
371 label: 'Öffnen',
372 title: 'Projektdatei öffnen (Strg+O)',
373 run: () => openProject(),
374 });
375 shell.addHeaderAction({
376 label: 'Speichern',
377 title: 'Projekt speichern (Strg+S)',
378 variant: 'primaer',
379 run: () => saveProject(false),
380 });
381 shell.addHeaderAction({
382 label: 'Speichern unter',
383 title: 'Projekt unter neuem Namen speichern (Strg+Umschalt+S)',
384 run: () => saveProject(true),
385 });
386 shell.addHeaderAction({
387 label: 'Rückgängig',
388 title: 'Letzte Änderung rückgängig machen (Strg+Z)',
389 enabled: (state) => state.canUndo,
390 run: () => {
391 const label = store.getState().undoLabel;
392 store.undo();
393 // Neu aufbauen, NICHT den Ort wechseln: Eine Ruecknahme bewegt den
394 // Anwender nicht von der Stelle. `shell.navigate` endete mit `focus()`
395 // auf der Ueberschrift, und danach war die Schaltflaeche unter dem
396 // Finger fort - ein zweiter Schritt zurueck nur noch ueber die ganze
397 // Navigation erreichbar (WCAG 2.4.3). `baueSichtbareAnsichtNeu` leistet
398 // denselben vollstaendigen Ab- und Wiederaufbau, den die Ansichten mit
399 // eigenem Zustand brauchen, und bewahrt dabei den Fokus.
400 shell.baueSichtbareAnsichtNeu();
401 if (label !== null) notify(`Rückgängig: ${label}`, 'info', 2500);
402 },
403 });
404 shell.addHeaderAction({
405 label: 'Wiederherstellen',
406 title: 'Änderung wiederherstellen (Strg+Y)',
407 enabled: (state) => state.canRedo,
408 run: () => {
409 const label = store.getState().redoLabel;
410 store.redo();
411 // Wie bei "Rückgängig" - siehe dort.
412 shell.baueSichtbareAnsichtNeu();
413 if (label !== null) notify(`Wiederhergestellt: ${label}`, 'info', 2500);
414 },
415 });
416 shell.addHeaderAction({
417 label: `Ansicht: ${themeLabel(settings.theme)}`,
418 title: 'Zwischen hellem, dunklem und Systemerscheinungsbild wechseln',
419 variant: 'schlicht',
420 run: () => {
421 // Frisch lesen und nur das eigene Feld setzen: `saveAppSettings` schreibt
422 // das GANZE Objekt und mischt nichts. Eine hier gehaltene Startkopie
423 // truege die Fundstellen von vor Stunden zurueck in den Speicher und
424 // loeschte damit alles, was der Anwender seither unter "Vorgaben"
425 // eingetragen hat - eine Angabe, die in die Planunterlage geht.
426 const gespeichert = loadAppSettings();
427 settings = { ...gespeichert, theme: nextTheme(gespeichert.theme) };
428 saveAppSettings(settings);
429 // Das Neuzeichnen der Leinwand haengt nicht hier, sondern am Wechsel
430 // selbst (`beiErscheinungsbildwechsel` weiter unten): Er kommt auch aus
431 // dem Betriebssystem, und beide Wege gehoeren an dieselbe Stelle.
432 applyTheme(settings.theme);
433 // Die Beschriftung nennt die EINSTELLUNG und wird deshalb hier
434 // mitgefuehrt - sie wechselt auch dann, wenn sich an den Farben nichts
435 // aendert (etwa von "Dunkel" auf "System" bei dunklem System).
436 const node = [...root.querySelectorAll('button')].find((b) =>
437 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- textContent fuehrt lib.dom als string, die Spezifikation als string | null
438 b.textContent?.startsWith('Ansicht:'),
439 );
440 if (node) node.textContent = `Ansicht: ${themeLabel(settings.theme)}`;
441 },
442 });
443 shell.addHeaderAction({
444 label: 'Glossar',
445 title: 'Alle Fachbegriffe nachschlagen',
446 variant: 'schlicht',
447 run: () => {
448 zeigeGlossar();
449 },
450 });
451 shell.addHeaderAction({
452 label: 'Meldungen',
453 title: 'Die Kurzmeldungen dieser Sitzung noch einmal ansehen',
454 variant: 'schlicht',
455 run: () => {
456 zeigeMeldungen();
457 },
458 });
459 shell.addHeaderAction({
460 label: 'Hilfe',
461 title: 'Kurzhilfe und Tastenkürzel anzeigen (F1)',
462 variant: 'schlicht',
463 run: () => {
464 showHelp();
465 },
466 });
467
468 // --- Ansichten -----------------------------------------------------------
469
470 // Reihenfolge = Bearbeitungsreihenfolge. Die Vertraeglichkeit steht bewusst
471 // VOR den Phasen: Ohne sie kann das Programm nicht warnen, wenn zwei
472 // feindliche Signalgruppen in dieselbe Phase geraten. Das Vermassen der Wege
473 // folgt danach, weil es Detailarbeit ist und die Struktur nicht beeinflusst.
474 for (const view of [
475 projectView,
476 // Der Lageplan steht vor den Signalgruppen: Wer zeichnet, laesst sich die
477 // Signalgruppen daraus vorschlagen, statt sie von Hand anzulegen.
478 lageplanView,
479 signalGroupsView,
480 compatibilityView,
481 phasesView,
482 conflictsView,
483 planView,
484 simulationView,
485 // Der Vergleich steht hinter dem Ergebnis und vor dem Pruefbericht: Er
486 // beurteilt einen fertigen Plan gegen einen zweiten und aendert nichts.
487 // Die Koordinierung steht neben dem Vergleich: Beide beurteilen einen
488 // fertigen Plan - der eine gegen einen zweiten Planfall, die andere gegen
489 // die Nachbaranlagen.
490 koordinierungView,
491 vergleichView,
492 reportView,
493 exportView,
494 settingsView,
495 ]) {
496 shell.addView(view);
497 }
498
499 /**
500 * Welche Ansicht gerade sichtbar ist.
501 *
502 * Zuvor wurde `shell.navigate` hier durch eine Huelle ersetzt, die den Namen
503 * nebenher in einer eigenen Variablen mitfuehrte. Zwei Buchfuehrungen ueber
504 * dieselbe Sache laufen auseinander: Wechselte die Shell aus eigenem Antrieb
505 * - ueber den Fuehrungshinweis oder ueber `context.navigate` aus einer
506 * Ansicht heraus -, zeigte die Variable weiter auf die vorherige Ansicht, und
507 * Strg+Z zeichnete danach die falsche neu. Die Shell weiss es selbst.
508 */
509 const currentViewId = (): string => shell.aktiveAnsichtId;
510
511 shell.start('projekt');
512
513 /*
514 * Wechselt das Erscheinungsbild, wird die sichtbare Ansicht neu gezeichnet.
515 *
516 * `applyTheme` setzt das Attribut am Wurzelelement; die Farben der
517 * Oberflaeche haengen an CSS-Variablen und folgen von selbst. Die
518 * Zeichenflaechen tun das nicht: Signalzeitenplan, Simulation und Lageplan
519 * lesen das Attribut im Augenblick des Zeichnens und behielten sonst die
520 * alten Farben, bis irgendetwas anderes ein Neuzeichnen ausloeste - eine
521 * weisse Zeichnung mitten in einer dunklen Oberflaeche.
522 *
523 * Hier und nur hier, weil es mehrere Zeichenflaechen gibt und der Wechsel aus
524 * zwei Richtungen kommt: ueber die Kopfzeile und aus dem Betriebssystem
525 * (Systemerscheinungsbild, Kontrastdesign). Jede Ansicht fuer sich nachziehen
526 * zu lassen, waeren so viele Beschreibungen desselben Vorgangs, wie es
527 * Ansichten gibt.
528 *
529 * Neu aufbauen, NICHT den Ort wechseln: Aus dem Betriebssystem kommt der
530 * Wechsel ohne Zutun des Anwenders, und `shell.navigate` warf den Fokus
531 * dabei mitten in der Eingabe auf die Ueberschrift der Ansicht. Was der
532 * Neuaufbau nicht rettet - den angefangenen Streckenzug im Lageplan und die
533 * laufende Simulation -, ist bei `Shell.baueSichtbareAnsichtNeu` vermerkt.
534 */
535 beiErscheinungsbildwechsel(() => {
536 shell.baueSichtbareAnsichtNeu();
537 });
538
539 /*
540 * Was beim Einlesen des Sitzungsstands auffiel, in derselben Aufstellung wie
541 * beim Oeffnen einer Datei (Befund 12).
542 *
543 * Erst hier, nicht in `restoreSession`: Das Fenster braucht die aufgebaute
544 * Oberflaeche. Zuvor nannte der Wiederanlauf nur die Anzahl der Meldungen -
545 * eine Zahl, aus der niemand ersieht, dass etwa ein Raeumweg auf den
546 * Hoechstwert der Anlagenart begrenzt wurde und die Zwischenzeiten deshalb
547 * andere sind als eingetragen.
548 */
549 const einlesebefund = restored.einlesebefund;
550 // Wie beim Oeffnen unbedingt gerufen: Ob etwas zu zeigen ist, entscheidet
551 // `showImportIssues`. Der Wiederanlauf hatte hier zuvor eine eigene, aermere
552 // Bedingung - und danach eine wortgleiche Abschrift der anderen.
553 if (einlesebefund !== null) {
554 showImportIssues(
555 einlesebefund.issues,
556 einlesebefund.migrated,
557 einlesebefund.intergreenComparison,
558 );
559 }
560
561 // --- Tastenkuerzel -------------------------------------------------------
562
563 window.addEventListener('keydown', (event) => {
564 const ctrl = event.ctrlKey || event.metaKey;
565
566 if (event.key === 'F1') {
567 event.preventDefault();
568 showHelp();
569 return;
570 }
571 if (!ctrl) return;
572
573 /*
574 * Nichts auswerten, solange ein modales Fenster offensteht.
575 *
576 * Der Stapelempfaenger in ui/feedback.ts faengt nur Escape und Tab ab; jede
577 * andere Taste blubbert bis hierher, obwohl der ganze Hintergrund
578 * stillgelegt ist. Strg+N und Strg+O bauten so ein weiteres Fenster auf den
579 * Stapel - genau das Muster, das fuer F1 als Befund M1 behoben ist -, und
580 * Strg+Z/Strg+Y bauten die stillgelegte Ansicht unter dem Dialog neu auf.
581 * F1 steht bewusst davor: Ein zweiter Druck holt die schon offene Kurzhilfe
582 * nach vorn, statt gar nichts zu tun.
583 */
584 if (offeneFensterAnzahl() > 0) return;
585
586 const key = event.key.toLowerCase();
587 if (key === 's') {
588 event.preventDefault();
589 void saveProject(event.shiftKey);
590 } else if (key === 'o') {
591 event.preventDefault();
592 void openProject();
593 } else if (key === 'n') {
594 event.preventDefault();
595 void newProject();
596 } else if (key === 'z' && !event.shiftKey) {
597 if (isTextEntry(event.target)) return;
598 event.preventDefault();
599 store.undo();
600 // Neu aufbauen statt den Ort zu wechseln - Begruendung bei der
601 // Kopfzeilenaktion "Rückgängig".
602 shell.baueSichtbareAnsichtNeu();
603 } else if (key === 'y' || (key === 'z' && event.shiftKey)) {
604 if (isTextEntry(event.target)) return;
605 event.preventDefault();
606 store.redo();
607 shell.baueSichtbareAnsichtNeu();
608 }
609 });
610
611 // --- Fehlerbehandlung ----------------------------------------------------
612
613 /**
614 * Unbehandelte Fehler sichtbar machen und protokollieren.
615 *
616 * Zuvor verschwand ein solcher Fehler in der Konsole, die im
617 * Auslieferungsstand niemand sieht: Die Oberflaeche blieb einfach stehen,
618 * ohne dass erkennbar war, warum. Jetzt erscheint eine Meldung, der Stand
619 * wird sofort gesichert, und der Fehler wird zusammen mit dem Zustand der
620 * Anwendung protokolliert - genau die Angaben, die man zur Nachstellung
621 * braucht.
622 */
623 const meldeFehler = (quelle: string, fehler: unknown): void => {
624 const nachricht = fehler instanceof Error ? fehler.message : String(fehler);
625 const stapel = fehler instanceof Error ? (fehler.stack ?? '') : '';
626 const state = store.getState();
627
628 // Hier NICHT try/catch: flush() liefert ein Versprechen, und ein
629 // try/catch um einen nicht abgewarteten Aufruf faengt ausschliesslich
630 // synchrone Ausnahmen ab. writeSession() in services/autoSave.ts hat ein
631 // finally, aber kein catch - eine Ablehnung waere also ausgerechnet
632 // waehrend der Fehlerbehandlung als unbehandelte Ablehnung entkommen.
633 autoSave.flush().catch(() => {
634 /* Sichern darf die Fehlerbehandlung nicht unterbrechen. */
635 });
636
637 desktopBridge()?.reportError({
638 quelle,
639 nachricht,
640 ...(stapel !== '' ? { stapel } : {}),
641 zustand: {
642 ansicht: currentViewId(),
643 signalgruppen: state.project.signalGroups.length,
644 phasen: state.project.phases.length,
645 konflikte: state.project.conflicts.length,
646 verkehrsstaerken: state.project.demands.length,
647 umlaufzeit: state.plan.cycleTime,
648 verfahren: state.project.program.method,
649 fehler: state.report.errorCount,
650 warnungen: state.report.warningCount,
651 ungespeichert: state.dirty,
652 },
653 });
654
655 console.error(`${quelle}:`, fehler);
656 notify(
657 `Es ist ein unerwarteter Fehler aufgetreten: ${nachricht}\n\n` +
658 'Ihr Arbeitsstand wurde gesichert. Bitte melden Sie den Vorgang zusammen mit dem, was Sie zuletzt getan haben.',
659 'fehler',
660 );
661 };
662
663 /**
664 * Meldungen, die zwar als Fehler gemeldet werden, aber keine sind.
665 *
666 * "ResizeObserver loop completed with undelivered notifications" ist der
667 * einzige Fall: Die Anzeige teilt damit mit, dass sie eine Groessenmeldung
668 * auf den naechsten Bilddurchlauf verschoben hat. Die Darstellung ist dabei
669 * einen Durchlauf spaeter richtig; nichts geht verloren. Der Anwender mit
670 * einer Fehlermeldung samt Bitte um Meldung zu behelligen waere irrefuehrend.
671 *
672 * Es bleibt bei genau dieser einen Ausnahme - eine Liste, die mit der Zeit
673 * waechst, wuerde die Fehlerbehandlung wieder aushoehlen.
674 */
675 const istHarmlos = (nachricht: string): boolean => nachricht.startsWith('ResizeObserver loop');
676
677 window.addEventListener('error', (event) => {
678 // `message` fuehrt lib.dom als Zeichenkette, der Linter beanstandet den
679 // Rueckfall deshalb. Er bleibt: Diese Stelle ist die letzte vor der
680 // Fehlermeldung an den Anwender, und sie bekommt auch von Hand erzeugte
681 // Ereignisse. Ein fehlendes Feld darf die Fehlerbehandlung nicht selbst
682 // zum Absturz bringen.
683 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- letzte Fehlerbehandlung vor der Meldung; siehe darueber
684 if (istHarmlos(String(event.message ?? ''))) return;
685 meldeFehler('Ausnahme', event.error ?? event.message);
686 });
687
688 window.addEventListener('unhandledrejection', (event) => {
689 meldeFehler('Zusage', event.reason);
690 });
691
692 // --- Desktop-Bruecke -----------------------------------------------------
693
694 const bridge = desktopBridge();
695 if (bridge) {
696 const meldeAenderungsstand = (): void => {
697 bridge.setDirty(store.getState().dirty);
698 };
699 store.subscribe(meldeAenderungsstand);
700 // Einmal sofort, nicht erst bei der naechsten Aenderung: Der Hauptprozess
701 // fragt vor dem Schliessen nur nach, solange er den Stand fuer ungespeichert
702 // haelt (electron/main.ts, `rendererIsDirty`). Ein wiederhergestellter Stand
703 // ist es von der ersten Sekunde an.
704 meldeAenderungsstand();
705
706 bridge.onMenuCommand((command) => {
707 switch (command) {
708 case 'neu':
709 void newProject();
710 break;
711 case 'oeffnen':
712 void openProject();
713 break;
714 case 'speichern':
715 /*
716 * Derselbe Waechter wie bei "Neu" und "Öffnen" darueber, und aus
717 * demselben Grund: Das Anwendungsmenue wird von der Stilllegung des
718 * Hintergrunds nicht erfasst. Der Tastenweg bricht bei offenem
719 * modalem Fenster ab; ohne den Waechter hier liesse sich mitten in
720 * der Vorlagenauswahl oder in der Rueckfrage "Ungespeicherte
721 * Änderungen" ein Speichervorgang ausloesen.
722 *
723 * "Speichern und schliessen" aus jener Rueckfrage kommt nicht hier
724 * durch, sondern ruft `saveProject` unmittelbar - dort ist das
725 * Fenster bereits vom Stapel.
726 */
727 if (offeneFensterAnzahl() > 0) break;
728 void saveProject(false);
729 break;
730 case 'speichern-unter':
731 if (offeneFensterAnzahl() > 0) break;
732 void saveProject(true);
733 break;
734 case 'export':
735 /*
736 * Derselbe Waechter, und er traegt auch hier: Ein Ortswechsel ist kein harmloserer
737 * Vorgang als ein Speichervorgang. `shell.navigate` raeumt die
738 * Ansicht ab, aus der das offene Fenster stammt, und endet mit dem
739 * Fokus auf der Ueberschrift der neuen - also IN dem Teilbaum, den
740 * `legeHintergrundStill` gerade stillgelegt hat. Der Tastenempfaenger
741 * bricht aus demselben Grund schon bei Strg+Z ab.
742 */
743 if (offeneFensterAnzahl() > 0) break;
744 shell.navigate('export');
745 break;
746 case 'hilfe':
747 showHelp();
748 break;
749 case 'ueber':
750 /*
751 * Ohne Riegel an dieser Stelle: `zeigeUeber` bringt beide Waechter
752 * selbst mit und in der Reihenfolge, auf die es ankommt - erst die
753 * Rueckkehr in ein schon offenes "Über"-Fenster (Befund M1), dann
754 * der Abbruch bei jedem anderen offenen Fenster. Umgekehrt
755 * geschachtelt bliebe ein zweiter Menuebefehl bei offenem
756 * "Über"-Fenster ohne jede Rueckmeldung.
757 *
758 * Die Fassung wird uebergeben und nicht dort ermittelt: Sie steht
759 * einmal, in package.json, und kommt ueber `__APP_VERSION__` hier
760 * an.
761 */
762 zeigeUeber(APP_VERSION);
763 break;
764 default:
765 break;
766 }
767 });
768
769 bridge.onBeforeClose(async () => {
770 const state = store.getState();
771 if (!state.dirty) return true;
772 const answer = await confirmDialog({
773 title: 'Ungespeicherte Änderungen',
774 message: `Am Projekt "${state.project.meta.name}" bestehen ungespeicherte Änderungen.`,
775 confirmLabel: 'Ohne Speichern schließen',
776 cancelLabel: 'Abbrechen',
777 extraLabel: 'Speichern und schließen',
778 danger: true,
779 });
780 if (answer === 'abgebrochen') return false;
781 if (answer === 'zusatz') {
782 await saveProject(false);
783 return !store.getState().dirty;
784 }
785 return true;
786 });
787 } else {
788 // Im Browser bleibt nur die Standardabfrage des Fensters.
789 window.addEventListener('beforeunload', (event) => {
790 if (!store.getState().dirty) return;
791 event.preventDefault();
792 event.returnValue = '';
793 });
794 }
795
796 // --- Hilfsfunktionen -----------------------------------------------------
797
798 function chooseTemplate(): Promise<Project | null> {
799 return new Promise((resolve) => {
800 const panel = new Panel({ title: 'Neues Projekt', width: 520 });
801 let settled = false;
802 const finish = (value: Project | null): void => {
803 if (settled) return;
804 settled = true;
805 panel.close();
806 resolve(value);
807 };
808 panel.onClose(() => {
809 if (!settled) {
810 settled = true;
811 resolve(null);
812 }
813 });
814
815 panel.setContent(
816 el('p', { class: 'feld-hinweis', text: 'Womit möchten Sie beginnen?' }),
817 el(
818 'div',
819 { style: 'display:flex;flex-direction:column;gap:8px' },
820 templateOption(
821 'Geführter Einstieg',
822 // Nicht "Vier Fragen": Seit der Anlagenart fuehrt der Assistent bei
823 // Fussgaengerschutzanlage und einstreifiger Verkehrsfuehrung nur
824 // drei Schritte (assistent.ts, schritteFuer). Eine feste Zahl an
825 // dieser Stelle ist in zwei von drei Faellen falsch.
826 'Empfohlen, wenn Sie zum ersten Mal einen Signalzeitenplan aufstellen. Drei bis vier Fragen zur Örtlichkeit – daraus entstehen Signalgruppen, Konfliktbeziehungen und Phasen.',
827 () => {
828 // Wichtig: erst als erledigt markieren, dann schliessen. Sonst
829 // loest der Schliessen-Empfaenger die Zusage sofort mit "abgebrochen"
830 // auf, und das Ergebnis des Assistenten kommt nie an.
831 settled = true;
832 panel.close();
833 void starteAssistent().then((projekt) => {
834 if (projekt === null) {
835 // Abgebrochen - zurueck zur Auswahl.
836 void chooseTemplate().then(resolve);
837 return;
838 }
839 resolve(projekt);
840 });
841 },
842 ),
843 templateOption(
844 'Beispielprojekt',
845 'Fertiger vierarmiger Knotenpunkt mit vier Kfz-Signalgruppen, zwei Fußgängerfurten, zwei Phasen und Verkehrsstärken – zum Ansehen und Ausprobieren.',
846 () => {
847 finish(createStandardIntersectionProject());
848 },
849 ),
850 templateOption(
851 'Leeres Projekt',
852 'Ohne Signalgruppen und Phasen. Nur sinnvoll, wenn Sie den Ablauf bereits kennen.',
853 () => {
854 finish(createEmptyProject());
855 },
856 ),
857 ),
858 );
859 panel.setActions(button({ label: 'Abbrechen', onClick: () => finish(null) }));
860 });
861 }
862
863 function templateOption(title: string, description: string, onClick: () => void): HTMLElement {
864 const node = button({ label: '', onClick });
865 node.style.textAlign = 'left';
866 node.style.display = 'block';
867 node.style.padding = '12px';
868 // Ohne ausdruecklichen Namen liest ein Bildschirmleser Ueberschrift und
869 // Beschreibung als eine lange Zeichenkette vor.
870 node.setAttribute('aria-label', title);
871 node.replaceChildren(
872 el('strong', { text: title }),
873 el('span', { class: 'feld-hinweis', style: 'display:block', text: description }),
874 );
875 return node;
876 }
877
878 /**
879 * Zeigt, was beim Einlesen auffiel - und entscheidet selbst, ob es etwas zu
880 * zeigen gibt.
881 *
882 * Die Bedingung steht hier und nicht an den Aufrufstellen: Beide Wege in ein
883 * Projekt hinein - "Öffnen" und der Wiederanlauf aus dem Sitzungsspeicher -
884 * fuehren dieselbe Aufstellung, und eine wortgleiche Abschrift der Bedingung
885 * an zwei Stellen laeuft auseinander, sobald einer sie erweitert.
886 *
887 * Auch ohne Hinweise wird gezeigt, sobald eine Gegenueberstellung vorliegt:
888 * Bei einem uebernommenen 4.x-Projekt ist gerade sie die wichtigste Auskunft.
889 */
890 function showImportIssues(
891 issues: readonly { path: string; severity: string; message: string }[],
892 migrated: boolean,
893 vergleich: readonly IntergreenComparisonRow[] = [],
894 ): void {
895 if (issues.length === 0 && vergleich.length === 0) return;
896
897 const panel = new Panel({
898 title: migrated ? 'Projekt übernommen' : 'Hinweise zum Import',
899 width: 720,
900 });
901 panel.setContent(
902 el('p', {
903 text: migrated
904 ? 'Das Projekt stammt aus einer älteren Programmversion. Die folgenden Punkte sind zu beachten:'
905 : 'Beim Einlesen sind folgende Punkte aufgefallen:',
906 }),
907 issues.length === 0
908 ? null
909 : // Wie jede andere Tabelle der Anwendung durch `beschrifteTabelle`:
910 // Sie setzt die Beschriftung und den Spaltenbezug, die die
911 // Zusage zur Barrierefreiheit unter EK 1.3.1 fuer das
912 // GESAMTE Erzeugnis zusagt. Die vier Tabellen dieser Datei liefen als
913 // einzige daran vorbei; tests/ui/barrierefreiheit.test.ts erreicht
914 // sie nicht, weil er nur ueber die zwoelf Ansichten laeuft.
915 beschrifteTabelle(
916 el(
917 'div',
918 { class: 'tabelle-rahmen' },
919 el(
920 'table',
921 {},
922 el(
923 'thead',
924 {},
925 el(
926 'tr',
927 {},
928 el('th', { text: 'Art' }),
929 el('th', { text: 'Bereich' }),
930 el('th', { text: 'Hinweis' }),
931 ),
932 ),
933 el(
934 'tbody',
935 {},
936 ...issues.map((issue) =>
937 el(
938 'tr',
939 {},
940 el('td', { text: issue.severity }),
941 el('td', { text: issue.path }),
942 el('td', { text: issue.message }),
943 ),
944 ),
945 ),
946 ),
947 ),
948 { beschriftung: 'Hinweise zum Einlesen der Datei' },
949 ),
950 ...zwischenzeitVergleich(vergleich),
951 );
952 panel.setActions(
953 button({ label: 'Verstanden', variant: 'primaer', onClick: () => panel.close() }),
954 );
955 }
956
957 /**
958 * Gegenueberstellung der Zwischenzeiten aus 4.x mit den neu ermittelten.
959 *
960 * Der Kopf von migrate.ts sagt diese Gegenueberstellung ausdruecklich zu -
961 * "damit die Aenderung nachvollziehbar ist" -, und sie wurde auch aufgebaut,
962 * erreichte den Anwender aber nie: Niemand las das Feld. Uebrig blieb ein
963 * Sammelhinweis, dass die Zwischenzeiten neu gerechnet wurden. Um wie viel
964 * sie sich aendern, ist die eigentliche Frage: Der Altbestand rechnete mit
965 * 50 km/h Raeumgeschwindigkeit statt der 10 m/s des Regelwerks, seine Werte
966 * sind also zu kurz - und der Planer muss sehen, wie viel zu kurz.
967 */
968 function zwischenzeitVergleich(vergleich: readonly IntergreenComparisonRow[]): (Node | null)[] {
969 if (vergleich.length === 0) return [];
970 const plan = store.getPlan();
971 const gruppe = (name: string): string | undefined =>
972 store.getProject().signalGroups.find((g) => g.name === name)?.id;
973
974 const zeilen = vergleich.map((z) => {
975 const vonId = gruppe(z.from);
976 const nachId = gruppe(z.to);
977 const neu =
978 vonId === undefined || nachId === undefined
979 ? null
980 : (plan.intergreens.get(intergreenKey(vonId, nachId))?.value ?? null);
981 return { ...z, neu };
982 });
983
984 return [
985 el('h3', { text: 'Zwischenzeiten: bisher und jetzt', style: 'margin-top:18px' }),
986 el('p', {
987 class: 'feld-hinweis',
988 text:
989 'Die gespeicherte Zwischenzeitenmatrix wurde nicht übernommen. Sie war mit 50 km/h ' +
990 'Räumgeschwindigkeit gerechnet, das Regelwerk sieht 10 m/s vor – die alten Werte sind ' +
991 'zu kurz. Neu ermittelt wurde aus Räum- und Einfahrweg.',
992 }),
993 beschrifteTabelle(
994 el(
995 'div',
996 { class: 'tabelle-rahmen' },
997 el(
998 'table',
999 {},
1000 el(
1001 'thead',
1002 {},
1003 el(
1004 'tr',
1005 {},
1006 el('th', { text: 'Beziehung' }),
1007 el('th', { text: 'bisher' }),
1008 el('th', { text: 'jetzt' }),
1009 el('th', { text: 'Unterschied' }),
1010 ),
1011 ),
1012 el(
1013 'tbody',
1014 {},
1015 ...zeilen.map((z) =>
1016 el(
1017 'tr',
1018 {},
1019 el('td', { text: `${z.from} nach ${z.to}` }),
1020 el('td', { class: 'zahl', text: `${fmt.numShort(z.legacyValue, 0)} s` }),
1021 el('td', {
1022 class: 'zahl',
1023 text: z.neu === null ? '–' : `${fmt.numShort(z.neu, 0)} s`,
1024 }),
1025 el('td', {
1026 class: 'zahl',
1027 text:
1028 z.neu === null
1029 ? '–'
1030 : `${z.neu > z.legacyValue ? '+' : ''}${fmt.numShort(z.neu - z.legacyValue, 0)} s`,
1031 }),
1032 ),
1033 ),
1034 ),
1035 ),
1036 ),
1037 { beschriftung: 'Zwischenzeiten: bisher und jetzt' },
1038 ),
1039 ];
1040 }
1041
1042 /**
1043 * Der Meldungsverlauf dieser Sitzung.
1044 *
1045 * Eine Kurzmeldung ist fluechtig - das ist ihr Zweck und zugleich ihr Mangel.
1046 * Wer mit einer Bildschirmlupe arbeitet, hat den Balken unten rechts oft noch
1047 * nicht gefunden, wenn er schon wieder verschwunden ist; und wer beim Speichern
1048 * kurz wegsieht, hatte bisher keinen Weg zurueck zu dem, was gemeldet wurde.
1049 * Ausgerechnet die abgelehnten Zahleneingaben stehen dort.
1050 *
1051 * Hier steht auch die ANZEIGEDAUER (Befund L8, WCAG 2.2.1). Sie gehoert
1052 * hierher und nicht unter "Vorgaben": Diese Ansicht fuehrt die Kennwerte der
1053 * RiLSA, und eine Bildschirmeinstellung zwischen Raeumgeschwindigkeit und
1054 * Gelbzeitstaffel waere dort nicht zu vermuten. Wer eine Meldung verpasst
1055 * hat, kommt genau hierher - und findet an derselben Stelle den Schalter, mit
1056 * dem es nicht wieder vorkommt.
1057 */
1058 function zeigeMeldungen(): void {
1059 const panel = new Panel({ title: 'Meldungen dieser Sitzung', width: 720 });
1060 const eintraege = meldungsverlauf();
1061 const uhrzeit = new Intl.DateTimeFormat('de-DE', { timeStyle: 'medium' });
1062 const bezeichnung: Record<string, string> = {
1063 info: 'Hinweis',
1064 erfolg: 'Erfolg',
1065 warnung: 'Warnung',
1066 fehler: 'Fehler',
1067 };
1068
1069 const dauerbeschriftung = (sekunden: number): string => {
1070 if (sekunden === 0) return 'stehen lassen, bis ich sie schließe';
1071 const standard = sekunden === DEFAULT_APP_SETTINGS.meldungsdauerSekunden;
1072 return `${sekunden} Sekunden${standard ? ' (Standard)' : ''}`;
1073 };
1074
1075 const dauerfeld = select({
1076 label: 'Anzeigedauer für Hinweise und Erfolgsmeldungen',
1077 value: String(settings.meldungsdauerSekunden),
1078 options: MELDUNGSDAUER_STUFEN.map((s) => ({
1079 value: String(s),
1080 label: dauerbeschriftung(s),
1081 })),
1082 hint:
1083 'Gilt für die kurzen Meldungen am Bildschirmrand. Warnungen und Fehler bleiben ohnehin ' +
1084 'stehen, bis sie geschlossen werden. Die Einstellung wird gesichert und gilt auch nach ' +
1085 'einem Neustart.',
1086 onChange: (wert) => {
1087 const sekunden = Number(wert);
1088 if (!MELDUNGSDAUER_STUFEN.includes(sekunden)) return;
1089 // Frisch lesen und nur das eigene Feld setzen - Begruendung bei der
1090 // Kopfzeilenschaltflaeche "Ansicht:".
1091 settings = { ...loadAppSettings(), meldungsdauerSekunden: sekunden };
1092 saveAppSettings(settings);
1093 setzeMeldungsdauer(sekunden);
1094 // Die Meldung ist zugleich die Probe: Sie erscheint mit der eben
1095 // gewaehlten Dauer.
1096 notify(
1097 sekunden === 0
1098 ? 'Meldungen bleiben jetzt stehen, bis Sie sie schließen.'
1099 : `Meldungen werden jetzt ${sekunden} Sekunden lang angezeigt.`,
1100 'erfolg',
1101 );
1102 },
1103 });
1104
1105 panel.setContent(
1106 dauerfeld,
1107 eintraege.length === 0
1108 ? emptyState('In dieser Sitzung wurde noch nichts gemeldet.')
1109 : el(
1110 'ul',
1111 { class: 'meldungsverlauf' },
1112 ...eintraege.map((eintrag) =>
1113 el(
1114 'li',
1115 { class: `meldung meldung-${eintrag.art}` },
1116 // Die Art steht als Wort da, nicht nur als Randfarbe: Sonst
1117 // laesst sich eine abgelehnte Eingabe nicht von einer
1118 // Erfolgsmeldung unterscheiden, sobald man Farben schlecht
1119 // trennt - und im Verlauf fehlt der zeitliche Zusammenhang, der
1120 // im Augenblick der Meldung noch half.
1121 el('strong', {
1122 text: `${uhrzeit.format(eintrag.zeitpunkt)} · ${bezeichnung[eintrag.art] ?? eintrag.art}`,
1123 }),
1124 el('span', { text: eintrag.text }),
1125 ),
1126 ),
1127 ),
1128 );
1129 panel.setActions(
1130 button({ label: 'Schließen', variant: 'primaer', onClick: () => panel.close() }),
1131 );
1132 }
1133
1134 /** Waechter gegen gestapelte Kurzhilfen - siehe `kurzhilfe` (Befund M1). */
1135 function showHelp(): void {
1136 // Schon offen: kein zweites Fenster, sondern zurueck in das vorhandene.
1137 // Ein Schliessen und Neuoeffnen waere fuer eine Sprachausgabe ein
1138 // Ortswechsel und wuerde den Lesestand des Anwenders verwerfen.
1139 if (kurzhilfe !== null) {
1140 kurzhilfe.fokussiere();
1141 return;
1142 }
1143
1144 const panel = new Panel({ title: 'Kurzhilfe', width: 760 });
1145 kurzhilfe = panel;
1146 panel.onClose(() => {
1147 kurzhilfe = null;
1148 });
1149 panel.setContent(
1150 el('h3', { text: 'Vorgehen' }),
1151 el(
1152 'ol',
1153 {},
1154 el('li', { text: 'Projektdaten und Knotenpunkt erfassen.' }),
1155 el('li', { text: 'Signalgruppen anlegen und Verkehrsstaerken eintragen.' }),
1156 el('li', {
1157 text: 'Konfliktbeziehungen in der Zwischenzeitenmatrix erfassen und Räum- sowie Einfahrwege aus dem Lageplan vermaßen.',
1158 }),
1159 el('li', { text: 'Phasen bilden und die Phasenfolge festlegen.' }),
1160 el('li', { text: 'Signalzeitenplan prüfen, Prüfbericht abarbeiten.' }),
1161 el('li', { text: 'Planunterlagen ausgeben.' }),
1162 ),
1163 el('h3', { text: 'Rechengrundlagen' }),
1164 el(
1165 'ul',
1166 {},
1167 el('li', { text: 'Zwischenzeit: tz = tü + tr − te, aufgerundet auf ganze Sekunden.' }),
1168 el('li', {
1169 text: 'Räumzeit tr = sr / vr mit sr einschließlich Fahrzeuglänge; Einfahrzeit te = se / ve.',
1170 }),
1171 el('li', {
1172 text: 'Gelbzeit: bis 50 km/h 3 s, bis 60 km/h 4 s, darüber 5 s. Rot-Gelb 1 s.',
1173 }),
1174 el('li', {
1175 text: 'Übergangszeit zwischen zwei Phasen: die größte Zwischenzeit zwischen endenden und beginnenden Signalgruppen.',
1176 }),
1177 el('li', {
1178 text: 'Umlaufzeit wahlweise nach Webster, Akçelik, HBS oder HCM - oder als feste Vorgabe.',
1179 }),
1180 ),
1181 el('h3', { text: 'Tastenkürzel' }),
1182 // Kopfzeile und Beschriftung wie bei jeder anderen Tabelle: Beide
1183 // Tafeln hatten weder das eine noch das andere und standen fuer eine
1184 // Sprachausgabe als namenloses Feld aus zwei Spalten da - entgegen der
1185 // Zusage zur Barrierefreiheit unter EK 1.3.1.
1186 tastenkuerzelTafel('Tastenkürzel der Anwendung', [
1187 shortcutRow('Strg + N', 'Neues Projekt'),
1188 shortcutRow('Strg + O', 'Projekt öffnen'),
1189 shortcutRow('Strg + S', 'Speichern'),
1190 shortcutRow('Strg + Umschalt + S', 'Speichern unter'),
1191 shortcutRow('Strg + Z', 'Rückgängig'),
1192 shortcutRow('Strg + Y', 'Wiederherstellen'),
1193 shortcutRow('F1', 'Diese Hilfe'),
1194 shortcutRow('Esc', 'Dialog schließen'),
1195 ]),
1196 el('h3', { text: 'Zeichenfläche des Lageplans' }),
1197 el('p', {
1198 text:
1199 'Gilt, solange die Zeichenfläche den Fokus hat. Dieselbe Belegung steht aufklappbar ' +
1200 'unter der Zeichenfläche selbst.',
1201 }),
1202 tastenkuerzelTafel('Tastenkürzel der Zeichenfläche des Lageplans', [
1203 shortcutRow('Pfeiltasten', 'Zeiger bewegen (Umschalt fein, Strg grob)'),
1204 shortcutRow('Leertaste', 'Punkt setzen'),
1205 shortcutRow('Eingabetaste', 'Linie abschliessen'),
1206 shortcutRow('Rückschritt', 'Letzten Punkt zurücknehmen'),
1207 shortcutRow('+ / -', 'Vergrößern und verkleinern'),
1208 shortcutRow('0', 'Einpassen'),
1209 shortcutRow('Bild auf / ab, Pos1 / Ende', 'Ausschnitt verschieben'),
1210 shortcutRow('Esc', 'Zeichnen abbrechen'),
1211 ]),
1212 el('h3', { text: 'Grenzen der Prüfung' }),
1213 el('p', {
1214 text:
1215 'Die Prüfung deckt die rechnerisch prüfbaren Anforderungen ab. Sie ersetzt weder die fachliche ' +
1216 'Verantwortung des Planers noch die verkehrsbehördliche Anordnung nach Paragraf 45 StVO. ' +
1217 'Örtliche Besonderheiten, Sichtverhältnisse und die bauliche Ausbildung sind gesondert zu würdigen.',
1218 }),
1219 el('h3', { text: 'Bei Störungen' }),
1220 (() => {
1221 /*
1222 * ERST DER MENUEWEG, DANN DER PFAD. Bis 5.42.1 schickte dieser Absatz
1223 * den Anwender allein auf den Pfad darunter. Je nach Installationsart
1224 * findet er die Datei dort im Explorer nicht unbedingt: Windows leitet
1225 * neue Dateien unter %APPDATA% in einen eigenen Bereich um, sofern
1226 * %APPDATA%\lsa-planer-professional vorher nicht bestand, und der Pfad,
1227 * den der Hauptprozess nennt, ist der unumgeleitete (Kopf von
1228 * electron/protokoll.ts). Bestand der Ordner schon - vom Setup oder vom
1229 * tragbaren Programm -, landen auch neue Dateien dort, und der Pfad
1230 * stimmt (gemessen am 17.09.2026 unter Windows 11). Der Satz unter dem
1231 * Menueweg nennt deshalb die Ausnahme, fuer Windows 11 - so, wie es
1232 * docs/datenschutz.md seit ihrer ersten Fassung 5.43.0 tut.
1233 * "Fehlerprotokoll speichern …" liest die Datei dort, wo das Programm
1234 * sie kennt, und fuehrt in jeder Auslieferung zu einer Kopie. Die
1235 * Beschriftung steht woertlich wie in electron/menu.ts.
1236 *
1237 * "Sie enthält nur technische Angaben, keine Projektinhalte" stand hier
1238 * und war staerker als der Beleg: Das Protokoll ist darauf angelegt,
1239 * keine Projektinhalte aufzunehmen, filtert die Meldungstexte selbst
1240 * aber nicht, und der Aufrufstapel kann den Windows-Benutzernamen tragen
1241 * (docs/datenschutz.md, Abschnitt "Fehlerprotokoll"). Deshalb der
1242 * Hinweis, dass sich die Kopie vor dem Versenden lesen laesst.
1243 *
1244 * Der Pfad bleibt als Auskunft stehen - meist stimmt er. Im
1245 * Browserbetrieb (npm run dev) gibt es weder Protokoll noch
1246 * Anwendungsmenue; dort steht allein der Satz, der das sagt, statt eines
1247 * Menuewegs, der ins Leere fuehrt.
1248 */
1249 const bruecke = desktopBridge();
1250 if (!bruecke) {
1251 return el('p', {
1252 class: 'feld-hinweis',
1253 text: 'Im Browserbetrieb steht kein Protokoll zur Verfügung; Fehler erscheinen in der Entwicklerkonsole.',
1254 });
1255 }
1256 const absatz = el('p', {
1257 text:
1258 'Die Anwendung schreibt unerwartete Fehler in ein Protokoll. Über ' +
1259 '„Hilfe → Fehlerprotokoll speichern …“ ' +
1260 'legen Sie eine Kopie an einem Ort Ihrer Wahl an; bitte legen Sie diese Kopie einer ' +
1261 'Fehlermeldung bei. Das Protokoll ist darauf angelegt, keine Projektinhalte ' +
1262 'aufzunehmen, und die Kopie ist eine gewöhnliche Textdatei, die Sie vor dem Versenden ' +
1263 'lesen können.',
1264 });
1265 const auskunft = el('p', {
1266 class: 'feld-hinweis',
1267 text:
1268 'Das Protokoll selbst liegt unter dem folgenden Pfad. Bei manchen Installationsarten ' +
1269 'zeigt der Explorer die Datei dort nicht unbedingt an, weil Windows die ' +
1270 'Programmdaten gesondert führt. Unter Windows 11 gilt das nicht, wenn das Programm ' +
1271 'aus dem Setup oder das tragbare Programm ihren Ordner schon vorher angelegt hatte.',
1272 });
1273 const pfad = el('p', { class: 'feld-hinweis', text: 'Pfad wird ermittelt …' });
1274 void bruecke
1275 .logPath()
1276 .then((wert) => {
1277 pfad.textContent = wert;
1278 pfad.style.userSelect = 'text';
1279 })
1280 .catch(() => {
1281 pfad.textContent = 'Der Pfad konnte nicht ermittelt werden.';
1282 });
1283 return el('div', {}, absatz, auskunft, pfad);
1284 })(),
1285 );
1286 panel.setActions(
1287 button({ label: 'Schließen', variant: 'primaer', onClick: () => panel.close() }),
1288 );
1289 }
1290
1291 function shortcutRow(keys: string, description: string): HTMLElement {
1292 return el(
1293 'tr',
1294 {},
1295 el('td', { class: 'eng' }, el('span', { class: 'tastenkuerzel', text: keys })),
1296 el('td', { text: description }),
1297 );
1298 }
1299
1300 /**
1301 * Eine Tafel mit Tastenkuerzeln - mit Kopfzeile und Beschriftung.
1302 *
1303 * Beide Tafeln der Kurzhilfe standen zuvor ohne `thead` und ohne `caption`
1304 * da. Eine Sprachausgabe nannte damit weder, wovon die Tafel handelt, noch
1305 * welche Spalte die Taste und welche die Wirkung fuehrt - obwohl die Zusage
1306 * zur Barrierefreiheit unter EK 1.3.1 fuer das gesamte Erzeugnis zusagt, dass
1307 * Tabellen `scope` an jedem Spaltenkopf und eine Beschriftung tragen. Den
1308 * Spaltenbezug setzt `beschrifteTabelle` nach; die Kopfzeile muss es geben,
1309 * damit es einen gibt.
1310 */
1311 function tastenkuerzelTafel(beschriftung: string, zeilen: HTMLElement[]): HTMLElement {
1312 return beschrifteTabelle(
1313 el(
1314 'div',
1315 { class: 'tabelle-rahmen' },
1316 el(
1317 'table',
1318 {},
1319 el('thead', {}, el('tr', {}, el('th', { text: 'Taste' }), el('th', { text: 'Wirkung' }))),
1320 el('tbody', {}, ...zeilen),
1321 ),
1322 ),
1323 { beschriftung },
1324 );
1325 }
1326 }
1327
1328 /**
1329 * Stellt den zuletzt bearbeiteten Stand her - und sagt, wenn das misslingt.
1330 *
1331 * Zuvor lieferte loadSession() fuer drei voellig verschiedene Ausgaenge
1332 * dasselbe null: nie etwas gespeichert, Datenbank nicht lesbar, Satz
1333 * beschaedigt. Der Anwender bekam in jedem Fall ein leeres Projekt, ohne ein
1334 * Wort - auch dann, wenn sein Arbeitsstand noch da, aber unlesbar war. Der
1335 * catch-Zweig darunter griff dabei praktisch nie, weil ladeSitzung() seine
1336 * Fehler selbst abfaengt.
1337 */
1338 interface Wiederanlauf {
1339 readonly project: Project;
1340 readonly filePath: string | null;
1341 /**
1342 * Ausgang des Ladeversuchs.
1343 *
1344 * Wird mit herausgereicht, weil der Start ihn braucht: Nach 'nicht-lesbar'
1345 * darf kein Sitzungsstand geschrieben werden, und ein 'geladen'
1346 * wiederhergestellter Stand steht in keiner Datei. Ohne diese Angabe konnte
1347 * `boot()` beides nicht auseinanderhalten.
1348 */
1349 readonly art: Sitzungsbefund['art'];
1350 /**
1351 * Konnte ein beschaedigter Satz beiseitegelegt werden?
1352 *
1353 * Nur bei `art === 'beschaedigt'` von Belang, sonst immer `true`. Wird
1354 * mitgereicht, weil der Start es braucht: Misslang das Beiseitelegen, steht
1355 * der beschaedigte Satz weiterhin unter dem Sitzungsschluessel, und der
1356 * Speicher sagt daneben zu, er werde "beim naechsten Start erneut geprueft".
1357 * Ohne diese Angabe kam der Wert nie bei `boot()` an - er ging allein in den
1358 * Meldungstext -, die Zwischenspeicherung lief ungesperrt weiter, und der
1359 * erste Sichtwechsel schrieb das leere Startprojekt darueber.
1360 */
1361 readonly beiseitegelegt: boolean;
1362 /**
1363 * Was beim Einlesen des Sitzungsstands auffiel - oder null.
1364 *
1365 * Wird mit herausgereicht, damit `boot()` dieselbe Aufstellung zeigen kann
1366 * wie beim Oeffnen einer Datei. Zuvor blieb hier nur die ANZAHL der
1367 * Meldungen uebrig, und die eigentliche Auskunft ging verloren: dass etwa ein
1368 * Raeumweg auf den Hoechstwert der Anlagenart begrenzt wurde und die
1369 * Zwischenzeiten deshalb andere sind als eingetragen.
1370 */
1371 readonly einlesebefund: MigrationResult | null;
1372 }
1373
1374 async function restoreSession(): Promise<Wiederanlauf> {
1375 // Meldungen erscheinen erst nach dem Aufbau der Oberflaeche, damit der Start
1376 // nicht blockiert.
1377 const spaeter = (text: string, art: 'warnung' | 'fehler'): void => {
1378 setTimeout(() => {
1379 notify(text, art);
1380 }, 400);
1381 };
1382
1383 try {
1384 const befund = await ladeSitzung();
1385
1386 switch (befund.art) {
1387 case 'leer':
1388 return {
1389 project: createEmptyProject(),
1390 filePath: null,
1391 art: befund.art,
1392 beiseitegelegt: true,
1393 einlesebefund: null,
1394 };
1395
1396 case 'nicht-lesbar':
1397 spaeter(
1398 'Der zuletzt bearbeitete Stand konnte nicht gelesen werden. Er ist nicht verloren – ' +
1399 'die Datenbank ließ sich nur nicht öffnen. Speichern Sie jetzt nichts, sondern ' +
1400 'starten Sie das Programm neu; bleibt es dabei, öffnen Sie Ihre zuletzt gespeicherte ' +
1401 'Projektdatei.',
1402 'fehler',
1403 );
1404 return {
1405 project: createEmptyProject(),
1406 filePath: null,
1407 art: befund.art,
1408 beiseitegelegt: true,
1409 einlesebefund: null,
1410 };
1411
1412 case 'beschaedigt':
1413 spaeter(
1414 befund.beiseitegelegt
1415 ? 'Der zuletzt bearbeitete Stand war beschädigt und ließ sich nicht einlesen. Er wurde ' +
1416 'beiseitegelegt und nicht gelöscht. Öffnen Sie Ihre zuletzt gespeicherte Projektdatei.'
1417 : 'Der zuletzt bearbeitete Stand war beschädigt und ließ sich nicht einlesen. Er konnte ' +
1418 'auch nicht beiseitegelegt werden – vermutlich ist der Speicher voll. Öffnen Sie ' +
1419 'Ihre zuletzt gespeicherte Projektdatei.',
1420 'fehler',
1421 );
1422 return {
1423 project: createEmptyProject(),
1424 filePath: null,
1425 art: befund.art,
1426 beiseitegelegt: befund.beiseitegelegt,
1427 einlesebefund: null,
1428 };
1429
1430 case 'geladen':
1431 // Die Meldungen selbst zeigt `boot()`, sobald die Oberflaeche steht -
1432 // in derselben Aufstellung wie beim Oeffnen einer Datei. Hier stand
1433 // zuvor nur ihre Anzahl als Kurzmeldung.
1434 return {
1435 project: befund.stand.project,
1436 filePath: null,
1437 art: befund.art,
1438 beiseitegelegt: true,
1439 einlesebefund: befund.stand,
1440 };
1441 }
1442 } catch (error) {
1443 console.error('Der zuletzt bearbeitete Stand konnte nicht wiederhergestellt werden:', error);
1444 spaeter(
1445 'Der zuletzt bearbeitete Stand konnte nicht wiederhergestellt werden. Öffnen Sie Ihre ' +
1446 'zuletzt gespeicherte Projektdatei.',
1447 'fehler',
1448 );
1449 // Wie 'nicht-lesbar' behandeln: Was der Speicher enthaelt, ist unbekannt,
1450 // und ueberschrieben werden darf er deshalb nicht.
1451 return {
1452 project: createEmptyProject(),
1453 filePath: null,
1454 art: 'nicht-lesbar',
1455 beiseitegelegt: true,
1456 einlesebefund: null,
1457 };
1458 }
1459 }
1460
1461 function isTextEntry(target: EventTarget | null): boolean {
1462 return (
1463 target instanceof HTMLInputElement ||
1464 target instanceof HTMLTextAreaElement ||
1465 (target instanceof HTMLElement && target.isContentEditable)
1466 );
1467 }
1468
1469 // Ein Startfehler darf nicht in einer weissen Seite enden - auch dann nicht,
1470 // wenn er erst nach dem ersten `await` auftritt. Ein try/catch um einen
1471 // asynchronen Aufruf faengt genau das NICHT, deshalb der Fehlerpfad an der
1472 // Zusage selbst.
1473 boot().catch((error: unknown) => {
1474 console.error(error);
1475 const root = document.getElementById('anwendung') ?? document.body;
1476 root.replaceChildren(
1477 el(
1478 'div',
1479 { style: 'padding:32px;max-width:70ch;margin:0 auto' },
1480 el('h1', { text: 'Die Anwendung konnte nicht gestartet werden.' }),
1481 el('p', { text: error instanceof Error ? error.message : String(error) }),
1482 el('p', {
1483 text: 'Bitte starten Sie das Programm neu. Bleibt der Fehler bestehen, senden Sie diese Meldung an den Hersteller.',
1484 }),
1485 ),
1486 );
1487 });