lsa-planer

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

/ src app actions.ts

27,4 KB Rohdatei
src/app/actions.ts — 738 Zeilen
1 import { createArm, createConflict, createPhase, createSignalGroup } from '@/domain/model/factory';
2 import { aendereKonflikt } from '@/domain/model/project';
3 import { legeProgrammAn, loescheProgramm, wechsleProgramm } from '@/domain/model/programme';
4 import type {
5 IntersectionArm,
6 Konfliktaenderung,
7 Koordinierungskette,
8 Koordinierungsknoten,
9 Phase,
10 Project,
11 SignalGroup,
12 TrafficDemand,
13 } from '@/domain/model/project';
14 import { createId } from '@/domain/id';
15 import type { TrafficMode } from '@/domain/rilsa/types';
16 import {
17 normalisiere,
18 type Arbeitsbereich,
19 type Signalgeber,
20 type Haltlinie,
21 type Lageplan,
22 type Planlinie,
23 } from '@/domain/geometrie/lageplan';
24
25 /**
26 * Aenderungen am Projekt.
27 *
28 * Alle Funktionen sind rein: sie geben ein neues Projekt zurueck und aendern das
29 * uebergebene nicht. Genau daran scheiterte im Altbestand das Rueckgaengig-
30 * machen: dort wurden Signalgruppen mit `splice` an Ort und Stelle geaendert,
31 * sodass der zuvor abgelegte Wiederherstellungspunkt dieselbe Liste
32 * referenzierte und sich mitveraenderte.
33 */
34
35 // --- Projektdaten -----------------------------------------------------------
36
37 export function setMeta(project: Project, patch: Partial<Project['meta']>): Project {
38 return { ...project, meta: { ...project.meta, ...patch } };
39 }
40
41 export function setIntersection(
42 project: Project,
43 patch: Partial<Project['intersection']>,
44 ): Project {
45 return { ...project, intersection: { ...project.intersection, ...patch } };
46 }
47
48 export function setProgram(project: Project, patch: Partial<Project['program']>): Project {
49 return { ...project, program: { ...project.program, ...patch } };
50 }
51
52 export function setSettings(project: Project, patch: Partial<Project['settings']>): Project {
53 return { ...project, settings: { ...project.settings, ...patch } };
54 }
55
56 // --- Zufahrten --------------------------------------------------------------
57
58 export function addArm(project: Project, name: string, direction: string): Project {
59 const arm = createArm(name, direction, 1, project.intersection.vZul);
60 return setIntersection(project, { arms: [...project.intersection.arms, arm] });
61 }
62
63 export function updateArm(project: Project, id: string, patch: Partial<IntersectionArm>): Project {
64 return setIntersection(project, {
65 arms: project.intersection.arms.map((a) => (a.id === id ? { ...a, ...patch } : a)),
66 });
67 }
68
69 export function removeArm(project: Project, id: string): Project {
70 return {
71 ...setIntersection(project, { arms: project.intersection.arms.filter((a) => a.id !== id) }),
72 // Zuordnungen aufloesen, statt sie ins Leere zeigen zu lassen.
73 signalGroups: project.signalGroups.map((g) => (g.armId === id ? { ...g, armId: null } : g)),
74 // Auch die Haltlinien: Sonst haengt die Zufahrtszugehoerigkeit der ganzen
75 // Familie an einem toten Verweis, und aufgeraeumt wuerde erst beim naechsten
76 // Einlesen - also erst nach dem Speichern.
77 lageplan: {
78 ...project.lageplan,
79 haltlinien: project.lageplan.haltlinien.map((h) =>
80 h.armId === id ? { ...h, armId: null } : h,
81 ),
82 },
83 };
84 }
85
86 // --- Signalgruppen ----------------------------------------------------------
87
88 export function addSignalGroup(project: Project, mode: TrafficMode): Project {
89 const prefix = mode === 'fuss' ? 'F' : mode === 'rad' ? 'R' : mode === 'oepnv' ? 'B' : 'K';
90 const used = new Set(project.signalGroups.map((g) => g.name));
91 let index = 1;
92 while (used.has(`${prefix}${index}`)) index += 1;
93
94 const group = createSignalGroup({
95 name: `${prefix}${index}`,
96 mode,
97 vZul: project.intersection.vZul,
98 index: project.signalGroups.length,
99 });
100 return { ...project, signalGroups: [...project.signalGroups, group] };
101 }
102
103 export function updateSignalGroup(
104 project: Project,
105 id: string,
106 patch: Partial<SignalGroup>,
107 ): Project {
108 return {
109 ...project,
110 signalGroups: project.signalGroups.map((g) => (g.id === id ? { ...g, ...patch } : g)),
111 };
112 }
113
114 /**
115 * Loescht eine Signalgruppe samt aller Verweise.
116 * Der Altbestand entfernte nur den Eintrag; Konfliktmatrix und Phasen
117 * verwiesen danach auf eine nicht mehr vorhandene Gruppe, und weil die Matrix
118 * ueber Feldindizes gefuehrt wurde, verschoben sich alle folgenden Zuordnungen.
119 */
120 export function removeSignalGroup(project: Project, id: string): Project {
121 return {
122 ...project,
123 signalGroups: project.signalGroups.filter((g) => g.id !== id),
124 conflicts: project.conflicts.filter((c) => c.fromId !== id && c.toId !== id),
125 phases: project.phases.map((p) => ({
126 ...p,
127 signalGroupIds: p.signalGroupIds.filter((sgId) => sgId !== id),
128 })),
129 demands: project.demands.filter((d) => d.signalGroupId !== id),
130 /*
131 * UND DIE VERKEHRSSTAERKEN DER UEBRIGEN TAGESPROGRAMME (Schema 16). Sonst
132 * fuehrt das Nachtprogramm nach dem Loeschen eine Verkehrsstaerke fuer
133 * einen Strom, den es nicht mehr gibt; beim naechsten Umschalten stuende
134 * sie wieder im Projekt, und der Pruefbericht meldete eine Zuordnung ins
135 * Leere - genau der Fehler, an dem der Altbestand mit der Konfliktmatrix
136 * scheiterte.
137 */
138 programmbibliothek: project.programmbibliothek.map((satz) => ({
139 ...satz,
140 demands: satz.demands.filter((d) => d.signalGroupId !== id),
141 })),
142 // Neu: Sonst zeigt die gezeichnete Linie nach dem Loeschen ins Leere, und
143 // die Ableitung vergliche gegen eine Gruppe, die es nicht mehr gibt.
144 // Der Signalgeber (Schema 19) geht denselben Weg: Sein Standort bleibt -
145 // er ist die Aussage -, die Verknuepfung faellt.
146 lageplan: {
147 ...project.lageplan,
148 linien: project.lageplan.linien.map((l) =>
149 l.signalGroupId === id ? { ...l, signalGroupId: null } : l,
150 ),
151 signalgeber: project.lageplan.signalgeber.map((g) =>
152 g.signalGroupId === id ? { ...g, signalGroupId: null } : g,
153 ),
154 },
155 };
156 }
157
158 export function moveSignalGroup(project: Project, id: string, delta: number): Project {
159 const index = project.signalGroups.findIndex((g) => g.id === id);
160 if (index === -1) return project;
161 const target = index + delta;
162 if (target < 0 || target >= project.signalGroups.length) return project;
163 const next = [...project.signalGroups];
164 const [moved] = next.splice(index, 1);
165 if (moved) next.splice(target, 0, moved);
166 return { ...project, signalGroups: next };
167 }
168
169 // --- Konfliktbeziehungen ----------------------------------------------------
170
171 /**
172 * Aendert eine Konfliktbeziehung; legt sie an, wenn es sie noch nicht gibt.
173 *
174 * Geschrieben wird ausschliesslich ueber `aendereKonflikt`. Dort haengt der
175 * Herkunftsnachweis der Wege dran, und dort gehoert er auch hin: Wuerde jede
176 * Aufrufstelle selbst mischen, muesste an einem Dutzend Stellen jemand daran
177 * denken, den Nachweis zurueckzusetzen - und beim naechsten neuen Feld wieder.
178 * Vergessen hiesse: Der Ausdruck weist eine Vermessung aus, die es fuer diese
179 * Zahl nie gegeben hat.
180 *
181 * EIN SCHLUESSEL FUER SUCHEN UND SCHREIBEN: Nachgeschlagen wird ueber das Paar
182 * (fromId, toId) - geschrieben wurde ueber `c.id === existing.id`. Zwei
183 * Schluessel fuer dieselbe Entscheidung: Trifft die Kennung ein zweites Mal zu,
184 * schreibt die Funktion an eine Beziehung, die sie nie nachgeschlagen hat, und
185 * entwertet dort auch noch den Herkunftsnachweis. Die Neuvergabe beim Einlesen
186 * entschaerft nur, was durch `parseProject` laeuft. Geschrieben wird
187 * jetzt ueber denselben Schluessel, ueber den gesucht wird - so wie
188 * `removeConflict` es schon tut.
189 */
190 export function setConflict(
191 project: Project,
192 fromId: string,
193 toId: string,
194 patch: Konfliktaenderung,
195 ): Project {
196 const existing = project.conflicts.some((c) => c.fromId === fromId && c.toId === toId);
197 if (existing) {
198 return {
199 ...project,
200 conflicts: project.conflicts.map((c) =>
201 c.fromId === fromId && c.toId === toId ? aendereKonflikt(c, patch) : c,
202 ),
203 };
204 }
205 return {
206 ...project,
207 conflicts: [...project.conflicts, aendereKonflikt(createConflict(fromId, toId), patch)],
208 };
209 }
210
211 /**
212 * Setzt Raeum- und Einfahrweg fuer mehrere Beziehungen zugleich.
213 *
214 * Hilfsmittel fuer den Beginn der Bearbeitung. Die Ansicht hat die Werte
215 * frueher selbst in die Liste geschrieben - und damit an `setConflict` und dem
216 * Herkunftsnachweis vorbei. Genau dieser Weg machte aus einer Uebernahme aus
217 * dem Lageplan stillschweigend eine Massenvorgabe, ueber der im Ausdruck
218 * weiterhin "Aus dem Lageplan vermessen" stand.
219 *
220 * `nurLeere` laesst bereits vermassene Beziehungen unberuehrt - auch ihren
221 * Nachweis, denn an ihnen aendert sich nichts.
222 *
223 * VERMASSEN HEISST: EINER DER BEIDEN WEGE STEHT (Fassung 5.10.0). Geprueft
224 * wurde bis dahin allein der Raeumweg. Raeum- und Einfahrweg sind in der
225 * Ansicht "Zwischenzeiten" zwei voneinander unabhaengige Felder; eine Beziehung
226 * mit eingetragenem Einfahrweg und noch offenem Raeumweg galt deshalb als leer,
227 * und der Sammelwert ueberschrieb den gemessenen Einfahrweg samt seinem
228 * Herkunftsnachweis - entgegen der Beschriftung des Kontrollkaestchens ("Nur
229 * Beziehungen ohne erfasste Wege ändern"). Der Einfahrweg geht mit negativem
230 * Vorzeichen in die Zwischenzeit ein; gegenueber dem gemessenen Wert faellt sie
231 * mit dem groesseren Sammelwert zu kurz aus. Bleibt der Raeumweg dabei bei 0 m,
232 * geht das nicht still durch: Das meldet der Pruefbericht eigens.
233 */
234 export function setzeAlleWege(
235 project: Project,
236 raeumweg: number,
237 einfahrweg: number,
238 nurLeere: boolean,
239 ): Project {
240 return {
241 ...project,
242 conflicts: project.conflicts.map((c) =>
243 nurLeere && (c.clearingDistance > 0 || c.enteringDistance > 0)
244 ? c
245 : aendereKonflikt(c, { clearingDistance: raeumweg, enteringDistance: einfahrweg }),
246 ),
247 };
248 }
249
250 export function removeConflict(project: Project, fromId: string, toId: string): Project {
251 return {
252 ...project,
253 conflicts: project.conflicts.filter((c) => !(c.fromId === fromId && c.toId === toId)),
254 };
255 }
256
257 /** Legt eine Konfliktbeziehung in beiden Richtungen an. */
258 export function toggleConflictPair(project: Project, aId: string, bId: string): Project {
259 const exists = project.conflicts.some(
260 (c) => (c.fromId === aId && c.toId === bId) || (c.fromId === bId && c.toId === aId),
261 );
262 if (exists) {
263 return {
264 ...project,
265 conflicts: project.conflicts.filter(
266 (c) => !((c.fromId === aId && c.toId === bId) || (c.fromId === bId && c.toId === aId)),
267 ),
268 };
269 }
270 return {
271 ...project,
272 conflicts: [...project.conflicts, createConflict(aId, bId), createConflict(bId, aId)],
273 };
274 }
275
276 // --- Phasen -----------------------------------------------------------------
277
278 /**
279 * Eine Phase anlegen.
280 *
281 * Sie wird an JEDE Programmreihenfolge angehaengt, nicht nur an die des
282 * aktiven (Schema 16): Eine Phase, die in einem Programm nicht vorkommt,
283 * bekommt dort nie Freigabe - das ist eine Entscheidung des Bearbeiters und
284 * darf nicht die stille Nebenwirkung davon sein, welches Programm beim
285 * Anlegen gerade sichtbar war.
286 */
287 export function addPhase(project: Project): Project {
288 const phase = createPhase(`Phase ${project.phases.length + 1}`);
289 return {
290 ...project,
291 phases: [...project.phases, phase],
292 program: { ...project.program, phaseOrder: [...project.program.phaseOrder, phase.id] },
293 programmbibliothek: project.programmbibliothek.map((satz) => ({
294 ...satz,
295 programm: { ...satz.programm, phaseOrder: [...satz.programm.phaseOrder, phase.id] },
296 })),
297 };
298 }
299
300 export function updatePhase(project: Project, id: string, patch: Partial<Phase>): Project {
301 return { ...project, phases: project.phases.map((p) => (p.id === id ? { ...p, ...patch } : p)) };
302 }
303
304 export function removePhase(project: Project, id: string): Project {
305 return {
306 ...project,
307 phases: project.phases.filter((p) => p.id !== id),
308 program: { ...project.program, phaseOrder: project.program.phaseOrder.filter((p) => p !== id) },
309 // Aus jeder Reihenfolge und aus jeder festen Freigabezeit (Schema 16):
310 // Was auf eine geloeschte Phase verweist, verweist ins Leere.
311 programmbibliothek: project.programmbibliothek.map((satz) => ({
312 ...satz,
313 programm: {
314 ...satz.programm,
315 phaseOrder: satz.programm.phaseOrder.filter((p) => p !== id),
316 },
317 festeFreigabezeiten: satz.festeFreigabezeiten.filter((f) => f.phaseId !== id),
318 })),
319 };
320 }
321
322 export function movePhase(project: Project, id: string, delta: number): Project {
323 const order = [...project.program.phaseOrder];
324 const index = order.indexOf(id);
325 if (index === -1) return project;
326 const target = index + delta;
327 if (target < 0 || target >= order.length) return project;
328 const [moved] = order.splice(index, 1);
329 if (moved !== undefined) order.splice(target, 0, moved);
330 return { ...project, program: { ...project.program, phaseOrder: order } };
331 }
332
333 /**
334 * Feindliche Signalgruppen, die der Phase bereits zugeordnet sind.
335 *
336 * Massgebend ist die erfasste Konfliktliste des Projekts - dieselbe Wahrheit,
337 * aus der die Zwischenzeitenmatrix entsteht. Bewusst NICHT die Matrixwerte:
338 * Auch eine feindliche Beziehung mit rechnerischer Zwischenzeit 0 s bleibt
339 * feindlich und gehoert nicht in eine gemeinsame Phase.
340 */
341 export function feindlichePartnerInPhase(
342 project: Project,
343 phaseId: string,
344 groupId: string,
345 ): readonly SignalGroup[] {
346 const phase = project.phases.find((p) => p.id === phaseId);
347 if (!phase) return [];
348 const feindlich = new Set<string>();
349 for (const conflict of project.conflicts) {
350 if (conflict.fromId === groupId) feindlich.add(conflict.toId);
351 if (conflict.toId === groupId) feindlich.add(conflict.fromId);
352 }
353 return project.signalGroups.filter(
354 (g) => g.id !== groupId && phase.signalGroupIds.includes(g.id) && feindlich.has(g.id),
355 );
356 }
357
358 export function togglePhaseGroup(project: Project, phaseId: string, groupId: string): Project {
359 /*
360 * KORREKTUR (Befund B10): Die Vertraeglichkeitsansicht verspricht, dass nur
361 * vertraegliche Gruppen in dieselbe Phase gelangen - bislang fuegte diese
362 * Funktion jede Gruppe ungeprueft hinzu, und das Versprechen war falsch.
363 * Jetzt wird die Zuordnung einer feindlichen Gruppe abgelehnt (das Projekt
364 * bleibt unveraendert); die Oberflaeche meldet den Grund ueber
365 * feindlichePartnerInPhase. Das Entfernen aus einer Phase bleibt immer
366 * moeglich, auch bei aus Altdaten geladenen Konfliktpaaren.
367 */
368 const phase = project.phases.find((p) => p.id === phaseId);
369 const wirdEntfernt = phase?.signalGroupIds.includes(groupId) ?? false;
370 if (!wirdEntfernt && feindlichePartnerInPhase(project, phaseId, groupId).length > 0) {
371 return project;
372 }
373 return {
374 ...project,
375 phases: project.phases.map((p) => {
376 if (p.id !== phaseId) return p;
377 const has = p.signalGroupIds.includes(groupId);
378 return {
379 ...p,
380 signalGroupIds: has
381 ? p.signalGroupIds.filter((id) => id !== groupId)
382 : [...p.signalGroupIds, groupId],
383 };
384 }),
385 };
386 }
387
388 /**
389 * Ersetzt die Phaseneinteilung durch eine vorgeschlagene.
390 *
391 * WAS DABEI FORTFAELLT, und es steht in der Rueckfrage der Oberflaeche: die
392 * bisherigen Phasen mit ihren Namen und ihren von Hand vorgegebenen
393 * Freigabezeiten - in JEDEM Tagesprogramm. Sie haengen an Phasenkennungen, und
394 * die neuen Phasen sind neue Phasen. Ein stehengelassener Eintrag zeigte auf
395 * eine Phase, die es nicht mehr gibt.
396 *
397 * Die Reihenfolge ist die des Vorschlags. Sie geht in die Uebergangszeiten
398 * ein: Eine andere Reihenfolge derselben Phasen ergibt eine andere Umlaufzeit,
399 * und die sucht dieses Programm nicht ab.
400 */
401 export function setzePhasen(
402 project: Project,
403 gruppenJePhase: readonly (readonly string[])[],
404 ): Project {
405 const phasen = gruppenJePhase.map((gruppen, index) =>
406 createPhase(`Phase ${String(index + 1)}`, [...gruppen]),
407 );
408 return {
409 ...project,
410 phases: phasen,
411 program: { ...project.program, phaseOrder: phasen.map((p) => p.id) },
412 programmbibliothek: project.programmbibliothek.map((satz) => ({
413 ...satz,
414 programm: { ...satz.programm, phaseOrder: phasen.map((p) => p.id) },
415 festeFreigabezeiten: [],
416 })),
417 };
418 }
419
420 // --- Koordinierung ----------------------------------------------------------
421
422 /**
423 * Die Kette eines Projekts, das noch keine hat.
424 *
425 * Sie beginnt LEER und nicht mit einem erfundenen Nachbarn: Ein Knotenpunkt,
426 * den niemand eingetragen hat, waere eine Behauptung ueber die Oertlichkeit.
427 */
428 const LEERE_KETTE: Koordinierungskette = {
429 progressionsgeschwindigkeit: null,
430 hauptrichtung: null,
431 gegenrichtung: null,
432 eigenerAbstand: 0,
433 nachbarn: [],
434 platz: 0,
435 };
436
437 /** Aendert die Kette; legt sie an, wo es noch keine gibt. */
438 export function setzeKoordinierung(project: Project, patch: Partial<Koordinierungskette>): Project {
439 const bisher = project.koordinierung ?? LEERE_KETTE;
440 return { ...project, koordinierung: { ...bisher, ...patch } };
441 }
442
443 /** Entfernt die Koordinierung ganz - der Knotenpunkt steht dann wieder allein. */
444 export function entferneKoordinierung(project: Project): Project {
445 return { ...project, koordinierung: null };
446 }
447
448 /** Legt eine Nachbaranlage am Ende der Kette an. */
449 export function koordinierungsknotenAnlegen(project: Project, name: string): Project {
450 const bisher = project.koordinierung ?? LEERE_KETTE;
451 const knoten: Koordinierungsknoten = {
452 id: createId('knoten'),
453 name,
454 abstand: 0,
455 freigabeBeginn: 0,
456 freigabeDauer: 0,
457 };
458 return setzeKoordinierung(project, { nachbarn: [...bisher.nachbarn, knoten] });
459 }
460
461 /** Aendert eine Nachbaranlage. */
462 export function koordinierungsknotenAendern(
463 project: Project,
464 id: string,
465 patch: Partial<Koordinierungsknoten>,
466 ): Project {
467 const bisher = project.koordinierung;
468 if (bisher === null) return project;
469 return setzeKoordinierung(project, {
470 nachbarn: bisher.nachbarn.map((n) => (n.id === id ? { ...n, ...patch } : n)),
471 });
472 }
473
474 /**
475 * Entfernt eine Nachbaranlage.
476 *
477 * Der eigene Platz wird mitgezogen: Faellt ein Knotenpunkt VOR diesem fort,
478 * rueckt dieser um eine Stelle nach vorn. Ohne das Nachziehen stuende er
479 * danach an einer anderen Stelle der Kette, ohne dass jemand ihn verschoben
480 * haette - und die Abstaende bezoegen sich auf einen anderen Nachbarn.
481 */
482 export function koordinierungsknotenEntfernen(project: Project, id: string): Project {
483 const bisher = project.koordinierung;
484 if (bisher === null) return project;
485 const index = bisher.nachbarn.findIndex((n) => n.id === id);
486 if (index === -1) return project;
487 const platz = index < bisher.platz ? Math.max(0, bisher.platz - 1) : bisher.platz;
488 return setzeKoordinierung(project, {
489 nachbarn: bisher.nachbarn.filter((n) => n.id !== id),
490 platz,
491 });
492 }
493
494 // --- Tagesprogramme ---------------------------------------------------------
495
496 /*
497 * Die drei Aktionen der Programmbibliothek reichen an den Fachkern durch. Sie
498 * stehen hier, weil die Oberflaeche ihre Aenderungen ausschliesslich ueber
499 * `actions` fuehrt - und nicht, weil sie etwas hinzufuegten.
500 */
501
502 /** Auf ein anderes Tagesprogramm umschalten. */
503 export function programmWechseln(project: Project, programmId: string): Project {
504 return wechsleProgramm(project, programmId);
505 }
506
507 /** Ein weiteres Tagesprogramm anlegen und darauf umschalten. */
508 export function programmAnlegen(project: Project, name: string): Project {
509 return legeProgrammAn(project, name);
510 }
511
512 /** Ein Tagesprogramm entfernen; das letzte bleibt stehen. */
513 export function programmEntfernen(project: Project, programmId: string): Project {
514 return loescheProgramm(project, programmId);
515 }
516
517 // --- Lageplan ---------------------------------------------------------------
518
519 export function setLageplan(project: Project, lageplan: Lageplan): Project {
520 // Einziger Trichter: Alle Schreibstellen der Oberflaeche laufen hierueber.
521 // `normalisiere` zieht den ersten Punkt jeder zugeordneten Fahrlinie auf ihre
522 // Haltlinie - eine verschobene Haltlinie nimmt dadurch alle ihre Stroeme mit.
523 return { ...project, lageplan: normalisiere(lageplan) };
524 }
525
526 // --- Haltlinien -------------------------------------------------------------
527
528 export function addHaltlinie(project: Project, haltlinie: Haltlinie): Project {
529 return setLageplan(project, {
530 ...project.lageplan,
531 haltlinien: [...project.lageplan.haltlinien, haltlinie],
532 });
533 }
534
535 export function setHaltlinie(project: Project, id: string, patch: Partial<Haltlinie>): Project {
536 return setLageplan(project, {
537 ...project.lageplan,
538 haltlinien: project.lageplan.haltlinien.map((h) => (h.id === id ? { ...h, ...patch } : h)),
539 });
540 }
541
542 /**
543 * Loescht eine Haltlinie.
544 *
545 * Ihre Stroeme bleiben erhalten und behalten ihren gezeichneten Verlauf
546 * vollstaendig - `punkte` fuehrt den ganzen Linienzug. Sie sind danach nur
547 * keiner Zufahrt mehr zugeordnet; fuer sie entscheidet dann wieder allein die
548 * Zeichnung. Die Oberflaeche fragt vorher zurueck, weil sich damit die
549 * Vertraeglichkeit dieser Stroeme untereinander aendert.
550 */
551 export function removeHaltlinie(project: Project, id: string): Project {
552 return setLageplan(project, {
553 ...project.lageplan,
554 haltlinien: project.lageplan.haltlinien.filter((h) => h.id !== id),
555 linien: project.lageplan.linien.map((l) =>
556 l.haltlinieId === id ? { ...l, haltlinieId: null } : l,
557 ),
558 });
559 }
560
561 export function setPlanlinie(project: Project, id: string, patch: Partial<Planlinie>): Project {
562 return setLageplan(project, {
563 ...project.lageplan,
564 linien: project.lageplan.linien.map((l) => (l.id === id ? { ...l, ...patch } : l)),
565 });
566 }
567
568 export function addPlanlinie(project: Project, linie: Planlinie): Project {
569 return setLageplan(project, {
570 ...project.lageplan,
571 linien: [...project.lageplan.linien, linie],
572 });
573 }
574
575 /**
576 * Loescht eine gezeichnete Linie.
577 * Die verknuepfte Signalgruppe bleibt bestehen - sie kann bereits in Phasen
578 * verwendet sein. Geloescht wird nur die Zeichnung.
579 */
580 export function removePlanlinie(project: Project, id: string): Project {
581 return setLageplan(project, {
582 ...project.lageplan,
583 linien: project.lageplan.linien.filter((l) => l.id !== id),
584 });
585 }
586
587 // --- Arbeitsbereiche --------------------------------------------------------
588
589 /*
590 * Die drei Aktionen des Arbeitsbereichs - dieselbe Bauform wie bei der
591 * Fahrlinie und ueber denselben Trichter `setLageplan`.
592 *
593 * KEIN AUFRAEUMEN IN FREMDEN LISTEN, und das ist keine Nachlaessigkeit: Auf
594 * einen Arbeitsbereich verweist nichts. Er traegt weder Signalgruppe noch
595 * Zufahrt, und deshalb beruehrt ihn weder `removeSignalGroup` noch
596 * `removeArm`. Bekaeme er einmal einen Verweis, gehoerte er in beide.
597 */
598 export function addArbeitsbereich(project: Project, bereich: Arbeitsbereich): Project {
599 return setLageplan(project, {
600 ...project.lageplan,
601 arbeitsbereiche: [...project.lageplan.arbeitsbereiche, bereich],
602 });
603 }
604
605 export function setArbeitsbereich(
606 project: Project,
607 id: string,
608 patch: Partial<Arbeitsbereich>,
609 ): Project {
610 return setLageplan(project, {
611 ...project.lageplan,
612 arbeitsbereiche: project.lageplan.arbeitsbereiche.map((a) =>
613 a.id === id ? { ...a, ...patch } : a,
614 ),
615 });
616 }
617
618 export function removeArbeitsbereich(project: Project, id: string): Project {
619 return setLageplan(project, {
620 ...project.lageplan,
621 arbeitsbereiche: project.lageplan.arbeitsbereiche.filter((a) => a.id !== id),
622 });
623 }
624
625 /**
626 * Die drei Aktionen des Signalgebers - dieselbe Bauform, ein Unterschied.
627 *
628 * ER TRAEGT EINEN VERWEIS, und deshalb gilt hier nicht, was beim
629 * Arbeitsbereich gilt: Wird eine Signalgruppe geloescht, muss der Verweis
630 * fallen, sonst zeigt er ins Leere. Das erledigt `removeSignalGroup` weiter
631 * unten mit - an einer Stelle und nicht hier, damit kein zweiter Weg entsteht.
632 */
633 export function addSignalgeber(project: Project, geber: Signalgeber): Project {
634 return setLageplan(project, {
635 ...project.lageplan,
636 signalgeber: [...project.lageplan.signalgeber, geber],
637 });
638 }
639
640 export function setSignalgeber(project: Project, id: string, patch: Partial<Signalgeber>): Project {
641 return setLageplan(project, {
642 ...project.lageplan,
643 signalgeber: project.lageplan.signalgeber.map((g) => (g.id === id ? { ...g, ...patch } : g)),
644 });
645 }
646
647 export function removeSignalgeber(project: Project, id: string): Project {
648 return setLageplan(project, {
649 ...project.lageplan,
650 signalgeber: project.lageplan.signalgeber.filter((g) => g.id !== id),
651 });
652 }
653
654 // --- Verkehrsstaerken -------------------------------------------------------
655
656 export function setDemand(
657 project: Project,
658 signalGroupId: string,
659 patch: Partial<Omit<TrafficDemand, 'signalGroupId'>>,
660 ): Project {
661 // Verkehrsstaerken sind ganzzahlig (Fahrzeuge je Stunde): Nach HBS 2015
662 // haengt die Stufe F an q > C, und beide Zahlen stehen ganzzahlig im
663 // Ausdruck; eine Verkehrsstaerke 243,4 neben einer Kapazitaet 243 waere
664 // dort nicht nachvollziehbar (Befund B1).
665 const bereinigt: Partial<Omit<TrafficDemand, 'signalGroupId'>> =
666 patch.volume === undefined || !Number.isFinite(patch.volume)
667 ? patch
668 : { ...patch, volume: Math.round(patch.volume) };
669 const existing = project.demands.find((d) => d.signalGroupId === signalGroupId);
670 if (existing) {
671 return {
672 ...project,
673 demands: project.demands.map((d) =>
674 d.signalGroupId === signalGroupId ? { ...d, ...bereinigt } : d,
675 ),
676 };
677 }
678 return {
679 ...project,
680 demands: [...project.demands, { signalGroupId, volume: 0, heavyVehicleShare: 0, ...bereinigt }],
681 };
682 }
683
684 export function removeDemand(project: Project, signalGroupId: string): Project {
685 return { ...project, demands: project.demands.filter((d) => d.signalGroupId !== signalGroupId) };
686 }
687
688 /**
689 * Der Lastzuganteil am Schwerverkehr - eintragen ODER entfernen.
690 *
691 * NEU (Schema 13). Warum das eine eigene Aktion braucht und nicht
692 * ueber `setDemand` laeuft: `lastzugAnteil` ist ein OPTIONALES Feld, und der
693 * Unterschied zwischen "0 % Lastzuege" und "nicht erfasst" ist genau der
694 * Unterschied zwischen HBS-Gleichung 2-5 und 2-6. Ueber `setDemand` liesse er
695 * sich nicht ausdruecken: `{ lastzugAnteil: undefined }` legt beim Ausbreiten
696 * einen Schluessel mit dem Wert `undefined` an, das Feld gilt danach als
697 * gesetzt-aber-leer, und die Unterscheidung haengt daran, welche Stelle als
698 * naechstes darauf sieht.
699 *
700 * `null` heisst deshalb hier ausdruecklich "nicht erfasst" und ENTFERNT das
701 * Feld. Das ist der Rueckweg zum Pauschalwert 1,90 - und es ist der Weg, der
702 * gebraucht wird, wenn sich herausstellt, dass die Zaehlung die Aufteilung
703 * nicht hergibt.
704 *
705 * NICHT BEGRENZT WIRD HIER: Ein Wert ausserhalb von 0 bis 1 bleibt stehen und
706 * wird vom Signalzeitenplan als unbrauchbar gemeldet
707 * (`lastzuganteil-ausserhalb`), statt stillschweigend auf 0 oder 1 gezogen zu
708 * werden. Eine stille Ersetzung an dieser Stelle ergaebe die guenstigste
709 * Aufteilung aus einer Fehleingabe.
710 */
711 export function setLastzugAnteil(
712 project: Project,
713 signalGroupId: string,
714 anteil: number | null,
715 ): Project {
716 const vorhanden = project.demands.find((d) => d.signalGroupId === signalGroupId);
717 const gesetzt = (demand: TrafficDemand): TrafficDemand => {
718 if (anteil === null) {
719 const { lastzugAnteil: _entfaellt, ...ohne } = demand;
720 return ohne;
721 }
722 return { ...demand, lastzugAnteil: anteil };
723 };
724 if (!vorhanden) {
725 // Ohne Verkehrsstaerke gibt es nichts aufzuteilen; ein Eintrag entstuende
726 // dann mit volume 0, und der Plan rechnete fuer diesen Strom ohnehin
727 // keine Auslastung. Erst die Verkehrsstaerke, dann ihre Aufteilung.
728 if (anteil === null) return project;
729 return {
730 ...project,
731 demands: [...project.demands, gesetzt({ signalGroupId, volume: 0, heavyVehicleShare: 0 })],
732 };
733 }
734 return {
735 ...project,
736 demands: project.demands.map((d) => (d.signalGroupId === signalGroupId ? gesetzt(d) : d)),
737 };
738 }