lsa-planer

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

/ src services export pdf.ts

260,8 KB Rohdatei
src/services/export/pdf.ts — 5886 Zeilen
1 import { jsPDF } from 'jspdf';
2 import autoTable, { type CellDef, type RowInput, type Styles } from 'jspdf-autotable';
3 import {
4 MESSFEHLER_SCHRANKE,
5 ZEICHENFEHLER_BILDPUNKTE,
6 planungsgrundlage,
7 type Lageplan,
8 type Planungsgrundlage,
9 } from '@/domain/geometrie/lageplan';
10 import { planblatt, type Planblatt } from '@/domain/geometrie/planblatt';
11 import { massketten, type Masskette } from '@/domain/geometrie/masskette';
12 import type { Blattbild } from './blattbild';
13 import { drawLageplanblatt, type LageplanZeichnungOptionen } from '@/render/lageplanDrawing';
14 import { drawZeitWeg, UMLAEUFE_IM_BILD } from '@/render/zeitWegDrawing';
15 import { koordinierungslage, HINDERNIS_TEXT } from '@/domain/plan/koordinierung';
16 import { modulo } from '@/domain/rilsa/coordination';
17 import { aspectTotals } from '@/domain/plan/aspects';
18 import {
19 fahrstreifengeometrie,
20 hatSaettigungsverkehrsstaerke,
21 intergreenKey,
22 massgebendeRaeumbeziehung,
23 umlaufzeitIstErsatzwert,
24 umlaufzeitMitVermerk,
25 type PlannedSignalGroup,
26 type SignalPlan,
27 } from '@/domain/plan/signalPlan';
28 import { geometriefaktor } from '@/domain/rilsa/capacity';
29 import { CYCLE_METHOD_LABELS, CYCLE_METHODS } from '@/domain/rilsa/cycle';
30 import type { Bewertungsverfahren, CycleTimeResult } from '@/domain/rilsa/types';
31 import {
32 resolveRilsaSettings,
33 settingFields,
34 settingFieldsByQuelle,
35 } from '@/domain/rilsa/settings';
36 import {
37 FUNDSTELLEN_HINWEIS,
38 PRUEFSTAND_ERLAEUTERUNG,
39 PRUEFSTAND_LABELS,
40 PRUEFSTAND_REIHENFOLGE,
41 QUELLEN,
42 type QuellenSchluessel,
43 rechtsgrundlagenFuer,
44 } from '@/domain/rilsa/quellen';
45 import {
46 ANLAGENART_GRENZEN,
47 ANLAGENART_LABELS,
48 ENGER_RADIUS_LABEL,
49 ENGSTELLE_PRAXIS,
50 MOVEMENT_LABELS,
51 RILSA_DEFAULTS,
52 STAURAUMBEDARF_JE_FAHRZEUG,
53 RSA_ALTERNATIVEN_ZUR_SIGNALREGELUNG,
54 TRAFFIC_MODE_LABELS,
55 UEBERFAHRZEIT_ANSATZ_LABELS,
56 fahrzeugartBeschriftung,
57 type AnlagenartGrenzen,
58 type RilsaDefaults,
59 type SchrankenHerkunft,
60 } from '@/domain/rilsa/constants';
61 import {
62 engerRadiusRichtungSatzteil,
63 ueberfahrzeitAnsatzOhneWirkung,
64 ueberfahrzeitAnsatzRichtungSatz,
65 verfahrenDerKfzRaeumstroeme,
66 } from '@/domain/rilsa/ansaetze';
67 import { engerRadiusWirktAn } from '@/domain/rilsa/intergreen';
68 import { engstellenFolgen, umlaufBeiFreigabe } from '@/domain/rilsa/engstelle';
69 import type { Project, SignalGroup } from '@/domain/model/project';
70 import { programmanzahl } from '@/domain/model/programme';
71 import { programmPruefungen } from '@/domain/validation';
72 import {
73 KRITERIUM_LABELS,
74 aufteilungslage,
75 engerRadiusZelle,
76 // Regelwert und angesetzter Wert je Kennwert - aus derselben Quelle wie die
77 // Aufstellung "Abweichungen von den Regelwerten".
78 fundstellenkennwerte,
79 quellenOhneFeld,
80 // Anteil, Aufteilung, Faktor und Gleichung eines Stroms - gebildet aus
81 // derselben Funktion des Fachkerns, mit der der Plan rechnet
82 // (plan/signalPlan.ts, groupSaturationFlow), und mit denselben Worten wie in
83 // der Tabellenausgabe (Fassung 5.6.0, Frage E6).
84 schwerverkehrsangabe,
85 schwerverkehrSatz,
86 stufeZelle,
87 stufentafeln,
88 ueberfahrzeitAnsatzSatz,
89 verfahrenLabel,
90 wirkungsvermerk,
91 } from './bewertung';
92 import { wegherkunftZelle } from './wegherkunft';
93 // Befund C22: Ausdruck und Tabellenausgabe muessen dieselbe Fensterangabe
94 // zeigen; die Zeichenkette entsteht deshalb an einer Stelle (siehe dort).
95 import { FREIGABEFENSTER_FUSSNOTE, freigabefensterZelle, hoechstfreigabezeitVorgabe } from './csv';
96 import { CATEGORY_LABELS, SEVERITY_LABELS, type ValidationReport } from '@/domain/validation';
97 import { messeignungSatz } from '@/domain/validation/rules';
98 import { PdfSurface, toWinAnsi } from '@/render/pdfSurface';
99 import { FASSUNGSANGABE } from '@/fassung';
100 import {
101 DEFAULT_LAYOUT,
102 LIGHT_THEME,
103 drawSignalPlan,
104 planHeight,
105 type PlanLayout,
106 } from '@/render/signalPlanDrawing';
107 import * as fmt from '@/ui/format';
108
109 /**
110 * Ausdruck der Planunterlagen.
111 *
112 * Aufbau: Deckblatt, Planungsgrundlage, Signalgruppen, Zwischenzeitenmatrix,
113 * Phasen, Signalzeitenplan als Vektorgrafik, Leistungsfaehigkeit, Pruefbericht.
114 *
115 * Korrekturen gegenueber dem Altbestand:
116 * - Der Ausdruck enthielt eine unbedingte Zeile "Die Anlage entspricht den
117 * Anforderungen und wird zur Genehmigung empfohlen" - unabhaengig davon, ob
118 * die Pruefung Fehler ergeben hatte. Diese Aussage ist entfallen; stattdessen
119 * steht das tatsaechliche Pruefergebnis im Dokument.
120 * - Der Signalzeitenplan wurde als Rastergrafik eingebettet; jetzt als Vektor.
121 * - Haken, Kreuze und Emoji sind mit den PDF-Standardschriften nicht
122 * darstellbar und erschienen als leere Kaesten. Sie sind durch Text ersetzt.
123 * - Befunde B1 bis B4 (Fassung 5.4.0): Der Leistungsabschnitt
124 * behauptete "Bewertung nach HBS 2015" fuer eine HCM-Rechnung mit der
125 * Kfz-Tafel des HBS 2001 und liess die Fussgaenger als "nicht massgebend"
126 * weg. Jetzt nennt er das gerechnete Verfahren samt Formelkette und Tafel,
127 * druckt die Abflusszeit tA und bewertet Fussgaenger und Radverkehr in
128 * eigener Tabelle nach der maximalen Wartezeit.
129 */
130
131 const PAGE_MARGIN = 36;
132 const HEADER_HEIGHT = 46;
133 const FOOTER_HEIGHT = 26;
134
135 /**
136 * Abstand zwischen Projektname und Grundlagenangabe in der Kopfzeile.
137 *
138 * Ohne ihn stiessen die beiden Texte bei einem langen Namen unmittelbar
139 * aneinander und lasen sich als ein Wort.
140 */
141 const KOPFZEILEN_ABSTAND = 12;
142
143 /** Zeilenabstand des Deckblatttitels (15 pt fett). */
144 const TITEL_ZEILENHOEHE = 18;
145
146 /**
147 * Hoehe des Kastens "Ergebnis der Pruefung" auf dem Deckblatt.
148 *
149 * Sie steht einmal, weil sie an drei Stellen gebraucht wird: zum Reservieren
150 * des Platzes, zum Zeichnen der Flaeche und zum Weiterruecken auf den
151 * Bemerkungsblock darunter. Liefen die ersten beiden auseinander, waere die
152 * Umbruchpruefung wieder wertlos; liefe die dritte mit, schoebe sich die
153 * Ueberschrift "Bemerkungen" in den Kasten hinein.
154 */
155 const ERGEBNISKASTEN_HOEHE = 52;
156
157 /** Abstand zwischen der Unterkante des Ergebniskastens und dem, was folgt. */
158 const ERGEBNISKASTEN_NACHLAUF = 16;
159
160 /**
161 * Der Satzspiegel, in dem JEDE Tabelle dieser Unterlage steht.
162 *
163 * OBEN UND UNTEN GEHOEREN HIER HINEIN, und das ist kein Beiwerk: Wo `margin`
164 * nur `left` und `right` nennt, setzt jspdf-autotable fuer die beiden anderen
165 * seinen eigenen Regelwert von 40 pt an
166 * (`parseSpacing(options.margin, 40 / doc.scaleFactor())`,
167 * jspdf.plugin.autotable.js:937). Das Folgeblatt einer mehrseitigen Tabelle
168 * beginnt dann bei 40 pt - mitten in der Kopfzeile, die von PAGE_MARGIN bis
169 * PAGE_MARGIN + HEADER_HEIGHT reicht, also bis 82 pt. Die wiederholte
170 * Tabellenkopfzeile ueberdruckte dort Projektname und Untertitel. Gemessen an
171 * der vollstaendigen Unterlage: bei sechzehn Signalgruppen mit langen Namen
172 * trugen 41 von 62 Blaettern Tabellentext oberhalb der Trennlinie. Unten
173 * dasselbe in klein: mit 40 statt 62 pt durfte eine Tabelle 22 pt in den
174 * Fusssteg laufen.
175 *
176 * WARUM EINE KONSTANTE UND NICHT ZWANZIG EINZELANGABEN: Der Fehler entstand
177 * dadurch, dass jeder Aufruf seinen Rand selbst mitbrachte und dabei zwei
178 * Seiten vergass. Eine Tabelle, die kuenftig hinzukommt, erbt den richtigen
179 * Satzspiegel jetzt, statt ihn erneut zusammenzusuchen.
180 */
181 const TABELLENRAND = {
182 left: PAGE_MARGIN,
183 right: PAGE_MARGIN,
184 top: PAGE_MARGIN + HEADER_HEIGHT,
185 bottom: PAGE_MARGIN + FOOTER_HEIGHT,
186 } as const;
187
188 export interface PdfOptions {
189 readonly includeSignalGroups: boolean;
190 readonly includeIntergreenMatrix: boolean;
191 readonly includePhases: boolean;
192 /** Die vier Rechenansaetze der Umlaufzeit nebeneinander. */
193 readonly includeCycleComparison: boolean;
194 readonly includePlan: boolean;
195 readonly includePerformance: boolean;
196 readonly includeReport: boolean;
197 readonly includeCalculationDetails: boolean;
198 /** Nachweis, woher die Kennwerte stammen - fuer die Pruefung der Unterlagen. */
199 readonly includeSources: boolean;
200 /** Selbst gepruefte Fundstellen, je Sachgebiet. */
201 readonly fundstellen: Readonly<Record<string, string>>;
202 }
203
204 export const DEFAULT_PDF_OPTIONS: PdfOptions = {
205 includeSignalGroups: true,
206 includeIntergreenMatrix: true,
207 includePhases: true,
208 /*
209 * VORBELEGT MIT true (auf ausdrueckliche Entscheidung des Anwenders): Eine
210 * Anordnung nach Paragraf 45 StVO ist zu begruenden, und die Wahl der
211 * Umlaufzeit war der eine Punkt, zu dem die Unterlage nichts sagte. Wer den
212 * Abschnitt nicht will, waehlt ihn ab; die Bestandteileuebersicht vermerkt
213 * das dann.
214 */
215 includeCycleComparison: true,
216 includePlan: true,
217 includePerformance: true,
218 includeReport: true,
219 /*
220 * KORREKTUR (Fassung 5.4.0, Befund C15): Hier stand false. Der Ausdruck
221 * lieferte damit von Haus aus eine Zwischenzeitenmatrix ohne den Nachweis,
222 * aus dem ihre Zahlen stammen - und genau diesen Nachweis verlangt die
223 * Behoerdenpraxis. Wer ihn abwaehlt, liest jetzt an der Stelle des
224 * Abschnitts, was der Unterlage damit fehlt (RECHENWEG_ABGEWAEHLT).
225 */
226 includeCalculationDetails: true,
227 includeSources: true,
228 fundstellen: {},
229 };
230
231 /**
232 * Wo der Rechenweg in DIESER Ausgabe steht - und wo nicht.
233 *
234 * Der Rechenweg haengt an ZWEI Bedingungen: am eigenen Schalter
235 * (`includeCalculationDetails`) und daran, ob der Abschnitt ueberhaupt gedruckt
236 * wird, in dem er steht - der Zwischenzeitennachweis in der
237 * Zwischenzeitenmatrix, der Wartezeitnachweis in der Leistungsfaehigkeit.
238 *
239 * KORREKTUR (Fassung 5.4.0): Die Uebersicht "Bestandteile der
240 * Anordnungsunterlage" fragte nur den Schalter ab und druckte "ja - Abschnitte
241 * ... und ..." fuer Abschnitte, die im Dokument gar nicht vorkamen; der
242 * Abwahlvermerk wiederum stand nur INNERHALB der beiden Abschnitte und fiel
243 * damit ganz weg, wenn beide abgewaehlt waren. Ausgerechnet der Lueckenausweis
244 * gab damit eine falsche Auskunft. Beide Aussagen kommen jetzt aus diesem einen
245 * Stand.
246 */
247 interface RechenwegStand {
248 /** Ist der Rechenweg selbst angefordert? */
249 readonly angefordert: boolean;
250 /** Wird die Zwischenzeitenmatrix gedruckt - der Ort des ersten Rechenwegs? */
251 readonly matrix: boolean;
252 /** Wird die Leistungsfaehigkeit gedruckt - der Ort des zweiten Rechenwegs? */
253 readonly leistung: boolean;
254 }
255
256 function rechenwegStand(options: PdfOptions): RechenwegStand {
257 return {
258 angefordert: options.includeCalculationDetails,
259 matrix: options.includeIntergreenMatrix,
260 leistung: options.includePerformance,
261 };
262 }
263
264 /** Steht der Rechenweg der Zwischenzeiten in dieser Ausgabe? */
265 function zwischenzeitRechenweg(stand: RechenwegStand): boolean {
266 return stand.angefordert && stand.matrix;
267 }
268
269 /** Steht der Rechenweg der Wartezeit in dieser Ausgabe? */
270 function wartezeitRechenweg(stand: RechenwegStand): boolean {
271 return stand.angefordert && stand.leistung;
272 }
273
274 /**
275 * Was an der Stelle des Rechenwegs steht, wenn er abgewaehlt wurde
276 * (Befund C15).
277 *
278 * Der Satz steht EINMAL im Dokument, weil er beide Rechenwege nennt - den der
279 * Zwischenzeiten und den der Wartezeit -, und zwar an der ersten Stelle, an der
280 * einer von beiden gestanden haette. Eine Luecke, die nirgends vermerkt ist,
281 * sieht wie eine vollstaendige Unterlage aus; das ist der Fehler, um den es
282 * geht.
283 *
284 * Die zweite Haelfte nennt nur Abschnitte, die es in dieser Ausgabe wirklich
285 * gibt: Bei abgewaehlter Matrix von "den Werten der Matrix" zu sprechen, waere
286 * derselbe Fehler eine Ebene tiefer. Sind beide Abschnitte abgewaehlt, gibt es
287 * im Dokument keine Stelle mehr, an der der Rechenweg gestanden haette - der
288 * Satz steht dann in der Uebersicht der Bestandteile und sagt genau das.
289 */
290 function rechenwegAbgewaehlt(stand: RechenwegStand): string {
291 const kopf =
292 'Der Rechenweg der Zwischenzeiten und der Wartezeit ist in dieser Ausgabe abgewählt; ';
293 if (stand.matrix && stand.leistung) {
294 return (
295 kopf +
296 'die Werte der Zwischenzeitenmatrix und der Leistungsfähigkeit sind ohne ihn nicht ' +
297 'nachrechenbar.'
298 );
299 }
300 if (stand.matrix) return kopf + 'die Werte der Matrix sind ohne ihn nicht nachrechenbar.';
301 if (stand.leistung) {
302 return kopf + 'die Werte der Leistungsfähigkeit sind ohne ihn nicht nachrechenbar.';
303 }
304 return (
305 kopf +
306 'die Abschnitte, in denen er gestanden hätte - Zwischenzeitenmatrix und Leistungsfähigkeit -, ' +
307 'sind ebenfalls abgewählt.'
308 );
309 }
310
311 interface Context {
312 readonly doc: jsPDF;
313 readonly project: Project;
314 readonly plan: SignalPlan;
315 readonly report: ValidationReport;
316 readonly pageWidth: number;
317 readonly pageHeight: number;
318 readonly contentWidth: number;
319 /**
320 * Das vorbereitete Luftbild fuer das zweite Lageplanblatt - oder null.
321 *
322 * Es wird HEREINGEREICHT und nicht hier erzeugt: Zuschneiden und Verkleinern
323 * gehen nur asynchron (siehe `blattbild.ts`), und diese Funktion ist synchron.
324 */
325 readonly blattbild: Blattbild | null;
326 cursor: number;
327 /**
328 * Wie die Zwischenzeitenmatrix DIESER Ausgabe gesetzt wurde - oder null,
329 * solange sie nicht gezeichnet ist.
330 *
331 * Gesetzt wird das erst beim Zeichnen der Matrix, gelesen erst im Abschnitt
332 * "Barrierefreiheit dieser Datei" - der steht am Ende und beschreibt die
333 * fertige Datei. Er darf keine Aussage ueber eine Darstellung machen, die in
334 * dieser Ausgabe gar nicht vorkommt: Wer kurze Bezeichnungen benutzt, hat
335 * die Namen im Kopf, und ein Absatz ueber Ordnungsnummern waere fuer SEINE
336 * Datei schlicht falsch.
337 *
338 * WARUM EIN BEFUND UND NICHT NUR EIN SCHALTER (Fassung 5.8.0): Der Absatz
339 * sagte bis dahin unbedingt zu, jeder Name stehe in der Zuordnung "ungeteilt
340 * in einer Zeile". Das trifft nur zu, solange der Name in eine Bannerzeile
341 * passt - gemessen bis rund 180 Zeichen. Ein Lueckenausweis darf keine Zusage
342 * machen, die er fuer DIESE Datei nicht halten kann; also traegt der Befund
343 * auch, ob es zugetroffen hat.
344 */
345 matrixSatzbefund: MatrixSatzbefund | null;
346 }
347
348 /** Was ueber den Satz der Matrix in dieser Ausgabe zu sagen ist. */
349 interface MatrixSatzbefund {
350 /** Tragen die Spaltenkoepfe Ordnungsnummern statt der Namen? */
351 readonly mitOrdnungsnummern: boolean;
352 /** Steht in der Zuordnung ueber der Tabelle JEDER Name ungeteilt in einer Zeile? */
353 readonly namenUngeteilt: boolean;
354 /**
355 * Wird die Zuordnung auf JEDEM Blatt des Ausschnitts wiederholt?
356 *
357 * Sie wird es, so lange sie in den Tabellenkopf passt. Ist sie fuer sich
358 * schon hoeher als ein Blatt, steht sie ungekuerzt vor dem Ausschnitt - und
359 * dann darf der Lueckenausweis nicht behaupten, sie stehe ueber jeder Seite.
360 */
361 readonly zuordnungAufJedemBlatt: boolean;
362 }
363
364 /**
365 * Ein leeres Dokument in der Seiteneinrichtung dieser Unterlage.
366 *
367 * EIGENE FUNKTION, WEIL ES ZWEI AUFRUFER GIBT: den Ausdruck und
368 * `lageplanblattVorlage`. Die Blattbreite folgt aus der Seitengroesse, und
369 * das Luftbild muss fuer GENAU das Blatt zugeschnitten werden, auf das es
370 * spaeter kommt. Stuende die Einrichtung zweimal im Baum, waere ein
371 * verschobenes Bild die erste Folge einer Aenderung an ihr - und das faellt
372 * niemandem auf, der die Seitengroesse aendert.
373 */
374 function neuesDokument(): jsPDF {
375 return new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4', compress: true });
376 }
377
378 /** Hoehe des Lageplanblatts in Blattpunkten. */
379 export const LAGEPLANBLATT_HOEHE = 300;
380
381 /**
382 * Das Blatt, auf das die Unterlage den Lageplan zeichnet.
383 *
384 * Oeffentlich, damit die Oberflaeche das Luftbild fuer DIESES Blatt vorbereiten
385 * kann, bevor sie den Ausdruck anstoesst. Sie darf die Masse nicht selbst
386 * bilden: Ein um einen Punkt abweichendes Blatt ergaebe ein um einen Punkt
387 * verschobenes Bild, und Linien, die neben der Fahrbahn liegen, sind auf einem
388 * Nachweisblatt keine Kleinigkeit.
389 */
390 export function lageplanblattVorlage(lageplan: Lageplan): Planblatt | null {
391 const breite = neuesDokument().internal.pageSize.getWidth() - 2 * PAGE_MARGIN;
392 return planblatt(lageplan, breite, LAGEPLANBLATT_HOEHE);
393 }
394
395 /**
396 * Erzeugt die Planunterlagen als PDF.
397 * Rueckgabe sind die rohen Bytes; das Einpacken in eine Datei uebernimmt die
398 * Speicherschicht, damit dieselbe Funktion auch im Test verwendbar bleibt.
399 */
400 export function buildProjectPdf(
401 project: Project,
402 plan: SignalPlan,
403 report: ValidationReport,
404 options: PdfOptions = DEFAULT_PDF_OPTIONS,
405 now: Date = new Date(),
406 blattbild: Blattbild | null = null,
407 ): Uint8Array {
408 const doc = neuesDokument();
409 doc.setLanguage('de-DE');
410
411 /*
412 * Befund L10 (2.4.2 Seite mit Titel, A; EN 301 549 10.2.4.2; WCAG-Technik
413 * PDF18).
414 *
415 * /Title war gesetzt und ist immer sprechend - ein leerer Projektname wird
416 * vor der Ausgabe als Fehler gemeldet. Ohne /DisplayDocTitle zeigen
417 * Anzeigeprogramme in Fenster- und Registerleiste trotzdem den DATEINAMEN.
418 * Eine Sprachausgabe sagt beim Wechsel zwischen offenen Dokumenten dann
419 * "plan-2026-03-11 (2).pdf" statt "Musterkreuzung - Signalzeitenplan".
420 */
421 doc.viewerPreferences({ DisplayDocTitle: true });
422
423 /*
424 * HIER NICHT /MarkInfo << /Marked true >> ERGAENZEN.
425 *
426 * Befund S1. Ueber die Ereignishaken von jsPDF liesse sich der Eintrag in
427 * drei Zeilen setzen, und das Dokument bestuende danach manche automatische
428 * Pruefung. Es waere eine falsche Angabe: Ein Strukturbaum (/StructTreeRoot)
429 * entsteht dadurch nicht, und jsPDF 2.5.2 kann keinen erzeugen - es gibt
430 * keine Schnittstelle fuer Marked Content, Artefakte oder Alternativtexte,
431 * und jspdf-autotable 3.8.4 hilft dabei nicht. Bildschirmleseprogramme
432 * schalten bei "/Marked true" von ihren Positionsheuristiken auf den - dann
433 * leeren - Strukturbaum um und lesen danach WENIGER vor als heute. Die
434 * Behauptung machte das Dokument also fuer genau die Nutzergruppe schlechter,
435 * der sie zu nuetzen vorgibt.
436 *
437 * Der Weg zu einem wirklich getaggten Dokument fuehrt ueber eine andere
438 * Ausgabe (webContents.printToPDF mit generateTaggedPDF aus einer
439 * semantischen Vorlage), nicht ueber einen Katalogeintrag. Bis dahin sagt
440 * der Abschnitt "Barrierefreiheit dieser Datei" am Ende der Unterlage, wie
441 * es steht - siehe drawBarrierefreiheit.
442 */
443
444 const context: Context = {
445 doc,
446 project,
447 plan,
448 report,
449 pageWidth: doc.internal.pageSize.getWidth(),
450 pageHeight: doc.internal.pageSize.getHeight(),
451 contentWidth: doc.internal.pageSize.getWidth() - 2 * PAGE_MARGIN,
452 blattbild,
453 cursor: PAGE_MARGIN + HEADER_HEIGHT,
454 matrixSatzbefund: null,
455 };
456
457 doc.setProperties({
458 title: toWinAnsi(`${project.meta.name} – Signalzeitenplan`),
459 subject: toWinAnsi(`Lichtsignalanlage ${project.meta.location}`),
460 author: toWinAnsi(project.meta.planner),
461 creator: 'LSA-Planer Professional',
462 });
463
464 // Erstes Lesezeichen; die uebrigen entstehen in newSection.
465 merkeLesezeichen(context, 'Deckblatt');
466 drawCoverSheet(context, now);
467
468 const rechenweg = rechenwegStand(options);
469
470 // Ebenfalls ohne Schalter (Befund C14): Der Abschnitt sagt, WAS diese
471 // Unterlage enthaelt und was nicht. Ihn abwaehlbar zu machen hiesse, den
472 // Lueckenausweis abwaehlbar zu machen.
473 drawAnordnungsunterlage(context, options, rechenweg);
474
475 // Bewusst ohne Schalter in PdfOptions - wie drawSettingsDeviations weiter
476 // unten ist der Abschnitt ein Nachweis. Ein Ausdruck, dem man die
477 // Vermessungsgrundlage abwaehlen kann, ist als Planunterlage wertlos.
478 drawPlanungsgrundlage(context, rechenweg);
479
480 /*
481 * Ebenfalls ohne Schalter: Wo mehrere Tagesprogramme nebeneinander
482 * stehen, ist die Auskunft, WELCHES dieser Ausdruck zeigt, kein Beiwerk. Bei
483 * einem einzigen Programm entfaellt der Abschnitt - dort gibt es nichts zu
484 * unterscheiden, und die Bestandteileuebersicht sagt es in einer Zeile.
485 */
486 if (programmanzahl(project) > 1) {
487 newSection(context, 'Tagesprogramme dieser Anlage');
488 drawProgrammuebersicht(context, now);
489 }
490
491 if (options.includeSignalGroups) {
492 newSection(context, 'Signalgruppen');
493 drawSignalGroupTable(context);
494 }
495
496 /*
497 * Befund C15: Der Vermerk ueber den abgewaehlten Rechenweg steht an der
498 * Stelle, an der der Abschnitt gestanden haette - einmal, denn der Satz nennt
499 * beide Rechenwege.
500 *
501 * Sind beide moeglichen Stellen abgewaehlt, hat ihn die Uebersicht der
502 * Bestandteile bereits gedruckt; dann gilt er hier schon als vermerkt.
503 */
504 let rechenwegVermerkt = rechenweg.angefordert || (!rechenweg.matrix && !rechenweg.leistung);
505
506 if (options.includeIntergreenMatrix) {
507 newSection(context, 'Zwischenzeitenmatrix');
508 drawIntergreenMatrix(context, options.includeSignalGroups);
509 if (zwischenzeitRechenweg(rechenweg)) {
510 newSection(context, 'Zwischenzeiten – Rechenweg');
511 drawIntergreenDetails(context, options.includeSignalGroups);
512 } else {
513 writeParagraph(context, rechenwegAbgewaehlt(rechenweg));
514 rechenwegVermerkt = true;
515 }
516 }
517
518 if (options.includePhases) {
519 newSection(context, 'Phasen und Phasenübergänge');
520 drawPhaseTables(context);
521 }
522
523 if (options.includeCycleComparison) {
524 newSection(context, 'Umlaufzeit – Vergleich der Rechenansätze');
525 drawCycleComparison(context);
526 }
527
528 /*
529 * Die Koordinierung steht VOR dem Signalzeitenplan: Sie sagt, in welchem
530 * Strassenzug dieser Plan liegt, und das ist eine Randbedingung seiner
531 * Zeiten - kein Nachtrag. Ohne erfassten Strassenzug entfaellt der
532 * Abschnitt; die Bestandteileuebersicht sagt es dann in ihrer Zeile.
533 */
534 if (project.koordinierung !== null) {
535 newSection(context, 'Koordinierung');
536 drawKoordinierung(context);
537 }
538
539 if (options.includePlan) {
540 newSection(context, 'Signalzeitenplan');
541 drawPlanGraphic(context, options.includePhases);
542 drawSignalTimeTable(context);
543 }
544
545 if (options.includePerformance) {
546 newSection(context, 'Leistungsfähigkeit');
547 drawPerformanceTable(context, options.includeSignalGroups);
548 drawFussRadTable(context);
549 // Wie beim Rechenweg der Zwischenzeiten: Die Formelkette der Wartezeit
550 // wird auf Wunsch mitgedruckt, damit sich tW aus den gedruckten Groessen
551 // nachrechnen laesst.
552 if (wartezeitRechenweg(rechenweg)) {
553 newSection(context, 'Leistungsfähigkeit – Rechenweg Wartezeit');
554 drawDelayDetails(context);
555 } else if (!rechenwegVermerkt) {
556 // Letzte Stelle, an der der Vermerk stehen kann - danach wird er nicht
557 // mehr abgefragt, deshalb hier ohne weitere Zuweisung.
558 writeParagraph(context, rechenwegAbgewaehlt(rechenweg));
559 }
560 }
561
562 if (options.includeReport) {
563 newSection(context, 'Prüfbericht');
564 drawReport(context);
565 }
566
567 drawSettingsDeviations(context);
568
569 if (options.includeSources) {
570 newSection(context, 'Grundlagen der Kennwerte');
571 drawLegalBasis(context, options.includePerformance);
572 drawAnlagenartGrenzen(context);
573 drawSourceRegister(context, options);
574 }
575
576 // Ohne Schalter, aus demselben Grund wie die Bestandteileuebersicht: Eine
577 // abwaehlbare Erklaerung zur Barrierefreiheit waere keine Erklaerung.
578 drawBarrierefreiheit(context, options.includePlan, options.includePhases, rechenweg);
579
580 paintHeadersAndFooters(context, now);
581
582 return new Uint8Array(doc.output('arraybuffer'));
583 }
584
585 // --- Seitengeruest ----------------------------------------------------------
586
587 /**
588 * Lesezeichen auf die zuletzt begonnene Seite.
589 *
590 * Das Dokument hatte bei 21 Seiten und 14 benannten Abschnitten
591 * kein einziges Lesezeichen (/Outlines 0). Ein Pflichtkriterium der Stufe AA
592 * ist das nicht - 2.4.5 ist fuer Nicht-Web-Dokumente in EN 301 549 gestrichen
593 * (10.2.4.5), PDF/UA fuehrt Lesezeichen als Empfehlung. Es ist aber der einzige
594 * Weg, in diesem ungetaggten Dokument ueberhaupt gezielt zu einem Abschnitt zu
595 * springen, statt Seite fuer Seite zu blaettern: Ohne Strukturbaum gibt es
596 * keine Ueberschriftennavigation, an der ein Bildschirmleseprogramm sich
597 * entlanghangeln koennte.
598 *
599 * Der Titel geht durch toWinAnsi wie jeder andere gedruckte Text - jsPDF
600 * schreibt ihn als Latin-1-Zeichenkette in das Outline-Objekt.
601 */
602 function merkeLesezeichen(context: Context, titel: string): void {
603 context.doc.outline.add(null, toWinAnsi(titel), {
604 pageNumber: context.doc.getNumberOfPages(),
605 });
606 }
607
608 function newSection(context: Context, title: string): void {
609 context.doc.addPage();
610 merkeLesezeichen(context, title);
611 context.cursor = PAGE_MARGIN + HEADER_HEIGHT;
612 context.doc.setFont('helvetica', 'bold');
613 context.doc.setFontSize(14);
614 context.doc.setTextColor('#1b2230');
615 context.doc.text(toWinAnsi(title), PAGE_MARGIN, context.cursor);
616 context.cursor += 20;
617 }
618
619 function ensureSpace(context: Context, needed: number): void {
620 if (context.cursor + needed <= context.pageHeight - PAGE_MARGIN - FOOTER_HEIGHT) return;
621 context.doc.addPage();
622 context.cursor = PAGE_MARGIN + HEADER_HEIGHT;
623 }
624
625 /**
626 * Was auf ein leeres Blatt passt - zwischen Kopfzeile und Fusszeile.
627 *
628 * Die Rechnung steht einmal: Sie ist die Schranke, an der die beiden
629 * Matrixproben (passt der Satz ueberhaupt auf ein Blatt, passt der
630 * Tabellenkopf) und die Absatzreserve haengen. Ausgeschriebene Fassungen davon
631 * liefen frueher oder spaeter auseinander.
632 */
633 function nutzhoeheJeBlatt(context: Context): number {
634 return context.pageHeight - (PAGE_MARGIN + HEADER_HEIGHT) - (PAGE_MARGIN + FOOTER_HEIGHT);
635 }
636
637 function paintHeadersAndFooters(context: Context, now: Date): void {
638 const { doc, project } = context;
639 const total = doc.getNumberOfPages();
640
641 for (let page = 1; page <= total; page += 1) {
642 doc.setPage(page);
643
644 doc.setDrawColor('#b3bccb');
645 doc.setLineWidth(0.5);
646 doc.line(
647 PAGE_MARGIN,
648 PAGE_MARGIN + HEADER_HEIGHT - 16,
649 context.pageWidth - PAGE_MARGIN,
650 PAGE_MARGIN + HEADER_HEIGHT - 16,
651 );
652
653 /*
654 * FASSUNG 5.9.0, BEFUND 43: Beide Kopfzeilentexte gingen ohne
655 * Breitenbegrenzung an doc.text. Die Grundlagenangabe steht rechtsbuendig
656 * am Satzspiegelrand; der Projektname lief bei einer ueblichen langen
657 * Bezeichnung in sie hinein, und beide waren an der Ueberlappung unlesbar -
658 * auf JEDER Seite. Ausgerechnet die einstreifige Verkehrsfuehrung traf es
659 * zuerst: Dort ist die Angabe am laengsten, und sie ist die Korrektur C16,
660 * die das Verhaeltnis von RSA 21 und RiLSA klarstellt.
661 *
662 * Der Name wird deshalb auf den Platz links der gemessenen
663 * Grundlagenangabe gekuerzt. Das darf er hier - vollstaendig steht er auf
664 * dem Deckblatt, das ihn umbricht statt ihn zu kuerzen.
665 */
666 const grundlage = toWinAnsi(
667 `LSA-Planer Professional · ${kopfzeilenGrundlage(project.anlagenart)}`,
668 );
669 doc.setFont('helvetica', 'normal');
670 doc.setFontSize(9);
671 const grundlageBreite = doc.getTextWidth(grundlage);
672
673 doc.setFont('helvetica', 'bold');
674 doc.setTextColor('#1b2230');
675 doc.text(
676 gekuerztAufBreite(
677 doc,
678 toWinAnsi(project.meta.name || 'Ohne Titel'),
679 context.contentWidth - grundlageBreite - KOPFZEILEN_ABSTAND,
680 ),
681 PAGE_MARGIN,
682 PAGE_MARGIN + 12,
683 );
684
685 doc.setFont('helvetica', 'normal');
686 doc.setTextColor('#5b6577');
687 // Die zweite Zeile hat rechts keinen Nachbarn, lief aber ebenso ueber die
688 // Blattkante hinaus - sie bekommt den ganzen Satzspiegel.
689 doc.text(
690 gekuerztAufBreite(
691 doc,
692 toWinAnsi(
693 [project.meta.projectNumber, project.meta.location, project.meta.variant]
694 .filter((s) => s !== '')
695 .join(' · '),
696 ),
697 context.contentWidth,
698 ),
699 PAGE_MARGIN,
700 PAGE_MARGIN + 26,
701 );
702 doc.text(grundlage, context.pageWidth - PAGE_MARGIN, PAGE_MARGIN + 12, { align: 'right' });
703
704 doc.setFontSize(8);
705 doc.line(
706 PAGE_MARGIN,
707 context.pageHeight - PAGE_MARGIN - FOOTER_HEIGHT + 8,
708 context.pageWidth - PAGE_MARGIN,
709 context.pageHeight - PAGE_MARGIN - FOOTER_HEIGHT + 8,
710 );
711 /*
712 * "Ausgedruckt am" und nicht "Erstellt am" (Fassung 5.9.0).
713 *
714 * `now` ist der Zeitpunkt der Ausgabe. Unter "Erstellt am" stand damit in
715 * der Fusszeile jeder Seite ein anderer Zeitpunkt als unter derselben
716 * Beschriftung auf dem Deckblatt, wo das Anlagedatum des Projekts steht -
717 * und zugleich derselbe Wert, den das Deckblatt zwei Zellen weiter
718 * "Ausgedruckt am" nennt. Beides auf Blatt 1. Fuer eine Anordnung nach
719 * Paragraf 45 StVO blieb offen, welches Datum gilt; deshalb traegt jeder
720 * der beiden Zeitpunkte in der ganzen Unterlage nur noch eine Benennung.
721 *
722 * Die linke Fusszeile ging als einzige der vier Zeilen dieser Schleife ohne
723 * Breitenbegrenzung an doc.text. Sie traegt mit `project.meta.planner`
724 * freien Anwendertext ohne Laengengrenze; mit einer ueblichen
725 * Buerobezeichnung von 192 Zeichen schob sie sich unter die rechtsbuendige
726 * Seitenangabe und endete 92 pt jenseits der Blattkante - auf JEDEM Blatt.
727 * Unlesbar wurde dabei ausgerechnet die Angabe, die die Vollstaendigkeit
728 * des Schriftstuecks belegt.
729 *
730 * Derselbe Weg wie in der Kopfzeile: die Breite der Seitenangabe messen und
731 * die Bearbeiterzeile auf den verbleibenden Platz kuerzen. Auch das darf sie
732 * hier - vollstaendig steht der Bearbeiter auf dem Deckblatt und in den
733 * Dokumentangaben.
734 */
735 const seitenangabe = toWinAnsi(`Seite ${page} von ${total}`);
736 const seitenbreite = doc.getTextWidth(seitenangabe);
737 doc.text(
738 gekuerztAufBreite(
739 doc,
740 toWinAnsi(
741 `Ausgedruckt am ${fmt.dateTime(now.toISOString())} · Bearbeiter: ${project.meta.planner || '–'}`,
742 ),
743 context.contentWidth - seitenbreite - KOPFZEILEN_ABSTAND,
744 ),
745 PAGE_MARGIN,
746 context.pageHeight - PAGE_MARGIN - 6,
747 );
748 doc.text(seitenangabe, context.pageWidth - PAGE_MARGIN, context.pageHeight - PAGE_MARGIN - 6, {
749 align: 'right',
750 });
751 }
752 }
753
754 /**
755 * Was in der Kopfzeile JEDER Seite als Grundlage steht.
756 *
757 * KORREKTUR (Fassung 5.4.0, Befund C16): Hier stand auf jeder Seite pauschal
758 * "Planung nach RiLSA 2015" - auch im Ausdruck einer Baustellenampel, dessen
759 * Abschnitt "Schranken der Anlagenart" wenige Seiten weiter sagt: "Die
760 * Umlaufzeitgrenzen dieses Plans stammen NICHT aus der RiLSA". Zwei
761 * gegenlaeufige Aussagen in derselben Unterlage; wer nur die Kopfzeile liest,
762 * behaelt die falsche.
763 *
764 * Bei einer einstreifigen Verkehrsfuehrung nennt die Kopfzeile deshalb beide
765 * Regelwerke in ihrem Verhaeltnis: Die RSA 21 ist die Grundlage der
766 * Verkehrsfuehrung und verweist fuer die Berechnung auf die RiLSA (Teil A 3.2
767 * Abs. 3) - gerechnet wird also nach RiLSA, angeordnet nach RSA.
768 *
769 * Nur eine Beschriftung des Ausdrucks, kein Kennwert - deshalb hier und nicht
770 * im Fachkern. Die Zahlen und ihre Herkunft stehen in constants.ts
771 * (ANLAGENART_GRENZEN.herkunft) und im Fundstellenverzeichnis.
772 */
773 function kopfzeilenGrundlage(anlagenart: Project['anlagenart']): string {
774 return anlagenart === 'einstreifig'
775 ? 'Signalzeitenplan nach RiLSA 2015 (RSA 21, Teil A 3.2)'
776 : 'Signalzeitenplan nach RiLSA 2015';
777 }
778
779 // --- Deckblatt --------------------------------------------------------------
780
781 function drawCoverSheet(context: Context, now: Date): void {
782 const { doc, project, plan, report } = context;
783 context.cursor = PAGE_MARGIN + HEADER_HEIGHT + 20;
784
785 doc.setFont('helvetica', 'bold');
786 doc.setFontSize(22);
787 doc.setTextColor('#1b2230');
788 doc.text(toWinAnsi('Signalzeitenplan'), PAGE_MARGIN, context.cursor);
789 context.cursor += 26;
790
791 /*
792 * FASSUNG 5.9.0, BEFUND 43: Der Titel ging in 15 pt ohne Breitenangabe
793 * an doc.text und lief bei einer langen Bezeichnung ueber die Blattkante -
794 * dort war er abgeschnitten. Auf dem Deckblatt wird er deshalb umbrochen und
795 * NICHT gekuerzt: Die Unterlage muss die Anlage eindeutig bezeichnen.
796 *
797 * Und er wird auch in der HOEHE gemessen: Das Deckblatt traegt 23
798 * Titelzeilen; darueber lief der Titel in den Fusssteg, ueberdruckte die
799 * Fusszeile und fiel ab rund 2.700 Zeichen ganz aus dem Blatt - derselbe
800 * stille Verlust wie bei der Bemerkung (Befund 18) und ebenso ohne
801 * Laengengrenze am Eingabefeld.
802 */
803 doc.setFontSize(15);
804 setzeZeilenMitUmbruch(
805 context,
806 doc.splitTextToSize(
807 toWinAnsi(project.meta.name || 'Ohne Titel'),
808 context.contentWidth,
809 ) as string[],
810 TITEL_ZEILENHOEHE,
811 );
812 // Abstand zur ersten Tabelle, von der letzten Grundlinie aus: Der Cursor
813 // steht danach bereits eine Zeilenhoehe darunter.
814 context.cursor += 26 - TITEL_ZEILENHOEHE;
815
816 autoTable(doc, {
817 startY: context.cursor,
818 margin: TABELLENRAND,
819 theme: 'plain',
820 styles: { fontSize: 10, cellPadding: 3, textColor: '#1b2230' },
821 columnStyles: {
822 0: { fontStyle: 'bold', cellWidth: 150 },
823 2: { fontStyle: 'bold', cellWidth: 150 },
824 },
825 body: [
826 /*
827 * Die Anlagenart steht an erster Stelle, weil sie sagt, WONACH dieser
828 * Plan zu beurteilen ist.
829 *
830 * Zur verkehrsbehoerdlichen Anordnung nach Paragraf 45 StVO gehoert,
831 * welche Art Anlage angeordnet wird. Ohne die Angabe bliebe offen, ob
832 * eine Umlaufzeit von 240 s eine grobe Ueberschreitung der
833 * Sicherheitsgrenze am Knotenpunkt ist oder die zwangslaeufige Folge
834 * einer langen Engstelle - dieselbe Zahl, zwei gegensaetzliche Befunde.
835 *
836 * Die Benennung kommt aus dem Regelwerksmodul und ist dieselbe wie am
837 * Bildschirm; eine eigene Formulierung fuer das Papier liefe beim
838 * naechsten Wortwechsel auseinander.
839 *
840 * Auch die Zeile daneben haengt daran: Der Name der Anlage stand hier
841 * unter der festen Beschriftung "Knotenpunkt". An einer Arbeitsstelle
842 * gibt es keinen Knotenpunkt, und eine Planunterlage darf keinen
843 * behaupten - die Beschriftung ist deshalb neutral.
844 */
845 [
846 'Anlagenart',
847 ANLAGENART_LABELS[project.anlagenart],
848 'Bezeichnung der Anlage',
849 project.intersection.name || '–',
850 ],
851 [
852 'Projektnummer',
853 project.meta.projectNumber || '–',
854 'Auftraggeber',
855 project.meta.client || '–',
856 ],
857 ['Ort / Straßenzug', project.meta.location || '–', 'Bearbeiter', project.meta.planner || '–'],
858 [
859 'Zulässige Höchstgeschwindigkeit',
860 `${fmt.numShort(project.intersection.vZul)} km/h`,
861 'Planungsvariante',
862 project.meta.variant || '–',
863 ],
864 /*
865 * "Angelegt am" wie in der Ansicht "Projekt" (Fassung 5.9.0): Hier stand
866 * "Erstellt am" fuer das Anlagedatum, waehrend die Fusszeile derselben
867 * Seite "Erstellt am" fuer den Ausgabezeitpunkt fuehrte. Eine
868 * Beschriftung fuer zwei Zeitpunkte in einer Unterlage.
869 */
870 ['Signalprogramm', project.program.name, 'Angelegt am', fmt.dateTime(project.meta.createdAt)],
871 [
872 'Zuletzt geändert',
873 fmt.dateTime(project.meta.modifiedAt),
874 'Ausgedruckt am',
875 fmt.dateTime(now.toISOString()),
876 ],
877 /*
878 * DIE HERKUNFT DER UNTERLAGE (neu in 5.26.0). Ohne diese Zeile war zwei
879 * Ausdrucken desselben Projekts nicht anzusehen, welcher Rechenstand sie
880 * erzeugt hat - und zwischen 5.21.0 und 5.25.0 liegen der Deckel an der
881 * zulaessigen Hoechstgeschwindigkeit und die Verteilung des
882 * Freigabezeitrests, beide mit anderen Zahlen im Plan. Der Schemastand
883 * daneben sagt, aus welchem Dateiformat die Daten stammen; die
884 * mitgelieferte Beispieldatei traegt bewusst einen aelteren.
885 */
886 ['Programmfassung', FASSUNGSANGABE, 'Schemastand der Datei', project.schemaVersion],
887 ].map((row) => row.map((cell) => toWinAnsi(String(cell)))),
888 });
889
890 context.cursor = lastTableBottom(doc) + 20;
891
892 autoTable(doc, {
893 startY: context.cursor,
894 margin: TABELLENRAND,
895 theme: 'grid',
896 head: [['Kennwert', 'Wert', 'Kennwert', 'Wert'].map(toWinAnsi)],
897 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
898 styles: { fontSize: 10, cellPadding: 4 },
899 body: [
900 ...[
901 [
902 'Umlaufzeit',
903 // Mit Vermerk, wenn die Zahl kein Bemessungsergebnis ist (Befund C11).
904 umlaufzeitText(plan),
905 'Verfahren',
906 methodLabel(project.program.method),
907 ],
908 [
909 'Anzahl Phasen',
910 String(plan.phases.length),
911 'Summe Übergangszeiten',
912 fmt.seconds(plan.transitionTime),
913 ],
914 [
915 'Anzahl Signalgruppen',
916 String(project.signalGroups.length),
917 'Verlustzeit je Umlauf',
918 fmt.seconds(plan.lostTime),
919 ],
920 ].map((row) => row.map((cell) => toWinAnsi(String(cell)))),
921 /*
922 * NEU (Fassung 5.5.0, Frage E2): Der Ueberfahrzeit-Ansatz
923 * geht in jede nach Abschnitt 2 gerechnete Zwischenzeit eines
924 * Kfz-Raeumstroms ein und steht deshalb neben der Umlaufzeit und dem
925 * Bemessungsverfahren - auf dem Deckblatt, das jeder Leser sieht.
926 *
927 * WARUM HIER UND NICHT NUR BEI DEN ZWISCHENZEITEN: Die
928 * Zwischenzeitenmatrix ist abwaehlbar. Ein Ausdruck ohne sie enthielte
929 * sonst Freigabe- und Uebergangszeiten, die auf dem Ansatz beruhen, ohne
930 * ihn zu nennen. Die Begruendung samt Fehlerrichtung steht im Abschnitt
931 * "Zwischenzeitenmatrix" (ueberfahrzeitAnsatzSatz); hier steht nur die
932 * Benennung, und zwar dieselbe wie am Bildschirm und im Pruefbericht.
933 *
934 * "JEDE" HIESS HIER EINMAL WIRKLICH JEDE (bis 5.42.1), und die Zeile
935 * stand unbedingt: An einer einstreifigen Verkehrsfuehrung rechnet der
936 * Fachkern nach RiLSA 2015, Abschnitt 5.2.2 mit einer festen
937 * Ueberfahrzeit, und wo jede Kfz-Zwischenzeit von Hand vorgegeben ist,
938 * rechnet er ueberhaupt nicht. Das Deckblatt nannte gleichwohl einen
939 * Rechenansatz und verwies fuer die Begruendung auf einen Absatz, den
940 * dieselbe Unterlage dort nicht mehr druckt. Die Zeile bleibt - eine
941 * Einstellung, die nicht wirkt, gehoert genannt und nicht verschwiegen -,
942 * aber mit dem Wirkungsvermerk daneben, wie die Vorgabenverwaltung es
943 * fuer jeden wirkungslosen Kennwert haelt (`wirkungslosigkeit` in
944 * settings.ts). Die Antwort kommt aus dem Fachkern, damit Deckblatt,
945 * Matrix, Abweichungstafel und Pruefbericht nicht viererlei sagen.
946 *
947 * Die Beschriftung fuellt drei Spalten, weil sie ein Satz ist und kein
948 * Zahlenwert - in der schmalen Wertspalte stuende sie sechszeilig.
949 */
950 [
951 { content: toWinAnsi('Überfahrzeit Kfz (Rechenansatz)'), styles: { fontStyle: 'bold' } },
952 {
953 content: toWinAnsi(
954 ((): string => {
955 const benennung = UEBERFAHRZEIT_ANSATZ_LABELS[project.settings.ueberfahrzeitAnsatz];
956 const ohneWirkung = ueberfahrzeitAnsatzOhneWirkung(project, plan);
957 return ohneWirkung === null ? benennung : `${benennung} – ${ohneWirkung.vermerk}`;
958 })(),
959 ),
960 colSpan: 3,
961 },
962 ],
963 ] as RowInput[],
964 });
965
966 context.cursor = lastTableBottom(doc) + 24;
967
968 /*
969 * Der Kasten wurde unmittelbar an `context.cursor` gezeichnet - ohne
970 * Umbruchpruefung. Bei einer langen Projektbezeichnung (das Feld hat keine
971 * Laengengrenze, und auf dem Deckblatt wird sie bewusst umbrochen statt
972 * gekuerzt) ruecken die beiden Tabellen darueber nach unten; ab rund
973 * 400 Zeichen lag die farbige Flaeche im Fusssteg, ab rund 480 der
974 * Vorbehaltssatz, und ab rund 780 fiel er ganz aus dem Blatt - ersatzlos.
975 * Verloren ging damit die einzige Stelle des Dokuments, an der
976 * `report.errorCount` steht, samt dem Vorbehalt zu Paragraf 45 StVO.
977 *
978 * Der Kasten ist eine Einheit aus Flaeche, Ueberschrift, Ergebniszeile und
979 * Vorbehalt; er rutscht deshalb geschlossen auf das Folgeblatt. Gemessen wird
980 * seine volle Hoehe samt demselben Abstand, der ihn oben von der Tabelle
981 * trennt - Deckblatttitel (Befund 43) und Bemerkung (Befund 18) sind aus
982 * demselben Grund gemessen gesetzt, nur der Block dazwischen war es nicht.
983 */
984 ensureSpace(context, ERGEBNISKASTEN_HOEHE + 24);
985
986 // Pruefergebnis - sachlich, ohne Empfehlung.
987 const resultText =
988 report.errorCount > 0
989 ? `Die Prüfung ergab ${report.errorCount} Fehler und ${report.warningCount} Warnungen. ` +
990 'Der Signalzeitenplan ist in dieser Form nicht ausführbar.'
991 : report.warningCount > 0
992 ? `Die Prüfung ergab keine Fehler und ${report.warningCount} Warnungen. ` +
993 'Die Warnungen sind fachlich zu würdigen.'
994 : 'Die Prüfung ergab keine Beanstandungen.';
995
996 doc.setFillColor(
997 report.errorCount > 0 ? '#fdecea' : report.warningCount > 0 ? '#fff4e0' : '#e8f5ec',
998 );
999 doc.rect(PAGE_MARGIN, context.cursor, context.contentWidth, ERGEBNISKASTEN_HOEHE, 'F');
1000 doc.setDrawColor(
1001 report.errorCount > 0 ? '#b3261e' : report.warningCount > 0 ? '#8a5a00' : '#1f6b3a',
1002 );
1003 doc.setLineWidth(2);
1004 doc.line(PAGE_MARGIN, context.cursor, PAGE_MARGIN, context.cursor + ERGEBNISKASTEN_HOEHE);
1005
1006 doc.setFont('helvetica', 'bold');
1007 doc.setFontSize(11);
1008 doc.setTextColor('#1b2230');
1009 doc.text(toWinAnsi('Ergebnis der Prüfung'), PAGE_MARGIN + 10, context.cursor + 18);
1010 doc.setFont('helvetica', 'normal');
1011 doc.setFontSize(9.5);
1012 doc.text(toWinAnsi(resultText), PAGE_MARGIN + 10, context.cursor + 33, {
1013 maxWidth: context.contentWidth - 20,
1014 });
1015 doc.setFontSize(8);
1016 doc.setTextColor('#5b6577');
1017 doc.text(
1018 toWinAnsi(
1019 'Die rechnerische Prüfung ersetzt nicht die fachliche Verantwortung des Planers und keine ' +
1020 'verkehrsbehördliche Anordnung nach Paragraf 45 StVO.',
1021 ),
1022 PAGE_MARGIN + 10,
1023 context.cursor + 47,
1024 { maxWidth: context.contentWidth - 20 },
1025 );
1026
1027 if (project.meta.description.trim() !== '') {
1028 context.cursor += ERGEBNISKASTEN_HOEHE + ERGEBNISKASTEN_NACHLAUF;
1029 // Ueberschrift und die ersten Zeilen bleiben beisammen; die Bemerkung
1030 // selbst bringt ihren eigenen Umbruch mit (siehe writeBemerkung).
1031 ensureSpace(context, 60);
1032 doc.setFont('helvetica', 'bold');
1033 doc.setFontSize(10);
1034 doc.setTextColor('#1b2230');
1035 doc.text(toWinAnsi('Bemerkungen'), PAGE_MARGIN, context.cursor);
1036 context.cursor += 14;
1037 writeBemerkung(context, project.meta.description);
1038 }
1039 }
1040
1041 // --- Bestandteile der Anordnungsunterlage -----------------------------------
1042
1043 /**
1044 * Die Zeile "Rechenweg der Zwischenzeiten und der Wartezeit" der Uebersicht.
1045 *
1046 * Sie nennt nur Abschnitte, die im Dokument auch wirklich stehen. Vor der
1047 * Fassung 5.4.0 hing sie allein am Schalter `includeCalculationDetails` und
1048 * druckte "ja - Abschnitte ... und ..." auch dann, wenn Zwischenzeitenmatrix
1049 * und Leistungsfaehigkeit abgewaehlt waren und keiner der beiden Rechenwege im
1050 * Dokument vorkam. Der Abschnitt, der die Luecken der Unterlage ausweisen soll,
1051 * gab damit selbst eine falsche Auskunft.
1052 */
1053 function rechenwegZeile(stand: RechenwegStand): string {
1054 const zwischenzeiten = zwischenzeitRechenweg(stand);
1055 const wartezeit = wartezeitRechenweg(stand);
1056
1057 if (zwischenzeiten && wartezeit) {
1058 return 'ja – Abschnitte „Zwischenzeiten – Rechenweg" und „Leistungsfähigkeit – Rechenweg Wartezeit"';
1059 }
1060 if (zwischenzeiten) {
1061 return (
1062 'teilweise – Abschnitt „Zwischenzeiten – Rechenweg"; der Rechenweg der Wartezeit entfällt ' +
1063 'mit dem abgewählten Abschnitt „Leistungsfähigkeit"'
1064 );
1065 }
1066 if (wartezeit) {
1067 return (
1068 'teilweise – Abschnitt „Leistungsfähigkeit – Rechenweg Wartezeit"; der Rechenweg der ' +
1069 'Zwischenzeiten entfällt mit der abgewählten Zwischenzeitenmatrix'
1070 );
1071 }
1072 if (stand.angefordert) {
1073 return (
1074 'entfällt – angefordert, aber beide Abschnitte, in denen er steht (Zwischenzeitenmatrix, ' +
1075 'Leistungsfähigkeit), sind abgewählt'
1076 );
1077 }
1078 if (stand.matrix) return 'abgewählt – die Werte der Matrix sind ohne ihn nicht nachrechenbar';
1079 if (stand.leistung) {
1080 return 'abgewählt – die Werte der Leistungsfähigkeit sind ohne ihn nicht nachrechenbar';
1081 }
1082 return 'abgewählt – die Abschnitte, in denen er gestanden hätte, sind ebenfalls abgewählt';
1083 }
1084
1085 /**
1086 * Was diese Unterlage enthaelt - und was sie NICHT enthaelt.
1087 *
1088 * NEU (Befund C14). Der Ausdruck druckte, was angehakt war, und schwieg ueber
1089 * alles Uebrige. Das ist der gefaehrliche Fall: Eine Unterlage, die jeden
1090 * Abschnitt fuellt, den sie kennt, sieht vollstaendig aus. Fehlte der
1091 * Zwischenzeitennachweis, fehlten die Einsatzzeiten, fehlte der bemasste
1092 * Signallageplan - der Unterlage sah man das nicht an. Erst die Behoerde
1093 * stellte fest, dass die Anordnung so nicht ergehen kann.
1094 *
1095 * WARUM DER SIGNALLAGEPLAN AUSDRUECKLICH FEHLT: Dieses Programm zeichnet
1096 * Fahrlinien und rechnet daraus Raeum- und Einfahrwege. Wo die Signalgeber
1097 * stehen, welche Bauform sie haben und wie sie bemasst sind, kann es nicht
1098 * sagen - und ein Ausdruck, der einen "Lageplan" zeigt, laedt dazu ein, ihn
1099 * dafuer zu halten. Die Zeile sagt deshalb im Klartext, dass dieser Plan
1100 * gesondert beizubringen ist.
1101 *
1102 * Der Abschnitt haengt an keinem Schalter: Ein abwaehlbarer Lueckenausweis
1103 * waere kein Lueckenausweis.
1104 */
1105 function drawAnordnungsunterlage(
1106 context: Context,
1107 options: PdfOptions,
1108 rechenweg: RechenwegStand,
1109 ): void {
1110 const { doc, project } = context;
1111 const einstreifig = project.anlagenart === 'einstreifig';
1112
1113 newSection(context, 'Bestandteile der Anordnungsunterlage');
1114
1115 writeParagraph(
1116 context,
1117 'Diese Übersicht sagt, welche Bestandteile die vorliegende Ausgabe enthält und welche nicht. ' +
1118 'Abgewählte Abschnitte sind als solche benannt - eine fehlende Angabe ist kein Nachweis, ' +
1119 'dass es sie nicht gibt, sondern eine Lücke der Unterlage.',
1120 );
1121
1122 /** "ja" mit dem Abschnitt, in dem es steht - sonst der Grund des Fehlens. */
1123 const abschnitt = (enthalten: boolean, name: string): string =>
1124 enthalten ? `ja – Abschnitt „${name}"` : 'abgewählt – in dieser Ausgabe nicht enthalten';
1125
1126 /** Eine Angabe der Projektdaten oder der ausdrueckliche Ausweis ihres Fehlens. */
1127 const angabe = (wert: string | undefined): string => {
1128 const text = String(wert ?? '').trim();
1129 return text === '' ? 'nicht angegeben' : text;
1130 };
1131
1132 /** Ob eine wahlfreie Angabe der Projektdaten ueberhaupt gefuellt ist. */
1133 const angegeben = (wert: string | undefined): boolean => String(wert ?? '').trim() !== '';
1134
1135 /*
1136 * Der Verweis auf eine Unterlage, die dieses Programm nicht erzeugt.
1137 *
1138 * DIE ZEILE BEGINNT IN BEIDEN FAELLEN MIT "NICHT Bestandteil". Das ist der
1139 * Kern der Uebersicht seit Befund C14: Sie soll die Luecke ausweisen und
1140 * nicht verkleinern. Der eingetragene Verweis sagt danach, WORAUF sich diese
1141 * Unterlage bezieht - aus "fehlt" wird "fehlt hier, liegt als X bei" und
1142 * nicht "enthalten". Stuende der Verweis vorn, laese ein Pruefer beim
1143 * Ueberfliegen eine Kennung und hielte den Plan fuer dabei.
1144 */
1145 /*
1146 * DIE ZEILE ZUM SIGNALLAGEPLAN IST SEIT 5.42.0 DREIWERTIG.
1147 *
1148 * Sie war die unangenehme Auskunft der Uebersicht und die Behebung des
1149 * Befundes C14: "NICHT Bestandteil - gesondert beizubringen". Mit erfassten
1150 * Signalgebern waere das nicht mehr die ganze Wahrheit, denn ein Blatt mit
1151 * Standort, Blickrichtung und Kennung liegt dann bei.
1152 *
1153 * ES WIRD TROTZDEM KEIN "JA". Was fehlt, ist genau aufgezaehlt - Bauform,
1154 * Hoehe, Blendenmass, Sichtpruefung, Restfahrbahnbreite -, und der Satz
1155 * beginnt mit dem, was fehlt, nicht mit dem, was da ist. Ein Pruefer, der
1156 * die Zeile ueberfliegt, soll nicht "enthalten" lesen: Ein Signallageplan
1157 * ohne diese fuenf Angaben ist keiner, und ein halber, der sich fuer einen
1158 * ganzen ausgibt, waere schlechter als das ehrliche Fehlen.
1159 */
1160 const signallageplanZeile = (p: Project): string => {
1161 const anzahl = p.lageplan.signalgeber.length;
1162 if (anzahl === 0) return 'NICHT Bestandteil dieser Unterlage – gesondert beizubringen';
1163 return (
1164 `TEILWEISE – ${String(anzahl)} Signalgeberstandort(e) mit Blickrichtung und Kennung sind ` +
1165 'als eigenes Lageplanblatt enthalten. Nicht enthalten und gesondert beizubringen bleiben: ' +
1166 'Bauform, Höhe und Blendenmaß der Signalgeber, die Sichtprüfung sowie der Nachweis der ' +
1167 'verbleibenden Fahrbahnbreite.'
1168 );
1169 };
1170
1171 const fremdunterlage = (verweis: string | undefined): string =>
1172 angegeben(verweis)
1173 ? `NICHT Bestandteil dieser Unterlage – gesondert beizubringen; bezieht sich auf: ${String(verweis).trim()}`
1174 : 'NICHT Bestandteil dieser Unterlage – gesondert beizubringen';
1175
1176 autoTable(doc, {
1177 startY: context.cursor,
1178 margin: TABELLENRAND,
1179 theme: 'grid',
1180 styles: { fontSize: 9, cellPadding: 3, valign: 'top' },
1181 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
1182 columnStyles: { 0: { cellWidth: 260, fontStyle: 'bold' } },
1183 head: [['Bestandteil', 'enthalten?'].map(toWinAnsi)],
1184 body: [
1185 [
1186 'Signalzeitenplan (Grafik und Signalzeiten)',
1187 abschnitt(options.includePlan, 'Signalzeitenplan'),
1188 ],
1189 ['Signalgruppen', abschnitt(options.includeSignalGroups, 'Signalgruppen')],
1190 ['Zwischenzeitenmatrix', abschnitt(options.includeIntergreenMatrix, 'Zwischenzeitenmatrix')],
1191 // Befund C15: Der Zwischenzeitennachweis ist der Abschnitt, den die
1192 // Behoerdenpraxis verlangt; seine Abwahl steht deshalb mit ihrer Folge da
1193 // und nicht als blosser Vermerk "abgewaehlt".
1194 ['Rechenweg der Zwischenzeiten und der Wartezeit', rechenwegZeile(rechenweg)],
1195 [
1196 'Phasen und Phasenübergänge',
1197 abschnitt(options.includePhases, 'Phasen und Phasenübergänge'),
1198 ],
1199 [
1200 'Begründung der Umlaufzeit (Vergleich der vier Rechenansätze)',
1201 abschnitt(options.includeCycleComparison, 'Umlaufzeit – Vergleich der Rechenansätze'),
1202 ],
1203 ['Leistungsfähigkeit', abschnitt(options.includePerformance, 'Leistungsfähigkeit')],
1204 ['Prüfbericht', abschnitt(options.includeReport, 'Prüfbericht')],
1205 [
1206 'Nachweis der Kennwerte (Rechtsgrundlagen, Schranken der Anlagenart, Fundstellenverzeichnis)',
1207 abschnitt(options.includeSources, 'Grundlagen der Kennwerte'),
1208 ],
1209 ['Nachweis der Vermessungsgrundlage', 'ja – Abschnitt „Planungsgrundlage" (nicht abwählbar)'],
1210 // DIE WICHTIGSTE ZEILE DIESER UEBERSICHT, wo ein Projekt mehrere
1211 // Tagesprogramme fuehrt: Was hier gedruckt ist, gilt EINEM Programm - und
1212 // wer das nicht liest, haelt die Unterlage fuer die ganze Anordnung.
1213 ['Tagesprogramme dieser Anlage', programmzeile(project)],
1214 // Der Abschnitt entsteht nur mit erfasstem Strassenzug, und die Zeile
1215 // sagt beides.
1216 [
1217 'Koordinierung im Straßenzug (Grüne Welle)',
1218 project.koordinierung === null
1219 ? 'nein – für diesen Knotenpunkt ist kein Straßenzug erfasst'
1220 : 'ja – Abschnitt „Koordinierung" (nicht abwählbar)',
1221 ],
1222 // Die Zeile steht neben der des bemassten Signallageplans und nicht an
1223 // ihrer Stelle: Das Blatt zeigt die Fahrlinien, nicht die Signalgeber.
1224 [
1225 'Lageplan der Fahrlinien (maßstäblich, ohne Signalgeber)',
1226 planblatt(project.lageplan, 400, 300) === null
1227 ? 'nein – kein maßstäblich auswertbarer Lageplan im Projekt'
1228 : 'ja – Abschnitt „Planungsgrundlage" (nicht abwählbar)',
1229 ],
1230 // Die Erklaerung zur Barrierefreiheit steht am Ende der Unterlage; wer
1231 // hier nachsieht, was drin ist, soll sie finden.
1232 [
1233 'Erklärung zur Barrierefreiheit dieser Datei',
1234 'ja – Abschnitt „Barrierefreiheit dieser Datei" (nicht abwählbar)',
1235 ],
1236 ['Einsatzzeiten der Anordnung', angabe(project.meta.einsatzzeiten)],
1237 [
1238 'Betriebsverantwortlicher (Name, Firma, Erreichbarkeit)',
1239 angabe(project.meta.betriebsverantwortlicher),
1240 ],
1241 ['Bemaßter Signallageplan mit Signalgeberstandorten', signallageplanZeile(project)],
1242 ['Verkehrszeichenplan', fremdunterlage(project.meta.verkehrszeichenplan)],
1243 ...(angegeben(project.meta.regelplan)
1244 ? [['Regelplan der RSA 21 (Angabe des Planers)', String(project.meta.regelplan).trim()]]
1245 : []),
1246 ...(angegeben(project.meta.zustimmungBaulasttraeger)
1247 ? [
1248 [
1249 'Zustimmung des Straßenbaulastträgers',
1250 String(project.meta.zustimmungBaulasttraeger).trim(),
1251 ],
1252 ]
1253 : []),
1254 ].map((row) => row.map((cell) => toWinAnsi(String(cell)))),
1255 });
1256
1257 context.cursor = lastTableBottom(doc) + 14;
1258
1259 /*
1260 * Der Abwahlvermerk steht sonst an der Stelle des Rechenwegs. Sind beide
1261 * Stellen abgewaehlt, gibt es sie nicht - dann steht er hier, denn eine
1262 * Luecke, die nirgends vermerkt ist, sieht wie eine vollstaendige Unterlage
1263 * aus.
1264 */
1265 if (!rechenweg.angefordert && !rechenweg.matrix && !rechenweg.leistung) {
1266 writeParagraph(context, rechenwegAbgewaehlt(rechenweg));
1267 }
1268
1269 writeParagraph(
1270 context,
1271 project.lageplan.signalgeber.length === 0
1272 ? 'Ein bemaßter Signallageplan mit den Standorten der Signalgeber ist nicht Bestandteil ' +
1273 'dieser Unterlage. Dieses Programm zeichnet Fahrlinien und leitet daraus Räum- und ' +
1274 'Einfahrwege ab; wo die Signalgeber stehen, welche Bauform sie haben und wie sie ' +
1275 'bemaßt sind, geht daraus nicht hervor. Derselbe Vorbehalt gilt für den ' +
1276 'Verkehrszeichenplan. Beide Pläne sind gesondert beizubringen.'
1277 : 'Diese Unterlage führt ein Lageplanblatt mit den erfassten Signalgeberstandorten: ' +
1278 'Standort, Blickrichtung und Kennung, maßstäblich abgegriffen aus derselben Zeichnung ' +
1279 'wie die Räum- und Einfahrwege. Ein vollständiger bemaßter Signallageplan ist es ' +
1280 'nicht – Bauform, Höhe und Blendenmaß der Signalgeber, die Sichtprüfung und der ' +
1281 'Nachweis der verbleibenden Fahrbahnbreite gehen daraus nicht hervor und sind ' +
1282 'gesondert beizubringen. Für den Verkehrszeichenplan gilt der Vorbehalt unverändert.',
1283 );
1284
1285 writeParagraph(
1286 context,
1287 einstreifig
1288 ? 'Grundlage: RSA 21 Teil A 1.4/1.5 – Signallage- und Signalzeitenplan sind Teil des ' +
1289 'Verkehrszeichenplans, den der Unternehmer der Straßenverkehrsbehörde vorlegt; die ' +
1290 'verkehrstechnischen Unterlagen nach den ZTV transportable Lichtsignalanlagen 2023 ' +
1291 '(ARS Nr. 07/2024) nennen Einsatzzeiten und Betriebsverantwortlichen ausdrücklich. ' +
1292 'Abschnittsnummern und Wortlaut sind an der eigenen Ausgabe zu prüfen; der Prüfbericht ' +
1293 'führt fehlende Angaben als Warnung.'
1294 : 'Einsatzzeiten und Betriebsverantwortlicher sind üblicher Bestandteil einer ' +
1295 'verkehrsbehördlichen Anordnung nach § 45 StVO; ein bezifferter Regelwerkswert steht ' +
1296 'dahinter nicht. Der Prüfbericht führt sie deshalb als Hinweis, nicht als Warnung.',
1297 );
1298 }
1299
1300 // --- Barrierefreiheit dieser Datei ------------------------------------------
1301
1302 /**
1303 * Erklaerung zur Barrierefreiheit der erzeugten Datei.
1304 *
1305 * NEU (Fassung 5.7.0). Der Massstab war EN 301 549 V3.2.1 Kapitel 10 mit WCAG
1306 * 2.1 Stufe AA. Ergebnis fuer diese Datei: nicht konform. Sie ist ungetaggt -
1307 * im erzeugten Dokument wurden /StructTreeRoot, /MarkInfo, /Alt, /ActualText
1308 * und /Artifact ausgezaehlt, alle null -, und mit der eingesetzten Bibliothek
1309 * (jsPDF 2.5.2) ist das nicht zu beheben. Damit fallen 1.3.1, 1.3.2 und 1.1.1
1310 * der Stufe A fuer die Unterlage aus.
1311 *
1312 * WARUM DAS IM DOKUMENT SELBST STEHT: Aus demselben Grund, aus dem die
1313 * Uebersicht "Bestandteile der Anordnungsunterlage" die fehlenden Bestandteile
1314 * benennt, statt sie wegzulassen. Eine Datei, die aussieht wie jede andere
1315 * PDF-Datei, wird von einer oeffentlichen Stelle veroeffentlicht wie jede
1316 * andere - und die Stelle haftet dafuer, nicht der Hersteller. Sie kann ihre
1317 * Erklaerung nach Paragraf 12b BGG nur abgeben, wenn ihr jemand sagt, wie es
1318 * um die Datei steht. Dieser Abschnitt sagt es.
1319 *
1320 * WAS ER NICHT TUT: Er behauptet keine Konformitaet, keine Teilkonformitaet und
1321 * keine "weitgehende Barrierefreiheit". Die Zusagen, die er macht - Titel,
1322 * Sprache, Lesezeichen, auslesbarer Text, Signalzeitentabelle als Textfassung
1323 * der Zeichnung -, sind einzeln nachpruefbar und in tests/export/
1324 * bfUnterlagen.test.ts an der erzeugten Datei geprueft.
1325 *
1326 * Ohne Schalter: Ein abwaehlbarer Lueckenausweis waere kein Lueckenausweis.
1327 */
1328 function drawBarrierefreiheit(
1329 context: Context,
1330 mitPlanabschnitt: boolean,
1331 mitPhasenabschnitt: boolean,
1332 rechenweg: RechenwegStand,
1333 ): void {
1334 const { doc } = context;
1335
1336 newSection(context, 'Barrierefreiheit dieser Datei');
1337
1338 writeParagraph(
1339 context,
1340 'Dieser Abschnitt sagt, wie es um die Barrierefreiheit dieser Datei steht - nicht um die der ' +
1341 'geplanten Anlage. Er ist eine Angabe des Herstellers zum Stand der Datei und keine ' +
1342 'Rechtsberatung. Maßstab ist EN 301 549 V3.2.1 Kapitel 10 mit den Erfolgskriterien der ' +
1343 'WCAG 2.1 Stufe AA; Stand der Prüfung: August 2026.',
1344 );
1345
1346 writeParagraph(
1347 context,
1348 'Ergebnis: Diese Datei ist nicht getaggt. Sie erfüllt PDF/UA (DIN ISO 14289-1) nicht, und die ' +
1349 'Erfolgskriterien 1.3.1 Info und Beziehungen, 1.3.2 Bedeutungstragende Reihenfolge und ' +
1350 '1.1.1 Nicht-Text-Inhalte der Stufe A sind für sie nicht erfüllt.',
1351 );
1352
1353 autoTable(doc, {
1354 startY: context.cursor,
1355 margin: TABELLENRAND,
1356 theme: 'grid',
1357 styles: { fontSize: 9, cellPadding: 3, valign: 'top' },
1358 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
1359 columnStyles: { 0: { cellWidth: 260, fontStyle: 'bold' } },
1360 head: [['Merkmal', 'Stand in dieser Datei'].map(toWinAnsi)],
1361 body: [
1362 [
1363 'Auszeichnung der Struktur (Tags, Strukturbaum)',
1364 'nicht vorhanden – die Datei ist ungetaggt',
1365 ],
1366 [
1367 'Überschriften, Absätze, Kopfzellen der Tabellen',
1368 'nur optisch gesetzt, nicht programmatisch ausgezeichnet',
1369 ],
1370 ['Festgelegte Lesereihenfolge', 'nicht festgelegt'],
1371 [
1372 'Kopf- und Fußzeile als Seitenrahmen gekennzeichnet',
1373 'nein – sie stehen im Lesefluss und werden mitgelesen',
1374 ],
1375 [
1376 'Alternativtext der Zeichnung',
1377 /*
1378 * FASSUNG 5.9.0: Beide Zweige nannten die Tabelle
1379 * "Signalzeiten je Signalgruppe" als Textfassung - auch dann, wenn der
1380 * Abschnitt "Signalzeitenplan" abgewaehlt war und die Datei weder
1381 * Zeichnung noch Tabelle enthaelt.
1382 */
1383 !mitPlanabschnitt
1384 ? 'entfällt – der Abschnitt „Signalzeitenplan" wurde für diese Ausgabe abgewählt; ' +
1385 'diese Datei enthält keine Zeichnung'
1386 : mitPhasenabschnitt
1387 ? 'nicht vorhanden – gleichwertige Textfassung: Tabelle „Signalzeiten je Signalgruppe" ' +
1388 'und Abschnitt „Phasen und Phasenübergänge"'
1389 : 'nicht vorhanden – Textfassung der Signalbilder: Tabelle „Signalzeiten je ' +
1390 'Signalgruppe"; das Phasenband ist nicht in Textform enthalten',
1391 ],
1392 [
1393 'Dokumenttitel und Sprache (Deutsch)',
1394 'vorhanden; der Titel wird im Anzeigeprogramm ausgewiesen',
1395 ],
1396 ['Lesezeichen für jeden Abschnitt', 'vorhanden'],
1397 ['Text maschinell auslesbar (Vektorzeichnung, keine Rastergrafik)', 'ja'],
1398 ['Schriften in die Datei eingebettet', 'nein – Standardschriften, Zeichenvorrat Latin-1'],
1399 ].map((row) => row.map((cell) => toWinAnsi(cell))),
1400 });
1401
1402 context.cursor = lastTableBottom(doc) + 14;
1403
1404 writeParagraph(
1405 context,
1406 'Was das für Bildschirmleseprogramme bedeutet: Der Text ist auslesbar und die Sprache der ' +
1407 'Datei ist als Deutsch ausgewiesen, die Bedeutung der Teile ist es nicht. Überschriften ' +
1408 'werden nicht als Überschriften angesagt, es gibt keine Überschriftennavigation. Tabellen ' +
1409 'werden ohne Bezug zu Zeilen- und Spaltenköpfen vorgelesen; bei einer Matrix wie der der ' +
1410 'Zwischenzeiten bleibt damit offen, welche Zahl zu welchem Paar aus räumender und ' +
1411 'einfahrender Signalgruppe gehört. Kopf- und Fußzeile jeder Seite stehen im Lesefluss und ' +
1412 'unterbrechen ihn auf jeder Seite.' +
1413 // Nur, wo es eine Zeichnung gibt (Fassung 5.9.0).
1414 (mitPlanabschnitt
1415 ? ' Die Zeichnung des Signalzeitenplans erscheint als lose Folge von Zahlen, ohne ' +
1416 'Angabe, dass es sich um eine Zeichnung handelt.'
1417 : ''),
1418 );
1419
1420 /*
1421 * WARUM DIESER ABSATZ NEU IST (Ausschnittsatz der Zwischenzeitenmatrix).
1422 *
1423 * Der Abschnitt ist ein Lueckenausweis. Aendert sich die Darstellung eines
1424 * Abschnitts so, dass eine Sprachausgabe etwas ANDERES vorliest als zuvor,
1425 * muss er es sagen - sonst beschreibt er eine Datei, die es nicht mehr gibt.
1426 *
1427 * Was sich geaendert hat: Bei langen Signalgruppennamen trug der Kopf der
1428 * Matrix die Namen in so schmalen Spalten, dass jspdf-autotable sie MITTEN
1429 * IM WORT umbrach. Gemessen an einem Knotenpunkt mit 16 bis 32 Gruppen und
1430 * Namen von rund 140 Zeichen stand danach KEIN EINZIGER Name ungeteilt im
1431 * ausgelesenen Text (0 von 16, 0 von 24, 0 von 32). Die ersten drei
1432 * Kopfbruchstuecke lauteten dort, aus der Datei zurueckgelesen:
1433 * "Kraftfahrz" / "euge Nor" / "dzufahrt " - ein Zitat, keine Nachbildung
1434 * (Befund B7 technisch: hier stand vorher ein erfundenes "Kraftfah rzeuge
1435 * Nordzuf ahrt"). Jetzt tragen die Spaltenkoepfe Ordnungsnummern, und die
1436 * Zuordnung ueber der
1437 * Tabelle fuehrt jeden Namen ungeteilt in einer Zeile - gemessen 16 von 16,
1438 * 24 von 24, 32 von 32.
1439 *
1440 * WAS DIESER ABSATZ NICHT BEHAUPTEN DARF: dass damit 1.3.1 erfuellt waere.
1441 * Der Bezug zwischen Zelle und Kopfzelle ist weiterhin nur optisch gesetzt.
1442 * Die Aenderung macht die Namen vorlesbar, nicht die Matrix auswertbar.
1443 *
1444 * UND ER STEHT NUR DA, WO ER ZUTRIFFT - aus demselben Grund wie der Verweis
1445 * auf den Phasenabschnitt weiter unten: Wer die Matrix abwaehlt, bekommt
1446 * eine Datei ohne sie, und wer kurze Bezeichnungen benutzt, hat die Namen im
1447 * Kopf. In beiden Faellen beschriebe der Absatz etwas, das es in DIESER
1448 * Ausgabe nicht gibt. Dasselbe gilt fuer den Verweis auf den Rechenweg.
1449 *
1450 * ER SAGT AUCH, WIE WEIT DIE ZUSAGE TRAEGT (Fassung 5.8.0): "ungeteilt in
1451 * einer Zeile" galt frueher unbedingt, traf aber nur zu, solange der Name in
1452 * eine Bannerzeile passte. SignalGroup.name ist in der Laenge nicht begrenzt;
1453 * ab rund 180 Zeichen brach jede Zuordnungszeile um. Jetzt entscheidet der
1454 * beim Zeichnen ermittelte MatrixSatzbefund, welcher der beiden Saetze hier
1455 * steht.
1456 *
1457 * DASSELBE GILT FUER DEN ORT DER ZUORDNUNG: "auf jeder Seite" stimmt nur,
1458 * solange sie in den wiederholten Tabellenkopf passt. Ist sie fuer sich schon
1459 * hoeher als ein Blatt, steht sie ungekuerzt vor dem Ausschnitt - und dann
1460 * sagt der Lueckenausweis genau das.
1461 */
1462 const satzbefund = context.matrixSatzbefund;
1463 if (rechenweg.matrix && satzbefund?.mitOrdnungsnummern === true) {
1464 writeParagraph(
1465 context,
1466 'Zur Zwischenzeitenmatrix in dieser Datei: Die Namen der Signalgruppen taugen nicht als ' +
1467 'Spaltenkopf. Die Spaltenköpfe tragen deshalb die Ordnungsnummer der ' +
1468 'einfahrenden Signalgruppe, und ' +
1469 (satzbefund.zuordnungAufJedemBlatt
1470 ? 'über der Tabelle steht auf jeder Seite, welche Nummer zu welchem Namen gehört - '
1471 : 'vor jedem Ausschnitt steht, welche Nummer zu welchem Namen gehört; auf jeder Seite ' +
1472 'wiederholt wird die Zuordnung nicht, weil sie länger ist als ein Blatt - ') +
1473 (satzbefund.namenUngeteilt
1474 ? 'dort steht jeder Name ungeteilt in einer Zeile und wird am Stück vorgelesen. '
1475 : 'dort steht zu jeder Nummer ihr voller Name. Namen, die für eine Zeile zu lang sind, ' +
1476 'laufen über mehrere Zeilen weiter; jede Folgezeile wiederholt die Nummer, damit sie ' +
1477 'zuzuordnen bleibt. ') +
1478 'Das macht die Namen hörbar; den fehlenden programmatischen Bezug zwischen ' +
1479 'einer Zahl und ihren beiden Köpfen ersetzt es nicht. ' +
1480 (zwischenzeitRechenweg(rechenweg)
1481 ? 'Wer die Zwischenzeiten Beziehung für Beziehung hören will, findet sie im Abschnitt ' +
1482 '„Zwischenzeiten – Rechenweg", der jede Beziehung als „Name -> Name" mit ihrem ' +
1483 'Ergebnis führt.'
1484 : 'Der Abschnitt, der dieselben Zahlen Beziehung für Beziehung als „Name -> Name" ' +
1485 'führt, ist für diese Ausgabe abgewählt; ohne ihn gibt es keine Fassung dieser ' +
1486 'Zahlen, die eine Sprachausgabe Paar für Paar wiedergeben kann.'),
1487 );
1488 }
1489
1490 /*
1491 * WAS "VOLLSTAENDIG" HIER GENAU HEISST.
1492 *
1493 * Hier stand: "Der Inhalt der Zeichnung steht vollstaendig in Textform in der
1494 * Tabelle „Signalzeiten je Signalgruppe"". Das ist zu weit gefasst. Die
1495 * Zeichnung traegt ueber der Zeitachse ein PHASENBAND mit Phasennamen,
1496 * Phasenbeginn und Uebergangszeiten (drawPhaseBand in
1497 * src/render/signalPlanDrawing.ts); diese Angaben stehen in der
1498 * Signalzeitentabelle nicht, sondern im Abschnitt "Phasen und
1499 * Phasenuebergaenge". Die gleichwertige Textfassung der GANZEN Zeichnung sind
1500 * also zwei Tabellen, nicht eine.
1501 *
1502 * Der Abschnitt ist ein Lueckenausweis - er darf als einziger Teil der Datei
1503 * keine Angabe machen, die einer Nachpruefung nicht standhaelt. Deshalb wird
1504 * die Zusage aufgeteilt und nicht abgeschwaecht.
1505 *
1506 * Und deshalb haengt sie am Schalter: Wer die Phasen abwaehlt, bekommt eine
1507 * Datei ohne diesen Abschnitt - ein Verweis darauf waere dann falsch. Dann
1508 * wird die Luecke benannt, statt sie zu verschweigen.
1509 *
1510 * UND EBENSO AM SCHALTER DES SIGNALZEITENPLANS (Fassung 5.9.0): Zeichnung und
1511 * Signalzeitentabelle entstehen beide nur unter `options.includePlan`, und
1512 * der Absatz unter der Zeichnung mit ihnen. Der Satz stand trotzdem unbedingt
1513 * da und beschrieb bei abgewaehltem Abschnitt drei Dinge, die es in dieser
1514 * Ausgabe nicht gibt - waehrend die Uebersicht der Bestandteile wenige Seiten
1515 * davor die Abwahl korrekt ausweist.
1516 */
1517 if (mitPlanabschnitt) {
1518 writeParagraph(
1519 context,
1520 'Die Signalbilder der Zeichnung stehen vollständig in Textform in der Tabelle „Signalzeiten ' +
1521 'je Signalgruppe"; sie führt Freigabefenster, Freigabezeit, Rot-Gelb, Gelb und Rot je ' +
1522 'Signalgruppe auf und enthält damit auch die Zeiten, die in der Zeichnung wegen zu ' +
1523 'schmaler Abschnitte unbeschriftet bleiben. Das Phasenband über der Zeitachse - ' +
1524 'Phasennamen, Phasenbeginn und Übergangszeiten - steht ' +
1525 (mitPhasenabschnitt
1526 ? 'in den Tabellen des Abschnitts „Phasen und Phasenübergänge". Diese beiden Tabellen ' +
1527 'zusammen sind die gleichwertige Textfassung der Zeichnung'
1528 : 'nur in der Zeichnung: Der Abschnitt „Phasen und Phasenübergänge" wurde für diese ' +
1529 'Ausgabe abgewählt. Ohne ihn ist die Signalzeitentabelle die Textfassung der ' +
1530 'Signalbilder, nicht der ganzen Zeichnung') +
1531 ' - ein Alternativtext im Sinne von 1.1.1 ist sie mangels Auszeichnung nicht. Ein Absatz ' +
1532 'unter der Zeichnung verweist auf beides.',
1533 );
1534 } else {
1535 writeParagraph(
1536 context,
1537 'Zur Zeichnung des Signalzeitenplans: Der Abschnitt „Signalzeitenplan" wurde für diese ' +
1538 'Ausgabe abgewählt. Diese Datei enthält deshalb weder die Zeichnung noch die Tabelle ' +
1539 '„Signalzeiten je Signalgruppe" - über eine Textfassung der Signalbilder ist hier ' +
1540 'nichts auszusagen, weil es die Signalbilder in dieser Ausgabe nicht gibt.',
1541 );
1542 }
1543
1544 writeParagraph(
1545 context,
1546 'Was eine öffentliche Stelle tun muss, die diese Unterlage veröffentlicht: Wird sie im ' +
1547 'Internet bereitgestellt - etwa als Anlage einer öffentlichen Bekanntmachung nach § 27a ' +
1548 'VwVfG -, ist sie ein Dokument dieser Stelle und muss nach EN 301 549 Kapitel 10 ' +
1549 'barrierefrei sein. Diese Datei ist es nicht. Sie ist vor einer Veröffentlichung ' +
1550 'nachträglich auszuzeichnen (Strukturbaum mit Überschriften, Absätzen und Tabellenkopfzellen, ' +
1551 'Alternativtext der Zeichnung, Kopf- und Fußzeile als Seitenrahmen, eingebettete Schriften) ' +
1552 'und das Ergebnis mit einem Prüfwerkzeug wie PAC oder veraPDF nachzuprüfen. Ist das nicht ' +
1553 'leistbar, ist eine zugängliche Fassung des Inhalts bereitzustellen und die Lücke in der ' +
1554 'Erklärung zur Barrierefreiheit der Stelle zu benennen.' +
1555 // Auch dieser Schlusssatz nannte die Signalzeitentabelle unbedingt
1556 // (Fassung 5.9.0).
1557 (mitPlanabschnitt
1558 ? ' Für die Prüfung durch die Behörde genügt die Signalzeitentabelle als Textfassung; ' +
1559 'für eine Veröffentlichung genügt sie nicht.'
1560 : ''),
1561 );
1562 }
1563
1564 // --- Planungsgrundlage ------------------------------------------------------
1565
1566 /**
1567 * Nachweis der Vermessungsgrundlage.
1568 *
1569 * Der Ausdruck enthielt bisher weder Lageplan noch Luftbild und auch keine
1570 * Angabe, worauf die Raeum- und Einfahrwege beruhen. Ein Pruefer konnte die
1571 * Zwischenzeiten deshalb nicht nachvollziehen: Dieselbe Zahl kann aus einem
1572 * massstaeblichen Luftbild stammen oder von Hand geschaetzt sein. Alle Werte
1573 * kommen aus dem Modell; gerechnet wird in src/domain/geometrie/lageplan.ts.
1574 *
1575 * Der Abschnitt weist die UNTERLAGE nach, nicht die Herkunft der einzelnen
1576 * Masse. Seine Saetze sagen, was sich auf dieser Unterlage abgreifen laesst -
1577 * nicht, woher eine bestimmte Zahl stammt. Das ist die Trennung, auf die es
1578 * ankommt: Eine Unterlage kann massstaeblich sein, ohne dass ein einziger Weg
1579 * von ihr abgegriffen wurde.
1580 *
1581 * Woher die einzelnen Masse stammen, fuehrt das Modell seit Schemastand 9 mit
1582 * (`Conflict.wegnachweis`); ausgewiesen wird es in der Spalte "Herkunft der
1583 * Wege" der Rechenwegtabelle. Bis dahin gab es dafuer nur das freie
1584 * Bemerkungsfeld, und der Kopf dieser Funktion sagte entsprechend, das Modell
1585 * fuehre es "nirgends mit" - waehrend dieselbe Funktion weiter unten bereits
1586 * die Zeile "Nachweis der Wege" druckte.
1587 *
1588 * Zur Unterlage gehoert ihre Aufloesung. Ein Luftbild sieht grob wie fein
1589 * gleich aus, doch der Zeichenfehler geht ueber den Raeumweg unmittelbar in die
1590 * Zwischenzeit ein - an einer Fussgaengerfurt fast zehnmal so stark wie bei
1591 * einem Kraftfahrzeugstrom. Ohne die Zeile "Eignung als Messgrundlage" koennte
1592 * ein Pruefer aus diesen Unterlagen nicht erkennen, ob die Zwischenzeiten auf
1593 * einer tauglichen Unterlage beruhen.
1594 */
1595 /**
1596 * Die Zeile "Tagesprogramme" der Bestandteileuebersicht.
1597 *
1598 * Sie sagt bei einem Programm, dass es das einzige ist, und bei mehreren, dass
1599 * diese Unterlage EINES von ihnen zeigt. Der Unterschied ist keine Feinheit:
1600 * Eine Anordnung nach § 45 StVO gilt der Anlage, und wer vier Programme
1601 * schaltet, muss vier Signalzeitenplaene beibringen.
1602 */
1603 function programmzeile(project: Project): string {
1604 const anzahl = programmanzahl(project);
1605 if (anzahl === 1) return 'ja – dieses Projekt führt genau ein Tagesprogramm';
1606 return (
1607 `${String(anzahl)} Programme – diese Unterlage zeigt „${project.program.name}"; die übrigen ` +
1608 'sind gesondert auszugeben (Abschnitt „Tagesprogramme dieser Anlage")'
1609 );
1610 }
1611
1612 /**
1613 * Alle Tagesprogramme mit Umlaufzeit und Pruefergebnis.
1614 *
1615 * WAS DIESE TAFEL LEISTET UND WAS NICHT: Sie weist nach, dass JEDES Programm
1616 * geprueft wurde - das ist die sicherheitsrelevante Zusage der
1617 * Programmbibliothek. Sie ersetzt NICHT den Signalzeitenplan der uebrigen
1618 * Programme; der steht in dieser Datei nicht, und der Text darunter sagt das.
1619 */
1620 function drawProgrammuebersicht(context: Context, now: Date): void {
1621 const { doc, project } = context;
1622 const pruefungen = programmPruefungen(project, now);
1623
1624 autoTable(doc, {
1625 startY: context.cursor,
1626 margin: TABELLENRAND,
1627 theme: 'grid',
1628 styles: { fontSize: 9, cellPadding: 3 },
1629 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
1630 head: [
1631 [
1632 'Tagesprogramm',
1633 'Verfahren',
1634 'Umlaufzeit',
1635 'Fehler',
1636 'Warnungen',
1637 'in dieser Unterlage',
1638 ].map(toWinAnsi),
1639 ],
1640 body: pruefungen.map((eintrag) =>
1641 [
1642 eintrag.name,
1643 eintrag.verfahren === 'manuell' ? 'feste Vorgabe' : CYCLE_METHOD_LABELS[eintrag.verfahren],
1644 fmt.seconds(eintrag.umlaufzeit),
1645 String(eintrag.errorCount),
1646 String(eintrag.warningCount),
1647 eintrag.aktiv ? 'ja – vollständig' : 'nein – nur diese Zeile',
1648 ].map(toWinAnsi),
1649 ),
1650 });
1651
1652 context.cursor = lastTableBottom(doc) + 12;
1653
1654 writeParagraph(
1655 context,
1656 `Diese Anlage führt ${String(pruefungen.length)} Tagesprogramme. Signalgruppen, ` +
1657 'Konfliktbeziehungen, Zwischenzeiten und die Besetzung der Phasen sind ihnen gemeinsam und ' +
1658 'stehen in dieser Unterlage vollständig; eigen sind jedem Programm Umlaufzeit, Verfahren, ' +
1659 'Versatz, Verkehrsstärken und die von Hand vorgegebenen Freigabezeiten.',
1660 );
1661 writeParagraph(
1662 context,
1663 `AUSGEDRUCKT IST DER SIGNALZEITENPLAN DES PROGRAMMS „${project.program.name}". Für die ` +
1664 'übrigen Programme steht in dieser Datei nur die Zeile der Tafel oben – keine Signalzeiten, ' +
1665 'keine Grafik, kein Prüfbericht im Einzelnen. Wer die Anlage vollständig anordnen lassen ' +
1666 'will, gibt jedes Programm gesondert aus: in der Ansicht „Phasen" umschalten und erneut ' +
1667 'ausgeben.',
1668 );
1669 writeParagraph(
1670 context,
1671 'Geprüft wurden alle Programme, und zwar mit denselben Regeln: Die Spalten „Fehler" und ' +
1672 '„Warnungen" stammen aus je einem eigenen Prüflauf. Solange irgendein Programm einen Fehler ' +
1673 'führt, ist die Ausgabe gesperrt – auch dann, wenn das angezeigte Programm fehlerfrei ist.',
1674 );
1675 }
1676
1677 /**
1678 * Die Gruene Welle im Strassenzug.
1679 *
1680 * WAS DER ABSCHNITT LEISTET: Er weist die Abstimmung mit den Nachbaranlagen
1681 * nach - Abstaende, Reisezeiten, empfohlene Versaetze, Bandbreite in beiden
1682 * Fahrtrichtungen, dazu das Zeit-Weg-Diagramm.
1683 *
1684 * WAS ER AUSDRUECKLICH NICHT LEISTET: Er sagt nichts ueber die Nachbaranlagen
1685 * selbst. Deren Signalzeitenplaene, Zwischenzeiten und Pruefberichte stehen in
1686 * IHREN Unterlagen; hier stehen nur ihre Freigabefenster, wie sie der
1687 * Bearbeiter dieses Projekts eingetragen hat. Der Absatz darunter sagt das.
1688 */
1689 function drawKoordinierung(context: Context): void {
1690 const { doc, project, plan } = context;
1691 const lage = koordinierungslage(project, plan);
1692
1693 if (lage.hindernis !== null) {
1694 writeParagraph(context, HINDERNIS_TEXT[lage.hindernis]);
1695 return;
1696 }
1697 const ergebnis = lage.ergebnis;
1698 if (ergebnis === null) return;
1699
1700 autoTable(doc, {
1701 startY: context.cursor,
1702 margin: TABELLENRAND,
1703 theme: 'grid',
1704 styles: { fontSize: 9, cellPadding: 3 },
1705 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
1706 head: [
1707 [
1708 'Anlage',
1709 'Entfernung',
1710 'Reisezeit',
1711 'Empfohlener Versatz',
1712 'Freigabe Hauptrichtung (von–bis) [s]',
1713 ].map(toWinAnsi),
1714 ],
1715 body: ergebnis.intersections.map((eintrag, index) => {
1716 const glied = lage.kette[index];
1717 return [
1718 `${eintrag.name}${glied?.eigener === true ? ' (dieser Knotenpunkt)' : ''}`,
1719 `${fmt.numShort(eintrag.cumulativeDistance, 0)} m`,
1720 fmt.seconds(eintrag.travelTime),
1721 fmt.seconds(eintrag.offset),
1722 /*
1723 * IN DEN UMLAUF GEFALTET UND IN DERSELBEN SCHREIBWEISE WIE DIE
1724 * SIGNALZEITENTABELLE, ueber `freigabefensterZelle` (Fassung 5.43.0):
1725 * Hier stand "Beginn bis Beginn + Dauer" - "91 s bis 139 s" bei 95 s
1726 * Umlaufzeit, waehrend die Signalzeitentabelle derselben Unterlage
1727 * "91–44" fuehrt. Und "Freigabe ab" einer Nachbaranlage darf die
1728 * Umlaufzeit uebersteigen; die Koordinierung rechnet mit dem gefalteten
1729 * Beginn, das Diagramm zeichnet ihn, und die Tabelle nannte den
1730 * eingetragenen.
1731 *
1732 * GENANNT IST EIN FENSTER: das, mit dem die Koordinierung rechnet. Ist
1733 * die Hauptrichtung dieses Knotenpunkts in mehreren Fenstern frei,
1734 * fuehrt die Signalzeitentabelle alle und diese Zelle nur dieses eine;
1735 * gleich ist dann die Schreibweise, nicht die Zeichenkette.
1736 */
1737 glied === undefined
1738 ? '-'
1739 : freigabefensterZelle(
1740 [
1741 {
1742 start: modulo(glied.freigabe.start, ergebnis.cycleTime),
1743 duration: glied.freigabe.duration,
1744 },
1745 ],
1746 ergebnis.cycleTime,
1747 ),
1748 ].map(toWinAnsi);
1749 }),
1750 });
1751 context.cursor = lastTableBottom(doc) + 12;
1752 writeParagraph(
1753 context,
1754 'Freigabe Hauptrichtung: das Fenster in Sekunden ab Umlaufbeginn, in den Umlauf gefaltet – ' +
1755 'so, wie die Koordinierung rechnet und das Diagramm zeichnet. Reicht es über den ' +
1756 'Umlaufbeginn hinaus, liegt sein Ende vor seinem Beginn.',
1757 );
1758
1759 const hoehe = Math.max(150, Math.min(300, 70 + lage.kette.length * 34));
1760 ensureSpace(context, hoehe + 60);
1761 doc.setFont('helvetica', 'bold');
1762 doc.setFontSize(10);
1763 doc.setTextColor('#1b2230');
1764 doc.text(toWinAnsi('Zeit-Weg-Diagramm'), PAGE_MARGIN, context.cursor);
1765 context.cursor += 14;
1766 const surface = new PdfSurface(doc, PAGE_MARGIN, context.cursor, context.contentWidth, hoehe);
1767 drawZeitWeg(surface, lage, { mitGegenrichtung: true });
1768 context.cursor += hoehe + 10;
1769
1770 writeParagraph(
1771 context,
1772 `Waagerecht ${String(UMLAEUFE_IM_BILD)} Umläufe, senkrecht der Weg entlang des Straßenzugs. ` +
1773 'Die grünen Balken sind die Freigabezeiten der Hauptrichtung, die schräge Fläche ist das ' +
1774 'Band: die Startzeiten, mit denen ein Fahrzeug ohne Halt durchkommt. Die blaue Fläche ist ' +
1775 'die Gegenrichtung, wo sie erfasst ist.',
1776 );
1777
1778 writeParagraph(
1779 context,
1780 `Bandbreite in Hauptrichtung ${fmt.seconds(ergebnis.hinrichtung.breite)} ` +
1781 `(${fmt.ratio(ergebnis.hinrichtung.anteil, 2)} der Umlaufzeit, Beurteilung ` +
1782 `„${ergebnis.hinrichtung.quality}"). ` +
1783 (ergebnis.rueckrichtung === null
1784 ? 'Für die Gegenrichtung ist an mindestens einer Anlage kein Freigabefenster erfasst; ' +
1785 'ein zweites Band wird deshalb NICHT gerechnet und auch nicht angenommen. Der ' +
1786 'Zielkonflikt zwischen Hin- und Rückrichtung ist der eigentliche Gegenstand einer ' +
1787 'Koordinierung und bleibt damit unbeurteilt.'
1788 : `Bandbreite in der Gegenrichtung ${fmt.seconds(ergebnis.rueckrichtung.breite)} ` +
1789 `(${fmt.ratio(ergebnis.rueckrichtung.anteil, 2)}, Beurteilung ` +
1790 `„${ergebnis.rueckrichtung.quality}").`),
1791 );
1792
1793 writeParagraph(
1794 context,
1795 'DER VERSATZ IST EINE EMPFEHLUNG FÜR DIE NACHBARANLAGEN und verschiebt den Signalzeitenplan ' +
1796 'dieses Knotenpunkts nicht: Er gilt zwischen Anlagen, nicht innerhalb einer. Die ' +
1797 'Freigabezeiten der Nachbarn sind Eingaben dieses Projekts – ihre Signalzeitenpläne, ' +
1798 'Zwischenzeiten und Prüfberichte stehen in ihren eigenen Unterlagen und sind hier weder ' +
1799 'wiedergegeben noch geprüft. Eine Grüne Welle setzt zudem eine gemeinsame Umlaufzeit aller ' +
1800 `beteiligten Anlagen voraus; gerechnet ist mit ${fmt.seconds(ergebnis.cycleTime)} und einer ` +
1801 `Progressionsgeschwindigkeit von ${fmt.numShort(ergebnis.speed, 1)} km/h.`,
1802 );
1803
1804 for (const note of ergebnis.notes) {
1805 writeParagraph(
1806 context,
1807 `${note.severity === 'fehler' ? 'Fehler' : 'Hinweis'}: ${note.message}`,
1808 );
1809 }
1810 }
1811
1812 function drawPlanungsgrundlage(context: Context, rechenweg: RechenwegStand): void {
1813 const { doc, plan, project } = context;
1814 // Mit den Kennwerten des Projekts, nicht mit denen des Regelwerks: Der
1815 // Anwender darf die Raeumgeschwindigkeiten anpassen, und daran haengt die
1816 // Eignung. Sonst stuende im Ausdruck eine andere Einstufung als im
1817 // Pruefbericht - und keiner der beiden waere nachrechenbar.
1818 const grundlage = planungsgrundlage(project.lageplan, plan.defaults);
1819
1820 newSection(context, 'Planungsgrundlage');
1821
1822 if (!grundlage.hatBild) {
1823 writeParagraph(
1824 context,
1825 'Diesen Unterlagen liegt kein Lageplan und kein Luftbild bei. Die Räum- und Einfahrwege der ' +
1826 'Zwischenzeitenberechnung stammen daher nicht aus diesen Unterlagen; auf welcher Grundlage ' +
1827 'sie ermittelt wurden, geht aus ihnen nicht hervor und ist vom Planer gesondert nachzuweisen.',
1828 );
1829 writeParagraph(context, wegnachweisVerteilung(project));
1830 return;
1831 }
1832
1833 writeParagraph(
1834 context,
1835 'Die folgenden Angaben weisen die Unterlage aus, auf der die Fahrlinien dieses Projekts ' +
1836 'gezeichnet sind und auf der sich Räum- und Einfahrwege abgreifen lassen. Stammt die ' +
1837 'Unterlage aus einem Kartendienst, nennt die Herkunft Herausgeber, Ausschnittsmitte und ' +
1838 'Kantenlänge; damit lässt sich derselbe Ausschnitt wieder beschaffen und nachmessen. Bei ' +
1839 'einer eingelesenen Datei steht dort ihr Name.',
1840 );
1841
1842 autoTable(doc, {
1843 startY: context.cursor,
1844 margin: TABELLENRAND,
1845 theme: 'grid',
1846 styles: { fontSize: 9, cellPadding: 3, valign: 'top' },
1847 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
1848 columnStyles: { 0: { cellWidth: 175, fontStyle: 'bold' } },
1849 head: [['Angabe', 'Wert'].map(toWinAnsi)],
1850 body: [
1851 ['Herkunft der Unterlage', grundlage.herkunft],
1852 [
1853 'Geladen am',
1854 grundlage.ladedatumBekannt ? fmt.dateTime(grundlage.geladenAm) : 'nicht überliefert',
1855 ],
1856 [
1857 'Bildgröße',
1858 `${fmt.numShort(grundlage.breiteBildpunkte, 0)} × ` +
1859 `${fmt.numShort(grundlage.hoeheBildpunkte, 0)} Bildpunkte`,
1860 ],
1861 [
1862 'Kantenlänge am Boden',
1863 grundlage.kantenlaengeMeter === null
1864 ? 'nicht bestimmbar - der Maßstab fehlt'
1865 : fmt.meters(grundlage.kantenlaengeMeter, 0),
1866 ],
1867 [
1868 'Maßstab',
1869 grundlage.meterJeBildpunkt === null
1870 ? 'nicht festgelegt'
1871 : `${fmt.numShort(grundlage.meterJeBildpunkt, 3)} m je Bildpunkt`,
1872 ],
1873 ['Herkunft des Maßstabs', massstabsherkunft(grundlage)],
1874 ['Nachweis der Wege', wegnachweisVerteilung(project)],
1875 // Derselbe Befund und dieselbe Zahl wie im Pruefbericht; der Satz kommt
1876 // aus dem Regelwerksmodul, damit beide nicht auseinanderlaufen koennen.
1877 ['Eignung als Messgrundlage', messeignungSatz(grundlage.messgenauigkeit)],
1878 [
1879 'Gezeichnet',
1880 `${grundlage.anzahlHaltlinien} Haltlinie(n), ${grundlage.anzahlFahrlinien} Fahrlinie(n)`,
1881 ],
1882 ].map((row) => row.map((cell) => toWinAnsi(String(cell)))),
1883 });
1884
1885 context.cursor = lastTableBottom(doc) + 12;
1886
1887 writeParagraph(
1888 context,
1889 'Die Zeile "Eignung als Messgrundlage" beurteilt, wofür die Auflösung dieser Unterlage ' +
1890 `ausreicht. Angesetzt ist ein Zeichenfehler von ${ZEICHENFEHLER_BILDPUNKTE} Bildpunkten auf ` +
1891 'dem Räum- und noch einmal auf dem Einfahrweg. Hinnehmbar ist ein daraus folgender Fehler ' +
1892 `der Zwischenzeit von ${fmt.seconds(MESSFEHLER_SCHRANKE, 2)}: Die Zwischenzeit wird auf ganze ` +
1893 'Sekunden aufgerundet, und bis zu diesem Betrag bleibt die Sicherheitswirkung des Aufrundens ' +
1894 'erhalten. Weil die Räumgeschwindigkeit im Nenner steht, kostet derselbe Zeichenfehler an ' +
1895 'einer Fußgängerfurt ein Vielfaches dessen, was er bei einem Kraftfahrzeugstrom kostet. Die ' +
1896 'Angabe sagt, was sich auf dieser Unterlage abgreifen lässt - nicht, welche Wege tatsächlich ' +
1897 'hier abgegriffen wurden.',
1898 );
1899
1900 /*
1901 * Der zweite Halbsatz verwies unbedingt auf die Spalte "Herkunft der Wege" im
1902 * Rechenweg der Zwischenzeiten. Die Spalte entsteht aber nur in
1903 * `drawIntergreenDetails`, also nur unter
1904 * `includeCalculationDetails && includeIntergreenMatrix`. Wer eines von
1905 * beiden abwaehlt, wurde in einen Abschnitt geschickt, den diese Ausgabe
1906 * nicht enthaelt - gemessen blieb "Herkunft der Wege" dann genau einmal im
1907 * Dokument stehen, naemlich in diesem Verweis. Und das ausgerechnet im
1908 * Abschnitt, der die Vermessungsgrundlage der sicherheitsrelevanten Masse
1909 * nachweist.
1910 *
1911 * Die Zusage "führt das Projekt je Beziehung mit" bleibt: Sie gilt fuer das
1912 * Modell (`Conflict.wegnachweis`) und nicht fuer diese Ausgabe. Nur der
1913 * Zeiger auf den Ort haengt daran, ob es den Ort gibt.
1914 */
1915 const wegherkunftNachweis = zwischenzeitRechenweg(rechenweg)
1916 ? 'ausgewiesen wird es in der Spalte "Herkunft der Wege" im Rechenweg der Zwischenzeiten.'
1917 : 'der Rechenweg der Zwischenzeiten, der es in der Spalte "Herkunft der Wege" Beziehung für ' +
1918 'Beziehung ausweist, ist für diese Ausgabe abgewählt.';
1919
1920 writeParagraph(
1921 context,
1922 grundlage.auswertbar
1923 ? 'Der Lageplan ist maßstäblich auswertbar; Räum- und Einfahrwege lassen sich daraus ' +
1924 'abgreifen. Ob die Wege einer einzelnen Konfliktbeziehung tatsächlich von hier stammen, ' +
1925 `führt das Projekt je Beziehung mit; ${wegherkunftNachweis}`
1926 : 'Der Lageplan ist nicht auswertbar - es fehlt der Maßstab, oder es sind keine Fahrlinien ' +
1927 'gezeichnet. In diesem Zustand lassen sich Räum- und Einfahrwege daraus nicht abgreifen; ' +
1928 'als Nachweis der Vermaßung taugt die Unterlage daher nicht.',
1929 );
1930
1931 drawLageplanblattAbschnitt(context);
1932 }
1933
1934 /**
1935 * Das massstaebliche Lageplanblatt.
1936 *
1937 * WOZU: Die Zwischenzeiten dieser Anlage beruhen auf gezeichneten Fahrlinien,
1938 * und von diesen Linien stand bis hierher keine einzige in der Unterlage. Ein
1939 * Pruefer konnte einen Raeumweg von 34,2 m nicht nachvollziehen, sondern nur
1940 * glauben. Der Abschnitt "Planungsgrundlage" wies die Unterlage nach, auf der
1941 * gezeichnet wurde - nicht die Zeichnung selbst.
1942 *
1943 * WAS ES NICHT IST: ein Signallageplan. Keine Signalgeberstandorte, keine
1944 * Bemassung. Die Bestandteileuebersicht fuehrt den bemassten Signallageplan
1945 * unveraendert als "NICHT Bestandteil dieser Unterlage"; dieses Blatt tritt
1946 * nicht an seine Stelle, und der Absatz darunter sagt es.
1947 */
1948 function drawLageplanblattAbschnitt(context: Context): void {
1949 const { project } = context;
1950 const bild = project.lageplan.bild;
1951 const blatt = planblatt(project.lageplan, context.contentWidth, LAGEPLANBLATT_HOEHE);
1952 if (bild === null || blatt === null) return;
1953
1954 /*
1955 * NORDPFEIL NUR BEI EINEM GEOREFERENZIERTEN AUSSCHNITT. Er wird in
1956 * ETRS89/UTM angefordert, dort liegt Norden oben, und die Abbildung auf das
1957 * Blatt dreht nicht. Bei einem eingelesenen Bild - Katasterauszug, Foto
1958 * einer Zeichnung, gescannter Bestandsplan - ist die Ausrichtung unbekannt.
1959 * Ein geratener Nordpfeil sieht aus wie eine Messung.
1960 *
1961 * DIE FUSSZEILE STEHT AUF BEIDEN BLAETTERN. Bei einem Kartendienst nennt die
1962 * Herkunft Herausgeber, Ausschnittsmitte und Kantenlaenge; damit laesst sich
1963 * derselbe Ausschnitt wieder beschaffen. Bei einem eingelesenen Bild ist sie
1964 * ein Dateiname, und der verortet nichts - das sagt die Zeile dann auch,
1965 * statt es dem Leser zu ueberlassen.
1966 *
1967 * OHNE BILD GIBT ES DIESES BLATT NICHT, und das ist keine neue Entscheidung:
1968 * `drawPlanungsgrundlage` bricht den ganzen Abschnitt schon vorher ab. Die
1969 * Bedingung steht trotzdem hier - so haengt sie sichtbar an der Stelle, die
1970 * sie braucht, statt zwei Ebenen hoeher stillschweigend zu gelten.
1971 */
1972 const nordpfeil = project.lageplan.kalibrierung?.herkunft === 'georeferenziert';
1973 const fusszeile = nordpfeil
1974 ? `Grundlage: ${bild.herkunft}`
1975 : `Grundlage: ${bild.herkunft} – eingelesenes Bild, ohne Verortung und ohne Nordrichtung.`;
1976
1977 /*
1978 * DIE MASSKETTEN KOMMEN AUS DEM FACHKERN und stehen auf beiden Blaettern.
1979 *
1980 * Bemasst wird nur, was das Programm ohnehin fuehrt: die Laenge jeder
1981 * Haltlinie und Laenge wie Breite jedes Arbeitsbereichs. Was es NICHT
1982 * bemasst - den Abstand einer Haltlinie zum Arbeitsbereich und den Abstand
1983 * zweier Haltlinien -, begruendet `masskette.ts` an Ort und Stelle; kurz:
1984 * das eine benennt ein Bandende, das die Auskunft bewusst offenlaesst, das
1985 * andere waere eine zweite Zahl fuer die Engstellenlaenge.
1986 *
1987 * KEIN SCHALTER. Die Ketten treffen keine neue Aussage, sie schreiben eine
1988 * vorhandene an die Stelle, an der sie gilt. Ein abwaehlbarer Nachweis waere
1989 * dieselbe Bauart Fehler wie eine abwaehlbare Bestandteileuebersicht.
1990 */
1991 const ketten = massketten(project.lageplan, blatt);
1992
1993 zeichneBlatt(context, blatt, 'Lageplan der Fahrlinien', {
1994 mitNamen: true,
1995 nordpfeil,
1996 fusszeile,
1997 massketten: ketten,
1998 });
1999
2000 writeParagraph(
2001 context,
2002 'Die Zeichnung gibt die Fahr- und Haltlinien wieder, aus denen Räum- und Einfahrwege dieses ' +
2003 'Projekts abgegriffen sind – ohne das Luftbild darunter, damit kein Bildinhalt eine Linie ' +
2004 'verdeckt. ' +
2005 (blatt.massstabsnenner === null
2006 ? ''
2007 : `Der Blattmaßstab beträgt rund 1:${String(blatt.massstabsnenner)}. `) +
2008 'MASSGEBEND IST DER MASSSTABSBALKEN unten links, nicht das Verhältnis: Wird dieses Blatt ' +
2009 'verkleinert kopiert oder auf ein anderes Format gedruckt, stimmt das Verhältnis nicht ' +
2010 'mehr, der Balken schrumpft mit und bleibt richtig. Wer aus einem falsch skalierten Blatt ' +
2011 'einen Räumweg abgreift, bekommt eine zu kurze Zwischenzeit. ' +
2012 'Die Maßketten schreiben an, was das Projekt vermessen führt: die Länge jeder Haltlinie ' +
2013 'sowie Länge und Breite jedes Arbeitsbereichs. Sie sind keine zweite Rechnung – die Zahlen ' +
2014 'stammen aus derselben Vermessung wie jede andere Länge dieser Unterlage. Nicht bemaßt ' +
2015 'sind der Abstand einer Haltlinie zur Arbeitsstelle und der Abstand zweier Haltlinien: Die ' +
2016 'erste Angabe nennt bewusst nicht, zu welchem Ende sie misst, die zweite wäre eine zweite ' +
2017 'Zahl für die Engstellenlänge, und die ist hier der größte erfasste Räumweg. ' +
2018 'Das Blatt ist KEIN Signallageplan: Es führt keine Signalgeberstandorte und keine ' +
2019 'Bemaßung der Aufstellung; beides ist gesondert beizubringen. Und es sagt nicht, ob die Wege einer ' +
2020 'einzelnen Konfliktbeziehung wirklich von hier stammen – das führt das Projekt je ' +
2021 'Beziehung mit.',
2022 );
2023
2024 if (context.blattbild === null) return;
2025
2026 zeichneBlatt(
2027 context,
2028 blatt,
2029 'Derselbe Lageplan über dem Luftbild',
2030 { mitNamen: true, nordpfeil, fusszeile, aufBild: true, massketten: ketten },
2031 context.blattbild,
2032 );
2033
2034 writeParagraph(
2035 context,
2036 'Dasselbe Blatt, im selben Maßstab und mit denselben Linien, über der Bildgrundlage. Es ' +
2037 'dient der VERORTUNG: Das Blatt darüber zeigt Linien ohne Umgebung und lässt sich für ' +
2038 'sich allein keinem Ort zuordnen. ' +
2039 'Die Aufnahme ist auf den gezeigten Ausschnitt zugeschnitten und auf die Auflösung dieses ' +
2040 'Blattes verkleinert; ihre Herkunft steht unten rechts und ausführlich im Abschnitt ' +
2041 '„Planungsgrundlage“. ' +
2042 'GEMESSEN WIRD NICHT AM BILD: Maßgebend bleiben der Maßstabsbalken und die Linien. Die ' +
2043 'verkleinerte Aufnahme trägt die Lage, nicht die Länge. ' +
2044 'Wo die Zeichnung über den Rand der Aufnahme hinausreicht, bleibt der Grund weiß – dort ' +
2045 'liegt keine Bildgrundlage vor.',
2046 );
2047
2048 zeichneSignalgeberblatt(context, blatt, nordpfeil, fusszeile, ketten);
2049 }
2050
2051 /**
2052 * Das dritte Blatt: Lageplan mit den Signalgeberstandorten.
2053 *
2054 * ES ENTSTEHT NUR, WENN WELCHE ERFASST SIND. Ein leeres drittes Blatt waere
2055 * die Behauptung, es gebe hier nichts aufzustellen - und das ist etwas anderes
2056 * als "nicht erfasst".
2057 *
2058 * WARUM EIN EIGENES BLATT UND KEINE ZUSAETZLICHE EBENE AUF DEM ERSTEN: Ein
2059 * Signalgeber steht neben der Haltlinie, also genau dort, wo Raeum- und
2060 * Einfahrweg beginnen. Auf dem Nachweisblatt verdeckte er die Stelle, um die
2061 * es dort geht. Zwei Aussagen, zwei Blaetter - dieselbe Ueberlegung wie beim
2062 * Luftbildblatt.
2063 *
2064 * UND ES IST WEITERHIN KEIN SIGNALLAGEPLAN. Es zeigt Standort, Blickrichtung
2065 * und Kennung; Bauform, Hoehe, Blendenmass und die Sichtpruefung kennt dieses
2066 * Programm nicht, und die Uebersicht der Bestandteile sagt das mit denselben
2067 * Worten.
2068 */
2069 function zeichneSignalgeberblatt(
2070 context: Context,
2071 blatt: Planblatt,
2072 nordpfeil: boolean,
2073 fusszeile: string,
2074 ketten: readonly Masskette[],
2075 ): void {
2076 if (blatt.signalgeber.length === 0) return;
2077
2078 zeichneBlatt(context, blatt, 'Lageplan mit Signalgeberstandorten', {
2079 mitNamen: false,
2080 nordpfeil,
2081 fusszeile,
2082 massketten: ketten,
2083 mitSignalgebern: true,
2084 });
2085
2086 writeParagraph(
2087 context,
2088 `Dasselbe Blatt mit den ${String(blatt.signalgeber.length)} erfassten Signalgebern: Kreis ` +
2089 'mit Kennung am Standort, Pfeil in Blickrichtung. Die Fahrlinien sind ohne Beschriftung ' +
2090 'gezeichnet, damit die Kennungen der Signalgeber eindeutig bleiben. ' +
2091 'DAS SINNBILD IST NICHT MASSSTÄBLICH: Ein Ampelkopf ist rund einen Viertelmeter breit und ' +
2092 'wäre auf diesem Blatt kaum sichtbar; Kreis und Pfeil haben deshalb eine feste Größe. ' +
2093 'Maßstäblich und abgreifbar ist der Mittelpunkt des Kreises. ' +
2094 'EIN BEMASSTER SIGNALLAGEPLAN IST DIESES BLATT NICHT: Es führt Standort, Blickrichtung und ' +
2095 'Kennung, aber keine Bauform, keine Höhe, kein Blendenmaß, keine Sichtprüfung und keinen ' +
2096 'Nachweis der verbleibenden Fahrbahnbreite. Die Übersicht der Bestandteile sagt, was damit ' +
2097 'enthalten ist und was weiterhin gesondert beizubringen bleibt. ' +
2098 'Die Standorte gehen in keine Rechnung ein – Zwischenzeit, Freigabezeit und Umlaufzeit ' +
2099 'kennen sie nicht.',
2100 );
2101 }
2102
2103 /**
2104 * Ein Lageplanblatt mit Ueberschrift, wahlweise ueber einer Bildgrundlage.
2105 *
2106 * DAS BILD GEHT UNMITTELBAR AN jsPDF UND NICHT UEBER `Surface`. Die
2107 * Zeichenflaeche beschreibt Strich, Flaeche und Schrift; eine Rastergrafik ist
2108 * keines davon, und der Bildschirm zeichnet sein Luftbild ohnehin selbst
2109 * (`ui/lageplan/leinwand.ts`). Eine sechste Grundoperation, die nur eine der
2110 * beiden Flaechen ausfuehren koennte, waere eine Zusage, die eine von beiden
2111 * nicht haelt.
2112 */
2113 function zeichneBlatt(
2114 context: Context,
2115 blatt: Planblatt,
2116 ueberschrift: string,
2117 optionen: LageplanZeichnungOptionen,
2118 bild: Blattbild | null = null,
2119 ): void {
2120 const { doc } = context;
2121 ensureSpace(context, blatt.hoehe + 60);
2122 doc.setFont('helvetica', 'bold');
2123 doc.setFontSize(10);
2124 doc.setTextColor('#1b2230');
2125 doc.text(toWinAnsi(ueberschrift), PAGE_MARGIN, context.cursor);
2126 context.cursor += 14;
2127
2128 if (bild !== null) {
2129 doc.addImage(
2130 bild.datenUrl,
2131 'JPEG',
2132 PAGE_MARGIN + bild.zielX,
2133 context.cursor + bild.zielY,
2134 bild.zielBreite,
2135 bild.zielHoehe,
2136 );
2137 }
2138
2139 const surface = new PdfSurface(
2140 doc,
2141 PAGE_MARGIN,
2142 context.cursor,
2143 context.contentWidth,
2144 blatt.hoehe,
2145 );
2146 drawLageplanblatt(surface, blatt, optionen);
2147 context.cursor += blatt.hoehe + 10;
2148 }
2149
2150 /** Wie der Massstab zustande kam - in einem Satz fuer den Ausdruck. */
2151 function massstabsherkunft(grundlage: Planungsgrundlage): string {
2152 switch (grundlage.massstabHerkunft) {
2153 case 'georeferenziert':
2154 return 'aus dem Kartendienst übernommen (georeferenzierter Ausschnitt, nicht nachgemessen)';
2155 case 'gemessen':
2156 return (
2157 'an einer Strecke bekannter Länge abgegriffen: ' +
2158 `${fmt.meters(grundlage.bezugslaengeMeter ?? 0, 1)} auf ` +
2159 `${fmt.numShort(grundlage.bezugslaengeBildpunkte ?? 0, 0)} Bildpunkten`
2160 );
2161 case null:
2162 return 'nicht festgelegt';
2163 }
2164 }
2165
2166 // --- Tabellen ---------------------------------------------------------------
2167
2168 function drawSignalGroupTable(context: Context): void {
2169 const { doc, project, plan } = context;
2170
2171 autoTable(doc, {
2172 startY: context.cursor,
2173 margin: TABELLENRAND,
2174 theme: 'grid',
2175 styles: { fontSize: 8.5, cellPadding: 3 },
2176 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
2177 head: [
2178 [
2179 /*
2180 * DIE ORDNUNGSNUMMER: Traegt die Zwischenzeitenmatrix Nummern in den
2181 * Spaltenkoepfen - was sie bei langen Bezeichnungen tut -, verweist
2182 * der Absatz darueber auf "dieselbe Reihenfolge wie in der Tabelle
2183 * Signalgruppen". Ohne diese Spalte war die Aussage zwar zutreffend,
2184 * aber nur durch Abzaehlen von Zeilen nachpruefbar; mit ihr steht die
2185 * Nummer da, auf die verwiesen wird.
2186 *
2187 * Sie ist keine Eigenschaft der Signalgruppe, sondern ihre Stellung
2188 * in DIESER Unterlage - deshalb wird sie hier gezaehlt und nicht am
2189 * Modell gefuehrt.
2190 */
2191 'Nr.',
2192 'SG',
2193 'Verkehrsart',
2194 'Fahrbeziehung',
2195 'V zul',
2196 'Fahrstr.',
2197 'Fahrzeugart',
2198 // Merkmale der Fussgaengerfurt, die in die Mindestfreigabezeit eingehen
2199 // (Befund C1): Zusatzeinrichtung fuer Blinde und Sehbehinderte (ganze
2200 // statt halbe Furt) und erhoehter Zeitbedarf (1,0 statt 1,2 m/s). Ohne
2201 // die Spalte stuende "6 s" links in "Min./Vorgabe" neben einem
2202 // Regelwert von 5 s, und niemand saehe, woher die Sekunde kommt.
2203 'Furt',
2204 'Rot-Gelb',
2205 'Gelb',
2206 // MINDESTFREIGABEZEIT UND VORGABE IN EINER SPALTE: Seit der
2207 // Fassung 5.10.0 setzt die Freigabezeitverteilung fuer eine Phase die
2208 // kleinste Hoechstfreigabezeit ihrer Signalgruppen an statt des
2209 // Regelwerts. Stand sie nirgends, liess sich die gedruckte Phasendauer
2210 // aus der Unterlage nicht mehr herleiten.
2211 //
2212 // RECHTS STEHT DIE VORGABE, NICHT DIE GELTENDE SCHRANKE: Was gilt,
2213 // entscheidet `phaseMaxGreen` je PHASE; ohne Eintrag steht "–". Der
2214 // Absatz unter der Tabelle sagt es, und der Spaltenkopf sagt es so
2215 // weit, wie seine Breite reicht - siehe `hoechstfreigabezeitVorgabe` in
2216 // csv.ts.
2217 //
2218 // WARUM EINE SPALTE UND WARUM SO KURZ BENANNT: Diese Tabelle ist quer
2219 // voll. Die Breite, die ihre dreizehn Spalten brauchen, damit keine
2220 // Zelle umbricht, ist bis auf ein Zehntel dieselbe, die der
2221 // Satzspiegel hergibt (nachgemessen mit doc.getTextWidth: 770,9 pt
2222 // gegen 769,9 pt). Eine vierzehnte Spalte oder ein laengerer Kopf
2223 // nimmt den Nachbarn die Breite: Signalgruppennamen und Fahrzeugarten
2224 // brechen um, die Zeilen werden hoeher, die Tabelle bricht frueher auf
2225 // das naechste Blatt um - dann steht in der Spalte "Nr." NaN statt der
2226 // Nummer (nachgestellt mit einer eigenen Spalte "Max. Freigabe":
2227 // tests/export/pdfSignalgruppenNummer.test.ts faellt mit 3 Faellen,
2228 // waehrend pdfBeschriftungen.test.ts und pdfSatzspiegel.test.ts gruen
2229 // bleiben). Gemessen mit Zellenabstand: "Min./Max." 44,2 pt,
2230 // "Min./Vorgabe" 58,4 pt - das traegt; "Min./max. Vorg." 67,3 pt
2231 // traegt nicht mehr. Die Zelle darunter nennt die Einheit; die
2232 // Tabellenausgabe fuehrt beide Angaben als eigene Spalten mit vollem
2233 // Namen - sie wird gerechnet, diese hier gelesen.
2234 'Min./Vorgabe',
2235 // "(Summe)" wie in der Signalzeitentabelle (Befund C22): Die Zelle
2236 // nennt `totalGreen`, also die Summe aller Freigabefenster. Die
2237 // Fussnote dazu steht auf einer anderen Seite; ohne den Zusatz liest
2238 // sich die Spalte an einer Gruppe mit zwei Fenstern wie die Dauer eines
2239 // einzelnen.
2240 'Freigabe im Plan (Summe)',
2241 'Verkehrsstärke',
2242 ].map(toWinAnsi),
2243 ],
2244 body: project.signalGroups.map((group, index) => {
2245 const planned = plan.groups.find((g) => g.groupId === group.id);
2246 const demand = project.demands.find((d) => d.signalGroupId === group.id);
2247 const maxVorgabe = hoechstfreigabezeitVorgabe(group);
2248 return [
2249 String(index + 1),
2250 group.name,
2251 TRAFFIC_MODE_LABELS[group.mode],
2252 // Die Schluessel selbst gehoeren nicht in die Unterlage: "keine" liest
2253 // sich in einer Tabellenzelle wie eine fehlende Angabe, gemeint ist
2254 // "ohne Laengenzuschlag". Der Anwender hat die Werte in der Ansicht
2255 // "Signalgruppen" aus einer Auswahlliste mit ebendiesen Beschriftungen
2256 // gewaehlt und muss sie im Ausdruck wiederfinden.
2257 MOVEMENT_LABELS[group.movement],
2258 `${fmt.numShort(group.vZul)} km/h`,
2259 // Ueber fmt und nicht ueber String (Fassung 5.10.0): `num` in schema.ts
2260 // prueft die Fahrstreifenzahl nur gegen 1 bis 8 und rundet nicht auf
2261 // ganze Zahlen. Eine Projektdatei mit "lanes": 2.5 wird uebernommen,
2262 // geht mit 2,5 in die Saettigungsverkehrsstaerke ein - und stand hier
2263 // als "2.5" mit englischem Punkt, als einzige Zahl der Unterlage, die
2264 // nicht ueber fmt lief. In der Tabellenausgabe ist dieselbe Zelle
2265 // bereits nachgezogen (csv.ts). Bei ganzen Zahlen aendert sich nichts:
2266 // numShort laesst nachlaufende Nullen weg.
2267 fmt.numShort(group.lanes),
2268 fahrzeugartBeschriftung(group.vehicleClass, plan.defaults),
2269 furtmerkmale(group),
2270 fmt.seconds(planned?.times.redYellow ?? 0),
2271 fmt.seconds(planned?.times.yellow ?? 0),
2272 // Die Vorgabe steht auch ohne `planned`: Sie ist eine Eingangsgroesse
2273 // der Gruppe und kein Ergebnis des Plans. "–" heisst "keine
2274 // eingetragen"; was dann gilt, sagt der Absatz unter der Tabelle.
2275 `${fmt.seconds(planned?.times.minGreen ?? 0)} / ` +
2276 (maxVorgabe === null ? '–' : fmt.seconds(maxVorgabe)),
2277 planned === undefined || planned.greens.length === 0
2278 ? '–'
2279 : fmt.seconds(planned.totalGreen),
2280 // Fuer Fussgaenger und Radverkehr auch dann "–", wenn eine Zahl
2281 // gespeichert ist (Altdatei aus der Zeit vor Fassung 5.4.0,
2282 // Befund B4): Sie geht in keine Rechnung ein, und der
2283 // Pruefbericht sagt das. Eine gedruckte Verkehrsstaerke behauptete
2284 // eine Eingangsgroesse, die es nicht ist.
2285 //
2286 // UND "–" AUCH BEI 0 (Fassung 5.10.0): Ein Datensatz ist noch keine
2287 // Zaehlung. Wird in der Signalgruppentabelle allein der
2288 // Schwerverkehrsanteil ausgefuellt, legt die Oberflaeche einen
2289 // Datensatz mit `volume: 0` an; der Fachkern liest ihn seit den
2290 // Befunden 12 und 18 als "keine Angabe" (signalPlan.ts,
2291 // erfassteVerkehrsstaerke), und der Bildschirm zeichnet das Feld seit
2292 // Befund 58 leer. Hier stand daneben "0 Fz/h" - dieselbe Zeile, zwei
2293 // Aussagen. Dieselbe Bedingung wie in `schwerverkehrZelle`.
2294 demand === undefined || demand.volume <= 0 || !hatSaettigungsverkehrsstaerke(group.mode)
2295 ? '–'
2296 : fmt.vehiclesPerHour(demand.volume),
2297 ].map(toWinAnsi);
2298 }),
2299 });
2300
2301 context.cursor = lastTableBottom(doc) + 10;
2302
2303 // Rechenweg der Furtregel unter der Tabelle: Eine Mindestfreigabezeit ueber
2304 // dem Regelwert muss sich aus gedruckten Groessen nachrechnen lassen. Ohne
2305 // den Absatz waere "6 s" eine Behauptung.
2306 const furtregel = furtregelAbsatz(plan);
2307 if (furtregel !== null) writeParagraph(context, furtregel);
2308
2309 /*
2310 * Was in der Spalte "Min./Vorgabe" rechts steht - und was dort NICHT steht.
2311 *
2312 * Der Absatz steht unbedingt da, weil die Spalte unbedingt da steht
2313 * (dieselbe Begruendung wie beim Absatz zur Spalte "Enger Innenradius"):
2314 * Ein Kopf "Min./Vorgabe" ohne Erlaeuterung liesse offen, wovon die zweite
2315 * Zahl die Vorgabe ist, und "–" liesse offen, was ohne Eintrag gilt.
2316 *
2317 * Er sagt nur zu, was das Programm haelt: Gemeldet werden die beiden Faelle,
2318 * in denen `phaseMaxGreen` eine Vorgabe IN IHRER PHASE verwirft. Eine
2319 * Vorgabe ueber einer festen Freigabezeit kommt ebenfalls nicht zur Wirkung
2320 * und wird nicht gemeldet; eine Gruppe ohne Phase erreicht die Stelle gar
2321 * nicht. Beides ist hier deshalb auch nicht zugesagt - der Satz nennt die
2322 * Phase ausdruecklich.
2323 */
2324 writeParagraph(
2325 context,
2326 'Die Spalte "Min./Vorgabe" nennt links die maßgebende Mindestfreigabezeit und rechts die an ' +
2327 'der Signalgruppe eingetragene Höchstfreigabezeit; "–" heißt: an dieser Signalgruppe ist ' +
2328 'keine eingetragen. Die rechte Zahl ist eine Eingangsgröße und nicht die im Plan wirksame ' +
2329 'obere Schranke: Wirksam wird sie je Phase, und zwar als kleinste Vorgabe ihrer ' +
2330 `Signalgruppen. Trägt keine von ihnen eine, gilt der Regelwert von ` +
2331 `${fmt.seconds(plan.defaults.maxGreen)} aus den Projektvorgaben; eine feste Freigabezeit ` +
2332 'der Phase und die Mindestfreigabezeit gehen der Vorgabe ohnehin vor. Eine Vorgabe, die in ' +
2333 'ihrer Phase unter der Mindestfreigabezeit oder unter einer festen Freigabezeit liegt, ' +
2334 'meldet der Prüfbericht.',
2335 );
2336 context.cursor += 6;
2337 }
2338
2339 /**
2340 * Merkmale einer Fussgaengerfurt fuer die Signalgruppentabelle - Beschriftung
2341 * wie am Bildschirm ("Blindenzusatz", "erhoehter Zeitbedarf"). Fuer andere
2342 * Verkehrsarten "–": Die Merkmale gibt es dort nicht.
2343 */
2344 export function furtmerkmale(group: SignalGroup): string {
2345 if (group.mode !== 'fuss') return '–';
2346 const merkmale: string[] = [];
2347 if (group.blindenzusatz === true) merkmale.push('Blindenzusatz (ganze Furt)');
2348 if (group.reducedMobility) merkmale.push('erhöhter Zeitbedarf');
2349 return merkmale.length === 0 ? 'halbe Furt' : merkmale.join(', ');
2350 }
2351
2352 /**
2353 * Absatz zur Mindestfreigabezeit der Fussgaengergruppen: je Gruppe der
2354 * Rechenweg der Furtregel (Furtlaenge, halbe/ganze Furt, vr, Ergebnis) oder der
2355 * Vermerk, dass die Furtlaenge unbekannt ist. `null`, wenn es keine
2356 * Fussgaengergruppe gibt - dann gibt es auch nichts zu erklaeren.
2357 */
2358 export function furtregelAbsatz(plan: SignalPlan): string | null {
2359 const saetze: string[] = [];
2360 for (const g of plan.groups) {
2361 if (g.mode !== 'fuss') continue;
2362 const mfz = g.mindestfreigabezeit;
2363 if (mfz.furt === null) {
2364 saetze.push(
2365 `${g.name}: Furtlänge unbekannt (kein Räumweg an einer Konfliktbeziehung, in der die Gruppe ` +
2366 `räumt) – Furtregel nicht prüfbar, es gilt der Regelwert von ${fmt.seconds(mfz.regelwert)}.`,
2367 );
2368 continue;
2369 }
2370 const furt = mfz.furt;
2371 const anteil = furt.ganzeFurt ? 'ganze Furt (Blindenzusatz)' : 'halbe Furt';
2372 // Gerundet im Fachkern (furtFreigabebedarf), nicht hier noch einmal.
2373 const bedarf = furt.zeitbedarfGerundet;
2374 const ergebnis =
2375 mfz.massgebend === 'furt'
2376 ? `maßgebend ${fmt.seconds(mfz.wert)}`
2377 : mfz.massgebend === 'vorgabe'
2378 ? `maßgebend die Vorgabe von ${fmt.seconds(mfz.wert)}`
2379 : `maßgebend der Regelwert von ${fmt.seconds(mfz.wert)}`;
2380 // Furtweg mit zwei, Zeitbedarf mit drei Nachkommastellen - dieselbe
2381 // Genauigkeit wie im Hinweis des Plans (signalPlan.ts, furtNotizen):
2382 // "6,01 m / 1,2 m/s = 5,004 s, aufgerundet 6 s" ist nachrechenbar,
2383 // "6,0 m / 1,2 m/s = 5,00 s, aufgerundet 6 s" nicht.
2384 saetze.push(
2385 `${g.name}: Furt ${fmt.meters(furt.furtlaenge, 2)}, ${anteil} ${fmt.meters(furt.furtweg, 2)} / ` +
2386 `${fmt.metersPerSecond(furt.vr)} = ${fmt.seconds(furt.zeitbedarf, 3)}, aufgerundet ` +
2387 `${fmt.seconds(bedarf)}; ${ergebnis}.`,
2388 );
2389 }
2390 if (saetze.length === 0) return null;
2391 return (
2392 'Mindestfreigabezeit der Fußgängergruppen nach der Furtregel der RiLSA (Freigabe mindestens für ' +
2393 'die halbe Furt, mit Zusatzeinrichtung für Blinde und Sehbehinderte für die ganze Furt; ' +
2394 'Furtlänge = längster erfasster Räumweg der Gruppe, vr = Räumgeschwindigkeit der Gruppe; ' +
2395 'der größere Wert aus Regelwert, Vorgabe und Furtbedarf gilt): ' +
2396 saetze.join(' ') +
2397 ' ' +
2398 MITTELINSEL_VERMERK
2399 );
2400 }
2401
2402 /**
2403 * Was der Ausdruck ueber Furten mit Mittelinsel sagen muss.
2404 *
2405 * NEU (Befund D2): Das Programm kennt keine zweistufige Furt. Es ist eine
2406 * Einschraenkung des Leistungsumfangs, und eine Planunterlage, die sie
2407 * verschweigt, laesst den Pruefer glauben, die Zweistufigkeit sei geprueft
2408 * worden. Der Vermerk steht dort, wo die Fuss-Kennwerte stehen (unter der
2409 * Signalgruppentabelle), nicht in einer allgemeinen Vorbemerkung. Kein
2410 * Rechenwechsel: Es aendert sich nichts an den Zahlen, nur daran, was ueber sie
2411 * behauptet wird.
2412 *
2413 * BERICHTIGT 5.38.0, NACHGEBESSERT 5.38.1 - EINE ZUSICHERUNG UEBER EINE
2414 * EINGABE IST KEINE ZUSICHERUNG.
2415 * Hier stand: "Furten mit Mittelinsel gehen mit ihrer ganzen Laenge in diese
2416 * Rechnung ein ... konservativ, weil Raeumzeit und Furtbedarf ueber die volle
2417 * Breite angesetzt werden." Das behauptet etwas ueber DIESEN Plan, das dieser
2418 * Plan nicht wissen kann: Gerechnet wird mit dem je Konfliktbeziehung
2419 * ERFASSTEN Raeumweg, und ob der bis zur gegenueberliegenden Bordkante reicht
2420 * oder an der Insel endet, steht nirgends. Wer die Furt nur bis zur Insel
2421 * vermisst, bekam die kuerzeren Zeiten UND einen gedruckten Satz, der sie
2422 * konservativ nannte - in einer Unterlage, die zur Anordnung nach § 45 StVO
2423 * geht. Der Vermerk sagt jetzt, WOMIT gerechnet wurde, und verweist auf die
2424 * Furtlaenge, die derselbe Absatz zwei Zeilen darueber je Gruppe nennt.
2425 *
2426 * DREI NACHBESSERUNGEN IN 5.38.1. Erstens sagte der Vermerk, die Furtlaenge
2427 * stehe "oben bei jeder Gruppe" - fuer eine Gruppe ohne erfassten Raeumweg
2428 * druckt derselbe Absatz zwei Zeilen darueber aber "Furtlaenge unbekannt". Eine
2429 * unbelegbare Zusicherung war durch eine zweite ersetzt worden. Zweitens fiel
2430 * die RAEUMZEIT aus dem Text: Der alte Wortlaut nannte "Raeumzeit und
2431 * Furtbedarf", der neue verband den erfassten Weg nur noch mit der
2432 * Mindestfreigabezeit - dabei traegt derselbe halbierte Weg auch jede
2433 * Zwischenzeit dieser Gruppe. Drittens fehlte die RICHTUNG: Wer nur das PDF vor
2434 * sich hat, konnte nicht mehr entnehmen, ob die Vereinfachung zu seinen Gunsten
2435 * oder zu seinen Lasten geht. Sie steht jetzt wieder da, aber an die Bedingung
2436 * geknuepft, unter der sie gilt.
2437 */
2438 export const MITTELINSEL_VERMERK =
2439 'Eine progressive (zweistufige) Signalisierung der Furtabschnitte mit der Insel als ' +
2440 'Aufstellfläche bildet das Programm nicht ab und weist sie nicht nach: Es kennt keine Furt als ' +
2441 'Gebilde und rechnet je Signalgruppe, mit dem an der jeweiligen Konfliktbeziehung erfassten ' +
2442 'Räumweg. Aus dessen größtem Wert entsteht die Furtlänge; welche das je Gruppe ist – oder dass ' +
2443 'sie unbekannt bleibt –, sagen die Sätze davor. Ob der erfasste Weg von Bordkante zu Bordkante ' +
2444 'reicht, kann dieser Plan nicht feststellen: Eine Furt mit Mittelinsel ist über die Insel ' +
2445 'hinweg zu vermessen. Geschieht das, liegen Räumzeit, Zwischenzeit und Furtbedarf auf der ' +
2446 'sicheren Seite; wird nur bis zur Insel gemessen, fallen alle drei zu kurz aus.';
2447
2448 /* -- Zwischenzeitenmatrix: Satzmasse ---------------------------------------
2449 *
2450 * WARUM DIESE MASSE UEBERHAUPT FESTGELEGT WERDEN.
2451 *
2452 * jspdf-autotable verteilt ohne feste Breite die Satzbreite ANTEILIG auf alle
2453 * Spalten. Die Matrix hat n+1 Spalten, also faellt die Breite jeder Spalte mit
2454 * wachsendem n - und mit ihr die Zahl der Zeichen, die in eine Kopfzelle
2455 * passen. Der Umbruch geht dann nicht mehr an Wortgrenzen, sondern durch die
2456 * Woerter hindurch; die Kopfzeile wird bei langen Namen hoeher als das Blatt,
2457 * und autotable zerlegt sie in printFullRow in immer neue Bruchstuecke.
2458 * Gemessen an einem Knotenpunkt mit vollstaendig besetzter Konfliktmatrix und
2459 * Namen von rund 140 Zeichen: bei 32 Gruppen 1,3 s / 1 406 kB / 113 Seiten,
2460 * bei 40 Gruppen ABBRUCH. Der Ausdruck ist dort nicht langsam, sondern nicht
2461 * mehr herstellbar.
2462 *
2463 * WIE GENAU ER ABBRICHT, HAENGT AM FALL - der Kommentar nannte frueher nur den
2464 * Aufrufstapel, belegbar sind zwei Abbrucharten: 40 Gruppen mit
2465 * 140-Zeichen-Namen sterben an der Halde ("Ineffective mark-compacts near heap
2466 * limit", rund 4 GB, nach 71 s), 16 Gruppen mit 485-Zeichen-Namen am
2467 * Aufrufstapel ("Maximum call stack size exceeded", nach 42,5 s bei 2 791 MB).
2468 * Beidem geht dieselbe Meldung von autotable voraus: "Will not be able to print
2469 * row -1 correctly since it's minimum height is larger than page height". Fuer
2470 * die Sache ist der Unterschied gleichgueltig - fuer eine Fehlersuche nicht,
2471 * deshalb steht er hier.
2472 *
2473 * Die Masse unten schneiden das ab, indem sie die Spaltenbreite von n
2474 * ABKOPPELN. Was dann nicht mehr nebeneinander passt, steht in einem weiteren
2475 * Ausschnitt - nicht in einer schmaleren Spalte.
2476 */
2477
2478 /** Schriftgrad der Matrix - wie bisher. */
2479 const MATRIX_SCHRIFTGRAD = 8.5;
2480 /** Zellpolsterung der Matrix - wie bisher; sie faellt zweimal je Spalte an. */
2481 const MATRIX_POLSTER = 3;
2482 /** Zeilenabstand, den jspdf-autotable ansetzt (FONT_ROW_RATIO). */
2483 const MATRIX_ZEILENFAKTOR = 1.15;
2484 /** Beschriftung der Kopfecke - Leserichtung der Matrix. */
2485 const MATRIX_ECKE = 'räumt \\ fährt ein';
2486
2487 /**
2488 * Bis zu welcher Breite ein Signalgruppenname noch als SPALTENKOPF taugt.
2489 *
2490 * 34 pt tragen bei 8,5 pt Helvetica fett sechs Ziffernzeichen: "K12345" misst
2491 * 29,49 pt, "K123456" schon 34,17 pt und faellt heraus (nachgemessen; die
2492 * frueher hier stehende Angabe "rund acht Zeichen" war zu grosszuegig,
2493 * berichtigt mit Fassung 5.8.0). Damit deckt der Wert genau die Bezeichnung ab,
2494 * die das Modell erwartet (SignalGroup.name: "Bezeichnung im Plan, z. B. K1,
2495 * F2, R3"). Wer stattdessen einen Satz in das Namensfeld schreibt, bekommt im
2496 * Kopf die Ordnungsnummer und darueber die Zuordnung; erfunden wird dabei
2497 * nichts (siehe matrixSatz).
2498 *
2499 * DASS DIE GRENZE FRUEH LIEGT, IST BEABSICHTIGT, aber es ist keine Kleinigkeit:
2500 * Schon "Fussgaenger12" (13 Zeichen, 61,97 pt fett) faellt darunter. Solche
2501 * Plaene bekommen den Nummernsatz, obwohl ihre Namen vorher sauber an
2502 * Wortgrenzen umbrachen. Das ist vertretbar, weil das Banner die Zuordnung auf
2503 * jedem Blatt mitfuehrt - aber es ist mehr als "wer kurze Bezeichnungen
2504 * benutzt, sieht keine Aenderung" (Befund B6 fachlich), und deshalb steht es
2505 * hier und in den Unterlagen.
2506 *
2507 * Der Wert ist bewusst etwas groesser als die breiteste Zahlenspalte
2508 * ("188*" = 17,34 pt zuzueglich 2 x 3 pt Polster = 23,34 pt): Eine
2509 * Bezeichnung, die in eine Zahlenspalte passt, kostet keine einzige zusaetzliche
2510 * Spaltenbreite.
2511 */
2512 const MATRIX_KOPFNAME_HOECHSTBREITE = 34;
2513
2514 /** Hoechstbreite der Namensspalte (Zeilenkopf) - der Rest gehoert den Zahlen. */
2515 const MATRIX_NAMENSSPALTE_HOECHSTBREITE = 180;
2516
2517 /**
2518 * Die Breite, unter die jspdf-autotable eine Spalte nie verkleinert.
2519 *
2520 * In calculateWidths ist das `defaultMinWidth = 10 / scaleFactor`; die Einheit
2521 * dieser Ausgabe ist Punkt, also scaleFactor 1. Der Wert gehoert in die
2522 * Schranke, weil eine Spalte mit sehr schmalem Inhalt trotzdem diese Breite
2523 * belegt und den anderen Spalten damit Platz nimmt.
2524 */
2525 const MATRIX_MINDESTBREITE = 10;
2526
2527 /**
2528 * Textbreite in der Schrift der Matrix, ohne den Zustand des Dokuments zu
2529 * hinterlassen: Die Funktion wird zwischen Absaetzen aufgerufen, die ihre
2530 * eigene Schrift gesetzt haben.
2531 */
2532 function matrixBreite(doc: jsPDF, text: string, fett: boolean): number {
2533 const schrift = doc.getFont() as { fontName: string; fontStyle: string };
2534 const grad = doc.getFontSize();
2535 doc.setFont('helvetica', fett ? 'bold' : 'normal');
2536 doc.setFontSize(MATRIX_SCHRIFTGRAD);
2537 const breite = doc.getTextWidth(toWinAnsi(text));
2538 doc.setFont(schrift.fontName, schrift.fontStyle);
2539 doc.setFontSize(grad);
2540 return breite;
2541 }
2542
2543 /** Zeilenzahl, die ein Text in einer Zelle dieser Breite belegt. */
2544 function matrixZeilen(doc: jsPDF, text: string, breite: number, fett: boolean): number {
2545 const schrift = doc.getFont() as { fontName: string; fontStyle: string };
2546 const grad = doc.getFontSize();
2547 doc.setFont('helvetica', fett ? 'bold' : 'normal');
2548 doc.setFontSize(MATRIX_SCHRIFTGRAD);
2549 const zeilen = doc.splitTextToSize(toWinAnsi(text), Math.max(1, breite)) as string[];
2550 doc.setFont(schrift.fontName, schrift.fontStyle);
2551 doc.setFontSize(grad);
2552 return Math.max(1, zeilen.length);
2553 }
2554
2555 /** Hoehe einer Zelle mit so vielen Zeilen - dieselbe Rechnung wie autotable. */
2556 function matrixZellhoehe(zeilen: number): number {
2557 return zeilen * MATRIX_SCHRIFTGRAD * MATRIX_ZEILENFAKTOR + 2 * MATRIX_POLSTER;
2558 }
2559
2560 /** Der Inhalt einer Zelle der Matrix - eine Stelle fuer beide Satzarten. */
2561 function matrixWert(plan: SignalPlan, from: SignalGroup, to: SignalGroup): string {
2562 if (from.id === to.id) return '';
2563 const resolved = plan.intergreens.get(intergreenKey(from.id, to.id));
2564 if (!resolved) return '·';
2565 return resolved.source === 'vorgegeben'
2566 ? `${fmt.numShort(resolved.value, 0)}*`
2567 : fmt.numShort(resolved.value, 0);
2568 }
2569
2570 /**
2571 * Das Groesste ueber die Zellen der ganzen Matrix - ueber eine Faltung, NICHT
2572 * ueber den Ausbreitungsoperator.
2573 *
2574 * Beide Satzarten bildeten das Maximum vor der Fassung 5.11.0 als
2575 * `Math.max(0, ...zellen.flat().map(...))`. Die Matrix hat n^2 Zellen; V8 nimmt
2576 * nur rund 125.000 Argumente an, und ab 356 Signalgruppen (356^2 = 126.736)
2577 * brach der Aufruf mit "RangeError: Maximum call stack size exceeded" ab -
2578 * ohne Konfliktbeziehung, ohne Fehler im Pruefbericht und ohne dass eine
2579 * Unterlage entstand. Weder `parseSignalGroups` noch `addSignalGroup` kennen
2580 * eine Obergrenze; wer solche Anlagen annimmt, muss sie auch drucken.
2581 *
2582 * Der Wert ist derselbe wie zuvor - nur der Rechenweg dorthin fasst die Liste
2583 * nicht mehr als Argumentliste an.
2584 */
2585 function matrixHoechsteZellbreite(
2586 zellen: readonly (readonly string[])[],
2587 breite: (zelle: string) => number,
2588 ): number {
2589 let hoechste = 0;
2590 for (const zeile of zellen) {
2591 for (const zelle of zeile) hoechste = Math.max(hoechste, breite(zelle));
2592 }
2593 return hoechste;
2594 }
2595
2596 /** Die laengsten Woerter eines Textes - sie bestimmen, ob er umbrechbar ist. */
2597 function laengstesWort(doc: jsPDF, text: string, fett: boolean): number {
2598 const woerter = text.split(/\s+/).filter((w) => w !== '');
2599 if (woerter.length === 0) return 0;
2600 return Math.max(...woerter.map((w) => matrixBreite(doc, w, fett)));
2601 }
2602
2603 /** Was in der Zuordnung steht, wenn eine Signalgruppe keinen Namen traegt. */
2604 const MATRIX_OHNE_NAMEN = '(ohne Bezeichnung)';
2605
2606 /**
2607 * Was im Banner steht, wenn die Zuordnung selbst hoeher ist als ein Blatt.
2608 *
2609 * Sie wird dann nicht gekuerzt, sondern verlaesst den wiederholten
2610 * Tabellenkopf. Der Banner sagt, wo sie steht - sonst suchte der Leser eines
2611 * einzelnen Blattes sie dort, wo sie sonst immer ist.
2612 */
2613 const MATRIX_ZUORDNUNG_VOR_TABELLE =
2614 'Die Zuordnung der Nummern zu den Namen steht unmittelbar vor dieser Tabelle; sie ist länger ' +
2615 'als ein Blatt und lässt sich deshalb nicht auf jedem Blatt wiederholen.';
2616
2617 /**
2618 * Der Name einer Signalgruppe fuer Zuordnung und Zeilenkopf - nie leer.
2619 *
2620 * Ein vorhandener Name geht UNVERAENDERT durch (auch mit Rand): Diese Funktion
2621 * soll die Ausgabe nicht schoener machen, sondern nur die Luecke schliessen, in
2622 * der bisher gar nichts stand.
2623 */
2624 function matrixAnzeigename(group: SignalGroup | undefined): string {
2625 const name = group?.name ?? '';
2626 return name.trim() === '' ? MATRIX_OHNE_NAMEN : name;
2627 }
2628
2629 /**
2630 * Taugen die Namen ueberhaupt als Spaltenmarken?
2631 *
2632 * Eine Spaltenmarke muss die Signalgruppe EINDEUTIG benennen; sonst laesst sich
2633 * eine Zahl nicht mehr ihrem Paar zuordnen, und die Matrix ist als Nachweis
2634 * wertlos. Zwei Faelle nehmen ihr das:
2635 *
2636 * - Ein LEERER Name laesst den Kopf unbeschriftet. Sind alle leer, stand die
2637 * Matrix bisher ganz ohne Beschriftung da - bei 12 Gruppen 132 Zahlen, zu
2638 * denen die Unterlage nicht sagt, wozu sie gehoeren.
2639 * - ZWEI GLEICHE Namen setzen zweimal dieselbe Marke ueber zwei verschiedene
2640 * Spalten. Die Pruefung meldet das als Fehler (signalgruppen.name-doppelt),
2641 * aber die Unterlage wird trotzdem erzeugt - sie ist ja gerade das, woran
2642 * der Planer den Fehler sieht. Dann darf sie ihn nicht durch eine
2643 * mehrdeutige Beschriftung verstecken.
2644 *
2645 * Verglichen wird wie in der Pruefung (engine.ts): ohne Rand und ohne
2646 * Gross-/Kleinschreibung. Sonst nennte die Unterlage zwei Spalten gleich, die
2647 * die Pruefung als gleich beanstandet - oder umgekehrt.
2648 *
2649 * EINEN LEEREN NAMEN MELDET DIE PRUEFUNG INZWISCHEN AUCH: seit 5.8.0 gibt es
2650 * signalgruppen.name-leer, Schweregrad Fehler wie bei der Doppelung. Die Luecke
2651 * im Fachkern, von der hier frueher stand, sie bleibe eine, ist geschlossen -
2652 * die Ursache wird an ihrer Stelle behandelt.
2653 *
2654 * DIE VORKEHRUNG HIER BLEIBT TROTZDEM NOETIG, denn buildProjectPdf sperrt sich
2655 * nicht selbst: Der Pruefbericht wird ihm als INHALT gereicht, nicht als
2656 * Freigabe, und report.exportBlocked fragt es an keiner Stelle ab. Die Sperre
2657 * sitzt allein in der Ansicht "Ausgabe" (exportView.ts). Wer an ihr vorbei
2658 * ausgibt, bekommt die Matrix - und dann muss sie lesbar sein.
2659 *
2660 * In beiden Faellen treten Ordnungsnummern an die Stelle der Namen: Die Nummer
2661 * ist immer da und immer eindeutig, und die Zuordnung ueber der Tabelle nennt
2662 * den Namen dazu - auch den doppelten, auch den fehlenden. Erfunden wird nichts.
2663 */
2664 /**
2665 * Warum die Spaltenkoepfe Ordnungsnummern tragen - der KONKRETE Grund.
2666 *
2667 * "eine fehlt oder zwei lauten gleich" waere in einer Anordnungsunterlage eine
2668 * Zumutung: Der Leser muesste selbst nachsehen, was von beidem zutrifft. Der
2669 * Absatz nennt deshalb den Fall, der wirklich vorliegt - notfalls beide.
2670 */
2671 function matrixKopfgrund(groups: readonly SignalGroup[]): string {
2672 const leer = groups.some((g) => g.name.trim() === '');
2673 const gesehen = new Set<string>();
2674 let doppelt = false;
2675 for (const group of groups) {
2676 const marke = group.name.trim().toLowerCase();
2677 if (marke === '') continue;
2678 if (gesehen.has(marke)) doppelt = true;
2679 gesehen.add(marke);
2680 }
2681
2682 if (leer && doppelt) {
2683 return (
2684 'weil in diesem Plan mindestens eine Signalgruppe keine Bezeichnung trägt und zwei ' +
2685 'weitere dieselbe tragen'
2686 );
2687 }
2688 if (leer) return 'weil in diesem Plan mindestens eine Signalgruppe keine Bezeichnung trägt';
2689 if (doppelt) return 'weil in diesem Plan zwei Signalgruppen dieselbe Bezeichnung tragen';
2690 return 'weil die Namen dieses Plans für einen Spaltenkopf zu lang sind';
2691 }
2692
2693 function matrixMarkenSindEindeutig(groups: readonly SignalGroup[]): boolean {
2694 const gesehen = new Set<string>();
2695 for (const group of groups) {
2696 const marke = group.name.trim().toLowerCase();
2697 if (marke === '') return false;
2698 if (gesehen.has(marke)) return false;
2699 gesehen.add(marke);
2700 }
2701 return true;
2702 }
2703
2704 interface MatrixSatz {
2705 /** Traegt der Kopf Ordnungsnummern statt der Namen? */
2706 readonly mitNummern: boolean;
2707 /** Breite der Nummernspalte des Zeilenkopfs (0, wenn ohne Nummern). */
2708 readonly nummernspalte: number;
2709 /** Breite der Namensspalte des Zeilenkopfs. */
2710 readonly namensspalte: number;
2711 /** Wie viele einfahrende Signalgruppen ein Ausschnitt hoechstens traegt. */
2712 readonly spaltenJeBlock: number;
2713 /**
2714 * Wie viele Zeilen der Zeilenkopf hoechstens traegt - laengere Namen werden
2715 * dort mit Auslassungszeichen gekuerzt.
2716 */
2717 readonly zeilenkopfZeilen: number;
2718 /** Musste dafuer wirklich gekuerzt werden? Dann sagt es der Ausdruck. */
2719 readonly zeilenkopfGekuerzt: boolean;
2720 /**
2721 * Steht die Zuordnung der Nummern im wiederholten Tabellenkopf - also auf
2722 * jedem Blatt des Ausschnitts?
2723 *
2724 * Sie steht dort, so lange sie dorthin passt. Ist sie fuer sich schon hoeher
2725 * als ein Blatt, laesst sie sich nicht wiederholen: Eine Kopfzelle, die
2726 * hoeher ist als die Seite, bringt jspdf-autotable zum Absturz. Dann steht
2727 * sie ungekuerzt als Fliesstext vor dem Ausschnitt.
2728 */
2729 readonly zuordnungImKopf: boolean;
2730 }
2731
2732 /**
2733 * Aufteilung der Matrix auf Ausschnitte - GLEICHMAESSIG, nicht "voll, voll,
2734 * Rest".
2735 *
2736 * Bei 13 Signalgruppen und 12 moeglichen Spalten je Blatt entstuende sonst ein
2737 * Ausschnitt mit einer einzigen Spalte; das sieht wie ein Druckfehler aus und
2738 * laesst den Leser suchen, ob er ein Blatt verloren hat.
2739 */
2740 function matrixAufteilung(anzahl: number, hoechstens: number): number[] {
2741 const bloecke = Math.max(1, Math.ceil(anzahl / Math.max(1, hoechstens)));
2742 const grundmass = Math.floor(anzahl / bloecke);
2743 const rest = anzahl % bloecke;
2744 return Array.from({ length: bloecke }, (_, i) => grundmass + (i < rest ? 1 : 0));
2745 }
2746
2747 /**
2748 * Satzmass der Matrix: Spaltenbreiten und Zahl der Ausschnitte.
2749 *
2750 * Der Kopf traegt die NAMEN, solange sie als Bezeichnung taugen; Ordnungsnummern
2751 * treten an ihre Stelle, wenn einer breiter ist als
2752 * MATRIX_KOPFNAME_HOECHSTBREITE ODER wenn die Namen nicht eindeutig sind (leer
2753 * oder doppelt, siehe matrixMarkenSindEindeutig). Die Nummer ist keine
2754 * erfundene Kurzbezeichnung, sondern die Stellung der Gruppe in dieser
2755 * Unterlage: dieselbe Reihenfolge wie in der Tabelle "Signalgruppen" und in den
2756 * Zeilen dieser Matrix, in denen Nummer und voller Name nebeneinander stehen.
2757 * Ein ausgedachtes Kuerzel ("K3") koennte dagegen auf den Plannamen "K3" einer
2758 * ANDEREN Gruppe treffen - in einer Anordnungsunterlage ist das der Fehler, den
2759 * man nicht machen darf.
2760 */
2761 function matrixSatz(
2762 context: Context,
2763 groups: readonly SignalGroup[],
2764 zellen: readonly (readonly string[])[],
2765 ): MatrixSatz {
2766 const { doc } = context;
2767
2768 const breitesteZahl = matrixHoechsteZellbreite(zellen, (z) => matrixBreite(doc, z, false));
2769 const breitesterName = Math.max(...groups.map((g) => matrixBreite(doc, g.name, true)));
2770 const mitNummern =
2771 breitesterName > MATRIX_KOPFNAME_HOECHSTBREITE || !matrixMarkenSindEindeutig(groups);
2772
2773 const kopfmarke = mitNummern ? matrixBreite(doc, String(groups.length), true) : breitesterName;
2774 const wertbreite = Math.max(breitesteZahl, kopfmarke) + 2 * MATRIX_POLSTER;
2775
2776 const nummernspalte = mitNummern
2777 ? Math.max(matrixBreite(doc, 'Nr.', true), matrixBreite(doc, String(groups.length), true)) +
2778 2 * MATRIX_POLSTER
2779 : 0;
2780
2781 // Die Namensspalte traegt den Zeilenkopf und die Kopfecke; sie ist so breit
2782 // wie noetig, aber nie breiter als MATRIX_NAMENSSPALTE_HOECHSTBREITE - der
2783 // Rest des Blattes gehoert den Zahlen.
2784 const eckenbreite = matrixBreite(doc, MATRIX_ECKE, true) + 2 * MATRIX_POLSTER;
2785 const namensspalte = Math.min(
2786 MATRIX_NAMENSSPALTE_HOECHSTBREITE,
2787 Math.max(eckenbreite, breitesterName + 2 * MATRIX_POLSTER),
2788 );
2789
2790 const vorspalten = nummernspalte + namensspalte;
2791 const fuerWerte = context.contentWidth - vorspalten;
2792
2793 /*
2794 * Zweite Schranke, und die eigentlich wichtige: Kopf UND eine Koerperzeile
2795 * muessen zusammen auf ein Blatt passen. Genau daran scheitert der heutige
2796 * Satz - autotable meldet dann "minimum height is larger than page height"
2797 * und laeuft sich in printFullRow tot. Die Zuordnungsliste im Kopf waechst
2798 * mit der Zahl der Spalten je Ausschnitt; passt sie nicht mehr, wird der
2799 * Ausschnitt schmaler, nicht die Zeile hoeher.
2800 *
2801 * GEPRUEFT WIRD, WAS GESETZT WIRD (Fassung 5.8.0). Die Probe rechnete bis
2802 * dahin mit dem ersten Ausschnitt und mit "bloecke = 1". Gesetzt wird aber
2803 * mit der wirklichen Zahl der Ausschnitte, und deren Banner traegt eine Zeile
2804 * mehr ("Ausschnitt k von m ...") sowie den laengeren Text "Nummern der
2805 * Spaltenkoepfe IN DIESEM AUSSCHNITT:". Der gepruefte Kopf war dadurch
2806 * durchweg 9,78 pt niedriger als der gedruckte - bei n=40 mit langen Namen
2807 * 211,27 pt statt 221,05 pt. Es blieb in allen gemessenen Faellen Reserve,
2808 * aber es ist die eine Rechnung, die den Einsturz verhindern soll; sie muss
2809 * mit dem rechnen, was auf dem Blatt landet.
2810 */
2811 const nutzhoehe = nutzhoeheJeBlatt(context);
2812 const zeilenkopfNoetig = Math.max(
2813 ...groups.map((g) =>
2814 matrixZeilen(doc, matrixAnzeigename(g), namensspalte - 2 * MATRIX_POLSTER, false),
2815 ),
2816 );
2817 let zeilenkopfZeilen = zeilenkopfNoetig;
2818 let zuordnungImKopf = true;
2819
2820 let spaltenJeBlock = Math.max(1, Math.floor(fuerWerte / wertbreite));
2821 while (spaltenJeBlock > 1) {
2822 const kopfhoehe = matrixHoechsteKopfhoehe(context, groups, spaltenJeBlock, mitNummern, true);
2823 if (kopfhoehe + matrixZellhoehe(zeilenkopfZeilen) <= nutzhoehe) break;
2824 spaltenJeBlock -= 1;
2825 }
2826
2827 /*
2828 * Hier war vor der Fassung 5.11.0 Schluss - die Schleife verliess die
2829 * Schranke bei einer einzigen Spalte je Ausschnitt UNGEPRUEFT. Genau diesen
2830 * Fall konnte sie mit ihrem Hebel gar nicht erreichen: Die Koerperzeile ist so
2831 * hoch, wie der Zeilenkopf den Namen in der Namensspalte umbricht, und weniger
2832 * Spalten machen ihn nicht niedriger. Ein Name von 7.500 Zeichen ergibt dort
2833 * rund 1.800 pt gegen 451 pt Nutzhoehe; jspdf-autotable lief damit in
2834 * printFullRow in die Endlosteilung und die ganze Ausgabe brach mit
2835 * "RangeError: Maximum call stack size exceeded" ab - ohne Unterlage, ohne
2836 * Pruefbericht, ohne verwertbaren Hinweis.
2837 *
2838 * ZWEI HEBEL, IN DIESER REIHENFOLGE, denn nur einer von beiden darf etwas
2839 * wegnehmen:
2840 *
2841 * 1. Der ZEILENKOPF wird gekuerzt. Dort steht der Name ein zweites Mal - die
2842 * volle Fassung bleibt in der Zuordnung, und der Ausdruck sagt, dass
2843 * gekuerzt wurde.
2844 * 2. Reicht das nicht, weil schon die Zuordnung allein hoeher ist als ein
2845 * Blatt, verlaesst sie den Tabellenkopf und steht als Fliesstext vor dem
2846 * Ausschnitt - ungekuerzt. Sie zu kuerzen waere der eine Weg, der nicht
2847 * gangbar ist: Sie ist der Nachweis, mit dem sich ein einzelnes Blatt ohne
2848 * ein zweites aufloesen laesst.
2849 */
2850 const mitZuordnung = matrixHoechsteKopfhoehe(context, groups, spaltenJeBlock, mitNummern, true);
2851 if (mitZuordnung + matrixZellhoehe(zeilenkopfZeilen) > nutzhoehe) {
2852 zuordnungImKopf = mitZuordnung + matrixZellhoehe(1) <= nutzhoehe;
2853 const kopfhoehe = zuordnungImKopf
2854 ? mitZuordnung
2855 : matrixHoechsteKopfhoehe(context, groups, spaltenJeBlock, mitNummern, false);
2856 zeilenkopfZeilen = Math.max(
2857 1,
2858 Math.floor(
2859 (nutzhoehe - kopfhoehe - 2 * MATRIX_POLSTER) / (MATRIX_SCHRIFTGRAD * MATRIX_ZEILENFAKTOR),
2860 ),
2861 );
2862 }
2863
2864 return {
2865 mitNummern,
2866 nummernspalte,
2867 namensspalte,
2868 spaltenJeBlock,
2869 zeilenkopfZeilen,
2870 zeilenkopfGekuerzt: zeilenkopfZeilen < zeilenkopfNoetig,
2871 zuordnungImKopf,
2872 };
2873 }
2874
2875 /**
2876 * Der Name im Zeilenkopf, auf die zulaessige Zahl von Zeilen gekuerzt.
2877 *
2878 * Halbierend statt zeichenweise - der Name hat keine Laengengrenze. Gekuerzt
2879 * wird mit sichtbarer Auslassung; ein stillschweigend abgeschnittener Name
2880 * waere in einer Anordnungsunterlage das Gegenteil eines Hinweises.
2881 */
2882 function matrixZeilenkopf(
2883 context: Context,
2884 name: string,
2885 breite: number,
2886 hoechstzeilen: number,
2887 ): string {
2888 const { doc } = context;
2889 if (matrixZeilen(doc, name, breite, false) <= hoechstzeilen) return name;
2890
2891 const auslassung = '...';
2892 let unten = 0;
2893 let oben = name.length;
2894 while (unten < oben) {
2895 const mitte = Math.ceil((unten + oben) / 2);
2896 const probe = `${name.slice(0, mitte)}${auslassung}`;
2897 if (matrixZeilen(doc, probe, breite, false) <= hoechstzeilen) unten = mitte;
2898 else oben = mitte - 1;
2899 }
2900 return `${name.slice(0, unten).trimEnd()}${auslassung}`;
2901 }
2902
2903 /**
2904 * Die hoechste Kopfhoehe, die bei dieser Aufteilung vorkommt.
2905 *
2906 * Nicht der erste Ausschnitt, sondern der SCHLIMMSTE: Die Banner der
2907 * Ausschnitte tragen verschiedene Namen und verschieden breite Nummern, und
2908 * die Schranke muss fuer jedes Blatt halten, nicht nur fuer das erste.
2909 */
2910 function matrixHoechsteKopfhoehe(
2911 context: Context,
2912 groups: readonly SignalGroup[],
2913 spaltenJeBlock: number,
2914 mitNummern: boolean,
2915 mitZuordnung: boolean,
2916 ): number {
2917 const groessen = matrixAufteilung(groups.length, spaltenJeBlock);
2918 let hoechste = 0;
2919 let von = 0;
2920 for (let block = 0; block < groessen.length; block += 1) {
2921 const anzahl = groessen[block] ?? 0;
2922 hoechste = Math.max(
2923 hoechste,
2924 matrixKopfhoehe(
2925 context,
2926 groups,
2927 von,
2928 anzahl,
2929 mitNummern,
2930 block + 1,
2931 groessen.length,
2932 mitZuordnung,
2933 ),
2934 );
2935 von += anzahl;
2936 }
2937 return hoechste;
2938 }
2939
2940 /**
2941 * Der Banner ueber einem Ausschnitt - er steht als KOPFZEILE in der Tabelle
2942 * und wird deshalb von autotable auf JEDER Seite des Ausschnitts wiederholt.
2943 *
2944 * Das ist die Antwort auf die Auflage, die Zuordnung im Dokument selbst
2945 * aufloesbar zu halten: Wer ein einzelnes Blatt in der Hand haelt, liest ueber
2946 * der Tabelle, welcher Ausschnitt es ist und - bei Ordnungsnummern - welche
2947 * Nummer zu welchem Namen gehoert. Ein Verweis auf eine Liste an anderer Stelle
2948 * verlangte, ein zweites Blatt danebenzulegen.
2949 *
2950 * `mitZuordnung = false` ist der eine Fall, in dem das nicht geht: Ist die
2951 * Zuordnung fuer sich schon hoeher als ein Blatt, laesst sie sich nicht auf
2952 * jedem Blatt wiederholen - eine Kopfzelle ueber Blatthoehe bringt
2953 * jspdf-autotable zum Absturz. Sie steht dann ungekuerzt als Fliesstext vor dem
2954 * Ausschnitt, und der Banner sagt das.
2955 */
2956 function matrixBanner(
2957 context: Context,
2958 groups: readonly SignalGroup[],
2959 von: number,
2960 anzahl: number,
2961 mitNummern: boolean,
2962 block: number,
2963 bloecke: number,
2964 mitZuordnung: boolean,
2965 ): { text: string; namenUngeteilt: boolean } {
2966 const zeilen: string[] = [];
2967 let namenUngeteilt = true;
2968
2969 if (bloecke > 1) {
2970 zeilen.push(
2971 `Ausschnitt ${block} von ${bloecke} · einfahrende Signalgruppen ${von + 1} bis ` +
2972 `${von + anzahl} von ${groups.length}`,
2973 );
2974 }
2975 if (mitNummern && !mitZuordnung) {
2976 zeilen.push(MATRIX_ZUORDNUNG_VOR_TABELLE);
2977 } else if (mitNummern) {
2978 zeilen.push(
2979 bloecke > 1 ? 'Nummern der Spaltenköpfe in diesem Ausschnitt:' : 'Nummern der Spaltenköpfe:',
2980 );
2981 for (let i = von; i < von + anzahl; i += 1) {
2982 const teile = matrixZuordnungszeilen(context, i + 1, matrixAnzeigename(groups[i]));
2983 if (teile.length > 1) namenUngeteilt = false;
2984 zeilen.push(...teile);
2985 }
2986 }
2987 // Erst umschreiben, dann verbinden: toWinAnsi laesst den Zeilenumbruch
2988 // stehen (untere Grenze der Klasse ist \x00), die Zeilen bleiben also
2989 // getrennt.
2990 return { text: zeilen.map(toWinAnsi).join('\n'), namenUngeteilt };
2991 }
2992
2993 /**
2994 * Eine Zeile der Zuordnung "7 = <Name>" - und, wenn der Name dafuer zu lang
2995 * ist, ihre Fortsetzungszeilen.
2996 *
2997 * WARUM DIESE FUNKTION EXISTIERT (Fassung 5.8.0): Bis dahin entstand die Zeile
2998 * als eine Zeichenkette, und jspdf-autotable brach sie um, wenn sie nicht in
2999 * die Bannerbreite passte - gemessen ab rund 180 Zeichen Namenslaenge. Die
3000 * Fortsetzungszeile trug dann KEINE Nummer mehr: Auf dem Blatt stand ein
3001 * Namensrest, der zu jeder der aufgefuehrten Nummern gehoeren konnte. Damit war
3002 * die Zuordnung genau in dem Fall nicht mehr aufloesbar, fuer den sie da ist.
3003 *
3004 * Der Umbruch geschieht deshalb hier und nicht in der Bibliothek, und jede
3005 * Fortsetzungszeile wiederholt die Nummer mit Fortsetzungszeichen ("7 … Rest").
3006 * Gebrochen wird an Wortgrenzen, solange es welche gibt; ein einzelnes Wort,
3007 * das laenger ist als die Zeile, teilt splitTextToSize weiterhin - dann ist der
3008 * Name zerteilt, aber er bleibt seiner Nummer zugeordnet, und der Abschnitt
3009 * "Barrierefreiheit dieser Datei" sagt es (siehe MatrixSatzbefund).
3010 */
3011 function matrixZuordnungszeilen(context: Context, nummer: number, name: string): string[] {
3012 const { doc } = context;
3013 const breite = context.contentWidth - 2 * MATRIX_POLSTER;
3014 const marke = `${nummer} = `;
3015 const fortsetzung = `${nummer} … `;
3016
3017 const schrift = doc.getFont() as { fontName: string; fontStyle: string };
3018 const grad = doc.getFontSize();
3019 doc.setFont('helvetica', 'normal');
3020 doc.setFontSize(MATRIX_SCHRIFTGRAD);
3021 const markenbreite = doc.getTextWidth(toWinAnsi(marke));
3022 const fortsetzungsbreite = doc.getTextWidth(toWinAnsi(fortsetzung));
3023 // Die erste Zeile traegt "7 = ", jede weitere "7 … " - beide Marken kosten
3024 // Platz, und der Rest des Namens muss in den Rest der Zeile passen.
3025 const rest = Math.max(1, breite - Math.max(markenbreite, fortsetzungsbreite));
3026 const stuecke = doc.splitTextToSize(toWinAnsi(name), rest) as string[];
3027 doc.setFont(schrift.fontName, schrift.fontStyle);
3028 doc.setFontSize(grad);
3029
3030 if (stuecke.length <= 1) return [`${marke}${name}`];
3031 return stuecke.map((teil, i) => (i === 0 ? `${marke}${teil}` : `${fortsetzung}${teil}`));
3032 }
3033
3034 /** Hoehe des Banners samt Kopfzeile - fuer die Schranke und fuer ensureSpace. */
3035 function matrixKopfhoehe(
3036 context: Context,
3037 groups: readonly SignalGroup[],
3038 von: number,
3039 anzahl: number,
3040 mitNummern: boolean,
3041 block: number,
3042 bloecke: number,
3043 mitZuordnung: boolean,
3044 ): number {
3045 const banner = matrixBanner(
3046 context,
3047 groups,
3048 von,
3049 anzahl,
3050 mitNummern,
3051 block,
3052 bloecke,
3053 mitZuordnung,
3054 ).text;
3055 const bannerhoehe =
3056 banner === ''
3057 ? 0
3058 : matrixZellhoehe(
3059 matrixZeilen(context.doc, banner, context.contentWidth - 2 * MATRIX_POLSTER, false),
3060 );
3061 const kopfmarken = mitNummern
3062 ? 1
3063 : Math.max(
3064 ...groups
3065 .slice(von, von + anzahl)
3066 .map((g) => matrixZeilen(context.doc, g.name, MATRIX_KOPFNAME_HOECHSTBREITE, true)),
3067 );
3068 return bannerhoehe + matrixZellhoehe(Math.max(kopfmarken, 1));
3069 }
3070
3071 /**
3072 * Traegt der heutige Satz - eine Tabelle ueber die ganze Breite - die Matrix
3073 * noch?
3074 *
3075 * Vier Fragen, und alle vier sind Fragen nach dem SCHADEN, nicht nach dem
3076 * Geschmack. Solange keine davon mit Nein beantwortet wird, bleibt der Satz
3077 * Zeichen fuer Zeichen der bisherige.
3078 *
3079 * WO DER UMSCHLAGPUNKT LIEGT, HAENGT AN DEN DATEN, nicht an einer festen Zahl
3080 * (Fassung 5.8.0 - hier stand vorher "bis 31 unveraendert, ab 32", die
3081 * Unterlagen nannten 34/35, gemessen war beides nicht dasselbe Projekt). Er
3082 * haengt an der breitesten vorkommenden Zwischenzeit und an der breitesten
3083 * Bezeichnung, denn beide bestimmen die Mindestbreite einer Spalte. Fuer
3084 * Plannamen "K1"…"Kn" und zweistellige Zwischenzeiten liegt er bei dieser
3085 * Fassung zwischen n = 35 und n = 36; ein Projekt mit einer dreistelligen
3086 * Vorgabe schlaegt frueher um. Wer eine Zahl braucht, misst sie fuer SEIN
3087 * Projekt - die Schranke rechnet sie ohnehin selbst aus.
3088 */
3089 function matrixPasstAufEinBlatt(
3090 context: Context,
3091 groups: readonly SignalGroup[],
3092 zellen: readonly (readonly string[])[],
3093 ): boolean {
3094 const { doc } = context;
3095
3096 /*
3097 * (0) Die Spaltenmarken muessen die Signalgruppe eindeutig benennen. Sind sie
3098 * leer oder doppelt, traegt der heutige Satz eine mehrdeutige - oder gar
3099 * keine - Beschriftung, und keine Breitenrechnung repariert das. Dann
3100 * uebernimmt der Ausschnittsatz mit Ordnungsnummern.
3101 */
3102 if (!matrixMarkenSindEindeutig(groups)) return false;
3103
3104 /*
3105 * (1) und (2): KEINE ZAHL UND KEIN NAME DARF MITTEN IM WORT UMBRECHEN.
3106 * Eine umgebrochene Zwischenzeit ist keine schlechtere Darstellung, sie
3107 * ist eine andere Zahl ("188*" als "18" ueber "8*").
3108 *
3109 * GERECHNET WIRD MIT DEM VERFAHREN DER BIBLIOTHEK, nicht mit einem
3110 * Anteil. jspdf-autotable
3111 * bestimmt die Spaltenbreiten in calculateWidths in zwei Durchgaengen:
3112 * Durchgang 1 verkleinert nur bis max(minReadableWidth, minWidth), wobei
3113 * minReadableWidth = laengstes Wort + Polster und minWidth = 10 pt ist;
3114 * erst Durchgang 2 unterschreitet minReadableWidth - und GENAU DORT
3115 * bricht ein Wort. Kein Bruch im Wort heisst also:
3116 *
3117 * Summe ueber alle Spalten von max(laengstesWort + 2*Polster, 10 pt)
3118 * <= Satzbreite
3119 *
3120 * Das ist eine Bedingung an die SUMME, nicht an die einzelne Spalte.
3121 * Frueher stand hier je Spalte ein Vergleich gegen contentWidth/(n+1) -
3122 * also gegen den gleichen Anteil fuer alle. Beides faellt auseinander,
3123 * sobald eine Spalte mehr als ihren Anteil braucht, und die Kopfecke tut
3124 * das: Ihr laengstes Wort ("räumt", 23,55 pt) kostet mit Polster 29,55 pt,
3125 * waehrend der Anteil ab n = 26 darunter faellt. Der alte Vergleich liess
3126 * die Ecke ausdruecklich aussen vor - aber autotable verteilt den Fehlbetrag
3127 * auf ALLE Spalten, nicht nur auf die Ecke. Gemessen bei n=40 mit einem
3128 * Namen 0,03 pt unter dem Anteil: Die Wertespalten wurden 18,53 pt breit
3129 * statt der noetigen 18,75 pt - 0,22 pt zu schmal. Es brach dort noch
3130 * nichts, aber es hing an einer Rundung, nicht an der Rechnung.
3131 */
3132 const breitesteZahl = matrixHoechsteZellbreite(zellen, (z) => laengstesWort(doc, z, false));
3133
3134 /*
3135 * Die erste Spalte traegt ZWEIERLEI: oben die Kopfecke (fett), darunter die
3136 * Namen der raeumenden Signalgruppen (mager). autotable nimmt je Spalte das
3137 * Groesste ueber alle ihre Zellen - beides gehoert also hinein.
3138 *
3139 * Die Wertespalten rechnen mit der breitesten Zahl der GANZEN Matrix statt
3140 * mit der breitesten ihrer eigenen Spalte. Das ist grosszuegiger als noetig
3141 * und damit die sichere Seite.
3142 */
3143 const namensspaltenwort = Math.max(
3144 laengstesWort(doc, MATRIX_ECKE, true),
3145 ...groups.map((g) => laengstesWort(doc, g.name, false)),
3146 );
3147 const mindestbreiten = [
3148 Math.max(namensspaltenwort + 2 * MATRIX_POLSTER, MATRIX_MINDESTBREITE),
3149 ...groups.map((g) =>
3150 Math.max(
3151 Math.max(laengstesWort(doc, g.name, true), breitesteZahl) + 2 * MATRIX_POLSTER,
3152 MATRIX_MINDESTBREITE,
3153 ),
3154 ),
3155 ];
3156 const noetig = mindestbreiten.reduce((summe, b) => summe + b, 0);
3157 if (noetig > context.contentWidth) return false;
3158
3159 /*
3160 * (3) Die Kopfzeile darf nicht das halbe Blatt fuellen. Waechst sie ueber die
3161 * Blatthoehe, laeuft jspdf-autotable in printFullRow in eine Endlosteilung
3162 * und der Ausdruck bricht ab.
3163 *
3164 * Gerechnet wird mit der MINDESTBREITE der Spalte, nicht mit dem Anteil:
3165 * Steht (1)/(2) fest, bekommt jede Spalte mindestens diese Breite, meist
3166 * mehr. Die Zeilenzahl ist damit eine obere Schranke - die Rechnung irrt
3167 * also in die sichere Richtung, waehrend der Anteil auch zu gross sein
3168 * konnte.
3169 */
3170 const nutzhoehe = nutzhoeheJeBlatt(context);
3171 const kopfzeilen = Math.max(
3172 ...groups.map((g, i) =>
3173 matrixZeilen(doc, g.name, (mindestbreiten[i + 1] ?? 0) - 2 * MATRIX_POLSTER, true),
3174 ),
3175 );
3176 return matrixZellhoehe(kopfzeilen) <= nutzhoehe / 2;
3177 }
3178
3179 /** Der bisherige Satz - unveraendert, damit kurze Bezeichnungen es bleiben. */
3180 function zeichneMatrixHeute(
3181 context: Context,
3182 groups: readonly SignalGroup[],
3183 zellen: readonly (readonly string[])[],
3184 ): void {
3185 const { doc } = context;
3186
3187 autoTable(doc, {
3188 startY: context.cursor,
3189 margin: TABELLENRAND,
3190 theme: 'grid',
3191 styles: { fontSize: MATRIX_SCHRIFTGRAD, cellPadding: MATRIX_POLSTER, halign: 'center' },
3192 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
3193 columnStyles: { 0: { fontStyle: 'bold', halign: 'left' } },
3194 head: [[MATRIX_ECKE, ...groups.map((g) => g.name)].map(toWinAnsi)],
3195 body: groups.map((from, i) => [from.name, ...(zellen[i] ?? [])].map(toWinAnsi)),
3196 });
3197
3198 context.cursor = lastTableBottom(doc) + 10;
3199 }
3200
3201 /**
3202 * Der Ausschnittsatz: feste Spaltenbreiten, waagerechte Aufteilung, und - wenn
3203 * die Namen fuer einen Spaltenkopf zu lang sind - Ordnungsnummern im Kopf mit
3204 * der Zuordnung im wiederholten Tabellenkopf.
3205 *
3206 * WAS ER DER UNTERLAGE NIMMT, damit es niemand uebersieht: Der Blick auf die
3207 * ganze Matrix auf einem Blatt geht verloren, sobald sie geteilt wird. Er war
3208 * allerdings schon vorher nur nominell da - der heutige Satz verteilt dieselbe
3209 * Matrix bei 32 Gruppen mit langen Namen auf 113 Blatt und bei 40 auf gar
3210 * keines mehr.
3211 */
3212 function zeichneMatrixAusschnitte(
3213 context: Context,
3214 groups: readonly SignalGroup[],
3215 zellen: readonly (readonly string[])[],
3216 mitSignalgruppentabelle: boolean,
3217 ): void {
3218 const { doc } = context;
3219 const satz = matrixSatz(context, groups, zellen);
3220 const groessen = matrixAufteilung(groups.length, satz.spaltenJeBlock);
3221 const bloecke = groessen.length;
3222 let namenUngeteilt = true;
3223
3224 if (bloecke > 1) {
3225 writeParagraph(
3226 context,
3227 `Die Matrix ist für die Blattbreite zu breit und steht deshalb in ${bloecke} Ausschnitten. ` +
3228 `Jeder Ausschnitt führt alle ${groups.length} räumenden Signalgruppen in derselben ` +
3229 'Reihenfolge und einen anderen Teil der einfahrenden; welchen, steht über jedem ' +
3230 'Ausschnitt auf jeder Seite. Jede Zwischenzeit steht in genau einem Ausschnitt - erst ' +
3231 `alle ${bloecke} Ausschnitte zusammen sind die vollständige Matrix.`,
3232 );
3233 }
3234
3235 if (satz.mitNummern) {
3236 /*
3237 * Der Grund gehoert dazu, und es sind zwei verschiedene (Befunde B3/B4
3238 * fachlich): zu lange Namen - oder Namen, die als Marke nicht taugen, weil
3239 * einer fehlt oder zwei gleich lauten. Im zweiten Fall ist der Absatz
3240 * zugleich der Hinweis, dass mit den Daten etwas nicht stimmt; die Pruefung
3241 * sagt es an ihrer Stelle noch einmal deutlicher.
3242 */
3243 const grund = matrixKopfgrund(groups);
3244 /*
3245 * Der Verweis auf die Tabelle "Signalgruppen" steht nur da, wenn es sie in
3246 * DIESER Ausgabe gibt (Befund B5 technisch): Sonst schickt der Absatz den
3247 * Leser zu einem Abschnitt, den er nicht hat. Die Aufloesung selbst braucht
3248 * ihn nicht - sie steht in den Zeilen der Matrix und ueber jeder Tabelle.
3249 */
3250 const herkunft = mitSignalgruppentabelle
3251 ? 'sondern die Stellung der Signalgruppe in dieser Unterlage: dieselbe Nummer, die in der ' +
3252 'Tabelle „Signalgruppen" in der Spalte „Nr." steht, und dieselbe Reihenfolge wie in den ' +
3253 'Zeilen dieser Matrix, die Nummer und vollen Namen nebeneinander führen.'
3254 : 'sondern die Stellung der Signalgruppe in dieser Unterlage: dieselbe Reihenfolge wie in ' +
3255 'den Zeilen dieser Matrix, die Nummer und vollen Namen nebeneinander führen.';
3256 writeParagraph(
3257 context,
3258 `Die Spaltenköpfe tragen die Ordnungsnummer der einfahrenden Signalgruppe, ${grund}. ` +
3259 `Die Nummer ist keine neu vergebene Kurzbezeichnung, ${herkunft} ` +
3260 (satz.zuordnungImKopf
3261 ? 'Welche Nummer zu welchem Namen gehört, steht über der Tabelle auf jeder Seite. '
3262 : 'Welche Nummer zu welchem Namen gehört, steht vor jedem Ausschnitt; die Zuordnung ' +
3263 'ist länger als ein Blatt und lässt sich deshalb nicht auf jedem Blatt wiederholen. ') +
3264 'Die Tabellenausgabe (CSV) führt dieselbe Matrix mit den vollen Namen in beiden Achsen.',
3265 );
3266 }
3267
3268 /*
3269 * Die Kuerzung des Zeilenkopfs wird gesagt, nicht stillschweigend vollzogen.
3270 * Ohne den Satz stuende in der Anordnungsunterlage eine Bezeichnung mit
3271 * Auslassungszeichen, und der Leser wuesste nicht, wo die vollstaendige
3272 * steht.
3273 */
3274 if (satz.zeilenkopfGekuerzt) {
3275 writeParagraph(
3276 context,
3277 'Die Zeilenköpfe dieser Matrix sind gekürzt: Ein Name, der in der Namensspalte höher ' +
3278 'umbricht als ein Blatt trägt, würde die Tabelle unsetzbar machen. Gekürzte Namen enden ' +
3279 'mit "..."; vollständig stehen sie in der Zuordnung der Spaltenköpfe und in der ' +
3280 'Tabellenausgabe (CSV).',
3281 );
3282 }
3283
3284 let von = 0;
3285 for (let block = 0; block < bloecke; block += 1) {
3286 const anzahl = groessen[block] ?? 0;
3287 if (anzahl === 0) continue;
3288 const spalten = groups.slice(von, von + anzahl);
3289 const wertbreite = (context.contentWidth - satz.nummernspalte - satz.namensspalte) / anzahl;
3290
3291 const banner = matrixBanner(
3292 context,
3293 groups,
3294 von,
3295 anzahl,
3296 satz.mitNummern,
3297 block + 1,
3298 bloecke,
3299 satz.zuordnungImKopf,
3300 );
3301 if (!banner.namenUngeteilt) namenUngeteilt = false;
3302
3303 // Passt die Zuordnung nicht in den wiederholten Kopf, steht sie ungekuerzt
3304 // hier - als Fliesstext, der so viele Folgeblaetter bekommt wie noetig.
3305 if (satz.mitNummern && !satz.zuordnungImKopf) {
3306 const zeilen: string[] = [];
3307 for (let i = von; i < von + anzahl; i += 1) {
3308 const teile = matrixZuordnungszeilen(context, i + 1, matrixAnzeigename(groups[i]));
3309 if (teile.length > 1) namenUngeteilt = false;
3310 zeilen.push(...teile);
3311 }
3312 writeParagraph(
3313 context,
3314 `Zuordnung der Spaltenköpfe für Ausschnitt ${block + 1} von ${bloecke}:`,
3315 );
3316 doc.setFont('helvetica', 'normal');
3317 doc.setFontSize(MATRIX_SCHRIFTGRAD);
3318 doc.setTextColor('#1b2230');
3319 setzeZeilenMitUmbruch(
3320 context,
3321 zeilen.map(toWinAnsi),
3322 MATRIX_SCHRIFTGRAD * MATRIX_ZEILENFAKTOR,
3323 );
3324 context.cursor += 8;
3325 }
3326 const kopf: RowInput[] = [];
3327 if (banner.text !== '') {
3328 const zelle: CellDef = {
3329 content: banner.text,
3330 colSpan: anzahl + (satz.mitNummern ? 2 : 1),
3331 styles: { halign: 'left', fontStyle: 'normal' },
3332 };
3333 kopf.push([zelle]);
3334 }
3335 kopf.push(
3336 [
3337 ...(satz.mitNummern ? ['Nr.'] : []),
3338 MATRIX_ECKE,
3339 ...spalten.map((g, i) => (satz.mitNummern ? String(von + i + 1) : g.name)),
3340 ].map(toWinAnsi),
3341 );
3342
3343 const koerper: RowInput[] = groups.map((from, i) =>
3344 [
3345 ...(satz.mitNummern ? [String(i + 1)] : []),
3346 // Auch der Zeilenkopf sagt lieber "(ohne Bezeichnung)" als nichts: Eine
3347 // leere Zelle liest sich wie ein Druckfehler, und eine Sprachausgabe
3348 // ueberspringt sie ganz. Zu hohe Namen werden hier gekuerzt - siehe
3349 // matrixSatz.
3350 matrixZeilenkopf(
3351 context,
3352 matrixAnzeigename(from),
3353 satz.namensspalte - 2 * MATRIX_POLSTER,
3354 satz.zeilenkopfZeilen,
3355 ),
3356 ...(zellen[i] ?? []).slice(von, von + anzahl),
3357 ].map(toWinAnsi),
3358 );
3359
3360 const spaltenmasse: Record<number, Partial<Styles>> = {};
3361 let s = 0;
3362 if (satz.mitNummern) {
3363 spaltenmasse[s] = { cellWidth: satz.nummernspalte, fontStyle: 'bold', halign: 'right' };
3364 s += 1;
3365 }
3366 spaltenmasse[s] = { cellWidth: satz.namensspalte, fontStyle: 'bold', halign: 'left' };
3367 s += 1;
3368 for (let k = 0; k < anzahl; k += 1) {
3369 spaltenmasse[s + k] = { cellWidth: wertbreite };
3370 }
3371
3372 // Der Ausschnitt soll nicht mit seinem Kopf am Blattfuss beginnen.
3373 ensureSpace(
3374 context,
3375 matrixKopfhoehe(
3376 context,
3377 groups,
3378 von,
3379 anzahl,
3380 satz.mitNummern,
3381 block + 1,
3382 bloecke,
3383 satz.zuordnungImKopf,
3384 ) + 24,
3385 );
3386
3387 autoTable(doc, {
3388 startY: context.cursor,
3389 margin: TABELLENRAND,
3390 theme: 'grid',
3391 styles: {
3392 fontSize: MATRIX_SCHRIFTGRAD,
3393 cellPadding: MATRIX_POLSTER,
3394 halign: 'center',
3395 overflow: 'linebreak',
3396 },
3397 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
3398 columnStyles: spaltenmasse,
3399 /*
3400 * Eine Zeile bleibt auf einem Blatt. Ohne das zerreisst der Blattwechsel
3401 * den Namen der raeumenden Signalgruppe mitten im Wort, und auf dem
3402 * Folgeblatt steht eine Zeile Zahlen ueber einem Namensrest.
3403 */
3404 rowPageBreak: 'avoid',
3405 head: kopf,
3406 body: koerper,
3407 });
3408
3409 context.cursor = lastTableBottom(doc) + 10;
3410 von += anzahl;
3411 }
3412
3413 context.matrixSatzbefund = {
3414 mitOrdnungsnummern: satz.mitNummern,
3415 namenUngeteilt,
3416 zuordnungAufJedemBlatt: satz.zuordnungImKopf,
3417 };
3418 }
3419
3420 function drawIntergreenMatrix(context: Context, mitSignalgruppentabelle: boolean): void {
3421 const { project, plan } = context;
3422 const groups = project.signalGroups;
3423
3424 if (groups.length === 0) {
3425 writeParagraph(context, 'Es sind keine Signalgruppen erfasst.');
3426 return;
3427 }
3428
3429 const zellen = groups.map((from) => groups.map((to) => matrixWert(plan, from, to)));
3430
3431 if (matrixPasstAufEinBlatt(context, groups, zellen)) {
3432 zeichneMatrixHeute(context, groups, zellen);
3433 // Der heutige Satz traegt die Namen selbst im Kopf; eine Zuordnungsliste
3434 // gibt es dort nicht, und "ungeteilt" ist fuer sie keine Frage.
3435 context.matrixSatzbefund = {
3436 mitOrdnungsnummern: false,
3437 namenUngeteilt: true,
3438 zuordnungAufJedemBlatt: true,
3439 };
3440 } else {
3441 zeichneMatrixAusschnitte(context, groups, zellen, mitSignalgruppentabelle);
3442 }
3443
3444 writeParagraph(
3445 context,
3446 'Angaben in Sekunden. Ein Punkt bedeutet: die Signalgruppen sind verträglich, es ist keine ' +
3447 'Konfliktbeziehung erfasst. Mit Stern gekennzeichnete Werte sind von Hand vorgegeben und ersetzen ' +
3448 'die Berechnung.',
3449 );
3450
3451 /*
3452 * Der Rechenansatz der Ueberfahrzeit gehoert UNTER DIE MATRIX und nicht nur
3453 * in den Rechenweg (Fassung 5.5.0, Frage E2): Der Rechenweg
3454 * ist abwaehlbar, die Matrix steht dann allein da - und dieselbe Zahl hat je
3455 * nach Ansatz eine andere Ueberfahrzeit hinter sich. Der Satz kommt aus
3456 * bewertung.ts, damit Ausdruck und Bildschirm nicht auseinanderlaufen, und
3457 * rechnet mit den Vorgaben DIESES Plans (Befund C21).
3458 *
3459 * NUR, WO ER IN EINE ZAHL DIESER MATRIX EINGEHT (seit 5.43.0): Der Satz stand
3460 * unbedingt da - auch an einer einstreifigen Verkehrsfuehrung, wo
3461 * `buildSignalPlan` jeden raeumenden Kfz-Strom nach dem Verfahren der
3462 * Engstellensignalisierung rechnet und die Ueberfahrzeit fest bei tue = 4 s
3463 * liegt. Die Unterlage nannte dort eine Rechnung, die nicht stattgefunden
3464 * hat, neben den Zahlen, die der Pruefer nachrechnet. Weglassen allein waere
3465 * zu wenig: Ohne Rechenweg stuende die Matrix dann ohne jede Angabe zur
3466 * Ueberfahrzeit da - genau der Grund, aus dem der Satz hier steht. An seine
3467 * Stelle tritt das Verfahren, das gerechnet hat.
3468 *
3469 * UND DER DRITTE FALL, den keine Anlagenart hergibt: Ist JEDE Zwischenzeit
3470 * eines raeumenden Kraftfahrzeugs von Hand vorgegeben - oder raeumt an keiner
3471 * Beziehung ein Kraftfahrzeug -, hat der Ansatz ebenfalls nicht gerechnet,
3472 * und hier steht dann keiner der beiden Saetze. Das ist gewollt: Dass die
3473 * Zahlen vorgegeben sind, sagt die Sternfussnote darueber, und den Ansatz
3474 * nennt in diesem Ausdruck allein das Deckblatt - dort mit dem
3475 * Wirkungsvermerk daneben. Ein zweiter Absatz ueber eine Rechnung, die es
3476 * nicht gab, steht besser nicht unter der Matrix.
3477 *
3478 * DIE ANTWORT KOMMT AUS DEM FACHKERN (ansaetze.ts), weil Deckblatt,
3479 * Abweichungstafel und Pruefbericht dieselbe brauchen; die Bedingung hier
3480 * nachzubauen war der Anfang der vier auseinanderlaufenden Fassungen. Bewacht
3481 * von tests/export/matrixVerfahrenssatz.test.ts.
3482 */
3483 const ohneWirkung = ueberfahrzeitAnsatzOhneWirkung(project, plan);
3484
3485 if (ohneWirkung === null) {
3486 writeParagraph(
3487 context,
3488 ueberfahrzeitAnsatzSatz(project.settings.ueberfahrzeitAnsatz, plan.defaults),
3489 );
3490 } else if (verfahrenDerKfzRaeumstroeme(project, plan).has('engstelle')) {
3491 writeParagraph(context, ohneWirkung.begruendung);
3492 }
3493 }
3494
3495 function drawIntergreenDetails(context: Context, mitSignalgruppentabelle: boolean): void {
3496 const { doc, project, plan } = context;
3497 const rows: string[][] = [];
3498 let mitAnfahransatz = false;
3499 /*
3500 * ZWEI WEITERE FUSSNOTEN, BEIDE AUS DEMSELBEN GRUND (Fassung 5.27.0): Der
3501 * Rechenweg muss sich aus den gedruckten Groessen nachrechnen lassen.
3502 *
3503 * `mitEngstellenverfahren`: An einer Engstelle gilt eine andere Formel
3504 * (RiLSA 2015, Abschnitt 5.2.2) - andere Ueberfahrzeit, Raeumweg ohne
3505 * Fahrzeuglaenge, keine Einfahrzeit. Wer die Zeile mit der
3506 * Knotenpunktformel nachrechnet, kommt auf eine andere Zahl und haelt den
3507 * Plan fuer falsch.
3508 *
3509 * `mitUntergrenze`: Wo tue + tr die Untergrenze tG + 1 s unterschreiten,
3510 * steht in der Spalte tz NICHT tue + tr - te, sondern die angehobene Summe
3511 * abzueglich te. Ohne die Fussnote waere die Zeile aus ihren eigenen
3512 * Spalten nicht nachzurechnen.
3513 */
3514 let mitEngstellenverfahren = false;
3515 const untergrenzenZeilen: string[] = [];
3516 /*
3517 * Die raeumenden Signalgruppen des Anfahransatzes mit ihrer zulaessigen
3518 * Hoechstgeschwindigkeit - fuer den Fall, dass die Signalgruppentabelle
3519 * abgewaehlt ist und die Fussnote sie selbst nennen muss (Fassung 5.9.0).
3520 */
3521 const anfahrGruppen = new Map<string, string>();
3522
3523 for (const conflict of project.conflicts) {
3524 const from = project.signalGroups.find((g) => g.id === conflict.fromId);
3525 const to = project.signalGroups.find((g) => g.id === conflict.toId);
3526 const resolved = plan.intergreens.get(intergreenKey(conflict.fromId, conflict.toId));
3527 if (!from || !to || !resolved) continue;
3528 const calc = resolved.calculation;
3529 if (calc !== null && calc.raeumansatz === 'anfahren') {
3530 mitAnfahransatz = true;
3531 // Der Name in Anfuehrungszeichen, damit Name und Zahl in der Aufzaehlung
3532 // nicht unverbunden hintereinander stehen.
3533 anfahrGruppen.set(from.id, `„${from.name}" ${fmt.numShort(from.vZul)} km/h`);
3534 }
3535 if (calc !== null && calc.verfahren === 'engstelle') mitEngstellenverfahren = true;
3536 if (calc !== null && calc.ueberfahrRaeumzeitUntergrenze !== null) {
3537 untergrenzenZeilen.push(
3538 `${from.name} -> ${to.name} (${fmt.seconds(calc.ueberfahrRaeumzeitUntergrenze, 1)} s)`,
3539 );
3540 }
3541 rows.push(
3542 [
3543 `${from.name} -> ${to.name}`,
3544 calc === null ? 'vorgegeben' : fmt.meters(calc.clearingPath, 1),
3545 /*
3546 * Beim Anfahransatz (OePNV mit Halt vor dem Knotenpunkt) gibt es
3547 * keine konstante Raeumgeschwindigkeit - aus einer gedruckten vr
3548 * liesse sich tr nicht nachrechnen. In der Spalte steht deshalb die
3549 * Anfahrbeschleunigung; die Formel nennt die Fussnote unter der
3550 * Tabelle.
3551 */
3552 calc === null
3553 ? '–'
3554 : calc.raeumansatz === 'anfahren'
3555 ? `a = ${fmt.num(calc.anfahrbeschleunigung ?? 0, 1)} m/s²`
3556 : fmt.metersPerSecond(calc.clearingSpeed, 1),
3557 calc === null ? '–' : fmt.seconds(calc.clearingTime, 2),
3558 calc === null ? '–' : fmt.meters(calc.enteringPath, 1),
3559 calc === null ? '–' : fmt.metersPerSecond(calc.enteringSpeed, 1),
3560 calc === null ? '–' : fmt.seconds(calc.enteringTime, 2),
3561 calc === null ? '–' : fmt.seconds(calc.crossingTime, 1),
3562 calc === null ? '–' : fmt.seconds(calc.raw, 2),
3563 fmt.seconds(resolved.value),
3564 /*
3565 * NEU (Fassung 5.5.0, Frage E1): das Merkmal "enger
3566 * Innenradius" dieser Beziehung. Ohne die Spalte stuende in der Zeile
3567 * eine Raeumgeschwindigkeit von 5,0 m/s neben einem Regelwert von
3568 * 7,0 m/s, ohne dass die Unterlage sagt, woher der Unterschied kommt -
3569 * und ein Praxisansatz, der in der RiLSA 2015 nicht nachweisbar ist,
3570 * saehe aus wie ein Regelwerkswert.
3571 *
3572 * Die Spalte bleibt auch dann stehen, wenn keine Beziehung das Merkmal
3573 * traegt: Zwei Ausdrucke desselben Knotenpunkts sollen dieselbe
3574 * Tabellenform haben, sonst laesst sich der eine nicht neben den
3575 * anderen legen.
3576 */
3577 engerRadiusZelle(conflict.engerRadius),
3578 // Zwei getrennte Spalten, und das mit Absicht. "Herkunft der Wege"
3579 // kommt aus dem Modell und wird von jeder Aenderung an den Wegen, den
3580 // Geschwindigkeiten, der Ueberfahrzeit oder der Zwischenzeit auf "Von
3581 // Hand eingetragen" zurueckgesetzt - der Anwender kann das Feld nicht
3582 // beschreiben. Die Bemerkung daneben ist freier Text und kann aelter
3583 // sein als der Wert neben ihr; sie war frueher der einzige Anhalt und
3584 // hat als Nachweis genau daran versagt.
3585 wegherkunftZelle(conflict),
3586 conflict.note === '' ? '–' : conflict.note,
3587 ].map(toWinAnsi),
3588 );
3589 }
3590
3591 autoTable(doc, {
3592 startY: context.cursor,
3593 margin: TABELLENRAND,
3594 theme: 'grid',
3595 styles: { fontSize: 8, cellPadding: 2.5, valign: 'top' },
3596 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
3597 columnStyles: { 10: { cellWidth: 84 }, 11: { cellWidth: 100 }, 12: { cellWidth: 130 } },
3598 head: [
3599 [
3600 'Beziehung',
3601 'sr',
3602 'vr',
3603 'tr',
3604 'se',
3605 've',
3606 'te',
3607 'tü',
3608 'tü+tr-te',
3609 'tz',
3610 'Enger Innenradius',
3611 'Herkunft der Wege',
3612 'Bemerkung',
3613 ].map(toWinAnsi),
3614 ],
3615 body: rows,
3616 });
3617
3618 context.cursor = lastTableBottom(doc) + 10;
3619 writeParagraph(
3620 context,
3621 'tz = tü + tr - te, aufgerundet auf ganze Sekunden. Die Spalte "Herkunft der Wege" weist nach, ' +
3622 'worauf Räum- und Einfahrweg der Zeile beruhen: "Aus dem Lageplan vermessen" nennt den ' +
3623 'Zeitpunkt der Übernahme aus dem maßstäblichen Lageplan, "Von Hand eingetragen" bezeichnet ' +
3624 'vom Bearbeiter erfasste Maße, und "Herkunft nicht nachgewiesen" steht für Werte aus einem ' +
3625 'Altprojekt, einer Vorlage oder einer Datei älterer Fassung, über deren Ermittlung nichts ' +
3626 'bekannt ist. Das Programm führt die Angabe selbst und lässt sie nicht beschreiben: Der ' +
3627 'Nachweis aus dem Lageplan entfällt, sobald einer der beiden Wege, eine Räum- oder ' +
3628 'Einfahrgeschwindigkeit, die Überfahrzeit oder die Zwischenzeit von Hand geändert wird. ' +
3629 'Die Spalte "Bemerkung" gibt daneben den freien Vermerk des Bearbeiters wieder; ein Strich ' +
3630 'bedeutet, dass keine Bemerkung erfasst ist. Sie ist kein Nachweis der Herkunft und kann aus ' +
3631 'der Zeit vor einer späteren Änderung der Wege stammen.',
3632 );
3633
3634 /*
3635 * Was die neue Spalte bedeutet, und was sie NICHT bedeutet (Fassung 5.5.0, Frage E1).
3636 *
3637 * Der Absatz steht unbedingt da, weil die Spalte unbedingt da steht: Eine
3638 * Spaltenueberschrift "Enger Innenradius" ohne Erlaeuterung liest sich wie
3639 * ein Regelwerksmerkmal. Die beiden Geschwindigkeiten kommen aus den
3640 * Vorgaben dieses Plans, nicht aus dem Satz (Befund C21).
3641 *
3642 * WELCHER STROM ABBIEGT, GEHOERT DAZU: Eine Sammelsignalgruppe fuehrt
3643 * geradeaus UND rechts; der Raeumweg einer einzelnen Beziehung gehoert dann
3644 * dem Strom, der bei der Uebernahme aus dem Lageplan den Ausschlag gab. Ohne
3645 * diesen Satz steht in der Unterlage eine Zeile mit gesenkter
3646 * Raeumgeschwindigkeit neben einer Signalgruppentabelle, die "geradeaus"
3647 * fuehrt, und nichts erklaert den Unterschied.
3648 */
3649 writeParagraph(
3650 context,
3651 `Die Spalte "Enger Innenradius" gibt das Merkmal der einzelnen Konfliktbeziehung wieder. Ist ` +
3652 `es gesetzt, räumt ein abbiegender Kraftfahrzeugstrom ` +
3653 `${engerRadiusRichtungSatzteil(plan.defaults)}. Bei allen übrigen Strömen bleibt das ` +
3654 'Merkmal ohne Wirkung - ist es dort gesetzt, meldet der Prüfbericht eine Warnung. ' +
3655 'Ob ein Strom abbiegt, entscheidet dabei die Fahrbeziehung des Stroms, dem der Räumweg ' +
3656 'dieser Zeile gehört, und nicht die der räumenden Signalgruppe: Führt eine Signalgruppe ' +
3657 'geradeaus und rechts aus einem Fahrstreifen, so gehört der Räumweg dem Strom, der bei ' +
3658 'der Übernahme aus dem Lageplan den Ausschlag gab. ' +
3659 // DER RUECKFALL GEHOERT IN DENSELBEN ABSATZ: Festgehalten wird der
3660 // massgebende Strom allein bei der Uebernahme aus dem Lageplan. Ohne
3661 // diesen Satz entnimmt der Pruefer der Unterlage eine Zusicherung, die
3662 // das Programm im Regelfall - Altprojekt, Vorlage, von Hand erfasst -
3663 // nicht einhaelt, und haelt die Zeile faelschlich fuer nachgewiesen.
3664 // Wortlaut wie im Pruefbericht (validation/rules.ts,
3665 // 'zwischenzeiten.raeumbeziehung-nicht-nachgewiesen').
3666 'Ist nicht festgehalten, welcher Strom den Räumweg gefahren ist, wird mit der ' +
3667 'Fahrbeziehung der Signalgruppe gerechnet, und der Prüfbericht meldet das. ' +
3668 'Mit welcher Räumgeschwindigkeit gerechnet wurde, steht in der Spalte "vr". ' +
3669 // Vorrang der Handeingabe (Fassung 5.5.0): Er stand bis dahin nirgends -
3670 // weder hier noch in der Hilfe noch im Pruefbericht -, und die Rechnung
3671 // verwarf das Merkmal ohne jede Notiz.
3672 'Steht an derselben Beziehung eine Räumgeschwindigkeit von Hand in der Spalte "vr", so ' +
3673 'hat sie Vorrang: Gerechnet wird dann mit ihr, das Merkmal geht in keine Zahl ein, und ' +
3674 'der Prüfbericht warnt. Auch die Schwelle von 10 m prüft dieses ' +
3675 'Programm nicht nach: Es kennt keinen Abbiegeradius, die Angabe ist vom Bearbeiter zu ' +
3676 'verantworten. Die Spalte "tü" folgt bei Kraftfahrzeugströmen dem Rechenansatz der ' +
3677 'Überfahrzeit (welcher gilt, steht auf dem Deckblatt und im Abschnitt ' +
3678 '"Zwischenzeitenmatrix"); für Fußgänger, Radverkehr und ÖPNV gelten stattdessen deren ' +
3679 'eigene Regelwerte - der Ansatz erreicht sie nicht.',
3680 );
3681
3682 /*
3683 * Die Formel gehoert in die Unterlage, sobald eine Zeile nach ihr gerechnet
3684 * ist - sonst steht dort ein tr, das sich aus keiner gedruckten Groesse
3685 * ergibt. Ohne betroffene Zeile bleibt der Absatz weg, damit er nicht als
3686 * allgemeine Rechenregel missverstanden wird.
3687 */
3688 if (mitEngstellenverfahren) {
3689 writeParagraph(
3690 context,
3691 'An einer einstreifigen Verkehrsführung gilt für den Kraftfahrzeugverkehr das eigene ' +
3692 'Verfahren der Engstellensignalisierung (RiLSA 2015, Abschnitt 5.2.2): ' +
3693 'tz = tü + sr/Vr · 3,6, mit einer Überfahrzeit von tü = 4 s, dem Abstand der Haltlinien ' +
3694 'als Räumweg sr – OHNE Fahrzeuglänge – und einer mittleren Räumgeschwindigkeit Vr nach ' +
3695 'der zulässigen Höchstgeschwindigkeit (50, 40 bzw. 30 km/h bei 60, 50 bzw. 40 km/h). ' +
3696 'Eine Einfahrzeit wird nicht abgezogen; die Spalten se, ve und te führen deshalb 0. Die ' +
3697 'Spalte vr nennt dieselbe Räumgeschwindigkeit in m/s, damit tr = sr/vr nachzurechnen ' +
3698 'ist. Für Rad, Fußgänger und ÖPNV gilt auch dort Abschnitt 2.5.',
3699 );
3700 }
3701
3702 if (untergrenzenZeilen.length > 0) {
3703 writeParagraph(
3704 context,
3705 'Für geradeaus fahrende und abbiegende Kraftfahrzeuge verlangt die RiLSA 2015 ' +
3706 '(Abschnitt 2.5.2) eine Untergrenze: tü + tr mindestens Gelbzeit + 1 s. Wo sie greift, ' +
3707 'ist die Spalte tz nicht tü + tr − te, sondern diese Untergrenze abzüglich te; die ' +
3708 'Spalten tü und tr führen unverändert die gerechneten Werte. Betroffen ' +
3709 `${untergrenzenZeilen.length === 1 ? 'ist' : 'sind'} in diesem Plan: ` +
3710 `${untergrenzenZeilen.join(', ')}.`,
3711 );
3712 }
3713
3714 if (mitAnfahransatz) {
3715 /*
3716 * WOHER DIE VMAX KOMMT, STEHT NUR DA, WENN ES SIE IN DIESER AUSGABE GIBT
3717 * (Fassung 5.9.0; dasselbe Muster wie beim Verweis auf die Tabelle
3718 * "Signalgruppen" in zeichneMatrixAusschnitte).
3719 *
3720 * Die Spalte "V zul" entsteht nur unter `options.includeSignalGroups`, der
3721 * Rechenweg haengt an anderen Schaltern. Ohne die Tabelle verwies der
3722 * Absatz auf einen Abschnitt, den der Leser nicht hat, und das gedruckte
3723 * tr liess sich aus keiner gedruckten Groesse nachrechnen. Die
3724 * knotenpunktweite "Zulässige Höchstgeschwindigkeit" des Deckblatts ist
3725 * nicht gemeint - gerechnet wird mit der der raeumenden Signalgruppe.
3726 * Deshalb nennt der Absatz sie dann selbst - als eigener Satz und mit der
3727 * Zuordnung zur Zeile, nicht als Aneinanderreihung von Name und Zahl.
3728 */
3729 const vmaxHerkunft = mitSignalgruppentabelle
3730 ? '(Spalte "V zul" der Signalgruppentabelle)'
3731 : '(die Tabelle „Signalgruppen" ist für diese Ausgabe abgewählt)';
3732 const vmaxNachsatz = mitSignalgruppentabelle
3733 ? ''
3734 : ' Die räumenden Signalgruppen dieses Ansatzes und ihre Vmax: ' +
3735 `${[...anfahrGruppen.values()].join(', ')}. Maßgebend ist je Zeile die in der Spalte ` +
3736 '„Beziehung" zuerst genannte, also die räumende Signalgruppe.';
3737 writeParagraph(
3738 context,
3739 'Bei ÖPNV-Strömen, die vor dem Knotenpunkt halten, gilt nach RiLSA 2015 (Fall 4) der ' +
3740 'Anfahransatz: Die Spalte vr nennt statt der Geschwindigkeit die Anfahrbeschleunigung a, ' +
3741 'die Überfahrzeit tü beträgt 0 s, und die Räumzeit ist tr = Wurzel aus (2 · sr / a). ' +
3742 'Erreicht das Fahrzeug dabei die zulässige Höchstgeschwindigkeit Vmax der Signalgruppe ' +
3743 `${vmaxHerkunft}, wird der Restweg mit ihr gefahren: ` +
3744 `tr = Vmax/(3,6 · a) + (sr - Vmax²/(2 · 3,6² · a)) / (Vmax/3,6).${vmaxNachsatz}`,
3745 );
3746 }
3747 }
3748
3749 /**
3750 * Wie viele Beziehungen ihre Wege woher haben.
3751 *
3752 * Gehoert in die Planungsgrundlage, weil der Rechenweg der Zwischenzeiten nur
3753 * auf Wunsch gedruckt wird: Ohne diese Zeile stuende in einer abgewaehlten
3754 * Fassung nirgends, dass ein Teil der Masse nicht nachgewiesen ist. Gerade in
3755 * einer Unterlage OHNE Lageplan ist "woher dann?" die entscheidende Frage.
3756 */
3757 function wegnachweisVerteilung(project: Project): string {
3758 if (project.conflicts.length === 0) return 'Keine Konfliktbeziehungen erfasst.';
3759 // Eigene Kurzformen statt der kleingeschriebenen Benennung: "12 herkunft
3760 // nicht nachgewiesen" ist kein deutscher Satz.
3761 const kurz = {
3762 lageplan: 'aus dem Lageplan vermessen',
3763 handeingabe: 'von Hand eingetragen',
3764 unbestimmt: 'ohne Nachweis der Herkunft',
3765 } as const;
3766 const teile: string[] = [];
3767 for (const stufe of ['lageplan', 'handeingabe', 'unbestimmt'] as const) {
3768 const anzahl = project.conflicts.filter((c) => c.wegnachweis.herkunft === stufe).length;
3769 if (anzahl > 0) teile.push(`${anzahl} ${kurz[stufe]}`);
3770 }
3771 return `${project.conflicts.length} Beziehungen: ${teile.join(', ')}.`;
3772 }
3773
3774 /** Zustand eines Verfahrensergebnisses in einem Wort - wie in der Phasenansicht. */
3775 function zustandstext(bounded: CycleTimeResult['bounded']): string {
3776 switch (bounded) {
3777 case 'keine':
3778 return 'Rechenwert';
3779 case 'minimum':
3780 return 'Untergrenze maßgebend';
3781 case 'mindestumlauf':
3782 return 'Mindestumlauf maßgebend';
3783 case 'maximum':
3784 return 'über dem Höchstwert';
3785 case 'uebersaettigt':
3786 return 'nicht bemessbar';
3787 }
3788 }
3789
3790 /**
3791 * Die vier Rechenansaetze der Umlaufzeit nebeneinander.
3792 *
3793 * WOZU DAS IN EINER ANORDNUNGSUNTERLAGE STEHT: Eine Anordnung nach Paragraf 45
3794 * StVO ist eine Ermessensentscheidung und muss begruendet werden. Die
3795 * Umlaufzeit ist die Zahl, an der Wartezeit, Rueckstau und damit die Frage
3796 * haengen, ob die angeordnete Regelung den Verkehr bewaeltigt. Vor der
3797 * Fassung 5.29.0 stand in der Unterlage genau eine Zahl und ein Verfahrensname;
3798 * warum dieses Verfahren und nicht ein anderes, stand nirgends, und ein Pruefer
3799 * konnte es auch nicht nachrechnen, ohne das Programm selbst zu bedienen.
3800 *
3801 * ES IST KEINE EMPFEHLUNG UND WIRD ALS SOLCHE AUCH NICHT GEDRUCKT. Der
3802 * Abschnitt nennt vier Zahlen mit ihrem Zustand und den Wert, der den Bedarf
3803 * beider Zielgroessen deckt. Die Regel dahinter steht in keinem Regelwerk; der
3804 * Absatz darunter sagt das, und das Fundstellenverzeichnis fuehrt sie unter
3805 * `programm-umlaufzeitvergleich`.
3806 */
3807 function drawCycleComparison(context: Context): void {
3808 const { doc, plan, project } = context;
3809 const vergleich = plan.cycleComparison;
3810 if (vergleich === null) {
3811 writeParagraph(
3812 context,
3813 'Für diese Planung liegt kein Vergleich vor: Ohne Phasen gibt es keine Verlustzeit und ' +
3814 'damit keine Umlaufzeit zu vergleichen.',
3815 );
3816 return;
3817 }
3818
3819 const gewaehlt = project.program.method;
3820
3821 autoTable(doc, {
3822 startY: context.cursor,
3823 margin: TABELLENRAND,
3824 theme: 'grid',
3825 styles: { fontSize: 9, cellPadding: 3 },
3826 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
3827 head: [['Verfahren', 'Umlaufzeit', 'Rechenwert', 'Zustand'].map(toWinAnsi)],
3828 body: CYCLE_METHODS.map((method) => {
3829 const ergebnis = vergleich.results[method];
3830 return [
3831 method === gewaehlt
3832 ? `${CYCLE_METHOD_LABELS[method]} (gewählt)`
3833 : CYCLE_METHOD_LABELS[method],
3834 fmt.seconds(ergebnis.cycleTime),
3835 // Bei Uebersaettigung ist der Rechenwert unendlich; `fmt.seconds`
3836 // schreibt dafuer einen Strich (dieselbe Stelle wie am Bildschirm).
3837 fmt.seconds(ergebnis.raw, 1),
3838 zustandstext(ergebnis.bounded),
3839 ].map(toWinAnsi);
3840 }),
3841 });
3842
3843 context.cursor = lastTableBottom(doc) + 12;
3844
3845 writeParagraph(context, vergleich.reason);
3846 writeParagraph(
3847 context,
3848 'Diese Gegenüberstellung ist keine Empfehlung. Welcher Ansatz anzuwenden ist, sagt kein ' +
3849 'Regelwerk; die Wahl trifft der Planer und verantwortet sie fachlich. Auch die Regel, nach ' +
3850 'der der oben genannte Wert gebildet wird – maßgebend ist der größere aus ' +
3851 'kapazitätsorientiertem Bedarf und Wartezeitoptimum –, steht in keinem Regelwerk: Sie ist ' +
3852 'ein Ansatz dieses Programms und im Fundstellenverzeichnis als solcher ausgewiesen. ' +
3853 'Gerechnet wird der Signalzeitenplan ausschließlich mit dem gewählten Verfahren; die ' +
3854 'Beanstandungen der übrigen Ansätze stehen nur in dieser Tafel und gehen nicht in den ' +
3855 'Prüfbericht ein.',
3856 );
3857 if (gewaehlt === 'manuell') {
3858 writeParagraph(
3859 context,
3860 'Die Umlaufzeit dieser Planung ist von Hand vorgegeben; keines der vier Verfahren geht in ' +
3861 'sie ein. Die Tafel steht hier als Vergleichsmaßstab: Sie zeigt, was die Ansätze aus ' +
3862 'denselben Verkehrsstärken, Zwischenzeiten und Mindestfreigabezeiten ergeben würden.',
3863 );
3864 }
3865 }
3866
3867 function drawPhaseTables(context: Context): void {
3868 const { doc, project, plan } = context;
3869
3870 // Beginn und Freigabezeit der Phasen sind auf die Umlaufzeit verteilt.
3871 ersatzwertAbsatz(context);
3872
3873 autoTable(doc, {
3874 startY: context.cursor,
3875 margin: TABELLENRAND,
3876 theme: 'grid',
3877 styles: { fontSize: 9, cellPadding: 3 },
3878 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
3879 head: [['Nr.', 'Phase', 'Signalgruppen mit Freigabe', 'Beginn', 'Freigabezeit'].map(toWinAnsi)],
3880 body: plan.phases.map((phase, index) =>
3881 [
3882 String(index + 1),
3883 phase.name,
3884 phase.signalGroupIds
3885 .map((id) => project.signalGroups.find((g) => g.id === id)?.name ?? '?')
3886 .join(', '),
3887 fmt.seconds(phase.start),
3888 fmt.seconds(phase.duration),
3889 ].map(toWinAnsi),
3890 ),
3891 });
3892
3893 context.cursor = lastTableBottom(doc) + 16;
3894 ensureSpace(context, 120);
3895
3896 autoTable(doc, {
3897 startY: context.cursor,
3898 margin: TABELLENRAND,
3899 theme: 'grid',
3900 styles: { fontSize: 9, cellPadding: 3 },
3901 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
3902 head: [['Übergang', 'Beginn', 'Dauer', 'Maßgebende Beziehung'].map(toWinAnsi)],
3903 body: plan.transitions.map((transition) => {
3904 const from = plan.phases.find((p) => p.id === transition.fromPhaseId);
3905 const to = plan.phases.find((p) => p.id === transition.toPhaseId);
3906 return [
3907 `${from?.name ?? '?'} -> ${to?.name ?? '?'}`,
3908 fmt.seconds(transition.start),
3909 fmt.seconds(transition.duration),
3910 transition.critical === null
3911 ? 'keine feindliche Beziehung'
3912 : `${transition.critical.fromGroupName} räumt -> ${transition.critical.toGroupName} fährt ein`,
3913 ].map(toWinAnsi);
3914 }),
3915 });
3916
3917 context.cursor = lastTableBottom(doc) + 16;
3918 }
3919
3920 /*
3921 * Kleinstmasse der gestauchten Zeichnung.
3922 *
3923 * Befund M12. Gestaucht wird nur so weit, wie die Zeichnung lesbar bleibt;
3924 * darunter wird auf mehrere Blaetter aufgeteilt. Eine Zeichnung, die so lange
3925 * verkleinert wird, bis sie passt, ist am Ende genauso unbrauchbar wie eine,
3926 * die ueber den Rand laeuft.
3927 */
3928 const PLAN_MIN_ROW_HEIGHT = 14;
3929 const PLAN_MIN_ROW_GAP = 3;
3930 const PLAN_MIN_FONT_SIZE = 8;
3931
3932 /** Abstand zwischen der Zeichnung und dem Absatz darunter. */
3933 const PLAN_ABSATZ_ABSTAND = 14;
3934
3935 /**
3936 * Verweis auf die gleichwertige Textfassung der Zeichnung.
3937 *
3938 * Befund S1 (1.1.1 Nicht-Text-Inhalt, A): Die Zeichnung hat keinen
3939 * Alternativtext, und dass die Tabelle "Signalzeiten je Signalgruppe" denselben
3940 * Inhalt in Textform enthaelt, stand NIRGENDS im Dokument - ein Suchlauf ueber
3941 * den ganzen Text fand das Wort "Grafik" genau einmal, in der
3942 * Bestandteileliste.
3943 *
3944 * Der Absatz ist die WCAG-Technik G74 (Alternative im umgebenden Text, mit
3945 * Verweis). Er behebt 1.1.1 NICHT: Ohne Strukturbaum gibt es kein Figure, dem
3946 * eine Alternative zugeordnet waere. Er macht die Unterlage im Vorlesefall
3947 * benutzbar, und er ist die Voraussetzung dafuer, dass der Abschnitt
3948 * "Barrierefreiheit dieser Datei" ueberhaupt behaupten darf, die Tabelle sei
3949 * die Textfassung der Zeichnung.
3950 */
3951 /**
3952 * Der gleichbleibende Teil des Verweisabsatzes.
3953 *
3954 * Der Nachsatz zum Phasenband haengt daran, ob der Abschnitt "Phasen und
3955 * Phasenuebergaenge" ueberhaupt ausgegeben wird: Die Zeichnung traegt ueber der
3956 * Zeitachse Phasennamen und Uebergangszeiten, und die stehen in der
3957 * Signalzeitentabelle NICHT. Ein Verweis auf einen abgewaehlten Abschnitt waere
3958 * ein Verweis ins Leere.
3959 */
3960 function planVerweisRumpf(mitPhasenabschnitt: boolean): string {
3961 return (
3962 'Über diesem Absatz steht eine Zeichnung: der Signalzeitenplan als ' +
3963 'Balkendiagramm. Jede Zeile ist eine Signalgruppe, die waagerechte Achse ist der Umlauf in ' +
3964 'Sekunden; die farbigen Abschnitte sind die Signalbilder und tragen ihr Kürzel (R = Rot, ' +
3965 'RG = Rot-Gelb, F = Freigabe, GE = Gelb) und ihre Dauer. Dieselben Angaben stehen ' +
3966 'vollständig in der Tabelle „Signalzeiten je Signalgruppe", die auf die Zeichnung folgt; ' +
3967 'diese Tabelle ist die gleichwertige Textfassung der Zeichnung. ' +
3968 (mitPhasenabschnitt
3969 ? 'Über der Zeitachse steht das Phasenband mit Phasennamen und Übergangszeiten; es ist im ' +
3970 'Abschnitt „Phasen und Phasenübergänge" in Textform aufgeführt.'
3971 : 'Über der Zeitachse steht das Phasenband mit Phasennamen und Übergangszeiten; der ' +
3972 'Abschnitt „Phasen und Phasenübergänge", der sie in Textform aufführt, wurde für diese ' +
3973 'Ausgabe abgewählt.')
3974 );
3975 }
3976
3977 /** Blattangabe vor dem Verweisabsatz - nur, wenn die Zeichnung geteilt ist. */
3978 function planBlattangabe(blatt: number, blaetter: number, gruppen: string): string {
3979 return blaetter > 1 ? `Blatt ${blatt} von ${blaetter} der Zeichnung, ${gruppen}. ` : '';
3980 }
3981
3982 /** Bezeichnung der Signalgruppen eines Blattes - Einzahl, wenn es nur eine ist. */
3983 function blattGruppen(gruppen: readonly PlannedSignalGroup[]): string {
3984 const erste = gruppen[0]?.name ?? '–';
3985 const letzte = gruppen[gruppen.length - 1]?.name ?? '–';
3986 return gruppen.length === 1 ? `Signalgruppe ${erste}` : `Signalgruppen ${erste} bis ${letzte}`;
3987 }
3988
3989 /**
3990 * Laengste Blattangabe, die bei diesem Plan ueberhaupt entstehen kann.
3991 *
3992 * Obere Schranke fuer die Platzreserve unter der Zeichnung (Befund B1): die
3993 * hoechste denkbare Blattzahl - eine Signalgruppe je Blatt - und die beiden
3994 * laengsten Namen. Kein Blatt einer tatsaechlichen Aufteilung kann laenger
3995 * ausfallen, also genuegt EIN zweiter Anlauf.
3996 */
3997 function laengsteBlattangabe(plan: SignalPlan): string {
3998 const anzahl = Math.max(2, plan.groups.length);
3999 const namen = [...plan.groups.map((g) => g.name)].sort((a, b) => b.length - a.length);
4000 const erste = namen[0] ?? '–';
4001 const zweite = namen[1] ?? erste;
4002 return `Blatt ${anzahl} von ${anzahl} der Zeichnung, Signalgruppen ${erste} bis ${zweite}. `;
4003 }
4004
4005 function planVerweisAbsatz(
4006 blatt: number,
4007 blaetter: number,
4008 gruppen: string,
4009 mitPhasenabschnitt: boolean,
4010 ): string {
4011 return `${planBlattangabe(blatt, blaetter, gruppen)}${planVerweisRumpf(mitPhasenabschnitt)}`;
4012 }
4013
4014 /**
4015 * Fussnote zu Abschnitten, die fuer eine Beschriftung zu schmal sind.
4016 *
4017 * Befund M5 (1.4.1 Verwendung von Farbe, A): In der Zeichnung wird das Kuerzel
4018 * weggelassen, wenn der Abschnitt zu schmal ist (signalPlanDrawing.ts:246-250,
4019 * kein dritter Zweig). Im Musterplan mit 90 s Umlauf und 1 s Rot-Gelb steht
4020 * deshalb in keiner einzigen Signalgruppenzeile ein "RG" - der Abschnitt ist
4021 * dort ausschliesslich an seiner orangen Farbe zu erkennen, und im
4022 * Schwarzweissausdruck, dem Regelfall bei einer Anordnungsakte, gar nicht.
4023 *
4024 * ABGRENZUNG ZUR ZEICHNUNG: Die Zeichnung selbst benennt unter ihrer Legende,
4025 * WELCHE Signalbilder es diesmal getroffen hat (drawSchmalHinweis in
4026 * signalPlanDrawing.ts) - eine Angabe, die nur dort entstehen kann, weil nur
4027 * dort gemessen wird, was noch hineinpasst. Diese Fussnote wiederholt das nicht
4028 * und beruft sich auch nicht darauf: Sie sagt die Regel und nennt die Tabelle,
4029 * in der die Zeiten vollstaendig stehen. So bleibt sie richtig, gleich ob die
4030 * Zeichnung ihren Hinweis zeigt oder nicht - eine Fussnote, die ihren
4031 * Gegenstand ueberlebt, ist eine falsche Angabe.
4032 */
4033 const PLAN_SCHMALE_ABSCHNITTE =
4034 'Die Zeichnung beschriftet jeden Abschnitt mit dem Kürzel des Signalbilds und seiner Dauer, ' +
4035 'soweit die Breite des Abschnitts dafür ausreicht. Sehr kurze Signalbilder - im Regelfall ' +
4036 'Rot-Gelb mit 1 s - ergeben Abschnitte, die dafür zu schmal sind; in ihnen steht dann keine ' +
4037 'Beschriftung. Für diese Abschnitte wie für jeden anderen führt die Tabelle „Signalzeiten je ' +
4038 'Signalgruppe" das Signalbild mit seiner Dauer auf. Bei Farbfehlsichtigkeit und im ' +
4039 'Schwarzweißausdruck ist sie deshalb die verlässliche Quelle.';
4040
4041 /**
4042 * Layout der Zeichnung fuer die verfuegbare Hoehe.
4043 *
4044 * KORREKTUR (Befund M12): Die Hoehe wurde mit Math.min gekappt, die Zeichnung
4045 * erfuhr davon nichts - drawSignalPlan rechnet seine Zeilen aus dem Layout,
4046 * nicht aus der Flaechenhoehe, und PdfSurface beschneidet nicht. Ab 13
4047 * Signalgruppen lief die Zeichnung damit ueber den Seitenrand hinaus, und weil
4048 * die Legende ganz unten steht, fiel sie als Erstes weg: Danach trug in der
4049 * Zeichnung nur noch die Farbe, ohne jede Erklaerung - aus einem Layoutfehler
4050 * wurde ein Verstoss gegen 1.4.1.
4051 *
4052 * Gestaucht werden nur die Signalgruppenzeilen. Kopfbereich, Raender und der
4053 * Platz fuer Legende und Ersatzwertvermerk bleiben, wie sie sind; sie tragen
4054 * gerade den Textschluessel zu den Farben.
4055 */
4056 function planGrafikLayout(plan: SignalPlan, zeilen: number, verfuegbar: number): PlanLayout {
4057 const jeZeile = DEFAULT_LAYOUT.rowHeight + DEFAULT_LAYOUT.rowGap;
4058 // Feste Anteile: Rand, Kopfbereich, Legende und - bei uebersaettigter Anlage -
4059 // die Vermerkzeile. planHeight rechnet mindestens eine Zeile, die hier abgeht.
4060 const fest = planHeight({ groups: [], cycleResult: plan.cycleResult }, DEFAULT_LAYOUT) - jeZeile;
4061 if (fest + zeilen * jeZeile <= verfuegbar) return DEFAULT_LAYOUT;
4062
4063 const proZeile = Math.floor((verfuegbar - fest) / zeilen);
4064 const rowGap = Math.max(
4065 PLAN_MIN_ROW_GAP,
4066 Math.min(DEFAULT_LAYOUT.rowGap, Math.round(proZeile / 5)),
4067 );
4068 const rowHeight = Math.max(
4069 PLAN_MIN_ROW_HEIGHT,
4070 Math.min(DEFAULT_LAYOUT.rowHeight, proZeile - rowGap),
4071 );
4072 return {
4073 ...DEFAULT_LAYOUT,
4074 rowHeight,
4075 rowGap,
4076 // Die Schrift folgt der Zeilenhoehe, sonst steht die Beschriftung ueber dem
4077 // Balken, in dem sie liegen soll. Verhaeltnis wie im Grundlayout (22 zu 11).
4078 fontSize: Math.max(
4079 PLAN_MIN_FONT_SIZE,
4080 Math.min(DEFAULT_LAYOUT.fontSize, Math.round(rowHeight / 2)),
4081 ),
4082 };
4083 }
4084
4085 interface PlanAufteilung {
4086 readonly layout: PlanLayout;
4087 readonly blaetter: readonly (readonly PlannedSignalGroup[])[];
4088 }
4089
4090 /**
4091 * Layout und Aufteilung auf Blaetter, sodass jedes Blatt vollstaendig auf
4092 * seine Seite passt.
4093 *
4094 * Gesucht wird die kleinste Blattzahl, mit der die Zeichnung noch in den
4095 * erlaubten Grenzen darstellbar ist; die Signalgruppen werden darauf
4096 * GLEICHMAESSIG verteilt. Sonst entstuende bei 16 Signalgruppen ein Blatt mit
4097 * 14 Zeilen und eines mit 2 - fachlich unbrauchbar, weil sich die Zeilen zweier
4098 * so verschiedener Blaetter nicht mehr nebeneinanderlegen lassen.
4099 *
4100 * Jedes Blatt bekommt Zeitachse, Phasenband und Legende von drawSignalPlan
4101 * erneut. Deshalb werden hier die GRUPPEN geteilt und nicht die Zeichenflaeche
4102 * zerschnitten: Ein Blatt ohne Legende truege nur noch Farbe.
4103 */
4104 function planAufteilung(plan: SignalPlan, verfuegbar: number): PlanAufteilung {
4105 const anzahl = Math.max(1, plan.groups.length);
4106
4107 for (let blaetter = 1; blaetter <= anzahl; blaetter += 1) {
4108 const proBlatt = Math.ceil(anzahl / blaetter);
4109 const layout = planGrafikLayout(plan, proBlatt, verfuegbar);
4110 const hoehe = planHeight(
4111 { groups: plan.groups.slice(0, proBlatt), cycleResult: plan.cycleResult },
4112 layout,
4113 );
4114 if (hoehe <= verfuegbar) return { layout, blaetter: teileGruppen(plan.groups, proBlatt) };
4115 }
4116
4117 // Nicht erreichbar, solange eine einzelne Zeile in das kleinste Layout passt.
4118 // Der Rueckfall teilt auf je eine Signalgruppe je Blatt: lieber viele
4119 // Blaetter als eine Zeichnung, die ueber den Seitenrand hinauslaeuft.
4120 const kleinstes: PlanLayout = {
4121 ...DEFAULT_LAYOUT,
4122 rowHeight: PLAN_MIN_ROW_HEIGHT,
4123 rowGap: PLAN_MIN_ROW_GAP,
4124 fontSize: PLAN_MIN_FONT_SIZE,
4125 };
4126 return { layout: kleinstes, blaetter: teileGruppen(plan.groups, 1) };
4127 }
4128
4129 function teileGruppen(
4130 gruppen: readonly PlannedSignalGroup[],
4131 proBlatt: number,
4132 ): readonly (readonly PlannedSignalGroup[])[] {
4133 if (gruppen.length <= proBlatt) return [gruppen];
4134 const blaetter: (readonly PlannedSignalGroup[])[] = [];
4135 for (let von = 0; von < gruppen.length; von += proBlatt) {
4136 blaetter.push(gruppen.slice(von, von + proBlatt));
4137 }
4138 return blaetter;
4139 }
4140
4141 function drawPlanGraphic(context: Context, mitPhasenabschnitt: boolean): void {
4142 const { doc, plan } = context;
4143
4144 /*
4145 * Der Platz fuer die beiden Absaetze unter der Zeichnung wird VORHER
4146 * abgezogen. Sie gehoeren auf dasselbe Blatt wie die Zeichnung, auf die sie
4147 * sich beziehen; ein Verweis auf die Textfassung, den man erst auf der
4148 * naechsten Seite findet, ist keiner. Der Preis ist eine leicht gestauchte
4149 * Zeichnung ab neun Signalgruppen - er ist geringer als der Verlust des
4150 * Verweises.
4151 */
4152 /**
4153 * Hoehe der beiden Absaetze, wie sie mit dieser Blattangabe wirklich
4154 * gesetzt werden.
4155 *
4156 * KORREKTUR (Fassung 5.7.0): Gemessen wurde zuvor
4157 * `planVerweisAbsatz(1, 1, '')` - also die Fassung OHNE Blattangabe - und
4158 * dazu pauschal 18 pt fuer "eine Zeile und eine halbe zum Abrunden". Gedruckt
4159 * wird bei mehreren Blaettern aber "Blatt 1 von 2 der Zeichnung,
4160 * Signalgruppen <erste> bis <letzte>", und Signalgruppennamen sind freier
4161 * Anwendertext. Mit Namen, wie sie in einer Anordnungsunterlage vorkommen
4162 * ("K1 Zufahrt Nord, Hauptstraße stadteinwärts, Geradeaus und
4163 * Rechtsabbieger"), wuchs die Blattangabe ueber die eine reservierte Zeile
4164 * hinaus, `writeParagraph` rief `ensureSpace`, und die Fussnote landete auf
4165 * der Folgeseite - genau der Zustand, den der Kommentar oben ausschliesst.
4166 * Bei kurzen Namen (K1 bis K16) trat es nicht auf; die Pruefstuecke fuehrten
4167 * nur solche.
4168 */
4169 const unterschriftHoehe = (blattangabe: string): number =>
4170 // Abstand zwischen Zeichnung und Absatz - derselbe Wert wie unten beim
4171 // Setzen. Fehlt er hier, faellt die Fussnote auf die naechste Seite.
4172 PLAN_ABSATZ_ABSTAND +
4173 absatzHoehe(context, `${blattangabe}${planVerweisRumpf(mitPhasenabschnitt)}`) +
4174 absatzHoehe(context, PLAN_SCHMALE_ABSCHNITTE);
4175
4176 const verfuegbarBei = (unterschrift: number): number =>
4177 context.pageHeight - context.cursor - PAGE_MARGIN - FOOTER_HEIGHT - unterschrift;
4178
4179 /*
4180 * Zwei Anlaeufe statt einer Schaetzung.
4181 *
4182 * Erster Anlauf ohne Blattangabe: Bleibt es bei EINEM Blatt, wird auch keine
4183 * gedruckt, und die Reserve stimmt auf den Punkt. Erst wenn geteilt wird,
4184 * kommt die Angabe hinzu - dann wird mit ihrer laengstmoeglichen Fassung
4185 * gerechnet und neu geteilt. Laengstmoeglich heisst: die hoechste Blattzahl,
4186 * die ueberhaupt entstehen kann (eine Signalgruppe je Blatt), und die beiden
4187 * laengsten Namen des Plans. Das ist eine obere Schranke fuer JEDE Aufteilung,
4188 * also auch fuer die, die der zweite Anlauf liefert - einen dritten Anlauf
4189 * kann es nicht brauchen.
4190 */
4191 let aufteilung = planAufteilung(plan, verfuegbarBei(unterschriftHoehe('')));
4192 if (aufteilung.blaetter.length > 1) {
4193 aufteilung = planAufteilung(plan, verfuegbarBei(unterschriftHoehe(laengsteBlattangabe(plan))));
4194 }
4195 const { layout, blaetter } = aufteilung;
4196
4197 blaetter.forEach((gruppen, index) => {
4198 if (index > 0) newSection(context, 'Signalzeitenplan (Fortsetzung)');
4199
4200 const blatt: SignalPlan = { ...plan, groups: gruppen };
4201 const hoehe = planHeight(blatt, layout);
4202 const surface = new PdfSurface(doc, PAGE_MARGIN, context.cursor, context.contentWidth, hoehe);
4203 drawSignalPlan(surface, blatt, { theme: LIGHT_THEME, layout });
4204 context.cursor += hoehe + PLAN_ABSATZ_ABSTAND;
4205
4206 writeParagraph(
4207 context,
4208 planVerweisAbsatz(index + 1, blaetter.length, blattGruppen(gruppen), mitPhasenabschnitt),
4209 );
4210 writeParagraph(context, PLAN_SCHMALE_ABSCHNITTE);
4211 });
4212 }
4213
4214 /**
4215 * Umlaufzeit als Drucktext - mit Vermerk, wenn sie kein Bemessungsergebnis ist.
4216 *
4217 * KORREKTUR (Befund C11): Bei Uebersaettigung traegt der Plan die groesste
4218 * zulaessige Umlaufzeit als Ersatzwert (bounded 'uebersaettigt'); Deckblatt und
4219 * Signalzeiten druckten "120 s" wie ein Ergebnis. Jede Stelle, die die
4220 * Umlaufzeit druckt, laeuft ueber diese Funktion; Wortlaut und Zusammenfuegen
4221 * kommen aus dem Fachkern.
4222 */
4223 function umlaufzeitText(plan: Pick<SignalPlan, 'cycleTime' | 'cycleResult'>): string {
4224 return umlaufzeitMitVermerk(plan, fmt.seconds(plan.cycleTime));
4225 }
4226
4227 /**
4228 * Absatz ueber einer Tabelle, deren Zahlen auf der Umlaufzeit stehen, wenn
4229 * diese nur ein Ersatzwert ist (Befund C11). Ohne den Absatz staenden Rot-
4230 * zeiten, Freigabegrenzen, Phasenbeginne und Kapazitaeten zu einer Zahl, die
4231 * kein Ergebnis ist. Steht vor JEDER dieser Tabellen - Signalzeiten, Phasen,
4232 * Leistungsfaehigkeit, Rechenweg Wartezeit -, nicht nur vor einigen: Die
4233 * CSV-Pendants tragen den Vermerk mit derselben Begruendung.
4234 */
4235 function ersatzwertAbsatz(context: Context): void {
4236 const { plan } = context;
4237 if (!umlaufzeitIstErsatzwert(plan)) return;
4238 writeParagraph(
4239 context,
4240 `Umlaufzeit ${umlaufzeitText(plan)}. Die Anlage ist übersättigt; eine Umlaufzeit lässt sich ` +
4241 'nicht ermitteln. Die folgenden Zeiten beruhen auf dem Ersatzwert und sind kein ' +
4242 'Bemessungsergebnis - siehe Prüfbericht.',
4243 );
4244 }
4245
4246 function drawSignalTimeTable(context: Context): void {
4247 const { doc, plan } = context;
4248 doc.addPage();
4249 // Eigene Seite ohne newSection - das Lesezeichen braucht sie trotzdem: Sie
4250 // ist die Textfassung der Zeichnung und der Ort, auf den der Verweisabsatz
4251 // unter der Zeichnung zeigt.
4252 merkeLesezeichen(context, 'Signalzeiten je Signalgruppe');
4253 context.cursor = PAGE_MARGIN + HEADER_HEIGHT;
4254
4255 doc.setFont('helvetica', 'bold');
4256 doc.setFontSize(12);
4257 doc.text(toWinAnsi('Signalzeiten je Signalgruppe'), PAGE_MARGIN, context.cursor);
4258 context.cursor += 18;
4259
4260 ersatzwertAbsatz(context);
4261
4262 autoTable(doc, {
4263 startY: context.cursor,
4264 margin: TABELLENRAND,
4265 theme: 'grid',
4266 styles: { fontSize: 9, cellPadding: 3 },
4267 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
4268 head: [
4269 /*
4270 * KORREKTUR (Befund C22): Hier standen zwei Spalten "Freigabe von" und
4271 * "Freigabe bis", gefuellt aus greens[0] - dem ersten Fenster -, waehrend
4272 * "Freigabezeit" die Summe aller Fenster nannte. Eine Signalgruppe in
4273 * zwei getrennten Phasen erschien damit mit einem Fenster und einer
4274 * Freigabezeit, die dazu nicht passte; das zweite Fenster kam im Ausdruck
4275 * ueberhaupt nicht vor. Jetzt eine Spalte mit allen Fenstern
4276 * (freigabefensterZelle, gleiche Zeichenkette wie in der Tabellenausgabe)
4277 * und eine Summenspalte, die Summe heisst.
4278 */
4279 [
4280 'SG',
4281 'Freigabefenster (von–bis) [s]',
4282 'Freigabezeit (Summe)',
4283 'Rot-Gelb',
4284 'Gelb',
4285 'Rot',
4286 ].map(toWinAnsi),
4287 ],
4288 body: plan.groups.map((group) => {
4289 const totals = aspectTotals(group, plan.cycleTime);
4290 return [
4291 group.name,
4292 freigabefensterZelle(group.greens, plan.cycleTime),
4293 fmt.seconds(group.totalGreen),
4294 fmt.seconds(totals.rotgelb),
4295 fmt.seconds(totals.gelb),
4296 fmt.seconds(totals.rot),
4297 ].map(toWinAnsi);
4298 }),
4299 });
4300
4301 context.cursor = lastTableBottom(doc) + 10;
4302 // Ohne die Fussnote bliebe offen, warum in einer Zeile zwei Fenster stehen
4303 // und was die Summenspalte summiert (Befund C22).
4304 writeParagraph(context, FREIGABEFENSTER_FUSSNOTE);
4305 context.cursor += 6;
4306 }
4307
4308 // --- Leistungsfaehigkeit ----------------------------------------------------
4309
4310 /**
4311 * Bewertungsverfahren des Plans - dieselbe Quelle wie im Fachkern
4312 * (project.settings.serviceLevelScale bezeichnet seit Fassung 5.4.0,
4313 * Befund B2, das ganze Verfahren, nicht nur die Stufentafel).
4314 */
4315 function bewertungsverfahren(context: Context): Bewertungsverfahren {
4316 return context.project.settings.serviceLevelScale;
4317 }
4318
4319 /** Signalgruppen, die ueber Kapazitaet und mittlere Wartezeit bewertet werden. */
4320 function kfzOepnvGruppen(plan: SignalPlan): readonly PlannedSignalGroup[] {
4321 return plan.groups.filter((group) => hatSaettigungsverkehrsstaerke(group.mode));
4322 }
4323
4324 /** Signalgruppen, die ueber die maximale Wartezeit bewertet werden. */
4325 function fussRadGruppen(plan: SignalPlan): readonly PlannedSignalGroup[] {
4326 return plan.groups.filter((group) => group.mode === 'fuss' || group.mode === 'rad');
4327 }
4328
4329 /**
4330 * Instationaritaetsfaktor, mit dem TATSAECHLICH gerechnet wurde.
4331 *
4332 * Aus den Ergebnissen, nicht aus der Vorgabe: Ein ungueltiger Vorgabewert
4333 * faellt in delay.ts auf den Regelwert zurueck, und der Absatz unter der
4334 * Tabelle muss die Zahl nennen, die in tW eingegangen ist - sonst rechnet der
4335 * Pruefer mit der falschen nach. Ohne bewertete Gruppe gilt der Wert der
4336 * Vorgabenverwaltung.
4337 */
4338 function angesetzterInstationaritaetsfaktor(context: Context): number {
4339 for (const group of kfzOepnvGruppen(context.plan)) {
4340 const fin = group.delay?.rechenweg.instationaritaetsfaktor;
4341 if (fin !== null && fin !== undefined && Number.isFinite(fin)) return fin;
4342 }
4343 return context.plan.defaults.capacity.instationaritaetsfaktor;
4344 }
4345
4346 /**
4347 * Leistungsfaehigkeit der Kfz- und OePNV-Signalgruppen.
4348 *
4349 * KORREKTUR (Fassung 5.4.0, Befunde B1 bis B4): Bis dahin behauptete
4350 * der Absatz unter dieser Tabelle "Bewertung nach HBS 2015" - waehrend fuer
4351 * beide Einstellungen dieselbe HCM-Formel gerechnet und nach der Kfz-Tafel des
4352 * HBS 2001 bewertet wurde. Und er erklaerte, Fussgaengersignalgruppen seien
4353 * "nicht aufgefuehrt, da ihre Leistungsfaehigkeit ... nicht massgebend ist" -
4354 * das HBS 2015 bewertet sie sehr wohl, nur nach einem anderen Kriterium
4355 * (maximale Wartezeit, eigene Tabelle unten). Jetzt steht hier das Verfahren,
4356 * das gerechnet wurde, mit seiner Formelkette und seiner Tafel, und die Spalte
4357 * "Abflusszeit tA" macht die Kapazitaet aus den gedruckten Groessen
4358 * nachrechenbar (C = qS · tA/tU).
4359 */
4360 function drawPerformanceTable(context: Context, mitSignalgruppentabelle: boolean): void {
4361 const { doc, plan, project } = context;
4362 const verfahren = bewertungsverfahren(context);
4363 const gruppen = kfzOepnvGruppen(plan);
4364
4365 ensureSpace(context, 60);
4366 doc.setFont('helvetica', 'bold');
4367 doc.setFontSize(11);
4368 doc.setTextColor('#1b2230');
4369 doc.text(toWinAnsi('Kraftfahrzeuge und ÖPNV'), PAGE_MARGIN, context.cursor);
4370 context.cursor += 16;
4371
4372 if (gruppen.length === 0) {
4373 writeParagraph(context, 'Es sind keine Kfz- oder ÖPNV-Signalgruppen erfasst.');
4374 return;
4375 }
4376
4377 // Kapazitaet, Auslastung und Wartezeit rechnen mit der Umlaufzeit.
4378 ersatzwertAbsatz(context);
4379
4380 const rows = gruppen.map((group) => {
4381 const demand = project.demands.find((d) => d.signalGroupId === group.groupId);
4382 return [
4383 group.name,
4384 group.capacity === null ? '–' : fmt.vehiclesPerHour(group.capacity.saturationFlow),
4385 group.capacity === null ? '–' : fmt.seconds(group.capacity.abflusszeit, 1),
4386 group.capacity === null ? '–' : fmt.vehiclesPerHour(group.capacity.capacity),
4387 // "–" auch bei 0 (Fassung 5.10.0): Ein Datensatz ist noch keine
4388 // Zaehlung - wird allein der Schwerverkehrsanteil ausgefuellt, entsteht
4389 // ein Datensatz mit `volume: 0`. Der Fachkern liest ihn seit den Befunden
4390 // 12 und 18 als "keine Angabe" (signalPlan.ts, erfassteVerkehrsstaerke),
4391 // der Auslastungsgrad zwei Spalten weiter steht dann als "–" da und der
4392 // Bildschirm zeichnet das Feld seit Befund 58 leer. Hier stand "0 Fz/h":
4393 // eine Zaehlung, die niemand vorgenommen hat, neben zwei Strichen
4394 // derselben Zeile. Dieselbe Bedingung wie in `schwerverkehrZelle`
4395 // daneben.
4396 demand === undefined || demand.volume <= 0 ? '–' : fmt.vehiclesPerHour(demand.volume),
4397 schwerverkehrZelle(project, group.groupId),
4398 // Die Geometrie der Zufahrt: Sie mindert die Saettigungsverkehrsstaerke
4399 // der Spalte ganz links, und ohne sie liesse sich diese Zahl aus dem
4400 // Fundstellenverzeichnis nicht herleiten - derselbe Grund wie beim
4401 // Schwerverkehr daneben.
4402 geometrieZelle(project, group.groupId),
4403 // Drei Nachkommastellen wie im Rechenweg und im Pruefbericht: Mit zwei
4404 // stand bei q = 244 Fz/h und C = 243 Fz/h "1,00" neben "F (ueberlastet)"
4405 // - die gedruckte Zahl widersprach der gedruckten Stufe.
4406 group.capacity?.degreeOfSaturation === undefined
4407 ? '–'
4408 : fmt.ratio(group.capacity.degreeOfSaturation, 3),
4409 group.delay === null ? '–' : fmt.seconds(group.delay.averageDelay, 1),
4410 group.delay === null ? '–' : stufeZelle(group.delay.serviceLevel),
4411 ].map(toWinAnsi);
4412 });
4413
4414 autoTable(doc, {
4415 startY: context.cursor,
4416 margin: TABELLENRAND,
4417 theme: 'grid',
4418 styles: { fontSize: 9, cellPadding: 3 },
4419 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
4420 head: [
4421 [
4422 'SG',
4423 'Sättigungsverkehrsstärke',
4424 // Nach HBS 2015 tF + 1 s je Freigabezeitfenster, nach HCM die
4425 // Freigabezeit selbst - der Absatz unter der Tabelle sagt, was gilt.
4426 'Abflusszeit tA',
4427 'Kapazität',
4428 'Verkehrsstärke',
4429 // Der Anteil mindert die Saettigungsverkehrsstaerke, stand aber in
4430 // keiner Ausgabe. Der Pruefer las eine Kapazitaet, die er aus dem
4431 // Fundstellenverzeichnis nicht herleiten konnte, und hatte keine
4432 // Moeglichkeit, die Differenz aufzuloesen. Seit Schema 13 stehen in
4433 // der Zelle drei Zeilen: Anteil, Aufteilung und fSV mit der Gleichung
4434 // (siehe schwerverkehrZelle).
4435 'Schwerverkehr (Anteil, Aufteilung, fSV)',
4436 'Geometrie (b, R, s; Faktor)',
4437 'Auslastungsgrad',
4438 'Mittlere Wartezeit',
4439 'Qualitätsstufe',
4440 ].map(toWinAnsi),
4441 ],
4442 body: rows,
4443 });
4444
4445 context.cursor = lastTableBottom(doc) + 10;
4446 writeParagraph(context, verfahrensAbsatz(context, verfahren, gruppen, mitSignalgruppentabelle));
4447 }
4448
4449 /**
4450 * Wo n und die Fahrbeziehung stehen, aus denen die gedruckte
4451 * Saettigungsverkehrsstaerke entsteht (Fassung 5.10.0; dasselbe Muster wie beim
4452 * Anfahransatz, Fassung 5.9.0).
4453 *
4454 * Der Erlaeuterungsabsatz nennt die Kette qS0 · n · fA / fSV. Die
4455 * Leistungstabelle fuehrt keine Fahrstreifenspalte, weil n und die
4456 * Fahrbeziehung in der Signalgruppentabelle stehen - eine halbe Wiederholung
4457 * dort waere ohne die Fahrbeziehung derselben Zeile kein geschlossenes Glied.
4458 * Diese Begruendung faellt jedoch, sobald der Anwender die Tabelle
4459 * "Signalgruppen" abwaehlt: Dann stand die Kette in einer Unterlage, in der
4460 * weder "Fahrstr." noch "Fahrbeziehung" vorkommt und aus keiner gedruckten
4461 * Groesse zu schliessen ist. In diesem Fall traegt der Absatz beides selbst
4462 * nach - Name, Fahrstreifenzahl und Fahrbeziehung je Strom der Tabelle.
4463 *
4464 * Die Fahrstreifenzahl ueber `fmt` und nicht ueber `String` (Befund 55): Eine
4465 * Projektdatei mit "lanes": 2.5 wird uebernommen, und die englische
4466 * Schreibweise haette sich hier eine zweite Stelle gesucht.
4467 */
4468 function fahrstreifenHerkunftSatz(
4469 project: Project,
4470 gruppen: readonly PlannedSignalGroup[],
4471 mitSignalgruppentabelle: boolean,
4472 ): string {
4473 if (mitSignalgruppentabelle) {
4474 return ' n und die Fahrbeziehung stehen je Strom in der Tabelle "Signalgruppen" (Spalten "Fahrstr." und "Fahrbeziehung").';
4475 }
4476 // Der Name in Anfuehrungszeichen, damit Name und Zahl in der Aufzaehlung
4477 // nicht unverbunden hintereinander stehen - wie beim Anfahransatz.
4478 const angaben = gruppen.map((gruppe) => {
4479 const group = project.signalGroups.find((g) => g.id === gruppe.groupId);
4480 return group === undefined
4481 ? `"${gruppe.name}" ohne Angabe`
4482 : `"${group.name}" ${fmt.numShort(group.lanes)} Fahrstreifen, ${MOVEMENT_LABELS[group.movement]}`;
4483 });
4484 return (
4485 ' Die Tabelle "Signalgruppen", in der n und die Fahrbeziehung stehen, ist für diese Ausgabe ' +
4486 `abgewählt; für die Ströme dieser Tabelle gilt: ${angaben.join('; ')}.`
4487 );
4488 }
4489
4490 /**
4491 * Der Absatz, der sagt, WAS gerechnet wurde. Zahlen und Tafeln kommen aus den
4492 * Konstanten des Fachkerns (bewertung.ts), nicht aus dem Satz.
4493 */
4494 function verfahrensAbsatz(
4495 context: Context,
4496 verfahren: Bewertungsverfahren,
4497 gruppen: readonly PlannedSignalGroup[],
4498 mitSignalgruppentabelle: boolean,
4499 ): string {
4500 const tafeln = stufentafeln(verfahren);
4501 const mitOepnv = gruppen.some((g) => g.mode === 'oepnv');
4502 /*
4503 * KORREKTUR (Fassung 5.5.0, Frage E6): Hier stand "Der Schwerverkehrsanteil
4504 * mindert die Saettigungsverkehrsstaerke (Pkw-Aequivalent 2,0)" - unter der
4505 * Ueberschrift "Bewertung nach HBS 2015". Die 2,0 sind der Pkw-Gleichwert des
4506 * HCM 2010; das HBS rechnet mit dem Anpassungsfaktor fSV. Der Satz kommt
4507 * jetzt aus bewertung.ts und nennt die ganze Kette, mit den Vorgaben dieses
4508 * Plans.
4509 *
4510 * MIT DER DATENLAGE DIESES PLANS (Schema 13): Beide Gleichungen des HBS sind
4511 * im Einsatz, und welche gilt, entscheidet der einzelne Strom. Der Absatz
4512 * bekommt deshalb die Zaehlung mit - sonst stuende unter einer Tabelle, in
4513 * der jeder Strom eine erfasste Aufteilung hat, weiter der Vorbehalt zur
4514 * fehlenden Aufteilung.
4515 */
4516 const schwerverkehr =
4517 schwerverkehrSatz(context.plan.defaults, aufteilungslage(context.project)) +
4518 // Wo n und fA in DIESER Ausgabe stehen - der Satz aus bewertung.ts nennt
4519 // die Kette, nicht ihre Fundstelle.
4520 fahrstreifenHerkunftSatz(context.project, gruppen, mitSignalgruppentabelle);
4521
4522 if (verfahren === 'HBS') {
4523 const fin = fmt.numShort(angesetzterInstationaritaetsfaktor(context), 2);
4524 return (
4525 `Bewertung nach ${verfahrenLabel(verfahren)}: Kapazität C = qS · tA/tU mit der Abflusszeit tA = tF + 1 s je ` +
4526 'Freigabezeitfenster; mittlere Wartezeit tW = tW,G + tW,R (Grundwartezeit und Wartezeit im ' +
4527 `Reststau, Betrachtungszeitraum T = 1 h, Instationaritätsfaktor fin = ${fin}); ` +
4528 `Qualitätsstufe nach der mittleren Wartezeit (Kfz: ${tafeln.kfz}` +
4529 (mitOepnv ? `; ÖPNV: ${tafeln.oepnv}` : '') +
4530 '); Stufe F bei Überlastung (q > C), unabhängig von der Wartezeit. ' +
4531 schwerverkehr
4532 );
4533 }
4534 return (
4535 `Bewertung nach ${verfahrenLabel(verfahren)} (Highway Capacity Manual): Kapazität C = qS · g/tU mit der Freigabezeit ` +
4536 'g = tF (Spalte "Abflusszeit tA"); mittlere Wartezeit d = d1 + d2 (gleichmäßiger Anteil und ' +
4537 'Überlastungsanteil, Bemessungszeitraum T = 0,25 h, k = 0,5, I = 1,0); Qualitätsstufe nach ' +
4538 `der mittleren Wartezeit (${tafeln.kfz}), Stufe F außerdem bei Überlastung (q > C). ` +
4539 schwerverkehr
4540 );
4541 }
4542
4543 /**
4544 * Fussgaenger und Radverkehr.
4545 *
4546 * KORREKTUR (Fassung 5.4.0, Befund B4): Das HBS 2015 bewertet Fussgaenger und
4547 * Radverkehr an Lichtsignalanlagen nach der MAXIMALEN Wartezeit - der laengsten
4548 * Sperrzeit im Umlauf - mit eigener Tafel; eine Kapazitaet und ein
4549 * Auslastungsgrad werden dafuer nicht gebildet. Bis dahin fehlten die
4550 * Fussgaenger im Leistungsabschnitt ganz, und Radgruppen standen mit einer
4551 * Kfz-Kapazitaet in der Kfz-Tabelle. Nach HCM gibt es fuer beide keine Stufe;
4552 * das steht dann ausdruecklich hier, statt eine Zelle leer zu lassen.
4553 */
4554 function drawFussRadTable(context: Context): void {
4555 const { doc, plan } = context;
4556 const gruppen = fussRadGruppen(plan);
4557 if (gruppen.length === 0) return;
4558 const verfahren = bewertungsverfahren(context);
4559 const tafeln = stufentafeln(verfahren);
4560
4561 ensureSpace(context, 90);
4562 doc.setFont('helvetica', 'bold');
4563 doc.setFontSize(11);
4564 doc.setTextColor('#1b2230');
4565 doc.text(toWinAnsi('Fußgänger und Radverkehr'), PAGE_MARGIN, context.cursor);
4566 context.cursor += 16;
4567
4568 autoTable(doc, {
4569 startY: context.cursor,
4570 margin: TABELLENRAND,
4571 theme: 'grid',
4572 styles: { fontSize: 9, cellPadding: 3 },
4573 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
4574 head: [
4575 [
4576 'SG',
4577 'Verkehrsart',
4578 'Freigabezeit',
4579 'Längste Sperrzeit = maximale Wartezeit',
4580 'Mittlere Wartezeit',
4581 'Qualitätsstufe',
4582 ].map(toWinAnsi),
4583 ],
4584 body: gruppen.map((group) =>
4585 [
4586 group.name,
4587 TRAFFIC_MODE_LABELS[group.mode],
4588 fmt.seconds(group.totalGreen),
4589 group.delay === null ? '–' : fmt.seconds(group.delay.maximumDelay, 1),
4590 group.delay === null ? '–' : fmt.seconds(group.delay.averageDelay, 1),
4591 group.delay === null ? '–' : stufeZelle(group.delay.serviceLevel),
4592 ].map(toWinAnsi),
4593 ),
4594 });
4595
4596 context.cursor = lastTableBottom(doc) + 10;
4597
4598 const mittlere =
4599 'Die mittlere Wartezeit gilt für über den Umlauf gleichverteilt eintreffende Fußgänger bzw. ' +
4600 'Radfahrer (Summe der Sperrzeitquadrate / (2 · tU)); sie ist eine Kenngröße für den ' +
4601 'Variantenvergleich, keine Bewertungsgröße. Kapazität und Auslastungsgrad werden für ' +
4602 'Fußgänger und Radverkehr nicht gebildet.';
4603
4604 writeParagraph(
4605 context,
4606 tafeln.fussRad !== null
4607 ? 'Nach HBS 2015 werden Fußgänger und Radverkehr nach der maximalen Wartezeit bewertet - ' +
4608 `der längsten Sperrzeit im Umlauf: ${tafeln.fussRad}. ` +
4609 mittlere
4610 : 'Nach HCM wird für Fußgänger und Radverkehr keine Stufe vergeben; die HCM-Fußgängerbewertung ' +
4611 'ist in diesem Programm nicht hinterlegt. Ausgewiesen wird die maximale Wartezeit (längste ' +
4612 'Sperrzeit im Umlauf), nach der das HBS 2015 bewerten würde. ' +
4613 mittlere,
4614 );
4615 }
4616
4617 /**
4618 * Rechenweg der mittleren Wartezeit je Kfz- und OePNV-Signalgruppe.
4619 *
4620 * Grundsatz: Was gedruckt wird, muss sich aus den gedruckten Groessen
4621 * nachrechnen lassen. Die Leistungstabelle nennt tW; hier stehen die
4622 * Zwischenwerte, aus denen es entsteht (HBS: tA, fA, x, tW,G, NGE, tW,R; HCM:
4623 * g, x, d1, d2 mit T, k, I), und darunter die Formeln.
4624 */
4625 function drawDelayDetails(context: Context): void {
4626 const { doc, plan } = context;
4627 const verfahren = bewertungsverfahren(context);
4628 const gruppen = kfzOepnvGruppen(plan).filter((group) => group.delay !== null);
4629
4630 if (gruppen.length === 0) {
4631 writeParagraph(
4632 context,
4633 'Für keine Kfz- oder ÖPNV-Signalgruppe ist eine Verkehrsstärke erfasst; eine Wartezeit ' +
4634 'wurde nicht gerechnet.',
4635 );
4636 return;
4637 }
4638
4639 // tU ist bei Uebersaettigung nur der Ersatzwert; das gehoert VOR die
4640 // Formelkette, die mit ihm rechnet (Befund C11). Die Spalte selbst bleibt
4641 // die Zahl - der Vermerk in vierzehn Spalten je Zeile waere unlesbar.
4642 ersatzwertAbsatz(context);
4643
4644 const head =
4645 verfahren === 'HBS'
4646 ? [
4647 'SG',
4648 'tU',
4649 'tF',
4650 'tA',
4651 'fA = tA/tU',
4652 'C',
4653 'q',
4654 'x = q/C',
4655 'fin',
4656 'tW,G',
4657 'NGE',
4658 'tW,R',
4659 'tW',
4660 'Stufe',
4661 ]
4662 : [
4663 'SG',
4664 'tU',
4665 'g = tF',
4666 'g/tU',
4667 'C',
4668 'q',
4669 'x = q/C',
4670 'T',
4671 'k',
4672 'I',
4673 'd1',
4674 'd2',
4675 'd',
4676 'Stufe',
4677 ];
4678
4679 const body = gruppen.map((group) => {
4680 const delay = group.delay!;
4681 const capacity = group.capacity;
4682 const rw = delay.rechenweg;
4683 const demand = context.project.demands.find((d) => d.signalGroupId === group.groupId);
4684 const gemeinsam = [group.name, fmt.seconds(plan.cycleTime)];
4685 const kapazitaet = [
4686 capacity === null ? '–' : fmt.vehiclesPerHour(capacity.capacity),
4687 // Dieselbe Bedingung wie in der Leistungstabelle darueber, damit beide
4688 // Tabellen dieselbe Aussage machen. Erreichbar ist der Strich hier
4689 // nicht: Diese Tabelle fuehrt nur Gruppen mit `delay !== null`, und ein
4690 // Wartezeitergebnis entsteht nur aus einer ERFASSTEN Verkehrsstaerke
4691 // (signalPlan.ts) - `volume <= 0` gilt dort als keine.
4692 demand === undefined || demand.volume <= 0 ? '–' : fmt.vehiclesPerHour(demand.volume),
4693 fmt.ratio(delay.degreeOfSaturation, 3),
4694 ];
4695 // Stufe mit Kriterium: "F (Ueberlastung (q > C))" oder "B (mittlere
4696 // Wartezeit)" - der Zusatz "ueberlastet" aus der Leistungstabelle waere
4697 // hier doppelt.
4698 const stufe =
4699 delay.serviceLevel === null
4700 ? '–'
4701 : `${delay.serviceLevel.grade} (${KRITERIUM_LABELS[delay.serviceLevel.kriterium]})`;
4702 if (verfahren === 'HBS') {
4703 return [
4704 ...gemeinsam,
4705 fmt.seconds(group.totalGreen, 1),
4706 fmt.seconds(rw.abflusszeit, 1),
4707 fmt.ratio(rw.abflussanteil, 3),
4708 ...kapazitaet,
4709 rw.instationaritaetsfaktor === null ? '–' : fmt.numShort(rw.instationaritaetsfaktor, 2),
4710 fmt.seconds(delay.uniformDelay, 1),
4711 rw.reststau === null ? '–' : fmt.numShort(rw.reststau, 2),
4712 fmt.seconds(delay.incrementalDelay, 1),
4713 fmt.seconds(delay.averageDelay, 1),
4714 stufe,
4715 ].map(toWinAnsi);
4716 }
4717 return [
4718 ...gemeinsam,
4719 fmt.seconds(rw.abflusszeit, 1),
4720 fmt.ratio(rw.abflussanteil, 3),
4721 ...kapazitaet,
4722 `${fmt.numShort(rw.betrachtungszeitraum, 2)} h`,
4723 rw.hcmK === null ? '–' : fmt.num(rw.hcmK, 1),
4724 rw.hcmI === null ? '–' : fmt.num(rw.hcmI, 1),
4725 fmt.seconds(delay.uniformDelay, 1),
4726 fmt.seconds(delay.incrementalDelay, 1),
4727 fmt.seconds(delay.averageDelay, 1),
4728 stufe,
4729 ].map(toWinAnsi);
4730 });
4731
4732 autoTable(doc, {
4733 startY: context.cursor,
4734 margin: TABELLENRAND,
4735 theme: 'grid',
4736 styles: { fontSize: 8, cellPadding: 2.5, valign: 'top' },
4737 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
4738 head: [head.map(toWinAnsi)],
4739 body,
4740 });
4741
4742 context.cursor = lastTableBottom(doc) + 10;
4743
4744 /*
4745 * Formeln in ASCII-nahen Zeichen: Wurzelzeichen und das typografische Minus
4746 * liegen ausserhalb des WinAnsi-Vorrats und wuerden von toWinAnsi entfernt -
4747 * aus "(x - 1)" wuerde "(x 1)".
4748 */
4749 writeParagraph(
4750 context,
4751 verfahren === 'HBS'
4752 ? 'Formeln nach HBS 2015 (Leyn 2016, Gl. 2-7 bis 2-10 und 3-2): tW = tW,G + tW,R; ' +
4753 'tW,G = tU · (1 - fA)² / (2 · (1 - min(1; x) · fA)) mit fA = tA/tU und tA = tF + 1 s je ' +
4754 'Freigabezeitfenster; tW,R = NGE · 3600 / C; NGE = max { 0,58 · T · C/4 · [(fin · x - 1) + ' +
4755 'Wurzel aus ((fin · x - 1)² + 4 · fin · x / (0,58 · T · C))] ; T · C/4 · [(x - 1) + ' +
4756 'Wurzel aus ((x - 1)² + 4 · x / (T · C))] } mit T = 1 h; fin = 1 + ((q15/q) - 1) / 1,5 ' +
4757 '(Vorgabewert 1,1 ohne 15-Minuten-Zähldaten). Angaben mit Zwischenrundung auf die ' +
4758 'gedruckten Stellen; die Kapazität C ist ganzzahlig, x = q/C daraus gebildet. ' +
4759 /*
4760 * Bis hierher endete der Absatz vor der Fassung 5.11.0 - und
4761 * ein Pruefer, der die Zeile nach der genannten Gleichung nachrechnet,
4762 * kam an rund der Haelfte der Zeilen auf eine andere Zahl als die
4763 * gedruckte (im Regelbeispiel K1: 5,7 gegen 5,6 und K4: 3,8 gegen 3,7).
4764 * Der Grund steht in delay.ts und ist begruendet: Die Anteile muessen
4765 * auf den gedruckten Ziffern aufgehen, sonst widerlegt sich die Zeile
4766 * selbst. Ungesagt blieb er trotzdem, waehrend der Zusatz
4767 * "Zwischenrundung auf die gedruckten Stellen" ausdruecklich dazu
4768 * auffordert, mit ihnen nachzurechnen. Behoben wird deshalb hier und
4769 * nicht in delay.ts.
4770 */
4771 'Die Spalte tW,R ist nicht aus NGE und C gerundet, sondern als Differenz tW - tW,G auf ' +
4772 'den ausgegebenen Ziffern gebildet, damit tW,G + tW,R die gedruckte Gesamtwartezeit ' +
4773 'ergibt; gegenüber NGE · 3600 / C kann sie deshalb um bis zu 0,1 s abweichen.'
4774 : 'Formeln nach HCM: d = d1 + d2; d1 = 0,5 · tU · (1 - g/tU)² / (1 - min(1; x) · g/tU); ' +
4775 'd2 = 900 · T · [(x - 1) + Wurzel aus ((x - 1)² + 8 · k · I · x / (C · T))] mit ' +
4776 'T = 0,25 h, k = 0,5 (Festzeitsteuerung), I = 1,0 (isolierter Knotenpunkt). Angaben mit ' +
4777 'Zwischenrundung auf die gedruckten Stellen; die Kapazität C ist ganzzahlig, x = q/C ' +
4778 'daraus gebildet.',
4779 );
4780
4781 drawRueckstau(context);
4782 }
4783
4784 /**
4785 * Der Rueckstau als Laenge, gehalten gegen den erfassten Stauraum.
4786 *
4787 * WARUM ER HIER STEHT UND NICHT ALS SPALTE: Die Tabelle darueber fuehrt im
4788 * HBS-Verfahren vierzehn Spalten. Eine fuenfzehnte haette sie unlesbar
4789 * gemacht, und die Umrechnung gehoert ohnehin nicht in die Formelkette - sie
4790 * ist kein Glied der Wartezeitrechnung, sondern eine Auskunft daneben.
4791 *
4792 * WARUM ER UEBERHAUPT GEDRUCKT WIRD: Die Spalte NGE nennt eine Fahrzeugzahl.
4793 * Ob ein Aufstellstreifen ueberlaeuft, entscheidet eine Laenge, und daran
4794 * haengt eine Abwaegung nach Paragraf 45 StVO.
4795 */
4796 function drawRueckstau(context: Context): void {
4797 const { doc, plan } = context;
4798 const zeilen = plan.groups.filter((g) => g.rueckstau !== null);
4799 if (zeilen.length === 0) return;
4800
4801 ensureSpace(context, 90);
4802 doc.setFont('helvetica', 'bold');
4803 doc.setFontSize(10);
4804 doc.setTextColor('#1b2230');
4805 doc.text(toWinAnsi('Rückstau und Stauraum'), PAGE_MARGIN, context.cursor);
4806 context.cursor += 14;
4807
4808 autoTable(doc, {
4809 startY: context.cursor,
4810 margin: TABELLENRAND,
4811 theme: 'grid',
4812 styles: { fontSize: 9, cellPadding: 3 },
4813 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
4814 head: [
4815 ['SG', 'NGE', 'Fahrstreifen', 'Rückstau je Fahrstreifen', 'Stauraum', 'Auslastung'].map(
4816 toWinAnsi,
4817 ),
4818 ],
4819 body: zeilen.map((group) => {
4820 const r = group.rueckstau!;
4821 const fahrstreifen =
4822 context.project.signalGroups.find((g) => g.id === group.groupId)?.lanes ?? 1;
4823 return [
4824 group.name,
4825 `${fmt.numShort(r.fahrzeuge, 2)} Kfz`,
4826 // NICHT `String(...)`: Eine Fahrstreifenzahl darf gebrochen sein, und
4827 // `String(2.5)` schreibt den englischen Punkt. Genau das war ein
4828 // Befund, behoben mit Fassung 5.10.0, und sein Waechter hat diese Zeile
4829 // beim ersten Lauf gefangen.
4830 fmt.numShort(fahrstreifen, 2),
4831 fmt.meters(r.laenge, 1),
4832 r.stauraum === null ? 'nicht erfasst' : fmt.meters(r.stauraum, 1),
4833 r.auslastung === null
4834 ? '–'
4835 : `${fmt.percent(r.auslastung)}${r.ueberlauf ? ' – erreicht' : ''}`,
4836 ].map(toWinAnsi);
4837 }),
4838 });
4839
4840 context.cursor = lastTableBottom(doc) + 10;
4841
4842 writeParagraph(
4843 context,
4844 `Länge = NGE · ${fmt.meters(STAURAUMBEDARF_JE_FAHRZEUG, 0)} je wartendem Fahrzeug, geteilt ` +
4845 'durch die Fahrstreifen der Signalgruppe. Die Länge je Fahrzeug ist ein Praxiswert; weder ' +
4846 'die RiLSA 2015 noch das HBS 2015 nennen einen, und bei hohem Schwerverkehrsanteil ist ' +
4847 'der wirkliche Rückstau länger. ' +
4848 'VERGLICHEN WIRD DER MITTLERE RÜCKSTAU bei Freigabezeitende über die Betrachtungsstunde: ' +
4849 'In etwa der Hälfte der Umläufe steht mehr, und ein Stauraum, der gerade eben reicht, ' +
4850 'reicht dann nicht. Ein 95-Prozent-Wert wäre die richtige Größe für die Frage, ob ein ' +
4851 'Aufstellstreifen überläuft; dieses Programm führt ihn nicht und bildet ihn auch nicht ' +
4852 'aus einem geschätzten Faktor. Der Stauraum geht in keine Zahl der Bemessung ein.',
4853 );
4854 }
4855
4856 /**
4857 * Schwerverkehr einer Signalgruppe in drei Zeilen: Anteil, Aufteilung,
4858 * Anpassungsfaktor fSV mit der Gleichung, die ihn gebildet hat.
4859 *
4860 * ERGAENZT (Fassung 5.5.0, Frage E6): Frueher stand hier nur
4861 * der Anteil. Zwischen ihm und der gedruckten Saettigungsverkehrsstaerke lag
4862 * eine Umrechnung, die nirgends stand - und die gedruckte Erlaeuterung nannte
4863 * dafuer ein "Pkw-Aequivalent 2,0", das nachweislich nicht der Wert des HBS
4864 * ist. Der Faktor daneben ist das Glied, das die Spalte ohne eigene Angabe
4865 * nicht hergaebe.
4866 *
4867 * BERICHTIGT (Fassung 5.10.0): Hier stand die Zusage, mit dem Faktor daneben
4868 * lasse sich die Spalte "Saettigungsverkehrsstaerke" aus dem Regelwert je
4869 * Fahrstreifen nachrechnen (qS = 3600 / (fSV · tB)). Mit fSV allein geht das
4870 * nicht, und das Zeichen qS stand in einem Satz fuer zwei Groessen. Die Spalte
4871 * fuehrt qS0 · n · fA / fSV: qS0 ist der Grundwert je Fahrstreifen aus den
4872 * Vorgaben, n die Fahrstreifenzahl und fA die Abminderung fuer links und rechts
4873 * abbiegende Stroeme. Der Erlaeuterungsabsatz unter der Tabelle nennt diese
4874 * Kette vollstaendig (bewertung.ts, schwerverkehrSatz); die gleichlautende
4875 * Zusage in der Tabellenausgabe ist nachgezogen (csv.ts).
4876 *
4877 * WO N UND FA STEHEN: in der Signalgruppentabelle - n in ihrer Spalte
4878 * "Fahrstr.", die Fahrbeziehung, aus der fA folgt, in ihrer Spalte
4879 * "Fahrbeziehung". Eine eigene Fahrstreifenspalte in der Leistungstabelle
4880 * bliebe ohne die Fahrbeziehung derselben Zeile ein halbes Glied und stuende
4881 * zugleich ein zweites Mal in der Unterlage.
4882 *
4883 * SOLANGE ES DIESE TABELLE GIBT: Der Anwender kann sie abwaehlen. Der
4884 * Erlaeuterungsabsatz unter der Leistungstabelle nennt deshalb beide Faelle -
4885 * mit Tabelle die Spalten, ohne sie die Abwahl und die Werte selbst
4886 * (`fahrstreifenHerkunftSatz`).
4887 *
4888 * AUFTEILUNG UND GLEICHUNG (Schema 13): Seit die Aufteilung des Schwerverkehrs
4889 * erfassbar ist, reicht der Anteil zum Nachrechnen nicht mehr aus - zwei
4890 * Stroeme mit denselben 12 % tragen verschiedene Faktoren, je nachdem, ob ihre
4891 * Lastzuege gezaehlt sind. Die Zelle nennt deshalb auch, WAS erfasst ist und
4892 * nach welcher Gleichung gerechnet wurde. Bis Schema 13 bildete diese Funktion den
4893 * Faktor mit `schwerverkehrsfaktor(anteil)`, also OHNE die Aufteilung: Der
4894 * Ausdruck druckte dann 1,180 neben einer Saettigungsverkehrsstaerke, die zu
4895 * 1,300 gehoert.
4896 *
4897 * Faktor und Worte kommen aus `schwerverkehrsangabe` (bewertung.ts) - eine
4898 * zweite Fassung der Gleichung im Ausdruck waere genau die Stelle, an der Zahl
4899 * und Nachweis auseinanderlaufen, und andere Worte als in der
4900 * Tabellenausgabe waeren die Stelle, an der beide Ausgaben auseinanderlaufen.
4901 */
4902 function schwerverkehrZelle(project: Project, groupId: string): string {
4903 const demand = project.demands.find((d) => d.signalGroupId === groupId);
4904 if (demand === undefined || demand.volume <= 0) return '–';
4905 const angabe = schwerverkehrsangabe(demand);
4906 // Der Anteil kommt aus derselben Angabe wie fSV daneben und nicht noch
4907 // einmal aus `demand`: Sonst stuende hier der eingetragene und dort der
4908 // gerechnete Wert (bewertung.ts, anteilProzent).
4909 return (
4910 `${angabe.anteilProzent} %\n${angabe.datenlage}\n` +
4911 `fSV = ${fmt.numShort(angabe.fsv, 3)} (${angabe.gleichungKurz})`
4912 );
4913 }
4914
4915 /**
4916 * Die Geometrie einer Zufahrt in einer Zelle - erfasste Werte, angesetzter
4917 * Faktor und die beiden Faelle, in denen eine Eingabe nicht wirkt.
4918 *
4919 * "Standard (angenommen)" und nicht "Standard": Ein leeres Feld heisst nicht,
4920 * dass jemand 3,25 m gemessen hat, sondern dass niemand gemessen hat. Der
4921 * Unterschied ist derselbe wie bei der Wegherkunft 'unbestimmt' - behauptet
4922 * werden darf nur, was bekannt ist.
4923 */
4924 function geometrieZelle(project: Project, groupId: string): string {
4925 const group = project.signalGroups.find((g) => g.id === groupId);
4926 if (group === undefined) return '–';
4927 const faktor = geometriefaktor(fahrstreifengeometrie(group), group.movement);
4928 const teile: string[] = [];
4929 if (group.fahrstreifenbreite !== undefined) {
4930 teile.push(`b = ${fmt.meters(group.fahrstreifenbreite, 2)}`);
4931 }
4932 if (group.kurvenradius !== undefined) teile.push(`R = ${fmt.meters(group.kurvenradius, 1)}`);
4933 if (group.laengsneigung !== undefined) {
4934 teile.push(`s = ${fmt.numShort(group.laengsneigung, 1)} %`);
4935 }
4936 if (teile.length === 0) return 'Standard (angenommen)\nFaktor 1,000';
4937
4938 const zeilen = [teile.join('; '), `Faktor ${fmt.numShort(faktor.gesamt, 3)}`];
4939 if (faktor.gedeckelt) zeilen.push('Gefälle hebt nicht an (gedeckelt)');
4940 if (faktor.radiusVomAbbiegerfaktorGedeckt) zeilen.push('R ohne Wirkung (Abbiegerfaktor)');
4941 return zeilen.join('\n');
4942 }
4943
4944 function drawReport(context: Context): void {
4945 const { doc, report } = context;
4946
4947 if (report.findings.length === 0) {
4948 writeParagraph(context, 'Die Prüfung ergab keine Beanstandungen.');
4949 return;
4950 }
4951
4952 autoTable(doc, {
4953 startY: context.cursor,
4954 margin: TABELLENRAND,
4955 theme: 'grid',
4956 styles: { fontSize: 8.5, cellPadding: 3, valign: 'top' },
4957 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
4958 columnStyles: {
4959 0: { cellWidth: 55 },
4960 1: { cellWidth: 90 },
4961 2: { cellWidth: 150 },
4962 4: { cellWidth: 130 },
4963 },
4964 head: [['Art', 'Bereich', 'Beanstandung', 'Beschreibung', 'Fundstelle'].map(toWinAnsi)],
4965 body: report.findings.map((finding) =>
4966 [
4967 SEVERITY_LABELS[finding.severity],
4968 CATEGORY_LABELS[finding.category],
4969 finding.title,
4970 `${finding.message}\n\nZu tun: ${finding.suggestion}`,
4971 finding.reference,
4972 ].map(toWinAnsi),
4973 ),
4974 });
4975
4976 context.cursor = lastTableBottom(doc) + 16;
4977 }
4978
4979 /**
4980 * Abweichungen vom Regelansatz, die KEIN Zahlenwert sind
4981 * (Fassung 5.5.0, Fragen E1 und E2).
4982 *
4983 * `resolveRilsaSettings` kennt nur Kennwerte - Zahlen mit Regelwert, Einheit
4984 * und Schranke. Die beiden neuen Wahlmoeglichkeiten sind keine: Der
4985 * Ueberfahrzeit-Ansatz ist die Wahl zwischen zwei Rechenwegen, der enge
4986 * Innenradius ein Merkmal einzelner Konfliktbeziehungen. Beide veraendern die
4987 * Zwischenzeiten, und beide sind Praxisansaetze ohne Regelwerksbeleg - eine
4988 * Unterlage, die sie nicht unter "Abweichungen" fuehrt, verschweigt genau das,
4989 * was zu begruenden ist.
4990 *
4991 * Die Zeilen stehen in derselben Tabelle wie die Kennwerte, weil ein Pruefer
4992 * EINE Liste der Abweichungen erwartet und nicht zwei nebeneinander.
4993 *
4994 * UND KEINE ABWEICHUNG IST, WAS IN KEINE ZAHL EINGEHT (seit 5.43.0): Die Zeile
4995 * zum festen Ansatz stand allein nach der Einstellung da - also auch an einer
4996 * einstreifigen Verkehrsfuehrung, wo nach RiLSA 2015, Abschnitt 5.2.2 gerechnet
4997 * wird, und dort verlangte sie samt Richtungssatz eine Begruendung fuer eine
4998 * Wahl ohne Wirkung. Denselben Grundsatz haelt der Aufrufer fuer die Kennwerte
4999 * seit Befund C20 ausdruecklich fest; er gilt fuer einen Rechenansatz genauso.
5000 * Die Zeile bleibt mit dem Wirkungsvermerk - eine Einstellung, die nicht wirkt,
5001 * gehoert genannt -, aber ohne Richtungssatz: Was nicht rechnet, verkuerzt und
5002 * verlaengert nichts.
5003 */
5004 function rechenansatzAbweichungen(
5005 project: Project,
5006 plan: SignalPlan,
5007 ): readonly (readonly string[])[] {
5008 const zeilen: string[][] = [];
5009 const defaults = plan.defaults;
5010
5011 if (project.settings.ueberfahrzeitAnsatz === 'fest') {
5012 const ohneWirkung = ueberfahrzeitAnsatzOhneWirkung(project, plan);
5013 zeilen.push([
5014 'Überfahrzeit des Kraftfahrzeugverkehrs (Rechenansatz, kein Kennwert)',
5015 UEBERFAHRZEIT_ANSATZ_LABELS.gelbzeit,
5016 ohneWirkung !== null
5017 ? `${UEBERFAHRZEIT_ANSATZ_LABELS.fest} – ${ohneWirkung.vermerk}. ` +
5018 `${ohneWirkung.begruendung} ${ohneWirkung.zutun}`
5019 : `${UEBERFAHRZEIT_ANSATZ_LABELS.fest} – angesetzt sind ` +
5020 `${fmt.seconds(defaults.crossingTime.kfzGeradeaus)} geradeaus und ` +
5021 // Richtung aus den wirksamen Vorgaben (Fassung 5.5.0, Befund B5):
5022 // "VERKUERZT" gilt nur, solange die eingetragenen festen Werte unter
5023 // der Gelbzeit liegen; einstellbar sind sie von 1 bis 10 s.
5024 `${fmt.seconds(defaults.crossingTime.kfzAbbiegend)} abbiegend. ` +
5025 ueberfahrzeitAnsatzRichtungSatz(defaults),
5026 '–',
5027 ]);
5028 }
5029
5030 // Nur die gesetzten Merkmale zaehlen, nicht die Beziehungen insgesamt: Die
5031 // Zeile soll sagen, WIE OFT vom Regelfall abgewichen wird.
5032 //
5033 // WIRKSAM heisst: abbiegender Kfz-Strom UND keine Raeumgeschwindigkeit von
5034 // Hand (Fassung 5.5.0, Befund B2). Gezaehlt werden beide - die Zeile nennt, was erfasst
5035 // ist, und daneben, an wie vielen Beziehungen es tatsaechlich gerechnet hat.
5036 //
5037 // ABBIEGEND IST DER MASSGEBENDE STROM, nicht die Signalgruppe:
5038 // `massgebendeRaeumbeziehung` (signalPlan.ts) sagt an einer Stelle, mit
5039 // welcher Fahrbeziehung eine Beziehung raeumt. Mit `from.movement` zaehlte
5040 // die Zeile an einer Sammelsignalgruppe "geradeaus UND rechts" null wirksame
5041 // Beziehungen, waehrend derselbe Ausdruck in der Spalte "vr" derselben Zeile
5042 // die gesenkte Raeumgeschwindigkeit auswies.
5043 const mitMerkmal = project.conflicts.filter((c) => c.engerRadius === true).length;
5044 if (mitMerkmal > 0) {
5045 const wirksam = project.conflicts.filter((c) => {
5046 const from = project.signalGroups.find((g) => g.id === c.fromId);
5047 return (
5048 from !== undefined &&
5049 engerRadiusWirktAn({
5050 mode: from.mode,
5051 movement: massgebendeRaeumbeziehung(from, c),
5052 engerRadius: c.engerRadius === true,
5053 ...(c.clearingSpeedOverride !== null
5054 ? { clearingSpeedOverride: c.clearingSpeedOverride }
5055 : {}),
5056 })
5057 );
5058 }).length;
5059 zeilen.push([
5060 `Räumgeschwindigkeit Kfz abbiegend, Merkmal „enger Innenradius" an ${mitMerkmal} von ` +
5061 `${project.conflicts.length} Konfliktbeziehungen (${ENGER_RADIUS_LABEL})`,
5062 fmt.metersPerSecond(RILSA_DEFAULTS.clearingSpeed.kfzTurning, 1),
5063 `${fmt.metersPerSecond(defaults.clearingSpeed.kfzTurningEngerRadius, 1)} an den ` +
5064 (wirksam === mitMerkmal
5065 ? 'gekennzeichneten Beziehungen. '
5066 : `${wirksam} Beziehungen, an denen es wirkt (an den übrigen ist der räumende Strom ` +
5067 'kein abbiegender Kraftfahrzeugstrom oder es steht eine Räumgeschwindigkeit von ' +
5068 'Hand daneben, die Vorrang hat). ') +
5069 (defaults.clearingSpeed.kfzTurningEngerRadius < defaults.clearingSpeed.kfzTurning
5070 ? 'Die Wahl VERLÄNGERT die Zwischenzeiten.'
5071 : 'Die Wahl verlängert die Zwischenzeiten nicht: Der Wert entspricht der ' +
5072 'Räumgeschwindigkeit des Abbiegers ohne dieses Merkmal.'),
5073 'm/s',
5074 ]);
5075 }
5076
5077 return zeilen;
5078 }
5079
5080 function drawSettingsDeviations(context: Context): void {
5081 const { doc, project, plan } = context;
5082 // Mit der Anlagenart des Projekts: Die Umlaufzeit-Vorgaben werden gegen
5083 // DEREN Schranken aufgeloest (Befund C10). Ohne sie gaelte 'knotenpunkt',
5084 // und der Ausdruck einer einstreifigen Fuehrung fuehrte eine Abweichung, die
5085 // der Plan selbst nicht kennt - oder liesse eine aus, die er kennt.
5086 const resolved = resolveRilsaSettings(project.settings.rilsa, undefined, project.anlagenart);
5087 // Mit dem PLAN und nicht nur mit seinen Kennwerten: Die Zeilen nennen die
5088 // Kennwerte DIESES Plans, nicht die Regelwerte - wer die feste Ueberfahrzeit
5089 // oder die Raeumgeschwindigkeit bei engem Innenradius angepasst hat, bekaeme
5090 // sonst eine Abweichung gedruckt, die andere Zahlen nennt als die Rechnung
5091 // (Befund C21) -, und sie fragen am Plan nach, ob der Ansatz ueberhaupt
5092 // gerechnet hat (seit 5.43.0).
5093 const ansaetze = rechenansatzAbweichungen(project, plan);
5094 if (resolved.deviations.length === 0 && ansaetze.length === 0) return;
5095
5096 newSection(context, 'Abweichungen von den Regelwerten');
5097 const verfahren = bewertungsverfahren(context);
5098 // Mit der Anlagenart wie engine.ts checkSettings - fuer den Quellenschluessel
5099 // ohne Wirkung, aber der eine Aufrufer ohne sie waere der, der beim naechsten
5100 // Feld mit Schranke je Anlagenart falsch liegt.
5101 const quelleJeFeld = new Map(
5102 settingFields(undefined, project.anlagenart).map((f) => [f.key, f.quellenSchluessel]),
5103 );
5104 // Ein Kennwert, der in keine Zahl dieser Planung eingeht, ist keine zu
5105 // begruendende Abweichung. Das steht an der Zeile, statt eine Begruendung
5106 // fuer eine wirkungslose Zahl zu verlangen.
5107 //
5108 // Den GRUND liefert der Fachkern ueber wirkungsvermerk (settings.ts,
5109 // `wirkungslosigkeit`); vor Fassung 5.4.0 (Befund C20) stand
5110 // hier ein fest eingetragenes " (ohne Wirkung: Bewertungsverfahren HCM)",
5111 // waehrend die Bedingung bereits jeden wirkungslosen Kennwert traf. Die
5112 // Koordinierungs-Kennwerte haetten damit im Ausdruck ein Bewertungsverfahren
5113 // als Grund genannt bekommen, das mit ihnen nichts zu tun hat.
5114 writeParagraph(
5115 context,
5116 'Die folgende Aufstellung nennt, worin dieser Plan von den Regelwerten und vom Regelansatz ' +
5117 'dieses Programms abweicht: angepasste Kennwerte (RiLSA 2015, HBS 2015 oder Planungspraxis, ' +
5118 'siehe Fundstellenverzeichnis) und gewählte Rechenansätze, die kein Kennwert sind. Jede ' +
5119 'wirksame Abweichung ist fachlich zu begründen.',
5120 );
5121
5122 autoTable(doc, {
5123 startY: context.cursor,
5124 margin: TABELLENRAND,
5125 theme: 'grid',
5126 styles: { fontSize: 9, cellPadding: 3, valign: 'top' },
5127 headStyles: { fillColor: '#fff4e0', textColor: '#1b2230', fontStyle: 'bold' },
5128 head: [['Kennwert', 'Regelwert', 'Angesetzt', 'Einheit'].map(toWinAnsi)],
5129 body: [
5130 /*
5131 * Die Rechenansaetze zuerst: Sie wirken auf jede Zwischenzeit des Plans,
5132 * waehrend ein angepasster Kennwert eine einzelne Groesse betrifft. Wer
5133 * die Aufstellung von oben liest, soll die weitreichendere Abweichung
5134 * zuerst sehen (Fassung 5.5.0, Fragen E1 und E2).
5135 */
5136 ...ansaetze.map((zeile) => zeile.map(toWinAnsi)),
5137 ...resolved.deviations.map((d) =>
5138 [
5139 // Mit dem Ueberfahrzeit-Ansatz dieses Projekts (Fassung 5.5.0, Frage E2): Die
5140 // beiden festen Ueberfahrzeiten wirken nur beim Ansatz "fest". Ohne
5141 // ihn verlangte der Ausdruck eine Begruendung fuer einen Wert, der
5142 // in keine Zahl eingeht (Befund C20).
5143 `${d.label}${wirkungsvermerk(quelleJeFeld.get(d.key) ?? 'praxis', verfahren, {
5144 ueberfahrzeitAnsatz: project.settings.ueberfahrzeitAnsatz,
5145 // Ebenso (Fassung 5.5.0, Frage E1): Ohne eine Beziehung mit dem
5146 // Merkmal geht die Raeumgeschwindigkeit bei engem Innenradius in
5147 // keine Zahl ein.
5148 engerRadiusGesetzt: project.conflicts.some((c) => c.engerRadius === true),
5149 koordinierungErfasst: project.koordinierung !== null,
5150 })}`,
5151 fmt.numShort(d.standardValue, 3),
5152 fmt.numShort(d.appliedValue, 3),
5153 d.unit,
5154 ].map(toWinAnsi),
5155 ),
5156 ],
5157 });
5158
5159 context.cursor = lastTableBottom(doc) + 16;
5160 }
5161
5162 // --- Nachweis der Kennwerte -------------------------------------------------
5163
5164 /**
5165 * Rechtliche Einordnung.
5166 *
5167 * Fuer die Pruefung der Unterlagen ist entscheidend, dass die StVO selbst keine
5168 * Zahlenwerte fuer Signalzeiten enthaelt: Sie regelt die Bedeutung der Signale
5169 * und die Zustaendigkeit, die Kennwerte stehen in den Regelwerken der FGSV.
5170 *
5171 * ANLAGENARTABHAENGIG seit Fassung 5.4.0 (Befund C13): Die Tabelle
5172 * nannte unabhaengig von der Anlagenart nur Par. 37, Par. 45, VwV-StVO, RiLSA
5173 * und HBS. Fuer eine Baustellenampel fehlten damit genau die Regelwerke, nach
5174 * denen sie angeordnet, ausgeschrieben und abgenommen wird (RSA 21, TL und ZTV
5175 * transportable Lichtsignalanlagen 2023) - waehrend derselbe Ausdruck im
5176 * Pruefbericht laengst RSA- und ZTV-Anforderungen zitierte. Welche Grundlagen
5177 * hinzukommen, entscheidet der Fachkern (quellen.ts, rechtsgrundlagenFuer),
5178 * damit Ausdruck und Vorgabenansicht dieselbe Liste fuehren.
5179 */
5180 function drawLegalBasis(context: Context, mitLeistungsfaehigkeit: boolean): void {
5181 const { doc, project } = context;
5182
5183 writeParagraph(
5184 context,
5185 'Die nachstehenden Angaben weisen aus, worauf die verwendeten Kennwerte beruhen. Die StVO ' +
5186 'enthält selbst keine Zahlenwerte für Signalzeiten; sie regelt die Bedeutung der Signale und ' +
5187 'die Zuständigkeit der Straßenverkehrsbehörde.',
5188 );
5189
5190 autoTable(doc, {
5191 startY: context.cursor,
5192 margin: TABELLENRAND,
5193 theme: 'grid',
5194 styles: { fontSize: 8.5, cellPadding: 3, valign: 'top' },
5195 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
5196 columnStyles: { 0: { cellWidth: 190 }, 2: { cellWidth: 80 } },
5197 head: [['Grundlage', 'Inhalt', 'Prüfstand'].map(toWinAnsi)],
5198 body: rechtsgrundlagenFuer(project.anlagenart).map((grundlage) =>
5199 [
5200 grundlage.bezeichnung,
5201 grundlage.hinweis === undefined
5202 ? grundlage.inhalt
5203 : `${grundlage.inhalt}\n\n${grundlage.hinweis}`,
5204 PRUEFSTAND_LABELS[grundlage.pruefstand],
5205 ].map(toWinAnsi),
5206 ),
5207 });
5208
5209 context.cursor = lastTableBottom(doc) + 10;
5210
5211 // Die Tabelle nennt das HBS 2015 als Grundlage von Kapazitaet, Wartezeit
5212 // und Qualitaetsstufen - allgemein, fuer das Programm. Ob DIESES Projekt
5213 // danach bewertet, steht erst hier: Bei HCM stammen aus dem HBS nur die
5214 // Eingangsgroessen der Kapazitaet, und wer die Grundlagen liest, ohne den
5215 // Leistungsabschnitt daneben zu haben, hielte sonst die ganze Bewertung fuer
5216 // eine nach HBS 2015.
5217 writeParagraph(
5218 context,
5219 verfahrensGrundlage(bewertungsverfahren(context), mitLeistungsfaehigkeit),
5220 );
5221 context.cursor += 6;
5222 }
5223
5224 /**
5225 * Ein Satz dazu, nach welchem Verfahren dieses Projekt bewertet - fuer die
5226 * Grundlagen.
5227 *
5228 * Der HBS-Zweig schickte den Leser vor der Fassung 5.11.0 unbedingt in
5229 * den Abschnitt "Leistungsfaehigkeit" - auch dann, wenn dieser Abschnitt
5230 * abgewaehlt war und die Bestandteileuebersicht ihn auf Blatt 2 derselben Datei
5231 * bereits als "abgewählt - in dieser Ausgabe nicht enthalten" auswies. Zwei
5232 * gegenlaeufige Aussagen in einer Anordnungsunterlage.
5233 *
5234 * DER SATZ SELBST BLEIBT UNBEDINGT: Er unterscheidet HBS von HCM. Nur sein
5235 * Klammerzusatz haengt daran, ob es den genannten Abschnitt in DIESER Ausgabe
5236 * gibt - wie an den uebrigen Verweisen dieser Datei. Der HCM-Zweig beschreibt
5237 * das Verfahren selbst und verweist auf nichts; er bleibt unveraendert.
5238 */
5239 function verfahrensGrundlage(
5240 verfahren: Bewertungsverfahren,
5241 mitLeistungsfaehigkeit: boolean,
5242 ): string {
5243 const name = verfahrenLabel(verfahren);
5244 if (verfahren === 'HCM') {
5245 return (
5246 `Dieses Projekt bewertet nach dem ${name} (Highway Capacity Manual): Kapazität mit ` +
5247 'g = tF, Wartezeit d = d1 + d2, Level of Service. Aus dem HBS 2015 stammen hier nur ' +
5248 'Sättigungsverkehrsstärke und Abbiegerfaktor.'
5249 );
5250 }
5251 return mitLeistungsfaehigkeit
5252 ? `Dieses Projekt rechnet und bewertet nach dem ${name} (Verfahren siehe Abschnitt ` +
5253 'Leistungsfähigkeit).'
5254 : `Dieses Projekt rechnet und bewertet nach dem ${name}; der Abschnitt ` +
5255 '"Leistungsfähigkeit", der das Verfahren zeigt, ist für diese Ausgabe abgewählt.';
5256 }
5257
5258 /**
5259 * Schranken und Pruefregeln, die an der Anlagenart haengen.
5260 *
5261 * Ohne diesen Abschnitt stuende im Ausdruck eine Umlaufzeit, deren zulaessiger
5262 * Bereich nirgends benannt ist - und die Angabe, wonach sie zu beurteilen ist,
5263 * fehlte genau dort, wo der Ausdruck sonst jede Zahl auf ein Regelwerk
5264 * zurueckfuehrt.
5265 *
5266 * KORREKTUR (Fassung 5.4.0, Befund B8): Bis dahin schrieb diese Tabelle in JEDE
5267 * Zeile dieselbe Quelle der Anlagenart - "RiLSA 2015, gesichert" auch neben die
5268 * Wartezeitschwellen 90/120 s, den vollen Namen der RSA 21 auch neben die
5269 * Umlaufzeit von 30 bis 300 s. Weder die eine noch die andere Zahl steht im
5270 * jeweils genannten Regelwerk (Fassung 5.4.0, Fragen E4 und E7). Jede Zeile
5271 * bezieht ihre Grundlage und ihren Pruefstand jetzt aus `grenzen.herkunft` -
5272 * derselben Angabe, aus der auch der Pruefbericht seine Fundstelle nimmt
5273 * (Befund B9). Das Programm hatte den Sachverhalt intern laengst richtig
5274 * kommentiert; nur die gedruckte Spalte sagte etwas anderes.
5275 *
5276 * DIE SPALTE "ANGESETZT": Sie las danach trotzdem weiter aus
5277 * ANLAGENART_GRENZEN - also die Werte der Anlagenart und nicht die dieses
5278 * Plans. Wirksam sind aber die aus den Vorgaben abgeleiteten Grenzen
5279 * (`anlagenartGrenzen` in signalPlan.ts): Der Pruefbericht misst gegen
5280 * `plan.grenzen.cycleTime`, die Umlaufzeitermittlung rechnet damit, und die
5281 * Aufstellung "Abweichungen von den Regelwerten" fuehrt dieselbe Vorgabe als
5282 * Abweichung. Bei eingetragenen 45 bis 100 s stand hier "30 s bis 120 s" mit
5283 * dem Pruefstand "gesichert" - zwei Zahlenwerke in einer Anordnungsunterlage,
5284 * und quellen.ts verweist im selben Dokument auf genau diesen Abschnitt als die
5285 * Stelle, die die WIRKSAME Schranke nennt.
5286 *
5287 * DIE TABELLE UND DER SATZ DARUNTER FUEHREN DESHALB VERSCHIEDENE WERTE, und das
5288 * ist kein Versehen: In der Spalte "Angesetzt" steht, wonach dieser Plan
5289 * beurteilt wird; im Satz bleibt die Obergrenze der RiLSA stehen, weil ihre
5290 * Begruendung ("Wer so lange vor einem Signal steht, faehrt bei Rot") am
5291 * Regelwerkswert haengt. Eine selbst gesetzte Schranke ist eine
5292 * Betriebsentscheidung und traegt diese Begruendung nicht - sie wird daneben
5293 * benannt, nicht an ihre Stelle gesetzt.
5294 *
5295 * GRUNDLAGE UND PRUEFSTAND: Sie kommen weiter aus `grenzen.herkunft`, und
5296 * `anlagenartGrenzen` reicht die Herkunft der Anlagenart unveraendert durch.
5297 * Neben einem eingeengten Wert stand damit "RiLSA 2015 - Umlaufzeit am
5298 * Knotenpunkt (30 bis 120 s)" und der Pruefstand "gesichert" - die Verbindung
5299 * aus selbst gesetzter Schwelle und "gesichert", die dieselbe Datei fuer das
5300 * Fundstellenverzeichnis schon einmal aufgeloest hat (Fassung 5.9.0).
5301 * Aufgeloest wird sie hier auf demselben Weg: Der Vermerk haengt an der
5302 * Kennwertzelle, Grundlage und Pruefstand bleiben beim Regelwert und sind so
5303 * die Gegenprobe zum angesetzten Wert; der Einleitungsabsatz sagt es fuer die
5304 * ganze Tabelle.
5305 */
5306 function drawAnlagenartGrenzen(context: Context): void {
5307 const { doc, project } = context;
5308 const grenzen = context.plan.grenzen;
5309 const regelwerk = ANLAGENART_GRENZEN[project.anlagenart];
5310
5311 ensureSpace(context, 140);
5312
5313 doc.setFont('helvetica', 'bold');
5314 doc.setFontSize(11);
5315 doc.setTextColor('#1b2230');
5316 doc.text(toWinAnsi('Schranken der Anlagenart'), PAGE_MARGIN, context.cursor);
5317 context.cursor += 16;
5318
5319 writeParagraph(
5320 context,
5321 `Dieser Plan ist als "${ANLAGENART_LABELS[project.anlagenart]}" angelegt. Daran hängen die ` +
5322 'Schranken der Umlaufzeit, der größte eintragbare Räumweg und die Wartezeiten, ab denen die ' +
5323 'Prüfung warnt oder beanstandet. Die Angaben gelten für den gesamten Plan. Die Spalte ' +
5324 '"Grundlage" sagt je Zeile, woher der Zahlenwert stammt - nicht, welches Regelwerk die ' +
5325 'Anlagenart im Allgemeinen behandelt: Mehrere dieser Schwellen setzt dieses Programm selbst, ' +
5326 'und sie sind mit der Straßenverkehrsbehörde abzustimmen statt in einem Regelwerk ' +
5327 'nachzuschlagen. Steht unter "Angesetzt" eine eigene Vorgabe aus der Vorgabenverwaltung, ist ' +
5328 'das an der Zeile vermerkt: Grundlage und Prüfstand nennen dann weiterhin den Regelwert, von ' +
5329 'dem die Vorgabe abweicht, und nicht die Herkunft des angesetzten Wertes.',
5330 );
5331
5332 /** Grundlage und Pruefstand einer Zeile - beide aus derselben Herkunft. */
5333 const herkunft = (h: SchrankenHerkunft): readonly [string, string] => [
5334 h.text,
5335 PRUEFSTAND_LABELS[h.pruefstand],
5336 ];
5337
5338 /*
5339 * Der Vermerk an der Kennwertzelle - dieselbe Stelle, an der das
5340 * Fundstellenverzeichnis "(angesetzt: X, abweichend vom Regelwert)" fuehrt.
5341 * Ohne ihn stuende die eigene Vorgabe neben der Grundlage und dem Pruefstand
5342 * des Regelwerks, als waere sie dort belegt.
5343 *
5344 * "abweichend" und nicht "enger": Der zulaessige Bereich kann nur eingeengt
5345 * werden (Math.max/Math.min in anlagenartGrenzen), der empfohlene Bereich
5346 * aber auch aufgeweitet - und er verschiebt sich schon dann, wenn allein
5347 * eine eigene Obergrenze eingetragen ist und clamp() ihn in den zulaessigen
5348 * Bereich legt. Ausgeloest ist beides von einer eigenen Vorgabe; ohne eine
5349 * solche sind angesetzter Wert und Regelwert gleich.
5350 */
5351 const abweichungsvermerk = (angesetzt: string, regelwert: string): string =>
5352 angesetzt === regelwert ? '' : ` (eigene Vorgabe, abweichend vom Regelwert ${regelwert})`;
5353
5354 const spanne = (g: AnlagenartGrenzen, welche: 'zulaessig' | 'regelbereich'): string =>
5355 welche === 'zulaessig'
5356 ? `${fmt.seconds(g.cycleTime.min)} bis ${fmt.seconds(g.cycleTime.max)}`
5357 : `${fmt.seconds(g.cycleTime.recommendedMin)} bis ${fmt.seconds(g.cycleTime.recommendedMax)}`;
5358
5359 const zulaessig = spanne(grenzen, 'zulaessig');
5360 const regelbereich = spanne(grenzen, 'regelbereich');
5361
5362 autoTable(doc, {
5363 startY: context.cursor,
5364 margin: TABELLENRAND,
5365 theme: 'grid',
5366 styles: { fontSize: 8.5, cellPadding: 3, valign: 'top' },
5367 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
5368 columnStyles: {
5369 0: { cellWidth: 190, fontStyle: 'bold' },
5370 2: { cellWidth: 260 },
5371 3: { cellWidth: 80 },
5372 },
5373 head: [['Schranke', 'Angesetzt', 'Grundlage', 'Prüfstand'].map(toWinAnsi)],
5374 body: [
5375 [
5376 `Umlaufzeit, zulässig${abweichungsvermerk(zulaessig, spanne(regelwerk, 'zulaessig'))}`,
5377 zulaessig,
5378 ...herkunft(grenzen.herkunft.umlaufzeit),
5379 ],
5380 [
5381 'Umlaufzeit, empfohlener Bereich' +
5382 abweichungsvermerk(regelbereich, spanne(regelwerk, 'regelbereich')),
5383 regelbereich,
5384 ...herkunft(grenzen.herkunft.regelbereich),
5385 ],
5386 // Kein Regelwerkswert, sondern die Eingabegrenze dieses Programms - und
5387 // deshalb ausdruecklich so benannt. Sie gehoert trotzdem in die
5388 // Unterlage: Sie sagt, dass ein laengerer Raeumweg gar nicht erfassbar
5389 // gewesen waere, und erklaert damit, warum der Plan so aussieht, wie er
5390 // aussieht. Diese Zeile war die einzige, die ihre Herkunft schon vor
5391 // Befund B8 richtig fuehrte; jetzt fuehren sie alle so.
5392 [
5393 'Größter eintragbarer Räumweg',
5394 fmt.meters(grenzen.maxClearingDistance, 0),
5395 ...herkunft(grenzen.herkunft.raeumweg),
5396 ],
5397 [
5398 'Wartezeit: Warnung / Beanstandung',
5399 `ab ${fmt.seconds(grenzen.warteZeitWarnung)} / ab ${fmt.seconds(grenzen.warteZeitFehler)}`,
5400 ...herkunft(grenzen.herkunft.wartezeit),
5401 ],
5402 // Nur bei einstreifiger Fuehrung: die Laengen, ab denen der Pruefbericht
5403 // auf Abstimmungsbedarf hinweist (Befund C5). BEHOERDENPRAXIS, KEIN
5404 // REGELWERKSWERT - so benannt, damit ein Pruefer die 400 m nicht in der
5405 // RSA sucht.
5406 ...(project.anlagenart === 'einstreifig'
5407 ? [
5408 [
5409 'Engstellenlänge: Hinweis / Warnung',
5410 `ab ${fmt.meters(ENGSTELLE_PRAXIS.hinweisAb, 0)} / ab ${fmt.meters(ENGSTELLE_PRAXIS.warnungAb, 0)}`,
5411 'Behördenpraxis (z. B. Hessen Mobil), kein Regelwerkswert',
5412 PRUEFSTAND_LABELS.abzugleichen,
5413 ],
5414 ]
5415 : []),
5416 ].map((row) => row.map((cell) => toWinAnsi(String(cell)))),
5417 });
5418
5419 context.cursor = lastTableBottom(doc) + 12;
5420
5421 /*
5422 * Die Zahlen im Fliesstext kommen aus dem REGELWERK der Anlagenart, nicht aus
5423 * der Tabelle darueber. Ausgeschriebene Werte in einem Absatz sind genau die
5424 * Stelle, an der ein Ausdruck unbemerkt veraltet - deshalb sind sie
5425 * gerechnet und nicht getippt; welche der beiden Groessen sie nennen, ist
5426 * eine fachliche Entscheidung (siehe Kopf dieser Funktion, Befund 7).
5427 */
5428 const obergrenze = fmt.seconds(regelwerk.cycleTime.max);
5429 const knotenpunktGrenze = fmt.seconds(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.max);
5430 // Hat der Anwender enger vorgegeben, steht das daneben - sonst behauptete der
5431 // Satz eine Obergrenze, nach der dieser Plan gar nicht beurteilt wird.
5432 const eigeneObergrenze =
5433 grenzen.cycleTime.max < regelwerk.cycleTime.max
5434 ? ` Für diesen Plan ist eine engere Obergrenze von ${fmt.seconds(grenzen.cycleTime.max)} ` +
5435 'vorgegeben; sie ist eine Betriebsentscheidung dieses Projekts und trägt die vorstehende ' +
5436 'Begründung nicht. Beurteilt wird der Plan nach ihr.'
5437 : '';
5438
5439 writeParagraph(
5440 context,
5441 (grenzen.quelle === 'rilsa-umlaufzeit'
5442 ? `Die Obergrenze der Umlaufzeit von ${obergrenze} ist eine Sicherheitsgrenze und keine ` +
5443 'Bequemlichkeitsschwelle: Wer so lange vor einem Signal steht, hält die Anlage für ' +
5444 'gestört und fährt bei Rot. Der empfohlene Bereich liegt deutlich darunter; ihn zu ' +
5445 'überschreiten ist in den Planunterlagen zu begründen.'
5446 : /*
5447 * KORREKTUR (Fassung 5.4.0, Hinweis D1): Die Herleitung rechnete
5448 * mit RILSA_DEFAULTS statt mit den Vorgaben DIESES Plans. Bei einer
5449 * abweichenden Raeumgeschwindigkeit druckte der Ausdruck damit eine
5450 * Zwischenzeit- und Umlaufzahl, die im selben Dokument nirgends vorkommt -
5451 * und genau diese Zahl ist hier die BEGRUENDUNG der Umlaufzeitobergrenze.
5452 */
5453 herleitungEinstreifig(regelwerk, obergrenze, knotenpunktGrenze, context.plan.defaults)) +
5454 eigeneObergrenze,
5455 );
5456 }
5457
5458 /**
5459 * Herleitung der Umlaufzeitobergrenze fuer eine einstreifige Verkehrsfuehrung.
5460 *
5461 * Die Zahlen werden GERECHNET und stehen nicht im Satz. Zuvor trug der Text
5462 * feste 125 s und 290 s neben einer gerechneten Engstellenlaenge - und beide
5463 * waren falsch: Aus 1000 m folgen mit den Kennwerten dieses Programms 104 s
5464 * Zwischenzeit je Richtung. Eine Zahl, die in eine Planunterlage gedruckt wird
5465 * und sich aus keiner Rechnung des Programms ergibt, ist als Begruendung
5466 * wertlos - ein Pruefer, der sie nachrechnet, findet etwas anderes.
5467 */
5468 function herleitungEinstreifig(
5469 grenzen: AnlagenartGrenzen,
5470 obergrenze: string,
5471 knotenpunktGrenze: string,
5472 defaults: RilsaDefaults,
5473 ): string {
5474 const laenge = grenzen.maxClearingDistance;
5475 // 50 km/h und 20 s Freigabe je Richtung: der Fall, den man an einer
5476 // innerstaedtischen Arbeitsstelle tatsaechlich anordnen wuerde. Die
5477 // Untergrenze aus engstellenFolgen unterstellt 5 s und taugt als Begruendung
5478 // nicht - sie beschriebe eine Schaltung, die niemand vorsieht.
5479 const freigabeJeRichtung = 20;
5480 // `defaults` sind die Kennwerte DIESES Plans (Hinweis D1) - nicht die
5481 // Regelwerte: Die beiden Zahlen unten muessen sich mit dem Rest des
5482 // Ausdrucks nachrechnen lassen.
5483 const { zwischenzeit } = engstellenFolgen(laenge, 50, defaults);
5484 const umlauf = umlaufBeiFreigabe(laenge, 50, freigabeJeRichtung, defaults);
5485
5486 return (
5487 'Die Umlaufzeitgrenzen dieses Plans stammen NICHT aus der RiLSA. Die RiLSA regelt den ' +
5488 `Knotenpunkt; dort ist die Obergrenze von ${knotenpunktGrenze} eine Sicherheitsgrenze, ` +
5489 'weil längere Umläufe zu Rotlichtverstößen führen. An einer Arbeitsstelle gilt sie ' +
5490 'nicht: Der Räumweg ist dort die gesamte Engstelle, die Umlaufzeit folgt zwangsläufig ' +
5491 'aus ihrer Länge, und maßgebend ist stattdessen die zumutbare Wartezeit. Die hier ' +
5492 `angesetzte Obergrenze von ${obergrenze} ist aus den übrigen Schranken hergeleitet und ` +
5493 'keine Zahl aus einem Regelwerk - bei der größten eintragbaren Engstelle von ' +
5494 `${fmt.meters(laenge, 0)} ergeben sich ${fmt.seconds(zwischenzeit)} Zwischenzeit je ` +
5495 `Richtung und mit zwei Übergängen sowie ${fmt.seconds(freigabeJeRichtung)} Freigabe je ` +
5496 `Richtung ${fmt.seconds(umlauf)} Umlaufzeit. Wer länger absperrt, braucht ein anderes ` +
5497 `Verfahren; die RSA 21 sieht dann vor: ${RSA_ALTERNATIVEN_ZUR_SIGNALREGELUNG}. Die Werte ` +
5498 'sind vor dem Einsatz gegen die eigene Ausgabe der RSA abzugleichen und in den ' +
5499 'Planunterlagen zu begründen. Unabhängig davon stellen Straßenbaubehörden Baustellenampeln ' +
5500 `üblicherweise nur bis ${fmt.meters(ENGSTELLE_PRAXIS.hinweisAb, 0)} auf, ausnahmsweise und mit ` +
5501 `Zustimmung bis ${fmt.meters(ENGSTELLE_PRAXIS.warnungAb, 0)} – Behördenpraxis (z. B. Hessen ` +
5502 'Mobil), kein Regelwerkswert; der Prüfbericht weist ab diesen Längen auf den Abstimmungsbedarf hin.'
5503 );
5504 }
5505
5506 /** Fundstellenverzeichnis: jeder Kennwert mit Regelwerk, Sachgebiet und Pruefstand. */
5507 function drawSourceRegister(context: Context, options: PdfOptions): void {
5508 const { doc, plan, project } = context;
5509 ensureSpace(context, 120);
5510
5511 doc.setFont('helvetica', 'bold');
5512 doc.setFontSize(11);
5513 doc.setTextColor('#1b2230');
5514 doc.text(toWinAnsi('Fundstellenverzeichnis'), PAGE_MARGIN, context.cursor);
5515 context.cursor += 16;
5516
5517 writeParagraph(context, FUNDSTELLEN_HINWEIS);
5518
5519 /*
5520 * Die Fundstelle der EIGENEN Ausgabe geht der hinterlegten vor (5.27.0).
5521 * Bis dahin gab es nur die eigene, weil das Programm keine Nummer kannte -
5522 * jetzt kennt es die der RiLSA 2015, an der gekauften Ausgabe
5523 * nachgeschlagen. Wer eine andere Ausgabe hat, hat eine andere Gliederung
5524 * und traegt sie ein; deshalb die Reihenfolge und nicht umgekehrt.
5525 */
5526 const fundstelle = (schluessel: QuellenSchluessel): string => {
5527 const eigene = options.fundstellen[schluessel] ?? '';
5528 if (eigene !== '') return eigene;
5529 return QUELLEN[schluessel].fundstelle ?? 'nicht eingetragen';
5530 };
5531
5532 const verfahren = bewertungsverfahren(context);
5533 const rows: RowInput[] = [];
5534 const verbunden = (text: string): CellDef => ({ content: toWinAnsi(text), colSpan: 2 });
5535 /*
5536 * Die Zeilen entstanden vor der Fassung 5.9.0 aus
5537 * `settingFieldsByQuelle(plan.defaults, ...)` - das sind die AUFGELOESTEN
5538 * Kennwerte einschliesslich aller Vorgaben des Anwenders. In der Spalte
5539 * "Regelwert" stand damit bei gesetzter Vorgabe der selbst gesetzte Wert,
5540 * daneben das Regelwerk und der Pruefstand "gesichert". Derselbe Ausdruck
5541 * widersprach sich zweifach: Der Abschnitt "Abweichungen von den
5542 * Regelwerten" nennt fuer denselben Kennwert den echten Regelwert aus
5543 * RILSA_DEFAULTS, und der VwV-StVO-Eintrag DIESES Verzeichnisses haengt bei
5544 * abweichender Vorgabe ausdruecklich "abweichend vom Regelwert ..." an. Der
5545 * mitgedruckte Quellenhinweis "Die hier genannten Regelwerte sind die Werte
5546 * dieser Zeile" wurde von der Zelle daneben widerlegt.
5547 *
5548 * Die Spalte fuehrt deshalb den Regelwert. Verschwiegen wird der angesetzte
5549 * Wert nicht: Er steht mit dem Vermerk der Abweichung an der Kennwertzelle,
5550 * dort, wo auch der Wirkungsvermerk steht.
5551 *
5552 * NACHGEZOGEN (Fassung 5.12.0): WELCHER Wert der Regelwert ist und WANN eine
5553 * Vorgabe davon abweicht, entschied diese Stelle bis dahin selbst - aus
5554 * `settingFields` und einem Wertvergleich daneben. Bei den beiden
5555 * Regelbereichsfeldern der Umlaufzeit misst die Abweichungsliste aber gegen
5556 * den Wert, der ohne Eintrag fuer die ANLAGENART gilt, und an einer
5557 * Fussgaengerschutzanlage nannten die beiden Aufstellungen desselben
5558 * Ausdrucks damit verschiedene Regelwerte (40 gegen 60 s). Beides kommt jetzt
5559 * aus `fundstellenkennwerte` und damit aus derselben Quelle wie die
5560 * Aufstellung "Abweichungen von den Regelwerten": aus `resolveRilsaSettings`.
5561 * Der Rueckfall daneben nimmt den `regelwert` der Vorgabenzeile - seit der
5562 * Fassung 5.12.0 dieselbe Zahl, gebildet an derselben Stelle (settings.ts,
5563 * `regelwertVon`); mit `standard` stand dort wieder der Knotenpunktwert.
5564 */
5565 const kennwerte = fundstellenkennwerte(project.settings.rilsa, plan.anlagenart);
5566 for (const gruppe of settingFieldsByQuelle(RILSA_DEFAULTS, plan.anlagenart)) {
5567 for (const [index, feld] of gruppe.felder.entries()) {
5568 const kennwert = kennwerte.get(feld.key);
5569 const regelwert = kennwert?.regelwert ?? feld.regelwert;
5570 const angesetzt = kennwert?.angesetzt ?? null;
5571 const abweichung =
5572 angesetzt === null
5573 ? ''
5574 : ` (angesetzt: ${fmt.numShort(angesetzt, 3)} ${feld.unit}, abweichend vom Regelwert)`;
5575 rows.push(
5576 [
5577 index === 0 ? gruppe.quelle.kurz : '',
5578 // Mit dem Wirkungskontext dieses Projekts: Beim Regelansatz
5579 // "Ueberfahrzeit = Gelbzeit" gehen die beiden festen Ueberfahrzeiten
5580 // in keine Zahl ein, und ohne eine Beziehung mit dem Merkmal "enger
5581 // Innenradius" auch die zugehoerige Raeumgeschwindigkeit nicht. Das
5582 // Verzeichnis sagt es an der Zeile (Fassung 5.5.0, Fragen E1 und E2, Muster von
5583 // Befund C20).
5584 `${feld.label}${wirkungsvermerk(gruppe.schluessel, verfahren, {
5585 ueberfahrzeitAnsatz: project.settings.ueberfahrzeitAnsatz,
5586 engerRadiusGesetzt: project.conflicts.some((c) => c.engerRadius === true),
5587 koordinierungErfasst: project.koordinierung !== null,
5588 })}${abweichung}`,
5589 `${fmt.numShort(regelwert, 3)} ${feld.unit}`,
5590 index === 0 ? gruppe.quelle.sachgebiet : '',
5591 index === 0 ? fundstelle(gruppe.schluessel) : '',
5592 index === 0 ? PRUEFSTAND_LABELS[gruppe.quelle.pruefstand] : '',
5593 ].map(toWinAnsi),
5594 );
5595 }
5596 /*
5597 * "Zu beachten" auch fuer Feldgruppen, nicht nur fuer feldlose Quellen: Der
5598 * Hinweis der Quelle traegt den Vorbehalt des Pruefstands - bei
5599 * 'rilsa-signalzeiten' etwa, dass die Furtregel am Wortlaut und am Bezug
5600 * auf DIN 32981 abzugleichen ist. Vor der Fassung 5.4.0 stand er nur am
5601 * Bildschirm; der Ausdruck nannte "gesichert" ohne den Vorbehalt.
5602 */
5603 if (gruppe.quelle.hinweis !== undefined) {
5604 rows.push(['', verbunden(`Zu beachten: ${gruppe.quelle.hinweis}`), '', '', '']);
5605 }
5606 }
5607
5608 /*
5609 * Quellen ohne Vorgabenfeld - die Stufentafeln der Qualitaetsstufen, die
5610 * VwV-StVO und (bei einstreifiger Fuehrung) die RSA 21. Sie fehlten hier
5611 * ganz, weil das Verzeichnis nur aus den Vorgabenfeldern entsteht: Die
5612 * gedruckten Tafeln des Leistungsabschnitts hatten im Nachweis weder Herkunft
5613 * noch Pruefstand, und die Grundlagen der Pruefregeln C2/C3/C6 ebenso wenig.
5614 * Kennwert und Regelwert stehen in einer verbundenen Zelle: Eine Tafel ist
5615 * kein einzelner Zahlenwert und passte nicht in die schmale Regelwertspalte.
5616 */
5617 // Mit den Kennwerten DIESES Plans (Befund C21): Der VwV-StVO-Eintrag nennt
5618 // Rot-Gelb-Zeit und Gelbzeitstaffel, und beide sind einstellbar.
5619 for (const eintrag of quellenOhneFeld(
5620 verfahren,
5621 plan.anlagenart,
5622 plan.defaults,
5623 options.includeCycleComparison,
5624 )) {
5625 const { quelle } = eintrag;
5626 rows.push([
5627 toWinAnsi(quelle.kurz),
5628 verbunden(`${eintrag.bezeichnung}\n\n${quelle.verwendung}`),
5629 toWinAnsi(quelle.sachgebiet),
5630 toWinAnsi(fundstelle(eintrag.schluessel)),
5631 toWinAnsi(PRUEFSTAND_LABELS[quelle.pruefstand]),
5632 ]);
5633 for (const kennwert of eintrag.kennwerte) {
5634 rows.push(['', verbunden(`${kennwert.label}: ${kennwert.wert}`), '', '', '']);
5635 }
5636 if (quelle.hinweis !== undefined) {
5637 rows.push(['', verbunden(`Zu beachten: ${quelle.hinweis}`), '', '', '']);
5638 }
5639 }
5640
5641 autoTable(doc, {
5642 startY: context.cursor,
5643 margin: TABELLENRAND,
5644 theme: 'grid',
5645 /*
5646 * Eine Zeile dieses Verzeichnisses ist EINE Aussage - "Stufentafel
5647 * Fussgaenger und Radverkehr (maximale Wartezeit): A bis 30 s, ...".
5648 * Bricht sie mitten im Satz auf die naechste Seite um, stehen zwischen
5649 * ihren Haelften Fusszeile, Kopfzeile und Tabellenkopf, und ein Pruefer
5650 * muss die Aussage aus zwei Seiten zusammensetzen. Sie wandert deshalb
5651 * lieber ganz auf die naechste Seite. Aufgefallen, als der neue Abschnitt
5652 * "Bestandteile der Anordnungsunterlage" (Befund C14) den Satzspiegel
5653 * verschob - der Umbruch war vorher nur zufaellig guenstig.
5654 */
5655 rowPageBreak: 'avoid',
5656 styles: { fontSize: 8, cellPadding: 2.5, valign: 'top' },
5657 headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' },
5658 /*
5659 * Die Spalte "Fundstelle" bekommt eine feste Breite (Fassung 5.5.0, Fragen E1/E2).
5660 * Sie war die einzige neben "Kennwert" ohne, und autoTable teilt den Rest
5661 * nach Inhaltsbreite auf: Mit den neuen Kennwertbezeichnungen samt
5662 * Wirkungsvermerk ("Überfahrzeit Kfz geradeaus (Ansatz „feste
5663 * Überfahrzeit") (ohne Wirkung: Überfahrzeit = Gelbzeit)") zog die
5664 * Kennwertspalte so viel Platz an sich, dass die selbst eingetragene
5665 * Fundstelle "Abschnitt 3.4, Tabelle 6" als "Abschnitt 3.4, Tabelle" /
5666 * "6" umbrach - eine Fundstelle, die ueber zwei Zeilen zerrissen ist,
5667 * laesst sich nicht mehr abschreiben.
5668 */
5669 columnStyles: {
5670 0: { cellWidth: 70, fontStyle: 'bold' },
5671 2: { cellWidth: 62, halign: 'right' },
5672 3: { cellWidth: 175 },
5673 4: { cellWidth: 110 },
5674 5: { cellWidth: 62 },
5675 },
5676 head: [
5677 ['Regelwerk', 'Kennwert', 'Regelwert', 'Sachgebiet', 'Fundstelle', 'Prüfstand'].map(
5678 toWinAnsi,
5679 ),
5680 ],
5681 body: rows,
5682 });
5683
5684 context.cursor = lastTableBottom(doc) + 12;
5685
5686 /*
5687 * Die Legende kommt aus PRUEFSTAND_ERLAEUTERUNG und nicht aus einem eigenen
5688 * Satz: Vor der Fassung 5.4.0 stand hier eine zweite Fassung, die
5689 * "abzugleichen" nur als "am Original zu pruefen" erklaerte - waehrend die
5690 * Schrankentabelle desselben Ausdrucks dasselbe Wort fuer selbst gesetzte
5691 * Schwellen verwendet, fuer die es kein Original gibt. Zwei Fassungen einer
5692 * Erklaerung laufen genau so auseinander.
5693 */
5694 writeParagraph(
5695 context,
5696 PRUEFSTAND_REIHENFOLGE.map(
5697 (stand) => `Prüfstand "${PRUEFSTAND_LABELS[stand]}": ${PRUEFSTAND_ERLAEUTERUNG[stand]}`,
5698 ).join(' '),
5699 );
5700 }
5701
5702 // --- Hilfsfunktionen --------------------------------------------------------
5703
5704 /** Zeilenabstand eines Absatzes (9 pt in Grau). */
5705 const ABSATZ_ZEILENHOEHE = 12;
5706
5707 /** Abstand zum naechsten Block, den ein Absatz hinter sich laesst. */
5708 const ABSATZ_NACHLAUF = 8;
5709
5710 /**
5711 * Ein Absatz in 9 pt Grau - gemessen gesetzt, mit so vielen Folgeblaettern wie
5712 * noetig.
5713 *
5714 * Hier stand vor der Fassung 5.11.0 `ensureSpace` und danach
5715 * ein einzelnes `doc.text(lines, ...)`. `ensureSpace` legt genau EIN Folgeblatt
5716 * an; die Zeilen liefen danach fortlaufend nach unten - erst in den Fusssteg,
5717 * dann ueber die Fusszeile, die paintHeadersAndFooters spaeter darueber setzt,
5718 * und schliesslich aus dem Blatt. Ohne Hinweis.
5719 *
5720 * Drei Aufrufer fuehren Anwendertext ohne Laengengrenze: der Furtregelabsatz
5721 * (Rechenweg der Mindestfreigabezeit je Fussgaengergruppe, mit dem
5722 * Mittelinselvermerk am Ende), der Fahrstreifensatz der Leistungstabelle und
5723 * der Vmax-Nachsatz des Anfahransatzes. Bei den beiden letzten ist der Absatz
5724 * der EINZIGE Traeger seiner Nachweisgroesse, sobald die Tabelle
5725 * "Signalgruppen" abgewaehlt ist.
5726 *
5727 * Gesetzt wird deshalb ueber `setzeZeilenMitUmbruch` - denselben Weg, den
5728 * Deckblatttitel (Befund 43) und Bemerkung (Befund 18) schon gehen. Das
5729 * beseitigt zugleich, dass hier mit 12 pt je Zeile gerechnet, aber mit den
5730 * 10,35 pt des Feldsatzes gezeichnet wurde.
5731 *
5732 * DIE RESERVE BLEIBT: Passt der ganze Absatz noch auf dieses Blatt, beginnt er
5733 * dort; sonst beginnt er auf einem frischen. Mehr als ein volles Blatt wird
5734 * nicht verlangt - sonst legte ein ueberlanger Absatz auch dann ein Blatt an,
5735 * wenn er ohnehin oben beginnt, und liesse ein leeres zurueck.
5736 */
5737 function writeParagraph(context: Context, text: string): void {
5738 context.doc.setFont('helvetica', 'normal');
5739 context.doc.setFontSize(9);
5740 const lines = context.doc.splitTextToSize(toWinAnsi(text), context.contentWidth) as string[];
5741 // Mit der tatsaechlichen Hoehe statt pauschal 40 pt: Die Absaetze der
5742 // Barrierefreiheitserklaerung sind laenger als drei Zeilen und liefen sonst
5743 // unter die Fusszeile.
5744 ensureSpace(context, Math.min(absatzHoehe(context, text), nutzhoeheJeBlatt(context)));
5745 context.doc.setTextColor('#5b6577');
5746 setzeZeilenMitUmbruch(context, lines, ABSATZ_ZEILENHOEHE);
5747 context.cursor += ABSATZ_NACHLAUF;
5748 context.doc.setTextColor('#1b2230');
5749 }
5750
5751 /**
5752 * Kuerzt einen Text auf die gemessene Breite, mit Auslassungszeichen.
5753 *
5754 * Der Text muss bereits durch `toWinAnsi` gegangen sein, und Schrift und
5755 * Schriftgroesse muessen am Dokument gesetzt sein - gemessen wird mit dem, was
5756 * danach gezeichnet wird.
5757 */
5758 function gekuerztAufBreite(doc: jsPDF, text: string, breite: number): string {
5759 if (doc.getTextWidth(text) <= breite) return text;
5760 // toWinAnsi macht aus dem Auslassungszeichen drei Punkte; Latin-1 kennt es
5761 // nicht, und ein unsichtbar verschlucktes Zeichen waere hier das Gegenteil
5762 // eines Hinweises auf die Kuerzung.
5763 const auslassung = '...';
5764 // Halbierend statt zeichenweise: Die Kopfzeile wird fuer JEDE Seite neu
5765 // gesetzt, und ein Projektname hat keine Laengengrenze.
5766 let unten = 0;
5767 let oben = text.length;
5768 while (unten < oben) {
5769 const mitte = Math.ceil((unten + oben) / 2);
5770 if (doc.getTextWidth(`${text.slice(0, mitte)}${auslassung}`) <= breite) unten = mitte;
5771 else oben = mitte - 1;
5772 }
5773 return `${text.slice(0, unten).trimEnd()}${auslassung}`;
5774 }
5775
5776 /**
5777 * Setzt bereits umbrochene Zeilen mit fester Schrittweite und legt dabei so
5778 * viele Folgeblaetter an, wie noetig sind.
5779 *
5780 * Schrift, Groesse und Farbe setzt der Aufrufer - gemessen und gezeichnet wird
5781 * mit dem, was am Dokument steht. Danach steht der Cursor eine Schrittweite
5782 * unter der letzten Grundlinie.
5783 *
5784 * Deckblatttitel (Befund 43) und Bemerkung (Befund 18) liefen ohne diese
5785 * Rechnung in den Fusssteg, ueber die Fusszeile und schliesslich aus dem
5786 * Blatt - ohne Hinweis. Dasselbe galt fuer jeden Absatz aus `writeParagraph`,
5787 * sobald Signalgruppennamen hineinliefen. Deshalb ein Weg fuer alle drei:
5788 * Laeuft die Rechnung auseinander, laeuft auch der gezeichnete Abstand
5789 * auseinander.
5790 */
5791 function setzeZeilenMitUmbruch(
5792 context: Context,
5793 zeilen: readonly string[],
5794 zeilenhoehe: number,
5795 ): void {
5796 const { doc } = context;
5797 const unterkante = context.pageHeight - PAGE_MARGIN - FOOTER_HEIGHT;
5798 let offen = [...zeilen];
5799
5800 while (offen.length > 0) {
5801 // Sichert, dass wenigstens eine Zeile auf dieses Blatt passt; danach
5802 // ergibt die Rechnung darunter mindestens zwei.
5803 ensureSpace(context, zeilenhoehe);
5804 const platz = Math.floor((unterkante - context.cursor) / zeilenhoehe) + 1;
5805 const jetzt = offen.slice(0, Math.max(1, platz));
5806 // Jede Zeile mit eigener Grundlinie statt als Feld: Nur so ist der
5807 // gezeichnete Abstand derselbe, mit dem hier gerechnet wird.
5808 for (const [i, zeile] of jetzt.entries()) {
5809 doc.text(zeile, PAGE_MARGIN, context.cursor + i * zeilenhoehe);
5810 }
5811 context.cursor += jetzt.length * zeilenhoehe;
5812 offen = offen.slice(jetzt.length);
5813 }
5814 }
5815
5816 /** Zeilenabstand der Bemerkung - dieselbe Schrittweite wie in writeParagraph. */
5817 const BEMERKUNG_ZEILENHOEHE = 12;
5818
5819 /**
5820 * Die Bemerkung des Anwenders - gemessen gesetzt, mit so vielen Folgeblaettern
5821 * wie noetig.
5822 *
5823 * FASSUNG 5.9.0, BEFUND 18: Hier stand ein einzelnes
5824 * `doc.text(beschreibung, x, y, { maxWidth })` - eine von zwei Textstellen
5825 * dieser Datei, die ihre Hoehe nicht gemessen haben; die andere ist der
5826 * Deckblatttitel (Befund 43). `maxWidth` bricht in jsPDF nur um; ein Folgeblatt
5827 * legt es nicht an und beschnitten wird auch nichts. Die Zeilen liefen
5828 * fortlaufend nach unten: ab der 43. ueber die Fusszeile, die
5829 * paintHeadersAndFooters danach zusaetzlich darueber zeichnet, ab der 47.
5830 * ausserhalb des Blattes. Der Anwender bekam keinen Hinweis, dass seine
5831 * Bemerkung im Ausdruck fehlt - und das Feld hat keine Laengengrenze: Beim
5832 * Einlesen einer 4.x-Datei haengt die Migration "Anordnungsgrund" und
5833 * "Anordnungsnummer" an das Ende genau dieses Textes.
5834 *
5835 * WARUM NICHT writeParagraph: Das setzt 9 pt in Grau, die Bemerkung behaelt
5836 * ihre Auszeichnung (9,5 pt, #1b2230). Den Umbruch ueber mehrere Blaetter
5837 * teilen sich beide inzwischen (setzeZeilenMitUmbruch); vor der Fassung 5.11.0
5838 * war er der zweite Grund - writeParagraph legte damals hoechstens EIN
5839 * Folgeblatt an.
5840 */
5841 function writeBemerkung(context: Context, text: string): void {
5842 const { doc } = context;
5843 doc.setFont('helvetica', 'normal');
5844 doc.setFontSize(9.5);
5845 doc.setTextColor('#1b2230');
5846
5847 setzeZeilenMitUmbruch(
5848 context,
5849 doc.splitTextToSize(toWinAnsi(text), context.contentWidth) as string[],
5850 BEMERKUNG_ZEILENHOEHE,
5851 );
5852 }
5853
5854 /**
5855 * Hoehe, die writeParagraph fuer diesen Text braucht - ohne ihn zu drucken.
5856 *
5857 * Damit laesst sich Platz reservieren, BEVOR eine Zeichnung gesetzt wird, die
5858 * den Rest der Seite fuellt (Befund M12). Die Rechnung ist dieselbe wie in
5859 * writeParagraph; steht sie auseinander, stimmt die Reserve nicht mehr.
5860 */
5861 function absatzHoehe(context: Context, text: string): number {
5862 context.doc.setFont('helvetica', 'normal');
5863 context.doc.setFontSize(9);
5864 const lines = context.doc.splitTextToSize(toWinAnsi(text), context.contentWidth) as string[];
5865 return lines.length * ABSATZ_ZEILENHOEHE + ABSATZ_NACHLAUF;
5866 }
5867
5868 function lastTableBottom(doc: jsPDF): number {
5869 const result = (doc as unknown as { lastAutoTable?: { finalY?: number } }).lastAutoTable;
5870 return result?.finalY ?? PAGE_MARGIN + HEADER_HEIGHT;
5871 }
5872
5873 function methodLabel(method: Project['program']['method']): string {
5874 switch (method) {
5875 case 'webster':
5876 return 'Webster (wartezeitminimal)';
5877 case 'akcelik':
5878 return 'Akcelik (wartezeitminimal)';
5879 case 'hbs':
5880 return 'HBS (kapazitätsorientiert)';
5881 case 'hcm':
5882 return 'HCM (kapazitätsorientiert)';
5883 case 'manuell':
5884 return 'feste Vorgabe';
5885 }
5886 }