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