import { jsPDF } from 'jspdf'; import autoTable, { type CellDef, type RowInput, type Styles } from 'jspdf-autotable'; import { MESSFEHLER_SCHRANKE, ZEICHENFEHLER_BILDPUNKTE, planungsgrundlage, type Lageplan, type Planungsgrundlage, } from '@/domain/geometrie/lageplan'; import { planblatt, type Planblatt } from '@/domain/geometrie/planblatt'; import { massketten, type Masskette } from '@/domain/geometrie/masskette'; import type { Blattbild } from './blattbild'; import { drawLageplanblatt, type LageplanZeichnungOptionen } from '@/render/lageplanDrawing'; import { drawZeitWeg, UMLAEUFE_IM_BILD } from '@/render/zeitWegDrawing'; import { koordinierungslage, HINDERNIS_TEXT } from '@/domain/plan/koordinierung'; import { modulo } from '@/domain/rilsa/coordination'; import { aspectTotals } from '@/domain/plan/aspects'; import { fahrstreifengeometrie, hatSaettigungsverkehrsstaerke, intergreenKey, massgebendeRaeumbeziehung, umlaufzeitIstErsatzwert, umlaufzeitMitVermerk, type PlannedSignalGroup, type SignalPlan, } from '@/domain/plan/signalPlan'; import { geometriefaktor } from '@/domain/rilsa/capacity'; import { CYCLE_METHOD_LABELS, CYCLE_METHODS } from '@/domain/rilsa/cycle'; import type { Bewertungsverfahren, CycleTimeResult } from '@/domain/rilsa/types'; import { resolveRilsaSettings, settingFields, settingFieldsByQuelle, } from '@/domain/rilsa/settings'; import { FUNDSTELLEN_HINWEIS, PRUEFSTAND_ERLAEUTERUNG, PRUEFSTAND_LABELS, PRUEFSTAND_REIHENFOLGE, QUELLEN, type QuellenSchluessel, rechtsgrundlagenFuer, } from '@/domain/rilsa/quellen'; import { ANLAGENART_GRENZEN, ANLAGENART_LABELS, ENGER_RADIUS_LABEL, ENGSTELLE_PRAXIS, MOVEMENT_LABELS, RILSA_DEFAULTS, STAURAUMBEDARF_JE_FAHRZEUG, RSA_ALTERNATIVEN_ZUR_SIGNALREGELUNG, TRAFFIC_MODE_LABELS, UEBERFAHRZEIT_ANSATZ_LABELS, fahrzeugartBeschriftung, type AnlagenartGrenzen, type RilsaDefaults, type SchrankenHerkunft, } from '@/domain/rilsa/constants'; import { engerRadiusRichtungSatzteil, ueberfahrzeitAnsatzOhneWirkung, ueberfahrzeitAnsatzRichtungSatz, verfahrenDerKfzRaeumstroeme, } from '@/domain/rilsa/ansaetze'; import { engerRadiusWirktAn } from '@/domain/rilsa/intergreen'; import { engstellenFolgen, umlaufBeiFreigabe } from '@/domain/rilsa/engstelle'; import type { Project, SignalGroup } from '@/domain/model/project'; import { programmanzahl } from '@/domain/model/programme'; import { programmPruefungen } from '@/domain/validation'; import { KRITERIUM_LABELS, aufteilungslage, engerRadiusZelle, // Regelwert und angesetzter Wert je Kennwert - aus derselben Quelle wie die // Aufstellung "Abweichungen von den Regelwerten". fundstellenkennwerte, quellenOhneFeld, // Anteil, Aufteilung, Faktor und Gleichung eines Stroms - gebildet aus // derselben Funktion des Fachkerns, mit der der Plan rechnet // (plan/signalPlan.ts, groupSaturationFlow), und mit denselben Worten wie in // der Tabellenausgabe (Fassung 5.6.0, Frage E6). schwerverkehrsangabe, schwerverkehrSatz, stufeZelle, stufentafeln, ueberfahrzeitAnsatzSatz, verfahrenLabel, wirkungsvermerk, } from './bewertung'; import { wegherkunftZelle } from './wegherkunft'; // Befund C22: Ausdruck und Tabellenausgabe muessen dieselbe Fensterangabe // zeigen; die Zeichenkette entsteht deshalb an einer Stelle (siehe dort). import { FREIGABEFENSTER_FUSSNOTE, freigabefensterZelle, hoechstfreigabezeitVorgabe } from './csv'; import { CATEGORY_LABELS, SEVERITY_LABELS, type ValidationReport } from '@/domain/validation'; import { messeignungSatz } from '@/domain/validation/rules'; import { PdfSurface, toWinAnsi } from '@/render/pdfSurface'; import { FASSUNGSANGABE } from '@/fassung'; import { DEFAULT_LAYOUT, LIGHT_THEME, drawSignalPlan, planHeight, type PlanLayout, } from '@/render/signalPlanDrawing'; import * as fmt from '@/ui/format'; /** * Ausdruck der Planunterlagen. * * Aufbau: Deckblatt, Planungsgrundlage, Signalgruppen, Zwischenzeitenmatrix, * Phasen, Signalzeitenplan als Vektorgrafik, Leistungsfaehigkeit, Pruefbericht. * * Korrekturen gegenueber dem Altbestand: * - Der Ausdruck enthielt eine unbedingte Zeile "Die Anlage entspricht den * Anforderungen und wird zur Genehmigung empfohlen" - unabhaengig davon, ob * die Pruefung Fehler ergeben hatte. Diese Aussage ist entfallen; stattdessen * steht das tatsaechliche Pruefergebnis im Dokument. * - Der Signalzeitenplan wurde als Rastergrafik eingebettet; jetzt als Vektor. * - Haken, Kreuze und Emoji sind mit den PDF-Standardschriften nicht * darstellbar und erschienen als leere Kaesten. Sie sind durch Text ersetzt. * - Befunde B1 bis B4 (Fassung 5.4.0): Der Leistungsabschnitt * behauptete "Bewertung nach HBS 2015" fuer eine HCM-Rechnung mit der * Kfz-Tafel des HBS 2001 und liess die Fussgaenger als "nicht massgebend" * weg. Jetzt nennt er das gerechnete Verfahren samt Formelkette und Tafel, * druckt die Abflusszeit tA und bewertet Fussgaenger und Radverkehr in * eigener Tabelle nach der maximalen Wartezeit. */ const PAGE_MARGIN = 36; const HEADER_HEIGHT = 46; const FOOTER_HEIGHT = 26; /** * Abstand zwischen Projektname und Grundlagenangabe in der Kopfzeile. * * Ohne ihn stiessen die beiden Texte bei einem langen Namen unmittelbar * aneinander und lasen sich als ein Wort. */ const KOPFZEILEN_ABSTAND = 12; /** Zeilenabstand des Deckblatttitels (15 pt fett). */ const TITEL_ZEILENHOEHE = 18; /** * Hoehe des Kastens "Ergebnis der Pruefung" auf dem Deckblatt. * * Sie steht einmal, weil sie an drei Stellen gebraucht wird: zum Reservieren * des Platzes, zum Zeichnen der Flaeche und zum Weiterruecken auf den * Bemerkungsblock darunter. Liefen die ersten beiden auseinander, waere die * Umbruchpruefung wieder wertlos; liefe die dritte mit, schoebe sich die * Ueberschrift "Bemerkungen" in den Kasten hinein. */ const ERGEBNISKASTEN_HOEHE = 52; /** Abstand zwischen der Unterkante des Ergebniskastens und dem, was folgt. */ const ERGEBNISKASTEN_NACHLAUF = 16; /** * Der Satzspiegel, in dem JEDE Tabelle dieser Unterlage steht. * * OBEN UND UNTEN GEHOEREN HIER HINEIN, und das ist kein Beiwerk: Wo `margin` * nur `left` und `right` nennt, setzt jspdf-autotable fuer die beiden anderen * seinen eigenen Regelwert von 40 pt an * (`parseSpacing(options.margin, 40 / doc.scaleFactor())`, * jspdf.plugin.autotable.js:937). Das Folgeblatt einer mehrseitigen Tabelle * beginnt dann bei 40 pt - mitten in der Kopfzeile, die von PAGE_MARGIN bis * PAGE_MARGIN + HEADER_HEIGHT reicht, also bis 82 pt. Die wiederholte * Tabellenkopfzeile ueberdruckte dort Projektname und Untertitel. Gemessen an * der vollstaendigen Unterlage: bei sechzehn Signalgruppen mit langen Namen * trugen 41 von 62 Blaettern Tabellentext oberhalb der Trennlinie. Unten * dasselbe in klein: mit 40 statt 62 pt durfte eine Tabelle 22 pt in den * Fusssteg laufen. * * WARUM EINE KONSTANTE UND NICHT ZWANZIG EINZELANGABEN: Der Fehler entstand * dadurch, dass jeder Aufruf seinen Rand selbst mitbrachte und dabei zwei * Seiten vergass. Eine Tabelle, die kuenftig hinzukommt, erbt den richtigen * Satzspiegel jetzt, statt ihn erneut zusammenzusuchen. */ const TABELLENRAND = { left: PAGE_MARGIN, right: PAGE_MARGIN, top: PAGE_MARGIN + HEADER_HEIGHT, bottom: PAGE_MARGIN + FOOTER_HEIGHT, } as const; export interface PdfOptions { readonly includeSignalGroups: boolean; readonly includeIntergreenMatrix: boolean; readonly includePhases: boolean; /** Die vier Rechenansaetze der Umlaufzeit nebeneinander. */ readonly includeCycleComparison: boolean; readonly includePlan: boolean; readonly includePerformance: boolean; readonly includeReport: boolean; readonly includeCalculationDetails: boolean; /** Nachweis, woher die Kennwerte stammen - fuer die Pruefung der Unterlagen. */ readonly includeSources: boolean; /** Selbst gepruefte Fundstellen, je Sachgebiet. */ readonly fundstellen: Readonly>; } export const DEFAULT_PDF_OPTIONS: PdfOptions = { includeSignalGroups: true, includeIntergreenMatrix: true, includePhases: true, /* * VORBELEGT MIT true (auf ausdrueckliche Entscheidung des Anwenders): Eine * Anordnung nach Paragraf 45 StVO ist zu begruenden, und die Wahl der * Umlaufzeit war der eine Punkt, zu dem die Unterlage nichts sagte. Wer den * Abschnitt nicht will, waehlt ihn ab; die Bestandteileuebersicht vermerkt * das dann. */ includeCycleComparison: true, includePlan: true, includePerformance: true, includeReport: true, /* * KORREKTUR (Fassung 5.4.0, Befund C15): Hier stand false. Der Ausdruck * lieferte damit von Haus aus eine Zwischenzeitenmatrix ohne den Nachweis, * aus dem ihre Zahlen stammen - und genau diesen Nachweis verlangt die * Behoerdenpraxis. Wer ihn abwaehlt, liest jetzt an der Stelle des * Abschnitts, was der Unterlage damit fehlt (RECHENWEG_ABGEWAEHLT). */ includeCalculationDetails: true, includeSources: true, fundstellen: {}, }; /** * Wo der Rechenweg in DIESER Ausgabe steht - und wo nicht. * * Der Rechenweg haengt an ZWEI Bedingungen: am eigenen Schalter * (`includeCalculationDetails`) und daran, ob der Abschnitt ueberhaupt gedruckt * wird, in dem er steht - der Zwischenzeitennachweis in der * Zwischenzeitenmatrix, der Wartezeitnachweis in der Leistungsfaehigkeit. * * KORREKTUR (Fassung 5.4.0): Die Uebersicht "Bestandteile der * Anordnungsunterlage" fragte nur den Schalter ab und druckte "ja - Abschnitte * ... und ..." fuer Abschnitte, die im Dokument gar nicht vorkamen; der * Abwahlvermerk wiederum stand nur INNERHALB der beiden Abschnitte und fiel * damit ganz weg, wenn beide abgewaehlt waren. Ausgerechnet der Lueckenausweis * gab damit eine falsche Auskunft. Beide Aussagen kommen jetzt aus diesem einen * Stand. */ interface RechenwegStand { /** Ist der Rechenweg selbst angefordert? */ readonly angefordert: boolean; /** Wird die Zwischenzeitenmatrix gedruckt - der Ort des ersten Rechenwegs? */ readonly matrix: boolean; /** Wird die Leistungsfaehigkeit gedruckt - der Ort des zweiten Rechenwegs? */ readonly leistung: boolean; } function rechenwegStand(options: PdfOptions): RechenwegStand { return { angefordert: options.includeCalculationDetails, matrix: options.includeIntergreenMatrix, leistung: options.includePerformance, }; } /** Steht der Rechenweg der Zwischenzeiten in dieser Ausgabe? */ function zwischenzeitRechenweg(stand: RechenwegStand): boolean { return stand.angefordert && stand.matrix; } /** Steht der Rechenweg der Wartezeit in dieser Ausgabe? */ function wartezeitRechenweg(stand: RechenwegStand): boolean { return stand.angefordert && stand.leistung; } /** * Was an der Stelle des Rechenwegs steht, wenn er abgewaehlt wurde * (Befund C15). * * Der Satz steht EINMAL im Dokument, weil er beide Rechenwege nennt - den der * Zwischenzeiten und den der Wartezeit -, und zwar an der ersten Stelle, an der * einer von beiden gestanden haette. Eine Luecke, die nirgends vermerkt ist, * sieht wie eine vollstaendige Unterlage aus; das ist der Fehler, um den es * geht. * * Die zweite Haelfte nennt nur Abschnitte, die es in dieser Ausgabe wirklich * gibt: Bei abgewaehlter Matrix von "den Werten der Matrix" zu sprechen, waere * derselbe Fehler eine Ebene tiefer. Sind beide Abschnitte abgewaehlt, gibt es * im Dokument keine Stelle mehr, an der der Rechenweg gestanden haette - der * Satz steht dann in der Uebersicht der Bestandteile und sagt genau das. */ function rechenwegAbgewaehlt(stand: RechenwegStand): string { const kopf = 'Der Rechenweg der Zwischenzeiten und der Wartezeit ist in dieser Ausgabe abgewählt; '; if (stand.matrix && stand.leistung) { return ( kopf + 'die Werte der Zwischenzeitenmatrix und der Leistungsfähigkeit sind ohne ihn nicht ' + 'nachrechenbar.' ); } if (stand.matrix) return kopf + 'die Werte der Matrix sind ohne ihn nicht nachrechenbar.'; if (stand.leistung) { return kopf + 'die Werte der Leistungsfähigkeit sind ohne ihn nicht nachrechenbar.'; } return ( kopf + 'die Abschnitte, in denen er gestanden hätte - Zwischenzeitenmatrix und Leistungsfähigkeit -, ' + 'sind ebenfalls abgewählt.' ); } interface Context { readonly doc: jsPDF; readonly project: Project; readonly plan: SignalPlan; readonly report: ValidationReport; readonly pageWidth: number; readonly pageHeight: number; readonly contentWidth: number; /** * Das vorbereitete Luftbild fuer das zweite Lageplanblatt - oder null. * * Es wird HEREINGEREICHT und nicht hier erzeugt: Zuschneiden und Verkleinern * gehen nur asynchron (siehe `blattbild.ts`), und diese Funktion ist synchron. */ readonly blattbild: Blattbild | null; cursor: number; /** * Wie die Zwischenzeitenmatrix DIESER Ausgabe gesetzt wurde - oder null, * solange sie nicht gezeichnet ist. * * Gesetzt wird das erst beim Zeichnen der Matrix, gelesen erst im Abschnitt * "Barrierefreiheit dieser Datei" - der steht am Ende und beschreibt die * fertige Datei. Er darf keine Aussage ueber eine Darstellung machen, die in * dieser Ausgabe gar nicht vorkommt: Wer kurze Bezeichnungen benutzt, hat * die Namen im Kopf, und ein Absatz ueber Ordnungsnummern waere fuer SEINE * Datei schlicht falsch. * * WARUM EIN BEFUND UND NICHT NUR EIN SCHALTER (Fassung 5.8.0): Der Absatz * sagte bis dahin unbedingt zu, jeder Name stehe in der Zuordnung "ungeteilt * in einer Zeile". Das trifft nur zu, solange der Name in eine Bannerzeile * passt - gemessen bis rund 180 Zeichen. Ein Lueckenausweis darf keine Zusage * machen, die er fuer DIESE Datei nicht halten kann; also traegt der Befund * auch, ob es zugetroffen hat. */ matrixSatzbefund: MatrixSatzbefund | null; } /** Was ueber den Satz der Matrix in dieser Ausgabe zu sagen ist. */ interface MatrixSatzbefund { /** Tragen die Spaltenkoepfe Ordnungsnummern statt der Namen? */ readonly mitOrdnungsnummern: boolean; /** Steht in der Zuordnung ueber der Tabelle JEDER Name ungeteilt in einer Zeile? */ readonly namenUngeteilt: boolean; /** * Wird die Zuordnung auf JEDEM Blatt des Ausschnitts wiederholt? * * Sie wird es, so lange sie in den Tabellenkopf passt. Ist sie fuer sich * schon hoeher als ein Blatt, steht sie ungekuerzt vor dem Ausschnitt - und * dann darf der Lueckenausweis nicht behaupten, sie stehe ueber jeder Seite. */ readonly zuordnungAufJedemBlatt: boolean; } /** * Ein leeres Dokument in der Seiteneinrichtung dieser Unterlage. * * EIGENE FUNKTION, WEIL ES ZWEI AUFRUFER GIBT: den Ausdruck und * `lageplanblattVorlage`. Die Blattbreite folgt aus der Seitengroesse, und * das Luftbild muss fuer GENAU das Blatt zugeschnitten werden, auf das es * spaeter kommt. Stuende die Einrichtung zweimal im Baum, waere ein * verschobenes Bild die erste Folge einer Aenderung an ihr - und das faellt * niemandem auf, der die Seitengroesse aendert. */ function neuesDokument(): jsPDF { return new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4', compress: true }); } /** Hoehe des Lageplanblatts in Blattpunkten. */ export const LAGEPLANBLATT_HOEHE = 300; /** * Das Blatt, auf das die Unterlage den Lageplan zeichnet. * * Oeffentlich, damit die Oberflaeche das Luftbild fuer DIESES Blatt vorbereiten * kann, bevor sie den Ausdruck anstoesst. Sie darf die Masse nicht selbst * bilden: Ein um einen Punkt abweichendes Blatt ergaebe ein um einen Punkt * verschobenes Bild, und Linien, die neben der Fahrbahn liegen, sind auf einem * Nachweisblatt keine Kleinigkeit. */ export function lageplanblattVorlage(lageplan: Lageplan): Planblatt | null { const breite = neuesDokument().internal.pageSize.getWidth() - 2 * PAGE_MARGIN; return planblatt(lageplan, breite, LAGEPLANBLATT_HOEHE); } /** * Erzeugt die Planunterlagen als PDF. * Rueckgabe sind die rohen Bytes; das Einpacken in eine Datei uebernimmt die * Speicherschicht, damit dieselbe Funktion auch im Test verwendbar bleibt. */ export function buildProjectPdf( project: Project, plan: SignalPlan, report: ValidationReport, options: PdfOptions = DEFAULT_PDF_OPTIONS, now: Date = new Date(), blattbild: Blattbild | null = null, ): Uint8Array { const doc = neuesDokument(); doc.setLanguage('de-DE'); /* * Befund L10 (2.4.2 Seite mit Titel, A; EN 301 549 10.2.4.2; WCAG-Technik * PDF18). * * /Title war gesetzt und ist immer sprechend - ein leerer Projektname wird * vor der Ausgabe als Fehler gemeldet. Ohne /DisplayDocTitle zeigen * Anzeigeprogramme in Fenster- und Registerleiste trotzdem den DATEINAMEN. * Eine Sprachausgabe sagt beim Wechsel zwischen offenen Dokumenten dann * "plan-2026-03-11 (2).pdf" statt "Musterkreuzung - Signalzeitenplan". */ doc.viewerPreferences({ DisplayDocTitle: true }); /* * HIER NICHT /MarkInfo << /Marked true >> ERGAENZEN. * * Befund S1. Ueber die Ereignishaken von jsPDF liesse sich der Eintrag in * drei Zeilen setzen, und das Dokument bestuende danach manche automatische * Pruefung. Es waere eine falsche Angabe: Ein Strukturbaum (/StructTreeRoot) * entsteht dadurch nicht, und jsPDF 2.5.2 kann keinen erzeugen - es gibt * keine Schnittstelle fuer Marked Content, Artefakte oder Alternativtexte, * und jspdf-autotable 3.8.4 hilft dabei nicht. Bildschirmleseprogramme * schalten bei "/Marked true" von ihren Positionsheuristiken auf den - dann * leeren - Strukturbaum um und lesen danach WENIGER vor als heute. Die * Behauptung machte das Dokument also fuer genau die Nutzergruppe schlechter, * der sie zu nuetzen vorgibt. * * Der Weg zu einem wirklich getaggten Dokument fuehrt ueber eine andere * Ausgabe (webContents.printToPDF mit generateTaggedPDF aus einer * semantischen Vorlage), nicht ueber einen Katalogeintrag. Bis dahin sagt * der Abschnitt "Barrierefreiheit dieser Datei" am Ende der Unterlage, wie * es steht - siehe drawBarrierefreiheit. */ const context: Context = { doc, project, plan, report, pageWidth: doc.internal.pageSize.getWidth(), pageHeight: doc.internal.pageSize.getHeight(), contentWidth: doc.internal.pageSize.getWidth() - 2 * PAGE_MARGIN, blattbild, cursor: PAGE_MARGIN + HEADER_HEIGHT, matrixSatzbefund: null, }; doc.setProperties({ title: toWinAnsi(`${project.meta.name} – Signalzeitenplan`), subject: toWinAnsi(`Lichtsignalanlage ${project.meta.location}`), author: toWinAnsi(project.meta.planner), creator: 'LSA-Planer Professional', }); // Erstes Lesezeichen; die uebrigen entstehen in newSection. merkeLesezeichen(context, 'Deckblatt'); drawCoverSheet(context, now); const rechenweg = rechenwegStand(options); // Ebenfalls ohne Schalter (Befund C14): Der Abschnitt sagt, WAS diese // Unterlage enthaelt und was nicht. Ihn abwaehlbar zu machen hiesse, den // Lueckenausweis abwaehlbar zu machen. drawAnordnungsunterlage(context, options, rechenweg); // Bewusst ohne Schalter in PdfOptions - wie drawSettingsDeviations weiter // unten ist der Abschnitt ein Nachweis. Ein Ausdruck, dem man die // Vermessungsgrundlage abwaehlen kann, ist als Planunterlage wertlos. drawPlanungsgrundlage(context, rechenweg); /* * Ebenfalls ohne Schalter: Wo mehrere Tagesprogramme nebeneinander * stehen, ist die Auskunft, WELCHES dieser Ausdruck zeigt, kein Beiwerk. Bei * einem einzigen Programm entfaellt der Abschnitt - dort gibt es nichts zu * unterscheiden, und die Bestandteileuebersicht sagt es in einer Zeile. */ if (programmanzahl(project) > 1) { newSection(context, 'Tagesprogramme dieser Anlage'); drawProgrammuebersicht(context, now); } if (options.includeSignalGroups) { newSection(context, 'Signalgruppen'); drawSignalGroupTable(context); } /* * Befund C15: Der Vermerk ueber den abgewaehlten Rechenweg steht an der * Stelle, an der der Abschnitt gestanden haette - einmal, denn der Satz nennt * beide Rechenwege. * * Sind beide moeglichen Stellen abgewaehlt, hat ihn die Uebersicht der * Bestandteile bereits gedruckt; dann gilt er hier schon als vermerkt. */ let rechenwegVermerkt = rechenweg.angefordert || (!rechenweg.matrix && !rechenweg.leistung); if (options.includeIntergreenMatrix) { newSection(context, 'Zwischenzeitenmatrix'); drawIntergreenMatrix(context, options.includeSignalGroups); if (zwischenzeitRechenweg(rechenweg)) { newSection(context, 'Zwischenzeiten – Rechenweg'); drawIntergreenDetails(context, options.includeSignalGroups); } else { writeParagraph(context, rechenwegAbgewaehlt(rechenweg)); rechenwegVermerkt = true; } } if (options.includePhases) { newSection(context, 'Phasen und Phasenübergänge'); drawPhaseTables(context); } if (options.includeCycleComparison) { newSection(context, 'Umlaufzeit – Vergleich der Rechenansätze'); drawCycleComparison(context); } /* * Die Koordinierung steht VOR dem Signalzeitenplan: Sie sagt, in welchem * Strassenzug dieser Plan liegt, und das ist eine Randbedingung seiner * Zeiten - kein Nachtrag. Ohne erfassten Strassenzug entfaellt der * Abschnitt; die Bestandteileuebersicht sagt es dann in ihrer Zeile. */ if (project.koordinierung !== null) { newSection(context, 'Koordinierung'); drawKoordinierung(context); } if (options.includePlan) { newSection(context, 'Signalzeitenplan'); drawPlanGraphic(context, options.includePhases); drawSignalTimeTable(context); } if (options.includePerformance) { newSection(context, 'Leistungsfähigkeit'); drawPerformanceTable(context, options.includeSignalGroups); drawFussRadTable(context); // Wie beim Rechenweg der Zwischenzeiten: Die Formelkette der Wartezeit // wird auf Wunsch mitgedruckt, damit sich tW aus den gedruckten Groessen // nachrechnen laesst. if (wartezeitRechenweg(rechenweg)) { newSection(context, 'Leistungsfähigkeit – Rechenweg Wartezeit'); drawDelayDetails(context); } else if (!rechenwegVermerkt) { // Letzte Stelle, an der der Vermerk stehen kann - danach wird er nicht // mehr abgefragt, deshalb hier ohne weitere Zuweisung. writeParagraph(context, rechenwegAbgewaehlt(rechenweg)); } } if (options.includeReport) { newSection(context, 'Prüfbericht'); drawReport(context); } drawSettingsDeviations(context); if (options.includeSources) { newSection(context, 'Grundlagen der Kennwerte'); drawLegalBasis(context, options.includePerformance); drawAnlagenartGrenzen(context); drawSourceRegister(context, options); } // Ohne Schalter, aus demselben Grund wie die Bestandteileuebersicht: Eine // abwaehlbare Erklaerung zur Barrierefreiheit waere keine Erklaerung. drawBarrierefreiheit(context, options.includePlan, options.includePhases, rechenweg); paintHeadersAndFooters(context, now); return new Uint8Array(doc.output('arraybuffer')); } // --- Seitengeruest ---------------------------------------------------------- /** * Lesezeichen auf die zuletzt begonnene Seite. * * Das Dokument hatte bei 21 Seiten und 14 benannten Abschnitten * kein einziges Lesezeichen (/Outlines 0). Ein Pflichtkriterium der Stufe AA * ist das nicht - 2.4.5 ist fuer Nicht-Web-Dokumente in EN 301 549 gestrichen * (10.2.4.5), PDF/UA fuehrt Lesezeichen als Empfehlung. Es ist aber der einzige * Weg, in diesem ungetaggten Dokument ueberhaupt gezielt zu einem Abschnitt zu * springen, statt Seite fuer Seite zu blaettern: Ohne Strukturbaum gibt es * keine Ueberschriftennavigation, an der ein Bildschirmleseprogramm sich * entlanghangeln koennte. * * Der Titel geht durch toWinAnsi wie jeder andere gedruckte Text - jsPDF * schreibt ihn als Latin-1-Zeichenkette in das Outline-Objekt. */ function merkeLesezeichen(context: Context, titel: string): void { context.doc.outline.add(null, toWinAnsi(titel), { pageNumber: context.doc.getNumberOfPages(), }); } function newSection(context: Context, title: string): void { context.doc.addPage(); merkeLesezeichen(context, title); context.cursor = PAGE_MARGIN + HEADER_HEIGHT; context.doc.setFont('helvetica', 'bold'); context.doc.setFontSize(14); context.doc.setTextColor('#1b2230'); context.doc.text(toWinAnsi(title), PAGE_MARGIN, context.cursor); context.cursor += 20; } function ensureSpace(context: Context, needed: number): void { if (context.cursor + needed <= context.pageHeight - PAGE_MARGIN - FOOTER_HEIGHT) return; context.doc.addPage(); context.cursor = PAGE_MARGIN + HEADER_HEIGHT; } /** * Was auf ein leeres Blatt passt - zwischen Kopfzeile und Fusszeile. * * Die Rechnung steht einmal: Sie ist die Schranke, an der die beiden * Matrixproben (passt der Satz ueberhaupt auf ein Blatt, passt der * Tabellenkopf) und die Absatzreserve haengen. Ausgeschriebene Fassungen davon * liefen frueher oder spaeter auseinander. */ function nutzhoeheJeBlatt(context: Context): number { return context.pageHeight - (PAGE_MARGIN + HEADER_HEIGHT) - (PAGE_MARGIN + FOOTER_HEIGHT); } function paintHeadersAndFooters(context: Context, now: Date): void { const { doc, project } = context; const total = doc.getNumberOfPages(); for (let page = 1; page <= total; page += 1) { doc.setPage(page); doc.setDrawColor('#b3bccb'); doc.setLineWidth(0.5); doc.line( PAGE_MARGIN, PAGE_MARGIN + HEADER_HEIGHT - 16, context.pageWidth - PAGE_MARGIN, PAGE_MARGIN + HEADER_HEIGHT - 16, ); /* * FASSUNG 5.9.0, BEFUND 43: Beide Kopfzeilentexte gingen ohne * Breitenbegrenzung an doc.text. Die Grundlagenangabe steht rechtsbuendig * am Satzspiegelrand; der Projektname lief bei einer ueblichen langen * Bezeichnung in sie hinein, und beide waren an der Ueberlappung unlesbar - * auf JEDER Seite. Ausgerechnet die einstreifige Verkehrsfuehrung traf es * zuerst: Dort ist die Angabe am laengsten, und sie ist die Korrektur C16, * die das Verhaeltnis von RSA 21 und RiLSA klarstellt. * * Der Name wird deshalb auf den Platz links der gemessenen * Grundlagenangabe gekuerzt. Das darf er hier - vollstaendig steht er auf * dem Deckblatt, das ihn umbricht statt ihn zu kuerzen. */ const grundlage = toWinAnsi( `LSA-Planer Professional · ${kopfzeilenGrundlage(project.anlagenart)}`, ); doc.setFont('helvetica', 'normal'); doc.setFontSize(9); const grundlageBreite = doc.getTextWidth(grundlage); doc.setFont('helvetica', 'bold'); doc.setTextColor('#1b2230'); doc.text( gekuerztAufBreite( doc, toWinAnsi(project.meta.name || 'Ohne Titel'), context.contentWidth - grundlageBreite - KOPFZEILEN_ABSTAND, ), PAGE_MARGIN, PAGE_MARGIN + 12, ); doc.setFont('helvetica', 'normal'); doc.setTextColor('#5b6577'); // Die zweite Zeile hat rechts keinen Nachbarn, lief aber ebenso ueber die // Blattkante hinaus - sie bekommt den ganzen Satzspiegel. doc.text( gekuerztAufBreite( doc, toWinAnsi( [project.meta.projectNumber, project.meta.location, project.meta.variant] .filter((s) => s !== '') .join(' · '), ), context.contentWidth, ), PAGE_MARGIN, PAGE_MARGIN + 26, ); doc.text(grundlage, context.pageWidth - PAGE_MARGIN, PAGE_MARGIN + 12, { align: 'right' }); doc.setFontSize(8); doc.line( PAGE_MARGIN, context.pageHeight - PAGE_MARGIN - FOOTER_HEIGHT + 8, context.pageWidth - PAGE_MARGIN, context.pageHeight - PAGE_MARGIN - FOOTER_HEIGHT + 8, ); /* * "Ausgedruckt am" und nicht "Erstellt am" (Fassung 5.9.0). * * `now` ist der Zeitpunkt der Ausgabe. Unter "Erstellt am" stand damit in * der Fusszeile jeder Seite ein anderer Zeitpunkt als unter derselben * Beschriftung auf dem Deckblatt, wo das Anlagedatum des Projekts steht - * und zugleich derselbe Wert, den das Deckblatt zwei Zellen weiter * "Ausgedruckt am" nennt. Beides auf Blatt 1. Fuer eine Anordnung nach * Paragraf 45 StVO blieb offen, welches Datum gilt; deshalb traegt jeder * der beiden Zeitpunkte in der ganzen Unterlage nur noch eine Benennung. * * Die linke Fusszeile ging als einzige der vier Zeilen dieser Schleife ohne * Breitenbegrenzung an doc.text. Sie traegt mit `project.meta.planner` * freien Anwendertext ohne Laengengrenze; mit einer ueblichen * Buerobezeichnung von 192 Zeichen schob sie sich unter die rechtsbuendige * Seitenangabe und endete 92 pt jenseits der Blattkante - auf JEDEM Blatt. * Unlesbar wurde dabei ausgerechnet die Angabe, die die Vollstaendigkeit * des Schriftstuecks belegt. * * Derselbe Weg wie in der Kopfzeile: die Breite der Seitenangabe messen und * die Bearbeiterzeile auf den verbleibenden Platz kuerzen. Auch das darf sie * hier - vollstaendig steht der Bearbeiter auf dem Deckblatt und in den * Dokumentangaben. */ const seitenangabe = toWinAnsi(`Seite ${page} von ${total}`); const seitenbreite = doc.getTextWidth(seitenangabe); doc.text( gekuerztAufBreite( doc, toWinAnsi( `Ausgedruckt am ${fmt.dateTime(now.toISOString())} · Bearbeiter: ${project.meta.planner || '–'}`, ), context.contentWidth - seitenbreite - KOPFZEILEN_ABSTAND, ), PAGE_MARGIN, context.pageHeight - PAGE_MARGIN - 6, ); doc.text(seitenangabe, context.pageWidth - PAGE_MARGIN, context.pageHeight - PAGE_MARGIN - 6, { align: 'right', }); } } /** * Was in der Kopfzeile JEDER Seite als Grundlage steht. * * KORREKTUR (Fassung 5.4.0, Befund C16): Hier stand auf jeder Seite pauschal * "Planung nach RiLSA 2015" - auch im Ausdruck einer Baustellenampel, dessen * Abschnitt "Schranken der Anlagenart" wenige Seiten weiter sagt: "Die * Umlaufzeitgrenzen dieses Plans stammen NICHT aus der RiLSA". Zwei * gegenlaeufige Aussagen in derselben Unterlage; wer nur die Kopfzeile liest, * behaelt die falsche. * * Bei einer einstreifigen Verkehrsfuehrung nennt die Kopfzeile deshalb beide * Regelwerke in ihrem Verhaeltnis: Die RSA 21 ist die Grundlage der * Verkehrsfuehrung und verweist fuer die Berechnung auf die RiLSA (Teil A 3.2 * Abs. 3) - gerechnet wird also nach RiLSA, angeordnet nach RSA. * * Nur eine Beschriftung des Ausdrucks, kein Kennwert - deshalb hier und nicht * im Fachkern. Die Zahlen und ihre Herkunft stehen in constants.ts * (ANLAGENART_GRENZEN.herkunft) und im Fundstellenverzeichnis. */ function kopfzeilenGrundlage(anlagenart: Project['anlagenart']): string { return anlagenart === 'einstreifig' ? 'Signalzeitenplan nach RiLSA 2015 (RSA 21, Teil A 3.2)' : 'Signalzeitenplan nach RiLSA 2015'; } // --- Deckblatt -------------------------------------------------------------- function drawCoverSheet(context: Context, now: Date): void { const { doc, project, plan, report } = context; context.cursor = PAGE_MARGIN + HEADER_HEIGHT + 20; doc.setFont('helvetica', 'bold'); doc.setFontSize(22); doc.setTextColor('#1b2230'); doc.text(toWinAnsi('Signalzeitenplan'), PAGE_MARGIN, context.cursor); context.cursor += 26; /* * FASSUNG 5.9.0, BEFUND 43: Der Titel ging in 15 pt ohne Breitenangabe * an doc.text und lief bei einer langen Bezeichnung ueber die Blattkante - * dort war er abgeschnitten. Auf dem Deckblatt wird er deshalb umbrochen und * NICHT gekuerzt: Die Unterlage muss die Anlage eindeutig bezeichnen. * * Und er wird auch in der HOEHE gemessen: Das Deckblatt traegt 23 * Titelzeilen; darueber lief der Titel in den Fusssteg, ueberdruckte die * Fusszeile und fiel ab rund 2.700 Zeichen ganz aus dem Blatt - derselbe * stille Verlust wie bei der Bemerkung (Befund 18) und ebenso ohne * Laengengrenze am Eingabefeld. */ doc.setFontSize(15); setzeZeilenMitUmbruch( context, doc.splitTextToSize( toWinAnsi(project.meta.name || 'Ohne Titel'), context.contentWidth, ) as string[], TITEL_ZEILENHOEHE, ); // Abstand zur ersten Tabelle, von der letzten Grundlinie aus: Der Cursor // steht danach bereits eine Zeilenhoehe darunter. context.cursor += 26 - TITEL_ZEILENHOEHE; autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'plain', styles: { fontSize: 10, cellPadding: 3, textColor: '#1b2230' }, columnStyles: { 0: { fontStyle: 'bold', cellWidth: 150 }, 2: { fontStyle: 'bold', cellWidth: 150 }, }, body: [ /* * Die Anlagenart steht an erster Stelle, weil sie sagt, WONACH dieser * Plan zu beurteilen ist. * * Zur verkehrsbehoerdlichen Anordnung nach Paragraf 45 StVO gehoert, * welche Art Anlage angeordnet wird. Ohne die Angabe bliebe offen, ob * eine Umlaufzeit von 240 s eine grobe Ueberschreitung der * Sicherheitsgrenze am Knotenpunkt ist oder die zwangslaeufige Folge * einer langen Engstelle - dieselbe Zahl, zwei gegensaetzliche Befunde. * * Die Benennung kommt aus dem Regelwerksmodul und ist dieselbe wie am * Bildschirm; eine eigene Formulierung fuer das Papier liefe beim * naechsten Wortwechsel auseinander. * * Auch die Zeile daneben haengt daran: Der Name der Anlage stand hier * unter der festen Beschriftung "Knotenpunkt". An einer Arbeitsstelle * gibt es keinen Knotenpunkt, und eine Planunterlage darf keinen * behaupten - die Beschriftung ist deshalb neutral. */ [ 'Anlagenart', ANLAGENART_LABELS[project.anlagenart], 'Bezeichnung der Anlage', project.intersection.name || '–', ], [ 'Projektnummer', project.meta.projectNumber || '–', 'Auftraggeber', project.meta.client || '–', ], ['Ort / Straßenzug', project.meta.location || '–', 'Bearbeiter', project.meta.planner || '–'], [ 'Zulässige Höchstgeschwindigkeit', `${fmt.numShort(project.intersection.vZul)} km/h`, 'Planungsvariante', project.meta.variant || '–', ], /* * "Angelegt am" wie in der Ansicht "Projekt" (Fassung 5.9.0): Hier stand * "Erstellt am" fuer das Anlagedatum, waehrend die Fusszeile derselben * Seite "Erstellt am" fuer den Ausgabezeitpunkt fuehrte. Eine * Beschriftung fuer zwei Zeitpunkte in einer Unterlage. */ ['Signalprogramm', project.program.name, 'Angelegt am', fmt.dateTime(project.meta.createdAt)], [ 'Zuletzt geändert', fmt.dateTime(project.meta.modifiedAt), 'Ausgedruckt am', fmt.dateTime(now.toISOString()), ], /* * DIE HERKUNFT DER UNTERLAGE (neu in 5.26.0). Ohne diese Zeile war zwei * Ausdrucken desselben Projekts nicht anzusehen, welcher Rechenstand sie * erzeugt hat - und zwischen 5.21.0 und 5.25.0 liegen der Deckel an der * zulaessigen Hoechstgeschwindigkeit und die Verteilung des * Freigabezeitrests, beide mit anderen Zahlen im Plan. Der Schemastand * daneben sagt, aus welchem Dateiformat die Daten stammen; die * mitgelieferte Beispieldatei traegt bewusst einen aelteren. */ ['Programmfassung', FASSUNGSANGABE, 'Schemastand der Datei', project.schemaVersion], ].map((row) => row.map((cell) => toWinAnsi(String(cell)))), }); context.cursor = lastTableBottom(doc) + 20; autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', head: [['Kennwert', 'Wert', 'Kennwert', 'Wert'].map(toWinAnsi)], headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, styles: { fontSize: 10, cellPadding: 4 }, body: [ ...[ [ 'Umlaufzeit', // Mit Vermerk, wenn die Zahl kein Bemessungsergebnis ist (Befund C11). umlaufzeitText(plan), 'Verfahren', methodLabel(project.program.method), ], [ 'Anzahl Phasen', String(plan.phases.length), 'Summe Übergangszeiten', fmt.seconds(plan.transitionTime), ], [ 'Anzahl Signalgruppen', String(project.signalGroups.length), 'Verlustzeit je Umlauf', fmt.seconds(plan.lostTime), ], ].map((row) => row.map((cell) => toWinAnsi(String(cell)))), /* * NEU (Fassung 5.5.0, Frage E2): Der Ueberfahrzeit-Ansatz * geht in jede nach Abschnitt 2 gerechnete Zwischenzeit eines * Kfz-Raeumstroms ein und steht deshalb neben der Umlaufzeit und dem * Bemessungsverfahren - auf dem Deckblatt, das jeder Leser sieht. * * WARUM HIER UND NICHT NUR BEI DEN ZWISCHENZEITEN: Die * Zwischenzeitenmatrix ist abwaehlbar. Ein Ausdruck ohne sie enthielte * sonst Freigabe- und Uebergangszeiten, die auf dem Ansatz beruhen, ohne * ihn zu nennen. Die Begruendung samt Fehlerrichtung steht im Abschnitt * "Zwischenzeitenmatrix" (ueberfahrzeitAnsatzSatz); hier steht nur die * Benennung, und zwar dieselbe wie am Bildschirm und im Pruefbericht. * * "JEDE" HIESS HIER EINMAL WIRKLICH JEDE (bis 5.42.1), und die Zeile * stand unbedingt: An einer einstreifigen Verkehrsfuehrung rechnet der * Fachkern nach RiLSA 2015, Abschnitt 5.2.2 mit einer festen * Ueberfahrzeit, und wo jede Kfz-Zwischenzeit von Hand vorgegeben ist, * rechnet er ueberhaupt nicht. Das Deckblatt nannte gleichwohl einen * Rechenansatz und verwies fuer die Begruendung auf einen Absatz, den * dieselbe Unterlage dort nicht mehr druckt. Die Zeile bleibt - eine * Einstellung, die nicht wirkt, gehoert genannt und nicht verschwiegen -, * aber mit dem Wirkungsvermerk daneben, wie die Vorgabenverwaltung es * fuer jeden wirkungslosen Kennwert haelt (`wirkungslosigkeit` in * settings.ts). Die Antwort kommt aus dem Fachkern, damit Deckblatt, * Matrix, Abweichungstafel und Pruefbericht nicht viererlei sagen. * * Die Beschriftung fuellt drei Spalten, weil sie ein Satz ist und kein * Zahlenwert - in der schmalen Wertspalte stuende sie sechszeilig. */ [ { content: toWinAnsi('Überfahrzeit Kfz (Rechenansatz)'), styles: { fontStyle: 'bold' } }, { content: toWinAnsi( ((): string => { const benennung = UEBERFAHRZEIT_ANSATZ_LABELS[project.settings.ueberfahrzeitAnsatz]; const ohneWirkung = ueberfahrzeitAnsatzOhneWirkung(project, plan); return ohneWirkung === null ? benennung : `${benennung} – ${ohneWirkung.vermerk}`; })(), ), colSpan: 3, }, ], ] as RowInput[], }); context.cursor = lastTableBottom(doc) + 24; /* * Der Kasten wurde unmittelbar an `context.cursor` gezeichnet - ohne * Umbruchpruefung. Bei einer langen Projektbezeichnung (das Feld hat keine * Laengengrenze, und auf dem Deckblatt wird sie bewusst umbrochen statt * gekuerzt) ruecken die beiden Tabellen darueber nach unten; ab rund * 400 Zeichen lag die farbige Flaeche im Fusssteg, ab rund 480 der * Vorbehaltssatz, und ab rund 780 fiel er ganz aus dem Blatt - ersatzlos. * Verloren ging damit die einzige Stelle des Dokuments, an der * `report.errorCount` steht, samt dem Vorbehalt zu Paragraf 45 StVO. * * Der Kasten ist eine Einheit aus Flaeche, Ueberschrift, Ergebniszeile und * Vorbehalt; er rutscht deshalb geschlossen auf das Folgeblatt. Gemessen wird * seine volle Hoehe samt demselben Abstand, der ihn oben von der Tabelle * trennt - Deckblatttitel (Befund 43) und Bemerkung (Befund 18) sind aus * demselben Grund gemessen gesetzt, nur der Block dazwischen war es nicht. */ ensureSpace(context, ERGEBNISKASTEN_HOEHE + 24); // Pruefergebnis - sachlich, ohne Empfehlung. const resultText = report.errorCount > 0 ? `Die Prüfung ergab ${report.errorCount} Fehler und ${report.warningCount} Warnungen. ` + 'Der Signalzeitenplan ist in dieser Form nicht ausführbar.' : report.warningCount > 0 ? `Die Prüfung ergab keine Fehler und ${report.warningCount} Warnungen. ` + 'Die Warnungen sind fachlich zu würdigen.' : 'Die Prüfung ergab keine Beanstandungen.'; doc.setFillColor( report.errorCount > 0 ? '#fdecea' : report.warningCount > 0 ? '#fff4e0' : '#e8f5ec', ); doc.rect(PAGE_MARGIN, context.cursor, context.contentWidth, ERGEBNISKASTEN_HOEHE, 'F'); doc.setDrawColor( report.errorCount > 0 ? '#b3261e' : report.warningCount > 0 ? '#8a5a00' : '#1f6b3a', ); doc.setLineWidth(2); doc.line(PAGE_MARGIN, context.cursor, PAGE_MARGIN, context.cursor + ERGEBNISKASTEN_HOEHE); doc.setFont('helvetica', 'bold'); doc.setFontSize(11); doc.setTextColor('#1b2230'); doc.text(toWinAnsi('Ergebnis der Prüfung'), PAGE_MARGIN + 10, context.cursor + 18); doc.setFont('helvetica', 'normal'); doc.setFontSize(9.5); doc.text(toWinAnsi(resultText), PAGE_MARGIN + 10, context.cursor + 33, { maxWidth: context.contentWidth - 20, }); doc.setFontSize(8); doc.setTextColor('#5b6577'); doc.text( toWinAnsi( 'Die rechnerische Prüfung ersetzt nicht die fachliche Verantwortung des Planers und keine ' + 'verkehrsbehördliche Anordnung nach Paragraf 45 StVO.', ), PAGE_MARGIN + 10, context.cursor + 47, { maxWidth: context.contentWidth - 20 }, ); if (project.meta.description.trim() !== '') { context.cursor += ERGEBNISKASTEN_HOEHE + ERGEBNISKASTEN_NACHLAUF; // Ueberschrift und die ersten Zeilen bleiben beisammen; die Bemerkung // selbst bringt ihren eigenen Umbruch mit (siehe writeBemerkung). ensureSpace(context, 60); doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor('#1b2230'); doc.text(toWinAnsi('Bemerkungen'), PAGE_MARGIN, context.cursor); context.cursor += 14; writeBemerkung(context, project.meta.description); } } // --- Bestandteile der Anordnungsunterlage ----------------------------------- /** * Die Zeile "Rechenweg der Zwischenzeiten und der Wartezeit" der Uebersicht. * * Sie nennt nur Abschnitte, die im Dokument auch wirklich stehen. Vor der * Fassung 5.4.0 hing sie allein am Schalter `includeCalculationDetails` und * druckte "ja - Abschnitte ... und ..." auch dann, wenn Zwischenzeitenmatrix * und Leistungsfaehigkeit abgewaehlt waren und keiner der beiden Rechenwege im * Dokument vorkam. Der Abschnitt, der die Luecken der Unterlage ausweisen soll, * gab damit selbst eine falsche Auskunft. */ function rechenwegZeile(stand: RechenwegStand): string { const zwischenzeiten = zwischenzeitRechenweg(stand); const wartezeit = wartezeitRechenweg(stand); if (zwischenzeiten && wartezeit) { return 'ja – Abschnitte „Zwischenzeiten – Rechenweg" und „Leistungsfähigkeit – Rechenweg Wartezeit"'; } if (zwischenzeiten) { return ( 'teilweise – Abschnitt „Zwischenzeiten – Rechenweg"; der Rechenweg der Wartezeit entfällt ' + 'mit dem abgewählten Abschnitt „Leistungsfähigkeit"' ); } if (wartezeit) { return ( 'teilweise – Abschnitt „Leistungsfähigkeit – Rechenweg Wartezeit"; der Rechenweg der ' + 'Zwischenzeiten entfällt mit der abgewählten Zwischenzeitenmatrix' ); } if (stand.angefordert) { return ( 'entfällt – angefordert, aber beide Abschnitte, in denen er steht (Zwischenzeitenmatrix, ' + 'Leistungsfähigkeit), sind abgewählt' ); } if (stand.matrix) return 'abgewählt – die Werte der Matrix sind ohne ihn nicht nachrechenbar'; if (stand.leistung) { return 'abgewählt – die Werte der Leistungsfähigkeit sind ohne ihn nicht nachrechenbar'; } return 'abgewählt – die Abschnitte, in denen er gestanden hätte, sind ebenfalls abgewählt'; } /** * Was diese Unterlage enthaelt - und was sie NICHT enthaelt. * * NEU (Befund C14). Der Ausdruck druckte, was angehakt war, und schwieg ueber * alles Uebrige. Das ist der gefaehrliche Fall: Eine Unterlage, die jeden * Abschnitt fuellt, den sie kennt, sieht vollstaendig aus. Fehlte der * Zwischenzeitennachweis, fehlten die Einsatzzeiten, fehlte der bemasste * Signallageplan - der Unterlage sah man das nicht an. Erst die Behoerde * stellte fest, dass die Anordnung so nicht ergehen kann. * * WARUM DER SIGNALLAGEPLAN AUSDRUECKLICH FEHLT: Dieses Programm zeichnet * Fahrlinien und rechnet daraus Raeum- und Einfahrwege. Wo die Signalgeber * stehen, welche Bauform sie haben und wie sie bemasst sind, kann es nicht * sagen - und ein Ausdruck, der einen "Lageplan" zeigt, laedt dazu ein, ihn * dafuer zu halten. Die Zeile sagt deshalb im Klartext, dass dieser Plan * gesondert beizubringen ist. * * Der Abschnitt haengt an keinem Schalter: Ein abwaehlbarer Lueckenausweis * waere kein Lueckenausweis. */ function drawAnordnungsunterlage( context: Context, options: PdfOptions, rechenweg: RechenwegStand, ): void { const { doc, project } = context; const einstreifig = project.anlagenart === 'einstreifig'; newSection(context, 'Bestandteile der Anordnungsunterlage'); writeParagraph( context, 'Diese Übersicht sagt, welche Bestandteile die vorliegende Ausgabe enthält und welche nicht. ' + 'Abgewählte Abschnitte sind als solche benannt - eine fehlende Angabe ist kein Nachweis, ' + 'dass es sie nicht gibt, sondern eine Lücke der Unterlage.', ); /** "ja" mit dem Abschnitt, in dem es steht - sonst der Grund des Fehlens. */ const abschnitt = (enthalten: boolean, name: string): string => enthalten ? `ja – Abschnitt „${name}"` : 'abgewählt – in dieser Ausgabe nicht enthalten'; /** Eine Angabe der Projektdaten oder der ausdrueckliche Ausweis ihres Fehlens. */ const angabe = (wert: string | undefined): string => { const text = String(wert ?? '').trim(); return text === '' ? 'nicht angegeben' : text; }; /** Ob eine wahlfreie Angabe der Projektdaten ueberhaupt gefuellt ist. */ const angegeben = (wert: string | undefined): boolean => String(wert ?? '').trim() !== ''; /* * Der Verweis auf eine Unterlage, die dieses Programm nicht erzeugt. * * DIE ZEILE BEGINNT IN BEIDEN FAELLEN MIT "NICHT Bestandteil". Das ist der * Kern der Uebersicht seit Befund C14: Sie soll die Luecke ausweisen und * nicht verkleinern. Der eingetragene Verweis sagt danach, WORAUF sich diese * Unterlage bezieht - aus "fehlt" wird "fehlt hier, liegt als X bei" und * nicht "enthalten". Stuende der Verweis vorn, laese ein Pruefer beim * Ueberfliegen eine Kennung und hielte den Plan fuer dabei. */ /* * DIE ZEILE ZUM SIGNALLAGEPLAN IST SEIT 5.42.0 DREIWERTIG. * * Sie war die unangenehme Auskunft der Uebersicht und die Behebung des * Befundes C14: "NICHT Bestandteil - gesondert beizubringen". Mit erfassten * Signalgebern waere das nicht mehr die ganze Wahrheit, denn ein Blatt mit * Standort, Blickrichtung und Kennung liegt dann bei. * * ES WIRD TROTZDEM KEIN "JA". Was fehlt, ist genau aufgezaehlt - Bauform, * Hoehe, Blendenmass, Sichtpruefung, Restfahrbahnbreite -, und der Satz * beginnt mit dem, was fehlt, nicht mit dem, was da ist. Ein Pruefer, der * die Zeile ueberfliegt, soll nicht "enthalten" lesen: Ein Signallageplan * ohne diese fuenf Angaben ist keiner, und ein halber, der sich fuer einen * ganzen ausgibt, waere schlechter als das ehrliche Fehlen. */ const signallageplanZeile = (p: Project): string => { const anzahl = p.lageplan.signalgeber.length; if (anzahl === 0) return 'NICHT Bestandteil dieser Unterlage – gesondert beizubringen'; return ( `TEILWEISE – ${String(anzahl)} Signalgeberstandort(e) mit Blickrichtung und Kennung sind ` + 'als eigenes Lageplanblatt enthalten. Nicht enthalten und gesondert beizubringen bleiben: ' + 'Bauform, Höhe und Blendenmaß der Signalgeber, die Sichtprüfung sowie der Nachweis der ' + 'verbleibenden Fahrbahnbreite.' ); }; const fremdunterlage = (verweis: string | undefined): string => angegeben(verweis) ? `NICHT Bestandteil dieser Unterlage – gesondert beizubringen; bezieht sich auf: ${String(verweis).trim()}` : 'NICHT Bestandteil dieser Unterlage – gesondert beizubringen'; autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 9, cellPadding: 3, valign: 'top' }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, columnStyles: { 0: { cellWidth: 260, fontStyle: 'bold' } }, head: [['Bestandteil', 'enthalten?'].map(toWinAnsi)], body: [ [ 'Signalzeitenplan (Grafik und Signalzeiten)', abschnitt(options.includePlan, 'Signalzeitenplan'), ], ['Signalgruppen', abschnitt(options.includeSignalGroups, 'Signalgruppen')], ['Zwischenzeitenmatrix', abschnitt(options.includeIntergreenMatrix, 'Zwischenzeitenmatrix')], // Befund C15: Der Zwischenzeitennachweis ist der Abschnitt, den die // Behoerdenpraxis verlangt; seine Abwahl steht deshalb mit ihrer Folge da // und nicht als blosser Vermerk "abgewaehlt". ['Rechenweg der Zwischenzeiten und der Wartezeit', rechenwegZeile(rechenweg)], [ 'Phasen und Phasenübergänge', abschnitt(options.includePhases, 'Phasen und Phasenübergänge'), ], [ 'Begründung der Umlaufzeit (Vergleich der vier Rechenansätze)', abschnitt(options.includeCycleComparison, 'Umlaufzeit – Vergleich der Rechenansätze'), ], ['Leistungsfähigkeit', abschnitt(options.includePerformance, 'Leistungsfähigkeit')], ['Prüfbericht', abschnitt(options.includeReport, 'Prüfbericht')], [ 'Nachweis der Kennwerte (Rechtsgrundlagen, Schranken der Anlagenart, Fundstellenverzeichnis)', abschnitt(options.includeSources, 'Grundlagen der Kennwerte'), ], ['Nachweis der Vermessungsgrundlage', 'ja – Abschnitt „Planungsgrundlage" (nicht abwählbar)'], // DIE WICHTIGSTE ZEILE DIESER UEBERSICHT, wo ein Projekt mehrere // Tagesprogramme fuehrt: Was hier gedruckt ist, gilt EINEM Programm - und // wer das nicht liest, haelt die Unterlage fuer die ganze Anordnung. ['Tagesprogramme dieser Anlage', programmzeile(project)], // Der Abschnitt entsteht nur mit erfasstem Strassenzug, und die Zeile // sagt beides. [ 'Koordinierung im Straßenzug (Grüne Welle)', project.koordinierung === null ? 'nein – für diesen Knotenpunkt ist kein Straßenzug erfasst' : 'ja – Abschnitt „Koordinierung" (nicht abwählbar)', ], // Die Zeile steht neben der des bemassten Signallageplans und nicht an // ihrer Stelle: Das Blatt zeigt die Fahrlinien, nicht die Signalgeber. [ 'Lageplan der Fahrlinien (maßstäblich, ohne Signalgeber)', planblatt(project.lageplan, 400, 300) === null ? 'nein – kein maßstäblich auswertbarer Lageplan im Projekt' : 'ja – Abschnitt „Planungsgrundlage" (nicht abwählbar)', ], // Die Erklaerung zur Barrierefreiheit steht am Ende der Unterlage; wer // hier nachsieht, was drin ist, soll sie finden. [ 'Erklärung zur Barrierefreiheit dieser Datei', 'ja – Abschnitt „Barrierefreiheit dieser Datei" (nicht abwählbar)', ], ['Einsatzzeiten der Anordnung', angabe(project.meta.einsatzzeiten)], [ 'Betriebsverantwortlicher (Name, Firma, Erreichbarkeit)', angabe(project.meta.betriebsverantwortlicher), ], ['Bemaßter Signallageplan mit Signalgeberstandorten', signallageplanZeile(project)], ['Verkehrszeichenplan', fremdunterlage(project.meta.verkehrszeichenplan)], ...(angegeben(project.meta.regelplan) ? [['Regelplan der RSA 21 (Angabe des Planers)', String(project.meta.regelplan).trim()]] : []), ...(angegeben(project.meta.zustimmungBaulasttraeger) ? [ [ 'Zustimmung des Straßenbaulastträgers', String(project.meta.zustimmungBaulasttraeger).trim(), ], ] : []), ].map((row) => row.map((cell) => toWinAnsi(String(cell)))), }); context.cursor = lastTableBottom(doc) + 14; /* * Der Abwahlvermerk steht sonst an der Stelle des Rechenwegs. Sind beide * Stellen abgewaehlt, gibt es sie nicht - dann steht er hier, denn eine * Luecke, die nirgends vermerkt ist, sieht wie eine vollstaendige Unterlage * aus. */ if (!rechenweg.angefordert && !rechenweg.matrix && !rechenweg.leistung) { writeParagraph(context, rechenwegAbgewaehlt(rechenweg)); } writeParagraph( context, project.lageplan.signalgeber.length === 0 ? 'Ein bemaßter Signallageplan mit den Standorten der Signalgeber ist nicht Bestandteil ' + 'dieser Unterlage. Dieses Programm zeichnet Fahrlinien und leitet daraus Räum- und ' + 'Einfahrwege ab; wo die Signalgeber stehen, welche Bauform sie haben und wie sie ' + 'bemaßt sind, geht daraus nicht hervor. Derselbe Vorbehalt gilt für den ' + 'Verkehrszeichenplan. Beide Pläne sind gesondert beizubringen.' : 'Diese Unterlage führt ein Lageplanblatt mit den erfassten Signalgeberstandorten: ' + 'Standort, Blickrichtung und Kennung, maßstäblich abgegriffen aus derselben Zeichnung ' + 'wie die Räum- und Einfahrwege. Ein vollständiger bemaßter Signallageplan ist es ' + 'nicht – Bauform, Höhe und Blendenmaß der Signalgeber, die Sichtprüfung und der ' + 'Nachweis der verbleibenden Fahrbahnbreite gehen daraus nicht hervor und sind ' + 'gesondert beizubringen. Für den Verkehrszeichenplan gilt der Vorbehalt unverändert.', ); writeParagraph( context, einstreifig ? 'Grundlage: RSA 21 Teil A 1.4/1.5 – Signallage- und Signalzeitenplan sind Teil des ' + 'Verkehrszeichenplans, den der Unternehmer der Straßenverkehrsbehörde vorlegt; die ' + 'verkehrstechnischen Unterlagen nach den ZTV transportable Lichtsignalanlagen 2023 ' + '(ARS Nr. 07/2024) nennen Einsatzzeiten und Betriebsverantwortlichen ausdrücklich. ' + 'Abschnittsnummern und Wortlaut sind an der eigenen Ausgabe zu prüfen; der Prüfbericht ' + 'führt fehlende Angaben als Warnung.' : 'Einsatzzeiten und Betriebsverantwortlicher sind üblicher Bestandteil einer ' + 'verkehrsbehördlichen Anordnung nach § 45 StVO; ein bezifferter Regelwerkswert steht ' + 'dahinter nicht. Der Prüfbericht führt sie deshalb als Hinweis, nicht als Warnung.', ); } // --- Barrierefreiheit dieser Datei ------------------------------------------ /** * Erklaerung zur Barrierefreiheit der erzeugten Datei. * * NEU (Fassung 5.7.0). Der Massstab war EN 301 549 V3.2.1 Kapitel 10 mit WCAG * 2.1 Stufe AA. Ergebnis fuer diese Datei: nicht konform. Sie ist ungetaggt - * im erzeugten Dokument wurden /StructTreeRoot, /MarkInfo, /Alt, /ActualText * und /Artifact ausgezaehlt, alle null -, und mit der eingesetzten Bibliothek * (jsPDF 2.5.2) ist das nicht zu beheben. Damit fallen 1.3.1, 1.3.2 und 1.1.1 * der Stufe A fuer die Unterlage aus. * * WARUM DAS IM DOKUMENT SELBST STEHT: Aus demselben Grund, aus dem die * Uebersicht "Bestandteile der Anordnungsunterlage" die fehlenden Bestandteile * benennt, statt sie wegzulassen. Eine Datei, die aussieht wie jede andere * PDF-Datei, wird von einer oeffentlichen Stelle veroeffentlicht wie jede * andere - und die Stelle haftet dafuer, nicht der Hersteller. Sie kann ihre * Erklaerung nach Paragraf 12b BGG nur abgeben, wenn ihr jemand sagt, wie es * um die Datei steht. Dieser Abschnitt sagt es. * * WAS ER NICHT TUT: Er behauptet keine Konformitaet, keine Teilkonformitaet und * keine "weitgehende Barrierefreiheit". Die Zusagen, die er macht - Titel, * Sprache, Lesezeichen, auslesbarer Text, Signalzeitentabelle als Textfassung * der Zeichnung -, sind einzeln nachpruefbar und in tests/export/ * bfUnterlagen.test.ts an der erzeugten Datei geprueft. * * Ohne Schalter: Ein abwaehlbarer Lueckenausweis waere kein Lueckenausweis. */ function drawBarrierefreiheit( context: Context, mitPlanabschnitt: boolean, mitPhasenabschnitt: boolean, rechenweg: RechenwegStand, ): void { const { doc } = context; newSection(context, 'Barrierefreiheit dieser Datei'); writeParagraph( context, 'Dieser Abschnitt sagt, wie es um die Barrierefreiheit dieser Datei steht - nicht um die der ' + 'geplanten Anlage. Er ist eine Angabe des Herstellers zum Stand der Datei und keine ' + 'Rechtsberatung. Maßstab ist EN 301 549 V3.2.1 Kapitel 10 mit den Erfolgskriterien der ' + 'WCAG 2.1 Stufe AA; Stand der Prüfung: August 2026.', ); writeParagraph( context, 'Ergebnis: Diese Datei ist nicht getaggt. Sie erfüllt PDF/UA (DIN ISO 14289-1) nicht, und die ' + 'Erfolgskriterien 1.3.1 Info und Beziehungen, 1.3.2 Bedeutungstragende Reihenfolge und ' + '1.1.1 Nicht-Text-Inhalte der Stufe A sind für sie nicht erfüllt.', ); autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 9, cellPadding: 3, valign: 'top' }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, columnStyles: { 0: { cellWidth: 260, fontStyle: 'bold' } }, head: [['Merkmal', 'Stand in dieser Datei'].map(toWinAnsi)], body: [ [ 'Auszeichnung der Struktur (Tags, Strukturbaum)', 'nicht vorhanden – die Datei ist ungetaggt', ], [ 'Überschriften, Absätze, Kopfzellen der Tabellen', 'nur optisch gesetzt, nicht programmatisch ausgezeichnet', ], ['Festgelegte Lesereihenfolge', 'nicht festgelegt'], [ 'Kopf- und Fußzeile als Seitenrahmen gekennzeichnet', 'nein – sie stehen im Lesefluss und werden mitgelesen', ], [ 'Alternativtext der Zeichnung', /* * FASSUNG 5.9.0: Beide Zweige nannten die Tabelle * "Signalzeiten je Signalgruppe" als Textfassung - auch dann, wenn der * Abschnitt "Signalzeitenplan" abgewaehlt war und die Datei weder * Zeichnung noch Tabelle enthaelt. */ !mitPlanabschnitt ? 'entfällt – der Abschnitt „Signalzeitenplan" wurde für diese Ausgabe abgewählt; ' + 'diese Datei enthält keine Zeichnung' : mitPhasenabschnitt ? 'nicht vorhanden – gleichwertige Textfassung: Tabelle „Signalzeiten je Signalgruppe" ' + 'und Abschnitt „Phasen und Phasenübergänge"' : 'nicht vorhanden – Textfassung der Signalbilder: Tabelle „Signalzeiten je ' + 'Signalgruppe"; das Phasenband ist nicht in Textform enthalten', ], [ 'Dokumenttitel und Sprache (Deutsch)', 'vorhanden; der Titel wird im Anzeigeprogramm ausgewiesen', ], ['Lesezeichen für jeden Abschnitt', 'vorhanden'], ['Text maschinell auslesbar (Vektorzeichnung, keine Rastergrafik)', 'ja'], ['Schriften in die Datei eingebettet', 'nein – Standardschriften, Zeichenvorrat Latin-1'], ].map((row) => row.map((cell) => toWinAnsi(cell))), }); context.cursor = lastTableBottom(doc) + 14; writeParagraph( context, 'Was das für Bildschirmleseprogramme bedeutet: Der Text ist auslesbar und die Sprache der ' + 'Datei ist als Deutsch ausgewiesen, die Bedeutung der Teile ist es nicht. Überschriften ' + 'werden nicht als Überschriften angesagt, es gibt keine Überschriftennavigation. Tabellen ' + 'werden ohne Bezug zu Zeilen- und Spaltenköpfen vorgelesen; bei einer Matrix wie der der ' + 'Zwischenzeiten bleibt damit offen, welche Zahl zu welchem Paar aus räumender und ' + 'einfahrender Signalgruppe gehört. Kopf- und Fußzeile jeder Seite stehen im Lesefluss und ' + 'unterbrechen ihn auf jeder Seite.' + // Nur, wo es eine Zeichnung gibt (Fassung 5.9.0). (mitPlanabschnitt ? ' Die Zeichnung des Signalzeitenplans erscheint als lose Folge von Zahlen, ohne ' + 'Angabe, dass es sich um eine Zeichnung handelt.' : ''), ); /* * WARUM DIESER ABSATZ NEU IST (Ausschnittsatz der Zwischenzeitenmatrix). * * Der Abschnitt ist ein Lueckenausweis. Aendert sich die Darstellung eines * Abschnitts so, dass eine Sprachausgabe etwas ANDERES vorliest als zuvor, * muss er es sagen - sonst beschreibt er eine Datei, die es nicht mehr gibt. * * Was sich geaendert hat: Bei langen Signalgruppennamen trug der Kopf der * Matrix die Namen in so schmalen Spalten, dass jspdf-autotable sie MITTEN * IM WORT umbrach. Gemessen an einem Knotenpunkt mit 16 bis 32 Gruppen und * Namen von rund 140 Zeichen stand danach KEIN EINZIGER Name ungeteilt im * ausgelesenen Text (0 von 16, 0 von 24, 0 von 32). Die ersten drei * Kopfbruchstuecke lauteten dort, aus der Datei zurueckgelesen: * "Kraftfahrz" / "euge Nor" / "dzufahrt " - ein Zitat, keine Nachbildung * (Befund B7 technisch: hier stand vorher ein erfundenes "Kraftfah rzeuge * Nordzuf ahrt"). Jetzt tragen die Spaltenkoepfe Ordnungsnummern, und die * Zuordnung ueber der * Tabelle fuehrt jeden Namen ungeteilt in einer Zeile - gemessen 16 von 16, * 24 von 24, 32 von 32. * * WAS DIESER ABSATZ NICHT BEHAUPTEN DARF: dass damit 1.3.1 erfuellt waere. * Der Bezug zwischen Zelle und Kopfzelle ist weiterhin nur optisch gesetzt. * Die Aenderung macht die Namen vorlesbar, nicht die Matrix auswertbar. * * UND ER STEHT NUR DA, WO ER ZUTRIFFT - aus demselben Grund wie der Verweis * auf den Phasenabschnitt weiter unten: Wer die Matrix abwaehlt, bekommt * eine Datei ohne sie, und wer kurze Bezeichnungen benutzt, hat die Namen im * Kopf. In beiden Faellen beschriebe der Absatz etwas, das es in DIESER * Ausgabe nicht gibt. Dasselbe gilt fuer den Verweis auf den Rechenweg. * * ER SAGT AUCH, WIE WEIT DIE ZUSAGE TRAEGT (Fassung 5.8.0): "ungeteilt in * einer Zeile" galt frueher unbedingt, traf aber nur zu, solange der Name in * eine Bannerzeile passte. SignalGroup.name ist in der Laenge nicht begrenzt; * ab rund 180 Zeichen brach jede Zuordnungszeile um. Jetzt entscheidet der * beim Zeichnen ermittelte MatrixSatzbefund, welcher der beiden Saetze hier * steht. * * DASSELBE GILT FUER DEN ORT DER ZUORDNUNG: "auf jeder Seite" stimmt nur, * solange sie in den wiederholten Tabellenkopf passt. Ist sie fuer sich schon * hoeher als ein Blatt, steht sie ungekuerzt vor dem Ausschnitt - und dann * sagt der Lueckenausweis genau das. */ const satzbefund = context.matrixSatzbefund; if (rechenweg.matrix && satzbefund?.mitOrdnungsnummern === true) { writeParagraph( context, 'Zur Zwischenzeitenmatrix in dieser Datei: Die Namen der Signalgruppen taugen nicht als ' + 'Spaltenkopf. Die Spaltenköpfe tragen deshalb die Ordnungsnummer der ' + 'einfahrenden Signalgruppe, und ' + (satzbefund.zuordnungAufJedemBlatt ? 'über der Tabelle steht auf jeder Seite, welche Nummer zu welchem Namen gehört - ' : 'vor jedem Ausschnitt steht, welche Nummer zu welchem Namen gehört; auf jeder Seite ' + 'wiederholt wird die Zuordnung nicht, weil sie länger ist als ein Blatt - ') + (satzbefund.namenUngeteilt ? 'dort steht jeder Name ungeteilt in einer Zeile und wird am Stück vorgelesen. ' : 'dort steht zu jeder Nummer ihr voller Name. Namen, die für eine Zeile zu lang sind, ' + 'laufen über mehrere Zeilen weiter; jede Folgezeile wiederholt die Nummer, damit sie ' + 'zuzuordnen bleibt. ') + 'Das macht die Namen hörbar; den fehlenden programmatischen Bezug zwischen ' + 'einer Zahl und ihren beiden Köpfen ersetzt es nicht. ' + (zwischenzeitRechenweg(rechenweg) ? 'Wer die Zwischenzeiten Beziehung für Beziehung hören will, findet sie im Abschnitt ' + '„Zwischenzeiten – Rechenweg", der jede Beziehung als „Name -> Name" mit ihrem ' + 'Ergebnis führt.' : 'Der Abschnitt, der dieselben Zahlen Beziehung für Beziehung als „Name -> Name" ' + 'führt, ist für diese Ausgabe abgewählt; ohne ihn gibt es keine Fassung dieser ' + 'Zahlen, die eine Sprachausgabe Paar für Paar wiedergeben kann.'), ); } /* * WAS "VOLLSTAENDIG" HIER GENAU HEISST. * * Hier stand: "Der Inhalt der Zeichnung steht vollstaendig in Textform in der * Tabelle „Signalzeiten je Signalgruppe"". Das ist zu weit gefasst. Die * Zeichnung traegt ueber der Zeitachse ein PHASENBAND mit Phasennamen, * Phasenbeginn und Uebergangszeiten (drawPhaseBand in * src/render/signalPlanDrawing.ts); diese Angaben stehen in der * Signalzeitentabelle nicht, sondern im Abschnitt "Phasen und * Phasenuebergaenge". Die gleichwertige Textfassung der GANZEN Zeichnung sind * also zwei Tabellen, nicht eine. * * Der Abschnitt ist ein Lueckenausweis - er darf als einziger Teil der Datei * keine Angabe machen, die einer Nachpruefung nicht standhaelt. Deshalb wird * die Zusage aufgeteilt und nicht abgeschwaecht. * * Und deshalb haengt sie am Schalter: Wer die Phasen abwaehlt, bekommt eine * Datei ohne diesen Abschnitt - ein Verweis darauf waere dann falsch. Dann * wird die Luecke benannt, statt sie zu verschweigen. * * UND EBENSO AM SCHALTER DES SIGNALZEITENPLANS (Fassung 5.9.0): Zeichnung und * Signalzeitentabelle entstehen beide nur unter `options.includePlan`, und * der Absatz unter der Zeichnung mit ihnen. Der Satz stand trotzdem unbedingt * da und beschrieb bei abgewaehltem Abschnitt drei Dinge, die es in dieser * Ausgabe nicht gibt - waehrend die Uebersicht der Bestandteile wenige Seiten * davor die Abwahl korrekt ausweist. */ if (mitPlanabschnitt) { writeParagraph( context, 'Die Signalbilder der Zeichnung stehen vollständig in Textform in der Tabelle „Signalzeiten ' + 'je Signalgruppe"; sie führt Freigabefenster, Freigabezeit, Rot-Gelb, Gelb und Rot je ' + 'Signalgruppe auf und enthält damit auch die Zeiten, die in der Zeichnung wegen zu ' + 'schmaler Abschnitte unbeschriftet bleiben. Das Phasenband über der Zeitachse - ' + 'Phasennamen, Phasenbeginn und Übergangszeiten - steht ' + (mitPhasenabschnitt ? 'in den Tabellen des Abschnitts „Phasen und Phasenübergänge". Diese beiden Tabellen ' + 'zusammen sind die gleichwertige Textfassung der Zeichnung' : 'nur in der Zeichnung: Der Abschnitt „Phasen und Phasenübergänge" wurde für diese ' + 'Ausgabe abgewählt. Ohne ihn ist die Signalzeitentabelle die Textfassung der ' + 'Signalbilder, nicht der ganzen Zeichnung') + ' - ein Alternativtext im Sinne von 1.1.1 ist sie mangels Auszeichnung nicht. Ein Absatz ' + 'unter der Zeichnung verweist auf beides.', ); } else { writeParagraph( context, 'Zur Zeichnung des Signalzeitenplans: Der Abschnitt „Signalzeitenplan" wurde für diese ' + 'Ausgabe abgewählt. Diese Datei enthält deshalb weder die Zeichnung noch die Tabelle ' + '„Signalzeiten je Signalgruppe" - über eine Textfassung der Signalbilder ist hier ' + 'nichts auszusagen, weil es die Signalbilder in dieser Ausgabe nicht gibt.', ); } writeParagraph( context, 'Was eine öffentliche Stelle tun muss, die diese Unterlage veröffentlicht: Wird sie im ' + 'Internet bereitgestellt - etwa als Anlage einer öffentlichen Bekanntmachung nach § 27a ' + 'VwVfG -, ist sie ein Dokument dieser Stelle und muss nach EN 301 549 Kapitel 10 ' + 'barrierefrei sein. Diese Datei ist es nicht. Sie ist vor einer Veröffentlichung ' + 'nachträglich auszuzeichnen (Strukturbaum mit Überschriften, Absätzen und Tabellenkopfzellen, ' + 'Alternativtext der Zeichnung, Kopf- und Fußzeile als Seitenrahmen, eingebettete Schriften) ' + 'und das Ergebnis mit einem Prüfwerkzeug wie PAC oder veraPDF nachzuprüfen. Ist das nicht ' + 'leistbar, ist eine zugängliche Fassung des Inhalts bereitzustellen und die Lücke in der ' + 'Erklärung zur Barrierefreiheit der Stelle zu benennen.' + // Auch dieser Schlusssatz nannte die Signalzeitentabelle unbedingt // (Fassung 5.9.0). (mitPlanabschnitt ? ' Für die Prüfung durch die Behörde genügt die Signalzeitentabelle als Textfassung; ' + 'für eine Veröffentlichung genügt sie nicht.' : ''), ); } // --- Planungsgrundlage ------------------------------------------------------ /** * Nachweis der Vermessungsgrundlage. * * Der Ausdruck enthielt bisher weder Lageplan noch Luftbild und auch keine * Angabe, worauf die Raeum- und Einfahrwege beruhen. Ein Pruefer konnte die * Zwischenzeiten deshalb nicht nachvollziehen: Dieselbe Zahl kann aus einem * massstaeblichen Luftbild stammen oder von Hand geschaetzt sein. Alle Werte * kommen aus dem Modell; gerechnet wird in src/domain/geometrie/lageplan.ts. * * Der Abschnitt weist die UNTERLAGE nach, nicht die Herkunft der einzelnen * Masse. Seine Saetze sagen, was sich auf dieser Unterlage abgreifen laesst - * nicht, woher eine bestimmte Zahl stammt. Das ist die Trennung, auf die es * ankommt: Eine Unterlage kann massstaeblich sein, ohne dass ein einziger Weg * von ihr abgegriffen wurde. * * Woher die einzelnen Masse stammen, fuehrt das Modell seit Schemastand 9 mit * (`Conflict.wegnachweis`); ausgewiesen wird es in der Spalte "Herkunft der * Wege" der Rechenwegtabelle. Bis dahin gab es dafuer nur das freie * Bemerkungsfeld, und der Kopf dieser Funktion sagte entsprechend, das Modell * fuehre es "nirgends mit" - waehrend dieselbe Funktion weiter unten bereits * die Zeile "Nachweis der Wege" druckte. * * Zur Unterlage gehoert ihre Aufloesung. Ein Luftbild sieht grob wie fein * gleich aus, doch der Zeichenfehler geht ueber den Raeumweg unmittelbar in die * Zwischenzeit ein - an einer Fussgaengerfurt fast zehnmal so stark wie bei * einem Kraftfahrzeugstrom. Ohne die Zeile "Eignung als Messgrundlage" koennte * ein Pruefer aus diesen Unterlagen nicht erkennen, ob die Zwischenzeiten auf * einer tauglichen Unterlage beruhen. */ /** * Die Zeile "Tagesprogramme" der Bestandteileuebersicht. * * Sie sagt bei einem Programm, dass es das einzige ist, und bei mehreren, dass * diese Unterlage EINES von ihnen zeigt. Der Unterschied ist keine Feinheit: * Eine Anordnung nach § 45 StVO gilt der Anlage, und wer vier Programme * schaltet, muss vier Signalzeitenplaene beibringen. */ function programmzeile(project: Project): string { const anzahl = programmanzahl(project); if (anzahl === 1) return 'ja – dieses Projekt führt genau ein Tagesprogramm'; return ( `${String(anzahl)} Programme – diese Unterlage zeigt „${project.program.name}"; die übrigen ` + 'sind gesondert auszugeben (Abschnitt „Tagesprogramme dieser Anlage")' ); } /** * Alle Tagesprogramme mit Umlaufzeit und Pruefergebnis. * * WAS DIESE TAFEL LEISTET UND WAS NICHT: Sie weist nach, dass JEDES Programm * geprueft wurde - das ist die sicherheitsrelevante Zusage der * Programmbibliothek. Sie ersetzt NICHT den Signalzeitenplan der uebrigen * Programme; der steht in dieser Datei nicht, und der Text darunter sagt das. */ function drawProgrammuebersicht(context: Context, now: Date): void { const { doc, project } = context; const pruefungen = programmPruefungen(project, now); autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 9, cellPadding: 3 }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, head: [ [ 'Tagesprogramm', 'Verfahren', 'Umlaufzeit', 'Fehler', 'Warnungen', 'in dieser Unterlage', ].map(toWinAnsi), ], body: pruefungen.map((eintrag) => [ eintrag.name, eintrag.verfahren === 'manuell' ? 'feste Vorgabe' : CYCLE_METHOD_LABELS[eintrag.verfahren], fmt.seconds(eintrag.umlaufzeit), String(eintrag.errorCount), String(eintrag.warningCount), eintrag.aktiv ? 'ja – vollständig' : 'nein – nur diese Zeile', ].map(toWinAnsi), ), }); context.cursor = lastTableBottom(doc) + 12; writeParagraph( context, `Diese Anlage führt ${String(pruefungen.length)} Tagesprogramme. Signalgruppen, ` + 'Konfliktbeziehungen, Zwischenzeiten und die Besetzung der Phasen sind ihnen gemeinsam und ' + 'stehen in dieser Unterlage vollständig; eigen sind jedem Programm Umlaufzeit, Verfahren, ' + 'Versatz, Verkehrsstärken und die von Hand vorgegebenen Freigabezeiten.', ); writeParagraph( context, `AUSGEDRUCKT IST DER SIGNALZEITENPLAN DES PROGRAMMS „${project.program.name}". Für die ` + 'übrigen Programme steht in dieser Datei nur die Zeile der Tafel oben – keine Signalzeiten, ' + 'keine Grafik, kein Prüfbericht im Einzelnen. Wer die Anlage vollständig anordnen lassen ' + 'will, gibt jedes Programm gesondert aus: in der Ansicht „Phasen" umschalten und erneut ' + 'ausgeben.', ); writeParagraph( context, 'Geprüft wurden alle Programme, und zwar mit denselben Regeln: Die Spalten „Fehler" und ' + '„Warnungen" stammen aus je einem eigenen Prüflauf. Solange irgendein Programm einen Fehler ' + 'führt, ist die Ausgabe gesperrt – auch dann, wenn das angezeigte Programm fehlerfrei ist.', ); } /** * Die Gruene Welle im Strassenzug. * * WAS DER ABSCHNITT LEISTET: Er weist die Abstimmung mit den Nachbaranlagen * nach - Abstaende, Reisezeiten, empfohlene Versaetze, Bandbreite in beiden * Fahrtrichtungen, dazu das Zeit-Weg-Diagramm. * * WAS ER AUSDRUECKLICH NICHT LEISTET: Er sagt nichts ueber die Nachbaranlagen * selbst. Deren Signalzeitenplaene, Zwischenzeiten und Pruefberichte stehen in * IHREN Unterlagen; hier stehen nur ihre Freigabefenster, wie sie der * Bearbeiter dieses Projekts eingetragen hat. Der Absatz darunter sagt das. */ function drawKoordinierung(context: Context): void { const { doc, project, plan } = context; const lage = koordinierungslage(project, plan); if (lage.hindernis !== null) { writeParagraph(context, HINDERNIS_TEXT[lage.hindernis]); return; } const ergebnis = lage.ergebnis; if (ergebnis === null) return; autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 9, cellPadding: 3 }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, head: [ [ 'Anlage', 'Entfernung', 'Reisezeit', 'Empfohlener Versatz', 'Freigabe Hauptrichtung (von–bis) [s]', ].map(toWinAnsi), ], body: ergebnis.intersections.map((eintrag, index) => { const glied = lage.kette[index]; return [ `${eintrag.name}${glied?.eigener === true ? ' (dieser Knotenpunkt)' : ''}`, `${fmt.numShort(eintrag.cumulativeDistance, 0)} m`, fmt.seconds(eintrag.travelTime), fmt.seconds(eintrag.offset), /* * IN DEN UMLAUF GEFALTET UND IN DERSELBEN SCHREIBWEISE WIE DIE * SIGNALZEITENTABELLE, ueber `freigabefensterZelle` (Fassung 5.43.0): * Hier stand "Beginn bis Beginn + Dauer" - "91 s bis 139 s" bei 95 s * Umlaufzeit, waehrend die Signalzeitentabelle derselben Unterlage * "91–44" fuehrt. Und "Freigabe ab" einer Nachbaranlage darf die * Umlaufzeit uebersteigen; die Koordinierung rechnet mit dem gefalteten * Beginn, das Diagramm zeichnet ihn, und die Tabelle nannte den * eingetragenen. * * GENANNT IST EIN FENSTER: das, mit dem die Koordinierung rechnet. Ist * die Hauptrichtung dieses Knotenpunkts in mehreren Fenstern frei, * fuehrt die Signalzeitentabelle alle und diese Zelle nur dieses eine; * gleich ist dann die Schreibweise, nicht die Zeichenkette. */ glied === undefined ? '-' : freigabefensterZelle( [ { start: modulo(glied.freigabe.start, ergebnis.cycleTime), duration: glied.freigabe.duration, }, ], ergebnis.cycleTime, ), ].map(toWinAnsi); }), }); context.cursor = lastTableBottom(doc) + 12; writeParagraph( context, 'Freigabe Hauptrichtung: das Fenster in Sekunden ab Umlaufbeginn, in den Umlauf gefaltet – ' + 'so, wie die Koordinierung rechnet und das Diagramm zeichnet. Reicht es über den ' + 'Umlaufbeginn hinaus, liegt sein Ende vor seinem Beginn.', ); const hoehe = Math.max(150, Math.min(300, 70 + lage.kette.length * 34)); ensureSpace(context, hoehe + 60); doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor('#1b2230'); doc.text(toWinAnsi('Zeit-Weg-Diagramm'), PAGE_MARGIN, context.cursor); context.cursor += 14; const surface = new PdfSurface(doc, PAGE_MARGIN, context.cursor, context.contentWidth, hoehe); drawZeitWeg(surface, lage, { mitGegenrichtung: true }); context.cursor += hoehe + 10; writeParagraph( context, `Waagerecht ${String(UMLAEUFE_IM_BILD)} Umläufe, senkrecht der Weg entlang des Straßenzugs. ` + 'Die grünen Balken sind die Freigabezeiten der Hauptrichtung, die schräge Fläche ist das ' + 'Band: die Startzeiten, mit denen ein Fahrzeug ohne Halt durchkommt. Die blaue Fläche ist ' + 'die Gegenrichtung, wo sie erfasst ist.', ); writeParagraph( context, `Bandbreite in Hauptrichtung ${fmt.seconds(ergebnis.hinrichtung.breite)} ` + `(${fmt.ratio(ergebnis.hinrichtung.anteil, 2)} der Umlaufzeit, Beurteilung ` + `„${ergebnis.hinrichtung.quality}"). ` + (ergebnis.rueckrichtung === null ? 'Für die Gegenrichtung ist an mindestens einer Anlage kein Freigabefenster erfasst; ' + 'ein zweites Band wird deshalb NICHT gerechnet und auch nicht angenommen. Der ' + 'Zielkonflikt zwischen Hin- und Rückrichtung ist der eigentliche Gegenstand einer ' + 'Koordinierung und bleibt damit unbeurteilt.' : `Bandbreite in der Gegenrichtung ${fmt.seconds(ergebnis.rueckrichtung.breite)} ` + `(${fmt.ratio(ergebnis.rueckrichtung.anteil, 2)}, Beurteilung ` + `„${ergebnis.rueckrichtung.quality}").`), ); writeParagraph( context, 'DER VERSATZ IST EINE EMPFEHLUNG FÜR DIE NACHBARANLAGEN und verschiebt den Signalzeitenplan ' + 'dieses Knotenpunkts nicht: Er gilt zwischen Anlagen, nicht innerhalb einer. Die ' + 'Freigabezeiten der Nachbarn sind Eingaben dieses Projekts – ihre Signalzeitenpläne, ' + 'Zwischenzeiten und Prüfberichte stehen in ihren eigenen Unterlagen und sind hier weder ' + 'wiedergegeben noch geprüft. Eine Grüne Welle setzt zudem eine gemeinsame Umlaufzeit aller ' + `beteiligten Anlagen voraus; gerechnet ist mit ${fmt.seconds(ergebnis.cycleTime)} und einer ` + `Progressionsgeschwindigkeit von ${fmt.numShort(ergebnis.speed, 1)} km/h.`, ); for (const note of ergebnis.notes) { writeParagraph( context, `${note.severity === 'fehler' ? 'Fehler' : 'Hinweis'}: ${note.message}`, ); } } function drawPlanungsgrundlage(context: Context, rechenweg: RechenwegStand): void { const { doc, plan, project } = context; // Mit den Kennwerten des Projekts, nicht mit denen des Regelwerks: Der // Anwender darf die Raeumgeschwindigkeiten anpassen, und daran haengt die // Eignung. Sonst stuende im Ausdruck eine andere Einstufung als im // Pruefbericht - und keiner der beiden waere nachrechenbar. const grundlage = planungsgrundlage(project.lageplan, plan.defaults); newSection(context, 'Planungsgrundlage'); if (!grundlage.hatBild) { writeParagraph( context, 'Diesen Unterlagen liegt kein Lageplan und kein Luftbild bei. Die Räum- und Einfahrwege der ' + 'Zwischenzeitenberechnung stammen daher nicht aus diesen Unterlagen; auf welcher Grundlage ' + 'sie ermittelt wurden, geht aus ihnen nicht hervor und ist vom Planer gesondert nachzuweisen.', ); writeParagraph(context, wegnachweisVerteilung(project)); return; } writeParagraph( context, 'Die folgenden Angaben weisen die Unterlage aus, auf der die Fahrlinien dieses Projekts ' + 'gezeichnet sind und auf der sich Räum- und Einfahrwege abgreifen lassen. Stammt die ' + 'Unterlage aus einem Kartendienst, nennt die Herkunft Herausgeber, Ausschnittsmitte und ' + 'Kantenlänge; damit lässt sich derselbe Ausschnitt wieder beschaffen und nachmessen. Bei ' + 'einer eingelesenen Datei steht dort ihr Name.', ); autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 9, cellPadding: 3, valign: 'top' }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, columnStyles: { 0: { cellWidth: 175, fontStyle: 'bold' } }, head: [['Angabe', 'Wert'].map(toWinAnsi)], body: [ ['Herkunft der Unterlage', grundlage.herkunft], [ 'Geladen am', grundlage.ladedatumBekannt ? fmt.dateTime(grundlage.geladenAm) : 'nicht überliefert', ], [ 'Bildgröße', `${fmt.numShort(grundlage.breiteBildpunkte, 0)} × ` + `${fmt.numShort(grundlage.hoeheBildpunkte, 0)} Bildpunkte`, ], [ 'Kantenlänge am Boden', grundlage.kantenlaengeMeter === null ? 'nicht bestimmbar - der Maßstab fehlt' : fmt.meters(grundlage.kantenlaengeMeter, 0), ], [ 'Maßstab', grundlage.meterJeBildpunkt === null ? 'nicht festgelegt' : `${fmt.numShort(grundlage.meterJeBildpunkt, 3)} m je Bildpunkt`, ], ['Herkunft des Maßstabs', massstabsherkunft(grundlage)], ['Nachweis der Wege', wegnachweisVerteilung(project)], // Derselbe Befund und dieselbe Zahl wie im Pruefbericht; der Satz kommt // aus dem Regelwerksmodul, damit beide nicht auseinanderlaufen koennen. ['Eignung als Messgrundlage', messeignungSatz(grundlage.messgenauigkeit)], [ 'Gezeichnet', `${grundlage.anzahlHaltlinien} Haltlinie(n), ${grundlage.anzahlFahrlinien} Fahrlinie(n)`, ], ].map((row) => row.map((cell) => toWinAnsi(String(cell)))), }); context.cursor = lastTableBottom(doc) + 12; writeParagraph( context, 'Die Zeile "Eignung als Messgrundlage" beurteilt, wofür die Auflösung dieser Unterlage ' + `ausreicht. Angesetzt ist ein Zeichenfehler von ${ZEICHENFEHLER_BILDPUNKTE} Bildpunkten auf ` + 'dem Räum- und noch einmal auf dem Einfahrweg. Hinnehmbar ist ein daraus folgender Fehler ' + `der Zwischenzeit von ${fmt.seconds(MESSFEHLER_SCHRANKE, 2)}: Die Zwischenzeit wird auf ganze ` + 'Sekunden aufgerundet, und bis zu diesem Betrag bleibt die Sicherheitswirkung des Aufrundens ' + 'erhalten. Weil die Räumgeschwindigkeit im Nenner steht, kostet derselbe Zeichenfehler an ' + 'einer Fußgängerfurt ein Vielfaches dessen, was er bei einem Kraftfahrzeugstrom kostet. Die ' + 'Angabe sagt, was sich auf dieser Unterlage abgreifen lässt - nicht, welche Wege tatsächlich ' + 'hier abgegriffen wurden.', ); /* * Der zweite Halbsatz verwies unbedingt auf die Spalte "Herkunft der Wege" im * Rechenweg der Zwischenzeiten. Die Spalte entsteht aber nur in * `drawIntergreenDetails`, also nur unter * `includeCalculationDetails && includeIntergreenMatrix`. Wer eines von * beiden abwaehlt, wurde in einen Abschnitt geschickt, den diese Ausgabe * nicht enthaelt - gemessen blieb "Herkunft der Wege" dann genau einmal im * Dokument stehen, naemlich in diesem Verweis. Und das ausgerechnet im * Abschnitt, der die Vermessungsgrundlage der sicherheitsrelevanten Masse * nachweist. * * Die Zusage "führt das Projekt je Beziehung mit" bleibt: Sie gilt fuer das * Modell (`Conflict.wegnachweis`) und nicht fuer diese Ausgabe. Nur der * Zeiger auf den Ort haengt daran, ob es den Ort gibt. */ const wegherkunftNachweis = zwischenzeitRechenweg(rechenweg) ? 'ausgewiesen wird es in der Spalte "Herkunft der Wege" im Rechenweg der Zwischenzeiten.' : 'der Rechenweg der Zwischenzeiten, der es in der Spalte "Herkunft der Wege" Beziehung für ' + 'Beziehung ausweist, ist für diese Ausgabe abgewählt.'; writeParagraph( context, grundlage.auswertbar ? 'Der Lageplan ist maßstäblich auswertbar; Räum- und Einfahrwege lassen sich daraus ' + 'abgreifen. Ob die Wege einer einzelnen Konfliktbeziehung tatsächlich von hier stammen, ' + `führt das Projekt je Beziehung mit; ${wegherkunftNachweis}` : 'Der Lageplan ist nicht auswertbar - es fehlt der Maßstab, oder es sind keine Fahrlinien ' + 'gezeichnet. In diesem Zustand lassen sich Räum- und Einfahrwege daraus nicht abgreifen; ' + 'als Nachweis der Vermaßung taugt die Unterlage daher nicht.', ); drawLageplanblattAbschnitt(context); } /** * Das massstaebliche Lageplanblatt. * * WOZU: Die Zwischenzeiten dieser Anlage beruhen auf gezeichneten Fahrlinien, * und von diesen Linien stand bis hierher keine einzige in der Unterlage. Ein * Pruefer konnte einen Raeumweg von 34,2 m nicht nachvollziehen, sondern nur * glauben. Der Abschnitt "Planungsgrundlage" wies die Unterlage nach, auf der * gezeichnet wurde - nicht die Zeichnung selbst. * * WAS ES NICHT IST: ein Signallageplan. Keine Signalgeberstandorte, keine * Bemassung. Die Bestandteileuebersicht fuehrt den bemassten Signallageplan * unveraendert als "NICHT Bestandteil dieser Unterlage"; dieses Blatt tritt * nicht an seine Stelle, und der Absatz darunter sagt es. */ function drawLageplanblattAbschnitt(context: Context): void { const { project } = context; const bild = project.lageplan.bild; const blatt = planblatt(project.lageplan, context.contentWidth, LAGEPLANBLATT_HOEHE); if (bild === null || blatt === null) return; /* * NORDPFEIL NUR BEI EINEM GEOREFERENZIERTEN AUSSCHNITT. Er wird in * ETRS89/UTM angefordert, dort liegt Norden oben, und die Abbildung auf das * Blatt dreht nicht. Bei einem eingelesenen Bild - Katasterauszug, Foto * einer Zeichnung, gescannter Bestandsplan - ist die Ausrichtung unbekannt. * Ein geratener Nordpfeil sieht aus wie eine Messung. * * DIE FUSSZEILE STEHT AUF BEIDEN BLAETTERN. Bei einem Kartendienst nennt die * Herkunft Herausgeber, Ausschnittsmitte und Kantenlaenge; damit laesst sich * derselbe Ausschnitt wieder beschaffen. Bei einem eingelesenen Bild ist sie * ein Dateiname, und der verortet nichts - das sagt die Zeile dann auch, * statt es dem Leser zu ueberlassen. * * OHNE BILD GIBT ES DIESES BLATT NICHT, und das ist keine neue Entscheidung: * `drawPlanungsgrundlage` bricht den ganzen Abschnitt schon vorher ab. Die * Bedingung steht trotzdem hier - so haengt sie sichtbar an der Stelle, die * sie braucht, statt zwei Ebenen hoeher stillschweigend zu gelten. */ const nordpfeil = project.lageplan.kalibrierung?.herkunft === 'georeferenziert'; const fusszeile = nordpfeil ? `Grundlage: ${bild.herkunft}` : `Grundlage: ${bild.herkunft} – eingelesenes Bild, ohne Verortung und ohne Nordrichtung.`; /* * DIE MASSKETTEN KOMMEN AUS DEM FACHKERN und stehen auf beiden Blaettern. * * Bemasst wird nur, was das Programm ohnehin fuehrt: die Laenge jeder * Haltlinie und Laenge wie Breite jedes Arbeitsbereichs. Was es NICHT * bemasst - den Abstand einer Haltlinie zum Arbeitsbereich und den Abstand * zweier Haltlinien -, begruendet `masskette.ts` an Ort und Stelle; kurz: * das eine benennt ein Bandende, das die Auskunft bewusst offenlaesst, das * andere waere eine zweite Zahl fuer die Engstellenlaenge. * * KEIN SCHALTER. Die Ketten treffen keine neue Aussage, sie schreiben eine * vorhandene an die Stelle, an der sie gilt. Ein abwaehlbarer Nachweis waere * dieselbe Bauart Fehler wie eine abwaehlbare Bestandteileuebersicht. */ const ketten = massketten(project.lageplan, blatt); zeichneBlatt(context, blatt, 'Lageplan der Fahrlinien', { mitNamen: true, nordpfeil, fusszeile, massketten: ketten, }); writeParagraph( context, 'Die Zeichnung gibt die Fahr- und Haltlinien wieder, aus denen Räum- und Einfahrwege dieses ' + 'Projekts abgegriffen sind – ohne das Luftbild darunter, damit kein Bildinhalt eine Linie ' + 'verdeckt. ' + (blatt.massstabsnenner === null ? '' : `Der Blattmaßstab beträgt rund 1:${String(blatt.massstabsnenner)}. `) + 'MASSGEBEND IST DER MASSSTABSBALKEN unten links, nicht das Verhältnis: Wird dieses Blatt ' + 'verkleinert kopiert oder auf ein anderes Format gedruckt, stimmt das Verhältnis nicht ' + 'mehr, der Balken schrumpft mit und bleibt richtig. Wer aus einem falsch skalierten Blatt ' + 'einen Räumweg abgreift, bekommt eine zu kurze Zwischenzeit. ' + 'Die Maßketten schreiben an, was das Projekt vermessen führt: die Länge jeder Haltlinie ' + 'sowie Länge und Breite jedes Arbeitsbereichs. Sie sind keine zweite Rechnung – die Zahlen ' + 'stammen aus derselben Vermessung wie jede andere Länge dieser Unterlage. Nicht bemaßt ' + 'sind der Abstand einer Haltlinie zur Arbeitsstelle und der Abstand zweier Haltlinien: Die ' + 'erste Angabe nennt bewusst nicht, zu welchem Ende sie misst, die zweite wäre eine zweite ' + 'Zahl für die Engstellenlänge, und die ist hier der größte erfasste Räumweg. ' + 'Das Blatt ist KEIN Signallageplan: Es führt keine Signalgeberstandorte und keine ' + 'Bemaßung der Aufstellung; beides ist gesondert beizubringen. Und es sagt nicht, ob die Wege einer ' + 'einzelnen Konfliktbeziehung wirklich von hier stammen – das führt das Projekt je ' + 'Beziehung mit.', ); if (context.blattbild === null) return; zeichneBlatt( context, blatt, 'Derselbe Lageplan über dem Luftbild', { mitNamen: true, nordpfeil, fusszeile, aufBild: true, massketten: ketten }, context.blattbild, ); writeParagraph( context, 'Dasselbe Blatt, im selben Maßstab und mit denselben Linien, über der Bildgrundlage. Es ' + 'dient der VERORTUNG: Das Blatt darüber zeigt Linien ohne Umgebung und lässt sich für ' + 'sich allein keinem Ort zuordnen. ' + 'Die Aufnahme ist auf den gezeigten Ausschnitt zugeschnitten und auf die Auflösung dieses ' + 'Blattes verkleinert; ihre Herkunft steht unten rechts und ausführlich im Abschnitt ' + '„Planungsgrundlage“. ' + 'GEMESSEN WIRD NICHT AM BILD: Maßgebend bleiben der Maßstabsbalken und die Linien. Die ' + 'verkleinerte Aufnahme trägt die Lage, nicht die Länge. ' + 'Wo die Zeichnung über den Rand der Aufnahme hinausreicht, bleibt der Grund weiß – dort ' + 'liegt keine Bildgrundlage vor.', ); zeichneSignalgeberblatt(context, blatt, nordpfeil, fusszeile, ketten); } /** * Das dritte Blatt: Lageplan mit den Signalgeberstandorten. * * ES ENTSTEHT NUR, WENN WELCHE ERFASST SIND. Ein leeres drittes Blatt waere * die Behauptung, es gebe hier nichts aufzustellen - und das ist etwas anderes * als "nicht erfasst". * * WARUM EIN EIGENES BLATT UND KEINE ZUSAETZLICHE EBENE AUF DEM ERSTEN: Ein * Signalgeber steht neben der Haltlinie, also genau dort, wo Raeum- und * Einfahrweg beginnen. Auf dem Nachweisblatt verdeckte er die Stelle, um die * es dort geht. Zwei Aussagen, zwei Blaetter - dieselbe Ueberlegung wie beim * Luftbildblatt. * * UND ES IST WEITERHIN KEIN SIGNALLAGEPLAN. Es zeigt Standort, Blickrichtung * und Kennung; Bauform, Hoehe, Blendenmass und die Sichtpruefung kennt dieses * Programm nicht, und die Uebersicht der Bestandteile sagt das mit denselben * Worten. */ function zeichneSignalgeberblatt( context: Context, blatt: Planblatt, nordpfeil: boolean, fusszeile: string, ketten: readonly Masskette[], ): void { if (blatt.signalgeber.length === 0) return; zeichneBlatt(context, blatt, 'Lageplan mit Signalgeberstandorten', { mitNamen: false, nordpfeil, fusszeile, massketten: ketten, mitSignalgebern: true, }); writeParagraph( context, `Dasselbe Blatt mit den ${String(blatt.signalgeber.length)} erfassten Signalgebern: Kreis ` + 'mit Kennung am Standort, Pfeil in Blickrichtung. Die Fahrlinien sind ohne Beschriftung ' + 'gezeichnet, damit die Kennungen der Signalgeber eindeutig bleiben. ' + 'DAS SINNBILD IST NICHT MASSSTÄBLICH: Ein Ampelkopf ist rund einen Viertelmeter breit und ' + 'wäre auf diesem Blatt kaum sichtbar; Kreis und Pfeil haben deshalb eine feste Größe. ' + 'Maßstäblich und abgreifbar ist der Mittelpunkt des Kreises. ' + 'EIN BEMASSTER SIGNALLAGEPLAN IST DIESES BLATT NICHT: Es führt Standort, Blickrichtung und ' + 'Kennung, aber keine Bauform, keine Höhe, kein Blendenmaß, keine Sichtprüfung und keinen ' + 'Nachweis der verbleibenden Fahrbahnbreite. Die Übersicht der Bestandteile sagt, was damit ' + 'enthalten ist und was weiterhin gesondert beizubringen bleibt. ' + 'Die Standorte gehen in keine Rechnung ein – Zwischenzeit, Freigabezeit und Umlaufzeit ' + 'kennen sie nicht.', ); } /** * Ein Lageplanblatt mit Ueberschrift, wahlweise ueber einer Bildgrundlage. * * DAS BILD GEHT UNMITTELBAR AN jsPDF UND NICHT UEBER `Surface`. Die * Zeichenflaeche beschreibt Strich, Flaeche und Schrift; eine Rastergrafik ist * keines davon, und der Bildschirm zeichnet sein Luftbild ohnehin selbst * (`ui/lageplan/leinwand.ts`). Eine sechste Grundoperation, die nur eine der * beiden Flaechen ausfuehren koennte, waere eine Zusage, die eine von beiden * nicht haelt. */ function zeichneBlatt( context: Context, blatt: Planblatt, ueberschrift: string, optionen: LageplanZeichnungOptionen, bild: Blattbild | null = null, ): void { const { doc } = context; ensureSpace(context, blatt.hoehe + 60); doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor('#1b2230'); doc.text(toWinAnsi(ueberschrift), PAGE_MARGIN, context.cursor); context.cursor += 14; if (bild !== null) { doc.addImage( bild.datenUrl, 'JPEG', PAGE_MARGIN + bild.zielX, context.cursor + bild.zielY, bild.zielBreite, bild.zielHoehe, ); } const surface = new PdfSurface( doc, PAGE_MARGIN, context.cursor, context.contentWidth, blatt.hoehe, ); drawLageplanblatt(surface, blatt, optionen); context.cursor += blatt.hoehe + 10; } /** Wie der Massstab zustande kam - in einem Satz fuer den Ausdruck. */ function massstabsherkunft(grundlage: Planungsgrundlage): string { switch (grundlage.massstabHerkunft) { case 'georeferenziert': return 'aus dem Kartendienst übernommen (georeferenzierter Ausschnitt, nicht nachgemessen)'; case 'gemessen': return ( 'an einer Strecke bekannter Länge abgegriffen: ' + `${fmt.meters(grundlage.bezugslaengeMeter ?? 0, 1)} auf ` + `${fmt.numShort(grundlage.bezugslaengeBildpunkte ?? 0, 0)} Bildpunkten` ); case null: return 'nicht festgelegt'; } } // --- Tabellen --------------------------------------------------------------- function drawSignalGroupTable(context: Context): void { const { doc, project, plan } = context; autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 8.5, cellPadding: 3 }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, head: [ [ /* * DIE ORDNUNGSNUMMER: Traegt die Zwischenzeitenmatrix Nummern in den * Spaltenkoepfen - was sie bei langen Bezeichnungen tut -, verweist * der Absatz darueber auf "dieselbe Reihenfolge wie in der Tabelle * Signalgruppen". Ohne diese Spalte war die Aussage zwar zutreffend, * aber nur durch Abzaehlen von Zeilen nachpruefbar; mit ihr steht die * Nummer da, auf die verwiesen wird. * * Sie ist keine Eigenschaft der Signalgruppe, sondern ihre Stellung * in DIESER Unterlage - deshalb wird sie hier gezaehlt und nicht am * Modell gefuehrt. */ 'Nr.', 'SG', 'Verkehrsart', 'Fahrbeziehung', 'V zul', 'Fahrstr.', 'Fahrzeugart', // Merkmale der Fussgaengerfurt, die in die Mindestfreigabezeit eingehen // (Befund C1): Zusatzeinrichtung fuer Blinde und Sehbehinderte (ganze // statt halbe Furt) und erhoehter Zeitbedarf (1,0 statt 1,2 m/s). Ohne // die Spalte stuende "6 s" links in "Min./Vorgabe" neben einem // Regelwert von 5 s, und niemand saehe, woher die Sekunde kommt. 'Furt', 'Rot-Gelb', 'Gelb', // MINDESTFREIGABEZEIT UND VORGABE IN EINER SPALTE: Seit der // Fassung 5.10.0 setzt die Freigabezeitverteilung fuer eine Phase die // kleinste Hoechstfreigabezeit ihrer Signalgruppen an statt des // Regelwerts. Stand sie nirgends, liess sich die gedruckte Phasendauer // aus der Unterlage nicht mehr herleiten. // // RECHTS STEHT DIE VORGABE, NICHT DIE GELTENDE SCHRANKE: Was gilt, // entscheidet `phaseMaxGreen` je PHASE; ohne Eintrag steht "–". Der // Absatz unter der Tabelle sagt es, und der Spaltenkopf sagt es so // weit, wie seine Breite reicht - siehe `hoechstfreigabezeitVorgabe` in // csv.ts. // // WARUM EINE SPALTE UND WARUM SO KURZ BENANNT: Diese Tabelle ist quer // voll. Die Breite, die ihre dreizehn Spalten brauchen, damit keine // Zelle umbricht, ist bis auf ein Zehntel dieselbe, die der // Satzspiegel hergibt (nachgemessen mit doc.getTextWidth: 770,9 pt // gegen 769,9 pt). Eine vierzehnte Spalte oder ein laengerer Kopf // nimmt den Nachbarn die Breite: Signalgruppennamen und Fahrzeugarten // brechen um, die Zeilen werden hoeher, die Tabelle bricht frueher auf // das naechste Blatt um - dann steht in der Spalte "Nr." NaN statt der // Nummer (nachgestellt mit einer eigenen Spalte "Max. Freigabe": // tests/export/pdfSignalgruppenNummer.test.ts faellt mit 3 Faellen, // waehrend pdfBeschriftungen.test.ts und pdfSatzspiegel.test.ts gruen // bleiben). Gemessen mit Zellenabstand: "Min./Max." 44,2 pt, // "Min./Vorgabe" 58,4 pt - das traegt; "Min./max. Vorg." 67,3 pt // traegt nicht mehr. Die Zelle darunter nennt die Einheit; die // Tabellenausgabe fuehrt beide Angaben als eigene Spalten mit vollem // Namen - sie wird gerechnet, diese hier gelesen. 'Min./Vorgabe', // "(Summe)" wie in der Signalzeitentabelle (Befund C22): Die Zelle // nennt `totalGreen`, also die Summe aller Freigabefenster. Die // Fussnote dazu steht auf einer anderen Seite; ohne den Zusatz liest // sich die Spalte an einer Gruppe mit zwei Fenstern wie die Dauer eines // einzelnen. 'Freigabe im Plan (Summe)', 'Verkehrsstärke', ].map(toWinAnsi), ], body: project.signalGroups.map((group, index) => { const planned = plan.groups.find((g) => g.groupId === group.id); const demand = project.demands.find((d) => d.signalGroupId === group.id); const maxVorgabe = hoechstfreigabezeitVorgabe(group); return [ String(index + 1), group.name, TRAFFIC_MODE_LABELS[group.mode], // Die Schluessel selbst gehoeren nicht in die Unterlage: "keine" liest // sich in einer Tabellenzelle wie eine fehlende Angabe, gemeint ist // "ohne Laengenzuschlag". Der Anwender hat die Werte in der Ansicht // "Signalgruppen" aus einer Auswahlliste mit ebendiesen Beschriftungen // gewaehlt und muss sie im Ausdruck wiederfinden. MOVEMENT_LABELS[group.movement], `${fmt.numShort(group.vZul)} km/h`, // Ueber fmt und nicht ueber String (Fassung 5.10.0): `num` in schema.ts // prueft die Fahrstreifenzahl nur gegen 1 bis 8 und rundet nicht auf // ganze Zahlen. Eine Projektdatei mit "lanes": 2.5 wird uebernommen, // geht mit 2,5 in die Saettigungsverkehrsstaerke ein - und stand hier // als "2.5" mit englischem Punkt, als einzige Zahl der Unterlage, die // nicht ueber fmt lief. In der Tabellenausgabe ist dieselbe Zelle // bereits nachgezogen (csv.ts). Bei ganzen Zahlen aendert sich nichts: // numShort laesst nachlaufende Nullen weg. fmt.numShort(group.lanes), fahrzeugartBeschriftung(group.vehicleClass, plan.defaults), furtmerkmale(group), fmt.seconds(planned?.times.redYellow ?? 0), fmt.seconds(planned?.times.yellow ?? 0), // Die Vorgabe steht auch ohne `planned`: Sie ist eine Eingangsgroesse // der Gruppe und kein Ergebnis des Plans. "–" heisst "keine // eingetragen"; was dann gilt, sagt der Absatz unter der Tabelle. `${fmt.seconds(planned?.times.minGreen ?? 0)} / ` + (maxVorgabe === null ? '–' : fmt.seconds(maxVorgabe)), planned === undefined || planned.greens.length === 0 ? '–' : fmt.seconds(planned.totalGreen), // Fuer Fussgaenger und Radverkehr auch dann "–", wenn eine Zahl // gespeichert ist (Altdatei aus der Zeit vor Fassung 5.4.0, // Befund B4): Sie geht in keine Rechnung ein, und der // Pruefbericht sagt das. Eine gedruckte Verkehrsstaerke behauptete // eine Eingangsgroesse, die es nicht ist. // // UND "–" AUCH BEI 0 (Fassung 5.10.0): Ein Datensatz ist noch keine // Zaehlung. Wird in der Signalgruppentabelle allein der // Schwerverkehrsanteil ausgefuellt, legt die Oberflaeche einen // Datensatz mit `volume: 0` an; der Fachkern liest ihn seit den // Befunden 12 und 18 als "keine Angabe" (signalPlan.ts, // erfassteVerkehrsstaerke), und der Bildschirm zeichnet das Feld seit // Befund 58 leer. Hier stand daneben "0 Fz/h" - dieselbe Zeile, zwei // Aussagen. Dieselbe Bedingung wie in `schwerverkehrZelle`. demand === undefined || demand.volume <= 0 || !hatSaettigungsverkehrsstaerke(group.mode) ? '–' : fmt.vehiclesPerHour(demand.volume), ].map(toWinAnsi); }), }); context.cursor = lastTableBottom(doc) + 10; // Rechenweg der Furtregel unter der Tabelle: Eine Mindestfreigabezeit ueber // dem Regelwert muss sich aus gedruckten Groessen nachrechnen lassen. Ohne // den Absatz waere "6 s" eine Behauptung. const furtregel = furtregelAbsatz(plan); if (furtregel !== null) writeParagraph(context, furtregel); /* * Was in der Spalte "Min./Vorgabe" rechts steht - und was dort NICHT steht. * * Der Absatz steht unbedingt da, weil die Spalte unbedingt da steht * (dieselbe Begruendung wie beim Absatz zur Spalte "Enger Innenradius"): * Ein Kopf "Min./Vorgabe" ohne Erlaeuterung liesse offen, wovon die zweite * Zahl die Vorgabe ist, und "–" liesse offen, was ohne Eintrag gilt. * * Er sagt nur zu, was das Programm haelt: Gemeldet werden die beiden Faelle, * in denen `phaseMaxGreen` eine Vorgabe IN IHRER PHASE verwirft. Eine * Vorgabe ueber einer festen Freigabezeit kommt ebenfalls nicht zur Wirkung * und wird nicht gemeldet; eine Gruppe ohne Phase erreicht die Stelle gar * nicht. Beides ist hier deshalb auch nicht zugesagt - der Satz nennt die * Phase ausdruecklich. */ writeParagraph( context, 'Die Spalte "Min./Vorgabe" nennt links die maßgebende Mindestfreigabezeit und rechts die an ' + 'der Signalgruppe eingetragene Höchstfreigabezeit; "–" heißt: an dieser Signalgruppe ist ' + 'keine eingetragen. Die rechte Zahl ist eine Eingangsgröße und nicht die im Plan wirksame ' + 'obere Schranke: Wirksam wird sie je Phase, und zwar als kleinste Vorgabe ihrer ' + `Signalgruppen. Trägt keine von ihnen eine, gilt der Regelwert von ` + `${fmt.seconds(plan.defaults.maxGreen)} aus den Projektvorgaben; eine feste Freigabezeit ` + 'der Phase und die Mindestfreigabezeit gehen der Vorgabe ohnehin vor. Eine Vorgabe, die in ' + 'ihrer Phase unter der Mindestfreigabezeit oder unter einer festen Freigabezeit liegt, ' + 'meldet der Prüfbericht.', ); context.cursor += 6; } /** * Merkmale einer Fussgaengerfurt fuer die Signalgruppentabelle - Beschriftung * wie am Bildschirm ("Blindenzusatz", "erhoehter Zeitbedarf"). Fuer andere * Verkehrsarten "–": Die Merkmale gibt es dort nicht. */ export function furtmerkmale(group: SignalGroup): string { if (group.mode !== 'fuss') return '–'; const merkmale: string[] = []; if (group.blindenzusatz === true) merkmale.push('Blindenzusatz (ganze Furt)'); if (group.reducedMobility) merkmale.push('erhöhter Zeitbedarf'); return merkmale.length === 0 ? 'halbe Furt' : merkmale.join(', '); } /** * Absatz zur Mindestfreigabezeit der Fussgaengergruppen: je Gruppe der * Rechenweg der Furtregel (Furtlaenge, halbe/ganze Furt, vr, Ergebnis) oder der * Vermerk, dass die Furtlaenge unbekannt ist. `null`, wenn es keine * Fussgaengergruppe gibt - dann gibt es auch nichts zu erklaeren. */ export function furtregelAbsatz(plan: SignalPlan): string | null { const saetze: string[] = []; for (const g of plan.groups) { if (g.mode !== 'fuss') continue; const mfz = g.mindestfreigabezeit; if (mfz.furt === null) { saetze.push( `${g.name}: Furtlänge unbekannt (kein Räumweg an einer Konfliktbeziehung, in der die Gruppe ` + `räumt) – Furtregel nicht prüfbar, es gilt der Regelwert von ${fmt.seconds(mfz.regelwert)}.`, ); continue; } const furt = mfz.furt; const anteil = furt.ganzeFurt ? 'ganze Furt (Blindenzusatz)' : 'halbe Furt'; // Gerundet im Fachkern (furtFreigabebedarf), nicht hier noch einmal. const bedarf = furt.zeitbedarfGerundet; const ergebnis = mfz.massgebend === 'furt' ? `maßgebend ${fmt.seconds(mfz.wert)}` : mfz.massgebend === 'vorgabe' ? `maßgebend die Vorgabe von ${fmt.seconds(mfz.wert)}` : `maßgebend der Regelwert von ${fmt.seconds(mfz.wert)}`; // Furtweg mit zwei, Zeitbedarf mit drei Nachkommastellen - dieselbe // Genauigkeit wie im Hinweis des Plans (signalPlan.ts, furtNotizen): // "6,01 m / 1,2 m/s = 5,004 s, aufgerundet 6 s" ist nachrechenbar, // "6,0 m / 1,2 m/s = 5,00 s, aufgerundet 6 s" nicht. saetze.push( `${g.name}: Furt ${fmt.meters(furt.furtlaenge, 2)}, ${anteil} ${fmt.meters(furt.furtweg, 2)} / ` + `${fmt.metersPerSecond(furt.vr)} = ${fmt.seconds(furt.zeitbedarf, 3)}, aufgerundet ` + `${fmt.seconds(bedarf)}; ${ergebnis}.`, ); } if (saetze.length === 0) return null; return ( 'Mindestfreigabezeit der Fußgängergruppen nach der Furtregel der RiLSA (Freigabe mindestens für ' + 'die halbe Furt, mit Zusatzeinrichtung für Blinde und Sehbehinderte für die ganze Furt; ' + 'Furtlänge = längster erfasster Räumweg der Gruppe, vr = Räumgeschwindigkeit der Gruppe; ' + 'der größere Wert aus Regelwert, Vorgabe und Furtbedarf gilt): ' + saetze.join(' ') + ' ' + MITTELINSEL_VERMERK ); } /** * Was der Ausdruck ueber Furten mit Mittelinsel sagen muss. * * NEU (Befund D2): Das Programm kennt keine zweistufige Furt. Es ist eine * Einschraenkung des Leistungsumfangs, und eine Planunterlage, die sie * verschweigt, laesst den Pruefer glauben, die Zweistufigkeit sei geprueft * worden. Der Vermerk steht dort, wo die Fuss-Kennwerte stehen (unter der * Signalgruppentabelle), nicht in einer allgemeinen Vorbemerkung. Kein * Rechenwechsel: Es aendert sich nichts an den Zahlen, nur daran, was ueber sie * behauptet wird. * * BERICHTIGT 5.38.0, NACHGEBESSERT 5.38.1 - EINE ZUSICHERUNG UEBER EINE * EINGABE IST KEINE ZUSICHERUNG. * Hier stand: "Furten mit Mittelinsel gehen mit ihrer ganzen Laenge in diese * Rechnung ein ... konservativ, weil Raeumzeit und Furtbedarf ueber die volle * Breite angesetzt werden." Das behauptet etwas ueber DIESEN Plan, das dieser * Plan nicht wissen kann: Gerechnet wird mit dem je Konfliktbeziehung * ERFASSTEN Raeumweg, und ob der bis zur gegenueberliegenden Bordkante reicht * oder an der Insel endet, steht nirgends. Wer die Furt nur bis zur Insel * vermisst, bekam die kuerzeren Zeiten UND einen gedruckten Satz, der sie * konservativ nannte - in einer Unterlage, die zur Anordnung nach § 45 StVO * geht. Der Vermerk sagt jetzt, WOMIT gerechnet wurde, und verweist auf die * Furtlaenge, die derselbe Absatz zwei Zeilen darueber je Gruppe nennt. * * DREI NACHBESSERUNGEN IN 5.38.1. Erstens sagte der Vermerk, die Furtlaenge * stehe "oben bei jeder Gruppe" - fuer eine Gruppe ohne erfassten Raeumweg * druckt derselbe Absatz zwei Zeilen darueber aber "Furtlaenge unbekannt". Eine * unbelegbare Zusicherung war durch eine zweite ersetzt worden. Zweitens fiel * die RAEUMZEIT aus dem Text: Der alte Wortlaut nannte "Raeumzeit und * Furtbedarf", der neue verband den erfassten Weg nur noch mit der * Mindestfreigabezeit - dabei traegt derselbe halbierte Weg auch jede * Zwischenzeit dieser Gruppe. Drittens fehlte die RICHTUNG: Wer nur das PDF vor * sich hat, konnte nicht mehr entnehmen, ob die Vereinfachung zu seinen Gunsten * oder zu seinen Lasten geht. Sie steht jetzt wieder da, aber an die Bedingung * geknuepft, unter der sie gilt. */ export const MITTELINSEL_VERMERK = 'Eine progressive (zweistufige) Signalisierung der Furtabschnitte mit der Insel als ' + 'Aufstellfläche bildet das Programm nicht ab und weist sie nicht nach: Es kennt keine Furt als ' + 'Gebilde und rechnet je Signalgruppe, mit dem an der jeweiligen Konfliktbeziehung erfassten ' + 'Räumweg. Aus dessen größtem Wert entsteht die Furtlänge; welche das je Gruppe ist – oder dass ' + 'sie unbekannt bleibt –, sagen die Sätze davor. Ob der erfasste Weg von Bordkante zu Bordkante ' + 'reicht, kann dieser Plan nicht feststellen: Eine Furt mit Mittelinsel ist über die Insel ' + 'hinweg zu vermessen. Geschieht das, liegen Räumzeit, Zwischenzeit und Furtbedarf auf der ' + 'sicheren Seite; wird nur bis zur Insel gemessen, fallen alle drei zu kurz aus.'; /* -- Zwischenzeitenmatrix: Satzmasse --------------------------------------- * * WARUM DIESE MASSE UEBERHAUPT FESTGELEGT WERDEN. * * jspdf-autotable verteilt ohne feste Breite die Satzbreite ANTEILIG auf alle * Spalten. Die Matrix hat n+1 Spalten, also faellt die Breite jeder Spalte mit * wachsendem n - und mit ihr die Zahl der Zeichen, die in eine Kopfzelle * passen. Der Umbruch geht dann nicht mehr an Wortgrenzen, sondern durch die * Woerter hindurch; die Kopfzeile wird bei langen Namen hoeher als das Blatt, * und autotable zerlegt sie in printFullRow in immer neue Bruchstuecke. * Gemessen an einem Knotenpunkt mit vollstaendig besetzter Konfliktmatrix und * Namen von rund 140 Zeichen: bei 32 Gruppen 1,3 s / 1 406 kB / 113 Seiten, * bei 40 Gruppen ABBRUCH. Der Ausdruck ist dort nicht langsam, sondern nicht * mehr herstellbar. * * WIE GENAU ER ABBRICHT, HAENGT AM FALL - der Kommentar nannte frueher nur den * Aufrufstapel, belegbar sind zwei Abbrucharten: 40 Gruppen mit * 140-Zeichen-Namen sterben an der Halde ("Ineffective mark-compacts near heap * limit", rund 4 GB, nach 71 s), 16 Gruppen mit 485-Zeichen-Namen am * Aufrufstapel ("Maximum call stack size exceeded", nach 42,5 s bei 2 791 MB). * Beidem geht dieselbe Meldung von autotable voraus: "Will not be able to print * row -1 correctly since it's minimum height is larger than page height". Fuer * die Sache ist der Unterschied gleichgueltig - fuer eine Fehlersuche nicht, * deshalb steht er hier. * * Die Masse unten schneiden das ab, indem sie die Spaltenbreite von n * ABKOPPELN. Was dann nicht mehr nebeneinander passt, steht in einem weiteren * Ausschnitt - nicht in einer schmaleren Spalte. */ /** Schriftgrad der Matrix - wie bisher. */ const MATRIX_SCHRIFTGRAD = 8.5; /** Zellpolsterung der Matrix - wie bisher; sie faellt zweimal je Spalte an. */ const MATRIX_POLSTER = 3; /** Zeilenabstand, den jspdf-autotable ansetzt (FONT_ROW_RATIO). */ const MATRIX_ZEILENFAKTOR = 1.15; /** Beschriftung der Kopfecke - Leserichtung der Matrix. */ const MATRIX_ECKE = 'räumt \\ fährt ein'; /** * Bis zu welcher Breite ein Signalgruppenname noch als SPALTENKOPF taugt. * * 34 pt tragen bei 8,5 pt Helvetica fett sechs Ziffernzeichen: "K12345" misst * 29,49 pt, "K123456" schon 34,17 pt und faellt heraus (nachgemessen; die * frueher hier stehende Angabe "rund acht Zeichen" war zu grosszuegig, * berichtigt mit Fassung 5.8.0). Damit deckt der Wert genau die Bezeichnung ab, * die das Modell erwartet (SignalGroup.name: "Bezeichnung im Plan, z. B. K1, * F2, R3"). Wer stattdessen einen Satz in das Namensfeld schreibt, bekommt im * Kopf die Ordnungsnummer und darueber die Zuordnung; erfunden wird dabei * nichts (siehe matrixSatz). * * DASS DIE GRENZE FRUEH LIEGT, IST BEABSICHTIGT, aber es ist keine Kleinigkeit: * Schon "Fussgaenger12" (13 Zeichen, 61,97 pt fett) faellt darunter. Solche * Plaene bekommen den Nummernsatz, obwohl ihre Namen vorher sauber an * Wortgrenzen umbrachen. Das ist vertretbar, weil das Banner die Zuordnung auf * jedem Blatt mitfuehrt - aber es ist mehr als "wer kurze Bezeichnungen * benutzt, sieht keine Aenderung" (Befund B6 fachlich), und deshalb steht es * hier und in den Unterlagen. * * Der Wert ist bewusst etwas groesser als die breiteste Zahlenspalte * ("188*" = 17,34 pt zuzueglich 2 x 3 pt Polster = 23,34 pt): Eine * Bezeichnung, die in eine Zahlenspalte passt, kostet keine einzige zusaetzliche * Spaltenbreite. */ const MATRIX_KOPFNAME_HOECHSTBREITE = 34; /** Hoechstbreite der Namensspalte (Zeilenkopf) - der Rest gehoert den Zahlen. */ const MATRIX_NAMENSSPALTE_HOECHSTBREITE = 180; /** * Die Breite, unter die jspdf-autotable eine Spalte nie verkleinert. * * In calculateWidths ist das `defaultMinWidth = 10 / scaleFactor`; die Einheit * dieser Ausgabe ist Punkt, also scaleFactor 1. Der Wert gehoert in die * Schranke, weil eine Spalte mit sehr schmalem Inhalt trotzdem diese Breite * belegt und den anderen Spalten damit Platz nimmt. */ const MATRIX_MINDESTBREITE = 10; /** * Textbreite in der Schrift der Matrix, ohne den Zustand des Dokuments zu * hinterlassen: Die Funktion wird zwischen Absaetzen aufgerufen, die ihre * eigene Schrift gesetzt haben. */ function matrixBreite(doc: jsPDF, text: string, fett: boolean): number { const schrift = doc.getFont() as { fontName: string; fontStyle: string }; const grad = doc.getFontSize(); doc.setFont('helvetica', fett ? 'bold' : 'normal'); doc.setFontSize(MATRIX_SCHRIFTGRAD); const breite = doc.getTextWidth(toWinAnsi(text)); doc.setFont(schrift.fontName, schrift.fontStyle); doc.setFontSize(grad); return breite; } /** Zeilenzahl, die ein Text in einer Zelle dieser Breite belegt. */ function matrixZeilen(doc: jsPDF, text: string, breite: number, fett: boolean): number { const schrift = doc.getFont() as { fontName: string; fontStyle: string }; const grad = doc.getFontSize(); doc.setFont('helvetica', fett ? 'bold' : 'normal'); doc.setFontSize(MATRIX_SCHRIFTGRAD); const zeilen = doc.splitTextToSize(toWinAnsi(text), Math.max(1, breite)) as string[]; doc.setFont(schrift.fontName, schrift.fontStyle); doc.setFontSize(grad); return Math.max(1, zeilen.length); } /** Hoehe einer Zelle mit so vielen Zeilen - dieselbe Rechnung wie autotable. */ function matrixZellhoehe(zeilen: number): number { return zeilen * MATRIX_SCHRIFTGRAD * MATRIX_ZEILENFAKTOR + 2 * MATRIX_POLSTER; } /** Der Inhalt einer Zelle der Matrix - eine Stelle fuer beide Satzarten. */ function matrixWert(plan: SignalPlan, from: SignalGroup, to: SignalGroup): string { if (from.id === to.id) return ''; const resolved = plan.intergreens.get(intergreenKey(from.id, to.id)); if (!resolved) return '·'; return resolved.source === 'vorgegeben' ? `${fmt.numShort(resolved.value, 0)}*` : fmt.numShort(resolved.value, 0); } /** * Das Groesste ueber die Zellen der ganzen Matrix - ueber eine Faltung, NICHT * ueber den Ausbreitungsoperator. * * Beide Satzarten bildeten das Maximum vor der Fassung 5.11.0 als * `Math.max(0, ...zellen.flat().map(...))`. Die Matrix hat n^2 Zellen; V8 nimmt * nur rund 125.000 Argumente an, und ab 356 Signalgruppen (356^2 = 126.736) * brach der Aufruf mit "RangeError: Maximum call stack size exceeded" ab - * ohne Konfliktbeziehung, ohne Fehler im Pruefbericht und ohne dass eine * Unterlage entstand. Weder `parseSignalGroups` noch `addSignalGroup` kennen * eine Obergrenze; wer solche Anlagen annimmt, muss sie auch drucken. * * Der Wert ist derselbe wie zuvor - nur der Rechenweg dorthin fasst die Liste * nicht mehr als Argumentliste an. */ function matrixHoechsteZellbreite( zellen: readonly (readonly string[])[], breite: (zelle: string) => number, ): number { let hoechste = 0; for (const zeile of zellen) { for (const zelle of zeile) hoechste = Math.max(hoechste, breite(zelle)); } return hoechste; } /** Die laengsten Woerter eines Textes - sie bestimmen, ob er umbrechbar ist. */ function laengstesWort(doc: jsPDF, text: string, fett: boolean): number { const woerter = text.split(/\s+/).filter((w) => w !== ''); if (woerter.length === 0) return 0; return Math.max(...woerter.map((w) => matrixBreite(doc, w, fett))); } /** Was in der Zuordnung steht, wenn eine Signalgruppe keinen Namen traegt. */ const MATRIX_OHNE_NAMEN = '(ohne Bezeichnung)'; /** * Was im Banner steht, wenn die Zuordnung selbst hoeher ist als ein Blatt. * * Sie wird dann nicht gekuerzt, sondern verlaesst den wiederholten * Tabellenkopf. Der Banner sagt, wo sie steht - sonst suchte der Leser eines * einzelnen Blattes sie dort, wo sie sonst immer ist. */ const MATRIX_ZUORDNUNG_VOR_TABELLE = 'Die Zuordnung der Nummern zu den Namen steht unmittelbar vor dieser Tabelle; sie ist länger ' + 'als ein Blatt und lässt sich deshalb nicht auf jedem Blatt wiederholen.'; /** * Der Name einer Signalgruppe fuer Zuordnung und Zeilenkopf - nie leer. * * Ein vorhandener Name geht UNVERAENDERT durch (auch mit Rand): Diese Funktion * soll die Ausgabe nicht schoener machen, sondern nur die Luecke schliessen, in * der bisher gar nichts stand. */ function matrixAnzeigename(group: SignalGroup | undefined): string { const name = group?.name ?? ''; return name.trim() === '' ? MATRIX_OHNE_NAMEN : name; } /** * Taugen die Namen ueberhaupt als Spaltenmarken? * * Eine Spaltenmarke muss die Signalgruppe EINDEUTIG benennen; sonst laesst sich * eine Zahl nicht mehr ihrem Paar zuordnen, und die Matrix ist als Nachweis * wertlos. Zwei Faelle nehmen ihr das: * * - Ein LEERER Name laesst den Kopf unbeschriftet. Sind alle leer, stand die * Matrix bisher ganz ohne Beschriftung da - bei 12 Gruppen 132 Zahlen, zu * denen die Unterlage nicht sagt, wozu sie gehoeren. * - ZWEI GLEICHE Namen setzen zweimal dieselbe Marke ueber zwei verschiedene * Spalten. Die Pruefung meldet das als Fehler (signalgruppen.name-doppelt), * aber die Unterlage wird trotzdem erzeugt - sie ist ja gerade das, woran * der Planer den Fehler sieht. Dann darf sie ihn nicht durch eine * mehrdeutige Beschriftung verstecken. * * Verglichen wird wie in der Pruefung (engine.ts): ohne Rand und ohne * Gross-/Kleinschreibung. Sonst nennte die Unterlage zwei Spalten gleich, die * die Pruefung als gleich beanstandet - oder umgekehrt. * * EINEN LEEREN NAMEN MELDET DIE PRUEFUNG INZWISCHEN AUCH: seit 5.8.0 gibt es * signalgruppen.name-leer, Schweregrad Fehler wie bei der Doppelung. Die Luecke * im Fachkern, von der hier frueher stand, sie bleibe eine, ist geschlossen - * die Ursache wird an ihrer Stelle behandelt. * * DIE VORKEHRUNG HIER BLEIBT TROTZDEM NOETIG, denn buildProjectPdf sperrt sich * nicht selbst: Der Pruefbericht wird ihm als INHALT gereicht, nicht als * Freigabe, und report.exportBlocked fragt es an keiner Stelle ab. Die Sperre * sitzt allein in der Ansicht "Ausgabe" (exportView.ts). Wer an ihr vorbei * ausgibt, bekommt die Matrix - und dann muss sie lesbar sein. * * In beiden Faellen treten Ordnungsnummern an die Stelle der Namen: Die Nummer * ist immer da und immer eindeutig, und die Zuordnung ueber der Tabelle nennt * den Namen dazu - auch den doppelten, auch den fehlenden. Erfunden wird nichts. */ /** * Warum die Spaltenkoepfe Ordnungsnummern tragen - der KONKRETE Grund. * * "eine fehlt oder zwei lauten gleich" waere in einer Anordnungsunterlage eine * Zumutung: Der Leser muesste selbst nachsehen, was von beidem zutrifft. Der * Absatz nennt deshalb den Fall, der wirklich vorliegt - notfalls beide. */ function matrixKopfgrund(groups: readonly SignalGroup[]): string { const leer = groups.some((g) => g.name.trim() === ''); const gesehen = new Set(); let doppelt = false; for (const group of groups) { const marke = group.name.trim().toLowerCase(); if (marke === '') continue; if (gesehen.has(marke)) doppelt = true; gesehen.add(marke); } if (leer && doppelt) { return ( 'weil in diesem Plan mindestens eine Signalgruppe keine Bezeichnung trägt und zwei ' + 'weitere dieselbe tragen' ); } if (leer) return 'weil in diesem Plan mindestens eine Signalgruppe keine Bezeichnung trägt'; if (doppelt) return 'weil in diesem Plan zwei Signalgruppen dieselbe Bezeichnung tragen'; return 'weil die Namen dieses Plans für einen Spaltenkopf zu lang sind'; } function matrixMarkenSindEindeutig(groups: readonly SignalGroup[]): boolean { const gesehen = new Set(); for (const group of groups) { const marke = group.name.trim().toLowerCase(); if (marke === '') return false; if (gesehen.has(marke)) return false; gesehen.add(marke); } return true; } interface MatrixSatz { /** Traegt der Kopf Ordnungsnummern statt der Namen? */ readonly mitNummern: boolean; /** Breite der Nummernspalte des Zeilenkopfs (0, wenn ohne Nummern). */ readonly nummernspalte: number; /** Breite der Namensspalte des Zeilenkopfs. */ readonly namensspalte: number; /** Wie viele einfahrende Signalgruppen ein Ausschnitt hoechstens traegt. */ readonly spaltenJeBlock: number; /** * Wie viele Zeilen der Zeilenkopf hoechstens traegt - laengere Namen werden * dort mit Auslassungszeichen gekuerzt. */ readonly zeilenkopfZeilen: number; /** Musste dafuer wirklich gekuerzt werden? Dann sagt es der Ausdruck. */ readonly zeilenkopfGekuerzt: boolean; /** * Steht die Zuordnung der Nummern im wiederholten Tabellenkopf - also auf * jedem Blatt des Ausschnitts? * * Sie steht dort, so lange sie dorthin passt. Ist sie fuer sich schon hoeher * als ein Blatt, laesst sie sich nicht wiederholen: Eine Kopfzelle, die * hoeher ist als die Seite, bringt jspdf-autotable zum Absturz. Dann steht * sie ungekuerzt als Fliesstext vor dem Ausschnitt. */ readonly zuordnungImKopf: boolean; } /** * Aufteilung der Matrix auf Ausschnitte - GLEICHMAESSIG, nicht "voll, voll, * Rest". * * Bei 13 Signalgruppen und 12 moeglichen Spalten je Blatt entstuende sonst ein * Ausschnitt mit einer einzigen Spalte; das sieht wie ein Druckfehler aus und * laesst den Leser suchen, ob er ein Blatt verloren hat. */ function matrixAufteilung(anzahl: number, hoechstens: number): number[] { const bloecke = Math.max(1, Math.ceil(anzahl / Math.max(1, hoechstens))); const grundmass = Math.floor(anzahl / bloecke); const rest = anzahl % bloecke; return Array.from({ length: bloecke }, (_, i) => grundmass + (i < rest ? 1 : 0)); } /** * Satzmass der Matrix: Spaltenbreiten und Zahl der Ausschnitte. * * Der Kopf traegt die NAMEN, solange sie als Bezeichnung taugen; Ordnungsnummern * treten an ihre Stelle, wenn einer breiter ist als * MATRIX_KOPFNAME_HOECHSTBREITE ODER wenn die Namen nicht eindeutig sind (leer * oder doppelt, siehe matrixMarkenSindEindeutig). Die Nummer ist keine * erfundene Kurzbezeichnung, sondern die Stellung der Gruppe in dieser * Unterlage: dieselbe Reihenfolge wie in der Tabelle "Signalgruppen" und in den * Zeilen dieser Matrix, in denen Nummer und voller Name nebeneinander stehen. * Ein ausgedachtes Kuerzel ("K3") koennte dagegen auf den Plannamen "K3" einer * ANDEREN Gruppe treffen - in einer Anordnungsunterlage ist das der Fehler, den * man nicht machen darf. */ function matrixSatz( context: Context, groups: readonly SignalGroup[], zellen: readonly (readonly string[])[], ): MatrixSatz { const { doc } = context; const breitesteZahl = matrixHoechsteZellbreite(zellen, (z) => matrixBreite(doc, z, false)); const breitesterName = Math.max(...groups.map((g) => matrixBreite(doc, g.name, true))); const mitNummern = breitesterName > MATRIX_KOPFNAME_HOECHSTBREITE || !matrixMarkenSindEindeutig(groups); const kopfmarke = mitNummern ? matrixBreite(doc, String(groups.length), true) : breitesterName; const wertbreite = Math.max(breitesteZahl, kopfmarke) + 2 * MATRIX_POLSTER; const nummernspalte = mitNummern ? Math.max(matrixBreite(doc, 'Nr.', true), matrixBreite(doc, String(groups.length), true)) + 2 * MATRIX_POLSTER : 0; // Die Namensspalte traegt den Zeilenkopf und die Kopfecke; sie ist so breit // wie noetig, aber nie breiter als MATRIX_NAMENSSPALTE_HOECHSTBREITE - der // Rest des Blattes gehoert den Zahlen. const eckenbreite = matrixBreite(doc, MATRIX_ECKE, true) + 2 * MATRIX_POLSTER; const namensspalte = Math.min( MATRIX_NAMENSSPALTE_HOECHSTBREITE, Math.max(eckenbreite, breitesterName + 2 * MATRIX_POLSTER), ); const vorspalten = nummernspalte + namensspalte; const fuerWerte = context.contentWidth - vorspalten; /* * Zweite Schranke, und die eigentlich wichtige: Kopf UND eine Koerperzeile * muessen zusammen auf ein Blatt passen. Genau daran scheitert der heutige * Satz - autotable meldet dann "minimum height is larger than page height" * und laeuft sich in printFullRow tot. Die Zuordnungsliste im Kopf waechst * mit der Zahl der Spalten je Ausschnitt; passt sie nicht mehr, wird der * Ausschnitt schmaler, nicht die Zeile hoeher. * * GEPRUEFT WIRD, WAS GESETZT WIRD (Fassung 5.8.0). Die Probe rechnete bis * dahin mit dem ersten Ausschnitt und mit "bloecke = 1". Gesetzt wird aber * mit der wirklichen Zahl der Ausschnitte, und deren Banner traegt eine Zeile * mehr ("Ausschnitt k von m ...") sowie den laengeren Text "Nummern der * Spaltenkoepfe IN DIESEM AUSSCHNITT:". Der gepruefte Kopf war dadurch * durchweg 9,78 pt niedriger als der gedruckte - bei n=40 mit langen Namen * 211,27 pt statt 221,05 pt. Es blieb in allen gemessenen Faellen Reserve, * aber es ist die eine Rechnung, die den Einsturz verhindern soll; sie muss * mit dem rechnen, was auf dem Blatt landet. */ const nutzhoehe = nutzhoeheJeBlatt(context); const zeilenkopfNoetig = Math.max( ...groups.map((g) => matrixZeilen(doc, matrixAnzeigename(g), namensspalte - 2 * MATRIX_POLSTER, false), ), ); let zeilenkopfZeilen = zeilenkopfNoetig; let zuordnungImKopf = true; let spaltenJeBlock = Math.max(1, Math.floor(fuerWerte / wertbreite)); while (spaltenJeBlock > 1) { const kopfhoehe = matrixHoechsteKopfhoehe(context, groups, spaltenJeBlock, mitNummern, true); if (kopfhoehe + matrixZellhoehe(zeilenkopfZeilen) <= nutzhoehe) break; spaltenJeBlock -= 1; } /* * Hier war vor der Fassung 5.11.0 Schluss - die Schleife verliess die * Schranke bei einer einzigen Spalte je Ausschnitt UNGEPRUEFT. Genau diesen * Fall konnte sie mit ihrem Hebel gar nicht erreichen: Die Koerperzeile ist so * hoch, wie der Zeilenkopf den Namen in der Namensspalte umbricht, und weniger * Spalten machen ihn nicht niedriger. Ein Name von 7.500 Zeichen ergibt dort * rund 1.800 pt gegen 451 pt Nutzhoehe; jspdf-autotable lief damit in * printFullRow in die Endlosteilung und die ganze Ausgabe brach mit * "RangeError: Maximum call stack size exceeded" ab - ohne Unterlage, ohne * Pruefbericht, ohne verwertbaren Hinweis. * * ZWEI HEBEL, IN DIESER REIHENFOLGE, denn nur einer von beiden darf etwas * wegnehmen: * * 1. Der ZEILENKOPF wird gekuerzt. Dort steht der Name ein zweites Mal - die * volle Fassung bleibt in der Zuordnung, und der Ausdruck sagt, dass * gekuerzt wurde. * 2. Reicht das nicht, weil schon die Zuordnung allein hoeher ist als ein * Blatt, verlaesst sie den Tabellenkopf und steht als Fliesstext vor dem * Ausschnitt - ungekuerzt. Sie zu kuerzen waere der eine Weg, der nicht * gangbar ist: Sie ist der Nachweis, mit dem sich ein einzelnes Blatt ohne * ein zweites aufloesen laesst. */ const mitZuordnung = matrixHoechsteKopfhoehe(context, groups, spaltenJeBlock, mitNummern, true); if (mitZuordnung + matrixZellhoehe(zeilenkopfZeilen) > nutzhoehe) { zuordnungImKopf = mitZuordnung + matrixZellhoehe(1) <= nutzhoehe; const kopfhoehe = zuordnungImKopf ? mitZuordnung : matrixHoechsteKopfhoehe(context, groups, spaltenJeBlock, mitNummern, false); zeilenkopfZeilen = Math.max( 1, Math.floor( (nutzhoehe - kopfhoehe - 2 * MATRIX_POLSTER) / (MATRIX_SCHRIFTGRAD * MATRIX_ZEILENFAKTOR), ), ); } return { mitNummern, nummernspalte, namensspalte, spaltenJeBlock, zeilenkopfZeilen, zeilenkopfGekuerzt: zeilenkopfZeilen < zeilenkopfNoetig, zuordnungImKopf, }; } /** * Der Name im Zeilenkopf, auf die zulaessige Zahl von Zeilen gekuerzt. * * Halbierend statt zeichenweise - der Name hat keine Laengengrenze. Gekuerzt * wird mit sichtbarer Auslassung; ein stillschweigend abgeschnittener Name * waere in einer Anordnungsunterlage das Gegenteil eines Hinweises. */ function matrixZeilenkopf( context: Context, name: string, breite: number, hoechstzeilen: number, ): string { const { doc } = context; if (matrixZeilen(doc, name, breite, false) <= hoechstzeilen) return name; const auslassung = '...'; let unten = 0; let oben = name.length; while (unten < oben) { const mitte = Math.ceil((unten + oben) / 2); const probe = `${name.slice(0, mitte)}${auslassung}`; if (matrixZeilen(doc, probe, breite, false) <= hoechstzeilen) unten = mitte; else oben = mitte - 1; } return `${name.slice(0, unten).trimEnd()}${auslassung}`; } /** * Die hoechste Kopfhoehe, die bei dieser Aufteilung vorkommt. * * Nicht der erste Ausschnitt, sondern der SCHLIMMSTE: Die Banner der * Ausschnitte tragen verschiedene Namen und verschieden breite Nummern, und * die Schranke muss fuer jedes Blatt halten, nicht nur fuer das erste. */ function matrixHoechsteKopfhoehe( context: Context, groups: readonly SignalGroup[], spaltenJeBlock: number, mitNummern: boolean, mitZuordnung: boolean, ): number { const groessen = matrixAufteilung(groups.length, spaltenJeBlock); let hoechste = 0; let von = 0; for (let block = 0; block < groessen.length; block += 1) { const anzahl = groessen[block] ?? 0; hoechste = Math.max( hoechste, matrixKopfhoehe( context, groups, von, anzahl, mitNummern, block + 1, groessen.length, mitZuordnung, ), ); von += anzahl; } return hoechste; } /** * Der Banner ueber einem Ausschnitt - er steht als KOPFZEILE in der Tabelle * und wird deshalb von autotable auf JEDER Seite des Ausschnitts wiederholt. * * Das ist die Antwort auf die Auflage, die Zuordnung im Dokument selbst * aufloesbar zu halten: Wer ein einzelnes Blatt in der Hand haelt, liest ueber * der Tabelle, welcher Ausschnitt es ist und - bei Ordnungsnummern - welche * Nummer zu welchem Namen gehoert. Ein Verweis auf eine Liste an anderer Stelle * verlangte, ein zweites Blatt danebenzulegen. * * `mitZuordnung = false` ist der eine Fall, in dem das nicht geht: Ist die * Zuordnung fuer sich schon hoeher als ein Blatt, laesst sie sich nicht auf * jedem Blatt wiederholen - eine Kopfzelle ueber Blatthoehe bringt * jspdf-autotable zum Absturz. Sie steht dann ungekuerzt als Fliesstext vor dem * Ausschnitt, und der Banner sagt das. */ function matrixBanner( context: Context, groups: readonly SignalGroup[], von: number, anzahl: number, mitNummern: boolean, block: number, bloecke: number, mitZuordnung: boolean, ): { text: string; namenUngeteilt: boolean } { const zeilen: string[] = []; let namenUngeteilt = true; if (bloecke > 1) { zeilen.push( `Ausschnitt ${block} von ${bloecke} · einfahrende Signalgruppen ${von + 1} bis ` + `${von + anzahl} von ${groups.length}`, ); } if (mitNummern && !mitZuordnung) { zeilen.push(MATRIX_ZUORDNUNG_VOR_TABELLE); } else if (mitNummern) { zeilen.push( bloecke > 1 ? 'Nummern der Spaltenköpfe in diesem Ausschnitt:' : 'Nummern der Spaltenköpfe:', ); for (let i = von; i < von + anzahl; i += 1) { const teile = matrixZuordnungszeilen(context, i + 1, matrixAnzeigename(groups[i])); if (teile.length > 1) namenUngeteilt = false; zeilen.push(...teile); } } // Erst umschreiben, dann verbinden: toWinAnsi laesst den Zeilenumbruch // stehen (untere Grenze der Klasse ist \x00), die Zeilen bleiben also // getrennt. return { text: zeilen.map(toWinAnsi).join('\n'), namenUngeteilt }; } /** * Eine Zeile der Zuordnung "7 = " - und, wenn der Name dafuer zu lang * ist, ihre Fortsetzungszeilen. * * WARUM DIESE FUNKTION EXISTIERT (Fassung 5.8.0): Bis dahin entstand die Zeile * als eine Zeichenkette, und jspdf-autotable brach sie um, wenn sie nicht in * die Bannerbreite passte - gemessen ab rund 180 Zeichen Namenslaenge. Die * Fortsetzungszeile trug dann KEINE Nummer mehr: Auf dem Blatt stand ein * Namensrest, der zu jeder der aufgefuehrten Nummern gehoeren konnte. Damit war * die Zuordnung genau in dem Fall nicht mehr aufloesbar, fuer den sie da ist. * * Der Umbruch geschieht deshalb hier und nicht in der Bibliothek, und jede * Fortsetzungszeile wiederholt die Nummer mit Fortsetzungszeichen ("7 … Rest"). * Gebrochen wird an Wortgrenzen, solange es welche gibt; ein einzelnes Wort, * das laenger ist als die Zeile, teilt splitTextToSize weiterhin - dann ist der * Name zerteilt, aber er bleibt seiner Nummer zugeordnet, und der Abschnitt * "Barrierefreiheit dieser Datei" sagt es (siehe MatrixSatzbefund). */ function matrixZuordnungszeilen(context: Context, nummer: number, name: string): string[] { const { doc } = context; const breite = context.contentWidth - 2 * MATRIX_POLSTER; const marke = `${nummer} = `; const fortsetzung = `${nummer} … `; const schrift = doc.getFont() as { fontName: string; fontStyle: string }; const grad = doc.getFontSize(); doc.setFont('helvetica', 'normal'); doc.setFontSize(MATRIX_SCHRIFTGRAD); const markenbreite = doc.getTextWidth(toWinAnsi(marke)); const fortsetzungsbreite = doc.getTextWidth(toWinAnsi(fortsetzung)); // Die erste Zeile traegt "7 = ", jede weitere "7 … " - beide Marken kosten // Platz, und der Rest des Namens muss in den Rest der Zeile passen. const rest = Math.max(1, breite - Math.max(markenbreite, fortsetzungsbreite)); const stuecke = doc.splitTextToSize(toWinAnsi(name), rest) as string[]; doc.setFont(schrift.fontName, schrift.fontStyle); doc.setFontSize(grad); if (stuecke.length <= 1) return [`${marke}${name}`]; return stuecke.map((teil, i) => (i === 0 ? `${marke}${teil}` : `${fortsetzung}${teil}`)); } /** Hoehe des Banners samt Kopfzeile - fuer die Schranke und fuer ensureSpace. */ function matrixKopfhoehe( context: Context, groups: readonly SignalGroup[], von: number, anzahl: number, mitNummern: boolean, block: number, bloecke: number, mitZuordnung: boolean, ): number { const banner = matrixBanner( context, groups, von, anzahl, mitNummern, block, bloecke, mitZuordnung, ).text; const bannerhoehe = banner === '' ? 0 : matrixZellhoehe( matrixZeilen(context.doc, banner, context.contentWidth - 2 * MATRIX_POLSTER, false), ); const kopfmarken = mitNummern ? 1 : Math.max( ...groups .slice(von, von + anzahl) .map((g) => matrixZeilen(context.doc, g.name, MATRIX_KOPFNAME_HOECHSTBREITE, true)), ); return bannerhoehe + matrixZellhoehe(Math.max(kopfmarken, 1)); } /** * Traegt der heutige Satz - eine Tabelle ueber die ganze Breite - die Matrix * noch? * * Vier Fragen, und alle vier sind Fragen nach dem SCHADEN, nicht nach dem * Geschmack. Solange keine davon mit Nein beantwortet wird, bleibt der Satz * Zeichen fuer Zeichen der bisherige. * * WO DER UMSCHLAGPUNKT LIEGT, HAENGT AN DEN DATEN, nicht an einer festen Zahl * (Fassung 5.8.0 - hier stand vorher "bis 31 unveraendert, ab 32", die * Unterlagen nannten 34/35, gemessen war beides nicht dasselbe Projekt). Er * haengt an der breitesten vorkommenden Zwischenzeit und an der breitesten * Bezeichnung, denn beide bestimmen die Mindestbreite einer Spalte. Fuer * Plannamen "K1"…"Kn" und zweistellige Zwischenzeiten liegt er bei dieser * Fassung zwischen n = 35 und n = 36; ein Projekt mit einer dreistelligen * Vorgabe schlaegt frueher um. Wer eine Zahl braucht, misst sie fuer SEIN * Projekt - die Schranke rechnet sie ohnehin selbst aus. */ function matrixPasstAufEinBlatt( context: Context, groups: readonly SignalGroup[], zellen: readonly (readonly string[])[], ): boolean { const { doc } = context; /* * (0) Die Spaltenmarken muessen die Signalgruppe eindeutig benennen. Sind sie * leer oder doppelt, traegt der heutige Satz eine mehrdeutige - oder gar * keine - Beschriftung, und keine Breitenrechnung repariert das. Dann * uebernimmt der Ausschnittsatz mit Ordnungsnummern. */ if (!matrixMarkenSindEindeutig(groups)) return false; /* * (1) und (2): KEINE ZAHL UND KEIN NAME DARF MITTEN IM WORT UMBRECHEN. * Eine umgebrochene Zwischenzeit ist keine schlechtere Darstellung, sie * ist eine andere Zahl ("188*" als "18" ueber "8*"). * * GERECHNET WIRD MIT DEM VERFAHREN DER BIBLIOTHEK, nicht mit einem * Anteil. jspdf-autotable * bestimmt die Spaltenbreiten in calculateWidths in zwei Durchgaengen: * Durchgang 1 verkleinert nur bis max(minReadableWidth, minWidth), wobei * minReadableWidth = laengstes Wort + Polster und minWidth = 10 pt ist; * erst Durchgang 2 unterschreitet minReadableWidth - und GENAU DORT * bricht ein Wort. Kein Bruch im Wort heisst also: * * Summe ueber alle Spalten von max(laengstesWort + 2*Polster, 10 pt) * <= Satzbreite * * Das ist eine Bedingung an die SUMME, nicht an die einzelne Spalte. * Frueher stand hier je Spalte ein Vergleich gegen contentWidth/(n+1) - * also gegen den gleichen Anteil fuer alle. Beides faellt auseinander, * sobald eine Spalte mehr als ihren Anteil braucht, und die Kopfecke tut * das: Ihr laengstes Wort ("räumt", 23,55 pt) kostet mit Polster 29,55 pt, * waehrend der Anteil ab n = 26 darunter faellt. Der alte Vergleich liess * die Ecke ausdruecklich aussen vor - aber autotable verteilt den Fehlbetrag * auf ALLE Spalten, nicht nur auf die Ecke. Gemessen bei n=40 mit einem * Namen 0,03 pt unter dem Anteil: Die Wertespalten wurden 18,53 pt breit * statt der noetigen 18,75 pt - 0,22 pt zu schmal. Es brach dort noch * nichts, aber es hing an einer Rundung, nicht an der Rechnung. */ const breitesteZahl = matrixHoechsteZellbreite(zellen, (z) => laengstesWort(doc, z, false)); /* * Die erste Spalte traegt ZWEIERLEI: oben die Kopfecke (fett), darunter die * Namen der raeumenden Signalgruppen (mager). autotable nimmt je Spalte das * Groesste ueber alle ihre Zellen - beides gehoert also hinein. * * Die Wertespalten rechnen mit der breitesten Zahl der GANZEN Matrix statt * mit der breitesten ihrer eigenen Spalte. Das ist grosszuegiger als noetig * und damit die sichere Seite. */ const namensspaltenwort = Math.max( laengstesWort(doc, MATRIX_ECKE, true), ...groups.map((g) => laengstesWort(doc, g.name, false)), ); const mindestbreiten = [ Math.max(namensspaltenwort + 2 * MATRIX_POLSTER, MATRIX_MINDESTBREITE), ...groups.map((g) => Math.max( Math.max(laengstesWort(doc, g.name, true), breitesteZahl) + 2 * MATRIX_POLSTER, MATRIX_MINDESTBREITE, ), ), ]; const noetig = mindestbreiten.reduce((summe, b) => summe + b, 0); if (noetig > context.contentWidth) return false; /* * (3) Die Kopfzeile darf nicht das halbe Blatt fuellen. Waechst sie ueber die * Blatthoehe, laeuft jspdf-autotable in printFullRow in eine Endlosteilung * und der Ausdruck bricht ab. * * Gerechnet wird mit der MINDESTBREITE der Spalte, nicht mit dem Anteil: * Steht (1)/(2) fest, bekommt jede Spalte mindestens diese Breite, meist * mehr. Die Zeilenzahl ist damit eine obere Schranke - die Rechnung irrt * also in die sichere Richtung, waehrend der Anteil auch zu gross sein * konnte. */ const nutzhoehe = nutzhoeheJeBlatt(context); const kopfzeilen = Math.max( ...groups.map((g, i) => matrixZeilen(doc, g.name, (mindestbreiten[i + 1] ?? 0) - 2 * MATRIX_POLSTER, true), ), ); return matrixZellhoehe(kopfzeilen) <= nutzhoehe / 2; } /** Der bisherige Satz - unveraendert, damit kurze Bezeichnungen es bleiben. */ function zeichneMatrixHeute( context: Context, groups: readonly SignalGroup[], zellen: readonly (readonly string[])[], ): void { const { doc } = context; autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: MATRIX_SCHRIFTGRAD, cellPadding: MATRIX_POLSTER, halign: 'center' }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, columnStyles: { 0: { fontStyle: 'bold', halign: 'left' } }, head: [[MATRIX_ECKE, ...groups.map((g) => g.name)].map(toWinAnsi)], body: groups.map((from, i) => [from.name, ...(zellen[i] ?? [])].map(toWinAnsi)), }); context.cursor = lastTableBottom(doc) + 10; } /** * Der Ausschnittsatz: feste Spaltenbreiten, waagerechte Aufteilung, und - wenn * die Namen fuer einen Spaltenkopf zu lang sind - Ordnungsnummern im Kopf mit * der Zuordnung im wiederholten Tabellenkopf. * * WAS ER DER UNTERLAGE NIMMT, damit es niemand uebersieht: Der Blick auf die * ganze Matrix auf einem Blatt geht verloren, sobald sie geteilt wird. Er war * allerdings schon vorher nur nominell da - der heutige Satz verteilt dieselbe * Matrix bei 32 Gruppen mit langen Namen auf 113 Blatt und bei 40 auf gar * keines mehr. */ function zeichneMatrixAusschnitte( context: Context, groups: readonly SignalGroup[], zellen: readonly (readonly string[])[], mitSignalgruppentabelle: boolean, ): void { const { doc } = context; const satz = matrixSatz(context, groups, zellen); const groessen = matrixAufteilung(groups.length, satz.spaltenJeBlock); const bloecke = groessen.length; let namenUngeteilt = true; if (bloecke > 1) { writeParagraph( context, `Die Matrix ist für die Blattbreite zu breit und steht deshalb in ${bloecke} Ausschnitten. ` + `Jeder Ausschnitt führt alle ${groups.length} räumenden Signalgruppen in derselben ` + 'Reihenfolge und einen anderen Teil der einfahrenden; welchen, steht über jedem ' + 'Ausschnitt auf jeder Seite. Jede Zwischenzeit steht in genau einem Ausschnitt - erst ' + `alle ${bloecke} Ausschnitte zusammen sind die vollständige Matrix.`, ); } if (satz.mitNummern) { /* * Der Grund gehoert dazu, und es sind zwei verschiedene (Befunde B3/B4 * fachlich): zu lange Namen - oder Namen, die als Marke nicht taugen, weil * einer fehlt oder zwei gleich lauten. Im zweiten Fall ist der Absatz * zugleich der Hinweis, dass mit den Daten etwas nicht stimmt; die Pruefung * sagt es an ihrer Stelle noch einmal deutlicher. */ const grund = matrixKopfgrund(groups); /* * Der Verweis auf die Tabelle "Signalgruppen" steht nur da, wenn es sie in * DIESER Ausgabe gibt (Befund B5 technisch): Sonst schickt der Absatz den * Leser zu einem Abschnitt, den er nicht hat. Die Aufloesung selbst braucht * ihn nicht - sie steht in den Zeilen der Matrix und ueber jeder Tabelle. */ const herkunft = mitSignalgruppentabelle ? 'sondern die Stellung der Signalgruppe in dieser Unterlage: dieselbe Nummer, die in der ' + 'Tabelle „Signalgruppen" in der Spalte „Nr." steht, und dieselbe Reihenfolge wie in den ' + 'Zeilen dieser Matrix, die Nummer und vollen Namen nebeneinander führen.' : 'sondern die Stellung der Signalgruppe in dieser Unterlage: dieselbe Reihenfolge wie in ' + 'den Zeilen dieser Matrix, die Nummer und vollen Namen nebeneinander führen.'; writeParagraph( context, `Die Spaltenköpfe tragen die Ordnungsnummer der einfahrenden Signalgruppe, ${grund}. ` + `Die Nummer ist keine neu vergebene Kurzbezeichnung, ${herkunft} ` + (satz.zuordnungImKopf ? 'Welche Nummer zu welchem Namen gehört, steht über der Tabelle auf jeder Seite. ' : 'Welche Nummer zu welchem Namen gehört, steht vor jedem Ausschnitt; die Zuordnung ' + 'ist länger als ein Blatt und lässt sich deshalb nicht auf jedem Blatt wiederholen. ') + 'Die Tabellenausgabe (CSV) führt dieselbe Matrix mit den vollen Namen in beiden Achsen.', ); } /* * Die Kuerzung des Zeilenkopfs wird gesagt, nicht stillschweigend vollzogen. * Ohne den Satz stuende in der Anordnungsunterlage eine Bezeichnung mit * Auslassungszeichen, und der Leser wuesste nicht, wo die vollstaendige * steht. */ if (satz.zeilenkopfGekuerzt) { writeParagraph( context, 'Die Zeilenköpfe dieser Matrix sind gekürzt: Ein Name, der in der Namensspalte höher ' + 'umbricht als ein Blatt trägt, würde die Tabelle unsetzbar machen. Gekürzte Namen enden ' + 'mit "..."; vollständig stehen sie in der Zuordnung der Spaltenköpfe und in der ' + 'Tabellenausgabe (CSV).', ); } let von = 0; for (let block = 0; block < bloecke; block += 1) { const anzahl = groessen[block] ?? 0; if (anzahl === 0) continue; const spalten = groups.slice(von, von + anzahl); const wertbreite = (context.contentWidth - satz.nummernspalte - satz.namensspalte) / anzahl; const banner = matrixBanner( context, groups, von, anzahl, satz.mitNummern, block + 1, bloecke, satz.zuordnungImKopf, ); if (!banner.namenUngeteilt) namenUngeteilt = false; // Passt die Zuordnung nicht in den wiederholten Kopf, steht sie ungekuerzt // hier - als Fliesstext, der so viele Folgeblaetter bekommt wie noetig. if (satz.mitNummern && !satz.zuordnungImKopf) { const zeilen: string[] = []; for (let i = von; i < von + anzahl; i += 1) { const teile = matrixZuordnungszeilen(context, i + 1, matrixAnzeigename(groups[i])); if (teile.length > 1) namenUngeteilt = false; zeilen.push(...teile); } writeParagraph( context, `Zuordnung der Spaltenköpfe für Ausschnitt ${block + 1} von ${bloecke}:`, ); doc.setFont('helvetica', 'normal'); doc.setFontSize(MATRIX_SCHRIFTGRAD); doc.setTextColor('#1b2230'); setzeZeilenMitUmbruch( context, zeilen.map(toWinAnsi), MATRIX_SCHRIFTGRAD * MATRIX_ZEILENFAKTOR, ); context.cursor += 8; } const kopf: RowInput[] = []; if (banner.text !== '') { const zelle: CellDef = { content: banner.text, colSpan: anzahl + (satz.mitNummern ? 2 : 1), styles: { halign: 'left', fontStyle: 'normal' }, }; kopf.push([zelle]); } kopf.push( [ ...(satz.mitNummern ? ['Nr.'] : []), MATRIX_ECKE, ...spalten.map((g, i) => (satz.mitNummern ? String(von + i + 1) : g.name)), ].map(toWinAnsi), ); const koerper: RowInput[] = groups.map((from, i) => [ ...(satz.mitNummern ? [String(i + 1)] : []), // Auch der Zeilenkopf sagt lieber "(ohne Bezeichnung)" als nichts: Eine // leere Zelle liest sich wie ein Druckfehler, und eine Sprachausgabe // ueberspringt sie ganz. Zu hohe Namen werden hier gekuerzt - siehe // matrixSatz. matrixZeilenkopf( context, matrixAnzeigename(from), satz.namensspalte - 2 * MATRIX_POLSTER, satz.zeilenkopfZeilen, ), ...(zellen[i] ?? []).slice(von, von + anzahl), ].map(toWinAnsi), ); const spaltenmasse: Record> = {}; let s = 0; if (satz.mitNummern) { spaltenmasse[s] = { cellWidth: satz.nummernspalte, fontStyle: 'bold', halign: 'right' }; s += 1; } spaltenmasse[s] = { cellWidth: satz.namensspalte, fontStyle: 'bold', halign: 'left' }; s += 1; for (let k = 0; k < anzahl; k += 1) { spaltenmasse[s + k] = { cellWidth: wertbreite }; } // Der Ausschnitt soll nicht mit seinem Kopf am Blattfuss beginnen. ensureSpace( context, matrixKopfhoehe( context, groups, von, anzahl, satz.mitNummern, block + 1, bloecke, satz.zuordnungImKopf, ) + 24, ); autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: MATRIX_SCHRIFTGRAD, cellPadding: MATRIX_POLSTER, halign: 'center', overflow: 'linebreak', }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, columnStyles: spaltenmasse, /* * Eine Zeile bleibt auf einem Blatt. Ohne das zerreisst der Blattwechsel * den Namen der raeumenden Signalgruppe mitten im Wort, und auf dem * Folgeblatt steht eine Zeile Zahlen ueber einem Namensrest. */ rowPageBreak: 'avoid', head: kopf, body: koerper, }); context.cursor = lastTableBottom(doc) + 10; von += anzahl; } context.matrixSatzbefund = { mitOrdnungsnummern: satz.mitNummern, namenUngeteilt, zuordnungAufJedemBlatt: satz.zuordnungImKopf, }; } function drawIntergreenMatrix(context: Context, mitSignalgruppentabelle: boolean): void { const { project, plan } = context; const groups = project.signalGroups; if (groups.length === 0) { writeParagraph(context, 'Es sind keine Signalgruppen erfasst.'); return; } const zellen = groups.map((from) => groups.map((to) => matrixWert(plan, from, to))); if (matrixPasstAufEinBlatt(context, groups, zellen)) { zeichneMatrixHeute(context, groups, zellen); // Der heutige Satz traegt die Namen selbst im Kopf; eine Zuordnungsliste // gibt es dort nicht, und "ungeteilt" ist fuer sie keine Frage. context.matrixSatzbefund = { mitOrdnungsnummern: false, namenUngeteilt: true, zuordnungAufJedemBlatt: true, }; } else { zeichneMatrixAusschnitte(context, groups, zellen, mitSignalgruppentabelle); } writeParagraph( context, 'Angaben in Sekunden. Ein Punkt bedeutet: die Signalgruppen sind verträglich, es ist keine ' + 'Konfliktbeziehung erfasst. Mit Stern gekennzeichnete Werte sind von Hand vorgegeben und ersetzen ' + 'die Berechnung.', ); /* * Der Rechenansatz der Ueberfahrzeit gehoert UNTER DIE MATRIX und nicht nur * in den Rechenweg (Fassung 5.5.0, Frage E2): Der Rechenweg * ist abwaehlbar, die Matrix steht dann allein da - und dieselbe Zahl hat je * nach Ansatz eine andere Ueberfahrzeit hinter sich. Der Satz kommt aus * bewertung.ts, damit Ausdruck und Bildschirm nicht auseinanderlaufen, und * rechnet mit den Vorgaben DIESES Plans (Befund C21). * * NUR, WO ER IN EINE ZAHL DIESER MATRIX EINGEHT (seit 5.43.0): Der Satz stand * unbedingt da - auch an einer einstreifigen Verkehrsfuehrung, wo * `buildSignalPlan` jeden raeumenden Kfz-Strom nach dem Verfahren der * Engstellensignalisierung rechnet und die Ueberfahrzeit fest bei tue = 4 s * liegt. Die Unterlage nannte dort eine Rechnung, die nicht stattgefunden * hat, neben den Zahlen, die der Pruefer nachrechnet. Weglassen allein waere * zu wenig: Ohne Rechenweg stuende die Matrix dann ohne jede Angabe zur * Ueberfahrzeit da - genau der Grund, aus dem der Satz hier steht. An seine * Stelle tritt das Verfahren, das gerechnet hat. * * UND DER DRITTE FALL, den keine Anlagenart hergibt: Ist JEDE Zwischenzeit * eines raeumenden Kraftfahrzeugs von Hand vorgegeben - oder raeumt an keiner * Beziehung ein Kraftfahrzeug -, hat der Ansatz ebenfalls nicht gerechnet, * und hier steht dann keiner der beiden Saetze. Das ist gewollt: Dass die * Zahlen vorgegeben sind, sagt die Sternfussnote darueber, und den Ansatz * nennt in diesem Ausdruck allein das Deckblatt - dort mit dem * Wirkungsvermerk daneben. Ein zweiter Absatz ueber eine Rechnung, die es * nicht gab, steht besser nicht unter der Matrix. * * DIE ANTWORT KOMMT AUS DEM FACHKERN (ansaetze.ts), weil Deckblatt, * Abweichungstafel und Pruefbericht dieselbe brauchen; die Bedingung hier * nachzubauen war der Anfang der vier auseinanderlaufenden Fassungen. Bewacht * von tests/export/matrixVerfahrenssatz.test.ts. */ const ohneWirkung = ueberfahrzeitAnsatzOhneWirkung(project, plan); if (ohneWirkung === null) { writeParagraph( context, ueberfahrzeitAnsatzSatz(project.settings.ueberfahrzeitAnsatz, plan.defaults), ); } else if (verfahrenDerKfzRaeumstroeme(project, plan).has('engstelle')) { writeParagraph(context, ohneWirkung.begruendung); } } function drawIntergreenDetails(context: Context, mitSignalgruppentabelle: boolean): void { const { doc, project, plan } = context; const rows: string[][] = []; let mitAnfahransatz = false; /* * ZWEI WEITERE FUSSNOTEN, BEIDE AUS DEMSELBEN GRUND (Fassung 5.27.0): Der * Rechenweg muss sich aus den gedruckten Groessen nachrechnen lassen. * * `mitEngstellenverfahren`: An einer Engstelle gilt eine andere Formel * (RiLSA 2015, Abschnitt 5.2.2) - andere Ueberfahrzeit, Raeumweg ohne * Fahrzeuglaenge, keine Einfahrzeit. Wer die Zeile mit der * Knotenpunktformel nachrechnet, kommt auf eine andere Zahl und haelt den * Plan fuer falsch. * * `mitUntergrenze`: Wo tue + tr die Untergrenze tG + 1 s unterschreiten, * steht in der Spalte tz NICHT tue + tr - te, sondern die angehobene Summe * abzueglich te. Ohne die Fussnote waere die Zeile aus ihren eigenen * Spalten nicht nachzurechnen. */ let mitEngstellenverfahren = false; const untergrenzenZeilen: string[] = []; /* * Die raeumenden Signalgruppen des Anfahransatzes mit ihrer zulaessigen * Hoechstgeschwindigkeit - fuer den Fall, dass die Signalgruppentabelle * abgewaehlt ist und die Fussnote sie selbst nennen muss (Fassung 5.9.0). */ const anfahrGruppen = new Map(); for (const conflict of project.conflicts) { const from = project.signalGroups.find((g) => g.id === conflict.fromId); const to = project.signalGroups.find((g) => g.id === conflict.toId); const resolved = plan.intergreens.get(intergreenKey(conflict.fromId, conflict.toId)); if (!from || !to || !resolved) continue; const calc = resolved.calculation; if (calc !== null && calc.raeumansatz === 'anfahren') { mitAnfahransatz = true; // Der Name in Anfuehrungszeichen, damit Name und Zahl in der Aufzaehlung // nicht unverbunden hintereinander stehen. anfahrGruppen.set(from.id, `„${from.name}" ${fmt.numShort(from.vZul)} km/h`); } if (calc !== null && calc.verfahren === 'engstelle') mitEngstellenverfahren = true; if (calc !== null && calc.ueberfahrRaeumzeitUntergrenze !== null) { untergrenzenZeilen.push( `${from.name} -> ${to.name} (${fmt.seconds(calc.ueberfahrRaeumzeitUntergrenze, 1)} s)`, ); } rows.push( [ `${from.name} -> ${to.name}`, calc === null ? 'vorgegeben' : fmt.meters(calc.clearingPath, 1), /* * Beim Anfahransatz (OePNV mit Halt vor dem Knotenpunkt) gibt es * keine konstante Raeumgeschwindigkeit - aus einer gedruckten vr * liesse sich tr nicht nachrechnen. In der Spalte steht deshalb die * Anfahrbeschleunigung; die Formel nennt die Fussnote unter der * Tabelle. */ calc === null ? '–' : calc.raeumansatz === 'anfahren' ? `a = ${fmt.num(calc.anfahrbeschleunigung ?? 0, 1)} m/s²` : fmt.metersPerSecond(calc.clearingSpeed, 1), calc === null ? '–' : fmt.seconds(calc.clearingTime, 2), calc === null ? '–' : fmt.meters(calc.enteringPath, 1), calc === null ? '–' : fmt.metersPerSecond(calc.enteringSpeed, 1), calc === null ? '–' : fmt.seconds(calc.enteringTime, 2), calc === null ? '–' : fmt.seconds(calc.crossingTime, 1), calc === null ? '–' : fmt.seconds(calc.raw, 2), fmt.seconds(resolved.value), /* * NEU (Fassung 5.5.0, Frage E1): das Merkmal "enger * Innenradius" dieser Beziehung. Ohne die Spalte stuende in der Zeile * eine Raeumgeschwindigkeit von 5,0 m/s neben einem Regelwert von * 7,0 m/s, ohne dass die Unterlage sagt, woher der Unterschied kommt - * und ein Praxisansatz, der in der RiLSA 2015 nicht nachweisbar ist, * saehe aus wie ein Regelwerkswert. * * Die Spalte bleibt auch dann stehen, wenn keine Beziehung das Merkmal * traegt: Zwei Ausdrucke desselben Knotenpunkts sollen dieselbe * Tabellenform haben, sonst laesst sich der eine nicht neben den * anderen legen. */ engerRadiusZelle(conflict.engerRadius), // Zwei getrennte Spalten, und das mit Absicht. "Herkunft der Wege" // kommt aus dem Modell und wird von jeder Aenderung an den Wegen, den // Geschwindigkeiten, der Ueberfahrzeit oder der Zwischenzeit auf "Von // Hand eingetragen" zurueckgesetzt - der Anwender kann das Feld nicht // beschreiben. Die Bemerkung daneben ist freier Text und kann aelter // sein als der Wert neben ihr; sie war frueher der einzige Anhalt und // hat als Nachweis genau daran versagt. wegherkunftZelle(conflict), conflict.note === '' ? '–' : conflict.note, ].map(toWinAnsi), ); } autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 8, cellPadding: 2.5, valign: 'top' }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, columnStyles: { 10: { cellWidth: 84 }, 11: { cellWidth: 100 }, 12: { cellWidth: 130 } }, head: [ [ 'Beziehung', 'sr', 'vr', 'tr', 'se', 've', 'te', 'tü', 'tü+tr-te', 'tz', 'Enger Innenradius', 'Herkunft der Wege', 'Bemerkung', ].map(toWinAnsi), ], body: rows, }); context.cursor = lastTableBottom(doc) + 10; writeParagraph( context, 'tz = tü + tr - te, aufgerundet auf ganze Sekunden. Die Spalte "Herkunft der Wege" weist nach, ' + 'worauf Räum- und Einfahrweg der Zeile beruhen: "Aus dem Lageplan vermessen" nennt den ' + 'Zeitpunkt der Übernahme aus dem maßstäblichen Lageplan, "Von Hand eingetragen" bezeichnet ' + 'vom Bearbeiter erfasste Maße, und "Herkunft nicht nachgewiesen" steht für Werte aus einem ' + 'Altprojekt, einer Vorlage oder einer Datei älterer Fassung, über deren Ermittlung nichts ' + 'bekannt ist. Das Programm führt die Angabe selbst und lässt sie nicht beschreiben: Der ' + 'Nachweis aus dem Lageplan entfällt, sobald einer der beiden Wege, eine Räum- oder ' + 'Einfahrgeschwindigkeit, die Überfahrzeit oder die Zwischenzeit von Hand geändert wird. ' + 'Die Spalte "Bemerkung" gibt daneben den freien Vermerk des Bearbeiters wieder; ein Strich ' + 'bedeutet, dass keine Bemerkung erfasst ist. Sie ist kein Nachweis der Herkunft und kann aus ' + 'der Zeit vor einer späteren Änderung der Wege stammen.', ); /* * Was die neue Spalte bedeutet, und was sie NICHT bedeutet (Fassung 5.5.0, Frage E1). * * Der Absatz steht unbedingt da, weil die Spalte unbedingt da steht: Eine * Spaltenueberschrift "Enger Innenradius" ohne Erlaeuterung liest sich wie * ein Regelwerksmerkmal. Die beiden Geschwindigkeiten kommen aus den * Vorgaben dieses Plans, nicht aus dem Satz (Befund C21). * * WELCHER STROM ABBIEGT, GEHOERT DAZU: Eine Sammelsignalgruppe fuehrt * geradeaus UND rechts; der Raeumweg einer einzelnen Beziehung gehoert dann * dem Strom, der bei der Uebernahme aus dem Lageplan den Ausschlag gab. Ohne * diesen Satz steht in der Unterlage eine Zeile mit gesenkter * Raeumgeschwindigkeit neben einer Signalgruppentabelle, die "geradeaus" * fuehrt, und nichts erklaert den Unterschied. */ writeParagraph( context, `Die Spalte "Enger Innenradius" gibt das Merkmal der einzelnen Konfliktbeziehung wieder. Ist ` + `es gesetzt, räumt ein abbiegender Kraftfahrzeugstrom ` + `${engerRadiusRichtungSatzteil(plan.defaults)}. Bei allen übrigen Strömen bleibt das ` + 'Merkmal ohne Wirkung - ist es dort gesetzt, meldet der Prüfbericht eine Warnung. ' + 'Ob ein Strom abbiegt, entscheidet dabei die Fahrbeziehung des Stroms, dem der Räumweg ' + 'dieser Zeile gehört, und nicht die der räumenden Signalgruppe: Führt eine Signalgruppe ' + 'geradeaus und rechts aus einem Fahrstreifen, so gehört der Räumweg dem Strom, der bei ' + 'der Übernahme aus dem Lageplan den Ausschlag gab. ' + // DER RUECKFALL GEHOERT IN DENSELBEN ABSATZ: Festgehalten wird der // massgebende Strom allein bei der Uebernahme aus dem Lageplan. Ohne // diesen Satz entnimmt der Pruefer der Unterlage eine Zusicherung, die // das Programm im Regelfall - Altprojekt, Vorlage, von Hand erfasst - // nicht einhaelt, und haelt die Zeile faelschlich fuer nachgewiesen. // Wortlaut wie im Pruefbericht (validation/rules.ts, // 'zwischenzeiten.raeumbeziehung-nicht-nachgewiesen'). 'Ist nicht festgehalten, welcher Strom den Räumweg gefahren ist, wird mit der ' + 'Fahrbeziehung der Signalgruppe gerechnet, und der Prüfbericht meldet das. ' + 'Mit welcher Räumgeschwindigkeit gerechnet wurde, steht in der Spalte "vr". ' + // Vorrang der Handeingabe (Fassung 5.5.0): Er stand bis dahin nirgends - // weder hier noch in der Hilfe noch im Pruefbericht -, und die Rechnung // verwarf das Merkmal ohne jede Notiz. 'Steht an derselben Beziehung eine Räumgeschwindigkeit von Hand in der Spalte "vr", so ' + 'hat sie Vorrang: Gerechnet wird dann mit ihr, das Merkmal geht in keine Zahl ein, und ' + 'der Prüfbericht warnt. Auch die Schwelle von 10 m prüft dieses ' + 'Programm nicht nach: Es kennt keinen Abbiegeradius, die Angabe ist vom Bearbeiter zu ' + 'verantworten. Die Spalte "tü" folgt bei Kraftfahrzeugströmen dem Rechenansatz der ' + 'Überfahrzeit (welcher gilt, steht auf dem Deckblatt und im Abschnitt ' + '"Zwischenzeitenmatrix"); für Fußgänger, Radverkehr und ÖPNV gelten stattdessen deren ' + 'eigene Regelwerte - der Ansatz erreicht sie nicht.', ); /* * Die Formel gehoert in die Unterlage, sobald eine Zeile nach ihr gerechnet * ist - sonst steht dort ein tr, das sich aus keiner gedruckten Groesse * ergibt. Ohne betroffene Zeile bleibt der Absatz weg, damit er nicht als * allgemeine Rechenregel missverstanden wird. */ if (mitEngstellenverfahren) { writeParagraph( context, 'An einer einstreifigen Verkehrsführung gilt für den Kraftfahrzeugverkehr das eigene ' + 'Verfahren der Engstellensignalisierung (RiLSA 2015, Abschnitt 5.2.2): ' + 'tz = tü + sr/Vr · 3,6, mit einer Überfahrzeit von tü = 4 s, dem Abstand der Haltlinien ' + 'als Räumweg sr – OHNE Fahrzeuglänge – und einer mittleren Räumgeschwindigkeit Vr nach ' + 'der zulässigen Höchstgeschwindigkeit (50, 40 bzw. 30 km/h bei 60, 50 bzw. 40 km/h). ' + 'Eine Einfahrzeit wird nicht abgezogen; die Spalten se, ve und te führen deshalb 0. Die ' + 'Spalte vr nennt dieselbe Räumgeschwindigkeit in m/s, damit tr = sr/vr nachzurechnen ' + 'ist. Für Rad, Fußgänger und ÖPNV gilt auch dort Abschnitt 2.5.', ); } if (untergrenzenZeilen.length > 0) { writeParagraph( context, 'Für geradeaus fahrende und abbiegende Kraftfahrzeuge verlangt die RiLSA 2015 ' + '(Abschnitt 2.5.2) eine Untergrenze: tü + tr mindestens Gelbzeit + 1 s. Wo sie greift, ' + 'ist die Spalte tz nicht tü + tr − te, sondern diese Untergrenze abzüglich te; die ' + 'Spalten tü und tr führen unverändert die gerechneten Werte. Betroffen ' + `${untergrenzenZeilen.length === 1 ? 'ist' : 'sind'} in diesem Plan: ` + `${untergrenzenZeilen.join(', ')}.`, ); } if (mitAnfahransatz) { /* * WOHER DIE VMAX KOMMT, STEHT NUR DA, WENN ES SIE IN DIESER AUSGABE GIBT * (Fassung 5.9.0; dasselbe Muster wie beim Verweis auf die Tabelle * "Signalgruppen" in zeichneMatrixAusschnitte). * * Die Spalte "V zul" entsteht nur unter `options.includeSignalGroups`, der * Rechenweg haengt an anderen Schaltern. Ohne die Tabelle verwies der * Absatz auf einen Abschnitt, den der Leser nicht hat, und das gedruckte * tr liess sich aus keiner gedruckten Groesse nachrechnen. Die * knotenpunktweite "Zulässige Höchstgeschwindigkeit" des Deckblatts ist * nicht gemeint - gerechnet wird mit der der raeumenden Signalgruppe. * Deshalb nennt der Absatz sie dann selbst - als eigener Satz und mit der * Zuordnung zur Zeile, nicht als Aneinanderreihung von Name und Zahl. */ const vmaxHerkunft = mitSignalgruppentabelle ? '(Spalte "V zul" der Signalgruppentabelle)' : '(die Tabelle „Signalgruppen" ist für diese Ausgabe abgewählt)'; const vmaxNachsatz = mitSignalgruppentabelle ? '' : ' Die räumenden Signalgruppen dieses Ansatzes und ihre Vmax: ' + `${[...anfahrGruppen.values()].join(', ')}. Maßgebend ist je Zeile die in der Spalte ` + '„Beziehung" zuerst genannte, also die räumende Signalgruppe.'; writeParagraph( context, 'Bei ÖPNV-Strömen, die vor dem Knotenpunkt halten, gilt nach RiLSA 2015 (Fall 4) der ' + 'Anfahransatz: Die Spalte vr nennt statt der Geschwindigkeit die Anfahrbeschleunigung a, ' + 'die Überfahrzeit tü beträgt 0 s, und die Räumzeit ist tr = Wurzel aus (2 · sr / a). ' + 'Erreicht das Fahrzeug dabei die zulässige Höchstgeschwindigkeit Vmax der Signalgruppe ' + `${vmaxHerkunft}, wird der Restweg mit ihr gefahren: ` + `tr = Vmax/(3,6 · a) + (sr - Vmax²/(2 · 3,6² · a)) / (Vmax/3,6).${vmaxNachsatz}`, ); } } /** * Wie viele Beziehungen ihre Wege woher haben. * * Gehoert in die Planungsgrundlage, weil der Rechenweg der Zwischenzeiten nur * auf Wunsch gedruckt wird: Ohne diese Zeile stuende in einer abgewaehlten * Fassung nirgends, dass ein Teil der Masse nicht nachgewiesen ist. Gerade in * einer Unterlage OHNE Lageplan ist "woher dann?" die entscheidende Frage. */ function wegnachweisVerteilung(project: Project): string { if (project.conflicts.length === 0) return 'Keine Konfliktbeziehungen erfasst.'; // Eigene Kurzformen statt der kleingeschriebenen Benennung: "12 herkunft // nicht nachgewiesen" ist kein deutscher Satz. const kurz = { lageplan: 'aus dem Lageplan vermessen', handeingabe: 'von Hand eingetragen', unbestimmt: 'ohne Nachweis der Herkunft', } as const; const teile: string[] = []; for (const stufe of ['lageplan', 'handeingabe', 'unbestimmt'] as const) { const anzahl = project.conflicts.filter((c) => c.wegnachweis.herkunft === stufe).length; if (anzahl > 0) teile.push(`${anzahl} ${kurz[stufe]}`); } return `${project.conflicts.length} Beziehungen: ${teile.join(', ')}.`; } /** Zustand eines Verfahrensergebnisses in einem Wort - wie in der Phasenansicht. */ function zustandstext(bounded: CycleTimeResult['bounded']): string { switch (bounded) { case 'keine': return 'Rechenwert'; case 'minimum': return 'Untergrenze maßgebend'; case 'mindestumlauf': return 'Mindestumlauf maßgebend'; case 'maximum': return 'über dem Höchstwert'; case 'uebersaettigt': return 'nicht bemessbar'; } } /** * Die vier Rechenansaetze der Umlaufzeit nebeneinander. * * WOZU DAS IN EINER ANORDNUNGSUNTERLAGE STEHT: Eine Anordnung nach Paragraf 45 * StVO ist eine Ermessensentscheidung und muss begruendet werden. Die * Umlaufzeit ist die Zahl, an der Wartezeit, Rueckstau und damit die Frage * haengen, ob die angeordnete Regelung den Verkehr bewaeltigt. Vor der * Fassung 5.29.0 stand in der Unterlage genau eine Zahl und ein Verfahrensname; * warum dieses Verfahren und nicht ein anderes, stand nirgends, und ein Pruefer * konnte es auch nicht nachrechnen, ohne das Programm selbst zu bedienen. * * ES IST KEINE EMPFEHLUNG UND WIRD ALS SOLCHE AUCH NICHT GEDRUCKT. Der * Abschnitt nennt vier Zahlen mit ihrem Zustand und den Wert, der den Bedarf * beider Zielgroessen deckt. Die Regel dahinter steht in keinem Regelwerk; der * Absatz darunter sagt das, und das Fundstellenverzeichnis fuehrt sie unter * `programm-umlaufzeitvergleich`. */ function drawCycleComparison(context: Context): void { const { doc, plan, project } = context; const vergleich = plan.cycleComparison; if (vergleich === null) { writeParagraph( context, 'Für diese Planung liegt kein Vergleich vor: Ohne Phasen gibt es keine Verlustzeit und ' + 'damit keine Umlaufzeit zu vergleichen.', ); return; } const gewaehlt = project.program.method; autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 9, cellPadding: 3 }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, head: [['Verfahren', 'Umlaufzeit', 'Rechenwert', 'Zustand'].map(toWinAnsi)], body: CYCLE_METHODS.map((method) => { const ergebnis = vergleich.results[method]; return [ method === gewaehlt ? `${CYCLE_METHOD_LABELS[method]} (gewählt)` : CYCLE_METHOD_LABELS[method], fmt.seconds(ergebnis.cycleTime), // Bei Uebersaettigung ist der Rechenwert unendlich; `fmt.seconds` // schreibt dafuer einen Strich (dieselbe Stelle wie am Bildschirm). fmt.seconds(ergebnis.raw, 1), zustandstext(ergebnis.bounded), ].map(toWinAnsi); }), }); context.cursor = lastTableBottom(doc) + 12; writeParagraph(context, vergleich.reason); writeParagraph( context, 'Diese Gegenüberstellung ist keine Empfehlung. Welcher Ansatz anzuwenden ist, sagt kein ' + 'Regelwerk; die Wahl trifft der Planer und verantwortet sie fachlich. Auch die Regel, nach ' + 'der der oben genannte Wert gebildet wird – maßgebend ist der größere aus ' + 'kapazitätsorientiertem Bedarf und Wartezeitoptimum –, steht in keinem Regelwerk: Sie ist ' + 'ein Ansatz dieses Programms und im Fundstellenverzeichnis als solcher ausgewiesen. ' + 'Gerechnet wird der Signalzeitenplan ausschließlich mit dem gewählten Verfahren; die ' + 'Beanstandungen der übrigen Ansätze stehen nur in dieser Tafel und gehen nicht in den ' + 'Prüfbericht ein.', ); if (gewaehlt === 'manuell') { writeParagraph( context, 'Die Umlaufzeit dieser Planung ist von Hand vorgegeben; keines der vier Verfahren geht in ' + 'sie ein. Die Tafel steht hier als Vergleichsmaßstab: Sie zeigt, was die Ansätze aus ' + 'denselben Verkehrsstärken, Zwischenzeiten und Mindestfreigabezeiten ergeben würden.', ); } } function drawPhaseTables(context: Context): void { const { doc, project, plan } = context; // Beginn und Freigabezeit der Phasen sind auf die Umlaufzeit verteilt. ersatzwertAbsatz(context); autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 9, cellPadding: 3 }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, head: [['Nr.', 'Phase', 'Signalgruppen mit Freigabe', 'Beginn', 'Freigabezeit'].map(toWinAnsi)], body: plan.phases.map((phase, index) => [ String(index + 1), phase.name, phase.signalGroupIds .map((id) => project.signalGroups.find((g) => g.id === id)?.name ?? '?') .join(', '), fmt.seconds(phase.start), fmt.seconds(phase.duration), ].map(toWinAnsi), ), }); context.cursor = lastTableBottom(doc) + 16; ensureSpace(context, 120); autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 9, cellPadding: 3 }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, head: [['Übergang', 'Beginn', 'Dauer', 'Maßgebende Beziehung'].map(toWinAnsi)], body: plan.transitions.map((transition) => { const from = plan.phases.find((p) => p.id === transition.fromPhaseId); const to = plan.phases.find((p) => p.id === transition.toPhaseId); return [ `${from?.name ?? '?'} -> ${to?.name ?? '?'}`, fmt.seconds(transition.start), fmt.seconds(transition.duration), transition.critical === null ? 'keine feindliche Beziehung' : `${transition.critical.fromGroupName} räumt -> ${transition.critical.toGroupName} fährt ein`, ].map(toWinAnsi); }), }); context.cursor = lastTableBottom(doc) + 16; } /* * Kleinstmasse der gestauchten Zeichnung. * * Befund M12. Gestaucht wird nur so weit, wie die Zeichnung lesbar bleibt; * darunter wird auf mehrere Blaetter aufgeteilt. Eine Zeichnung, die so lange * verkleinert wird, bis sie passt, ist am Ende genauso unbrauchbar wie eine, * die ueber den Rand laeuft. */ const PLAN_MIN_ROW_HEIGHT = 14; const PLAN_MIN_ROW_GAP = 3; const PLAN_MIN_FONT_SIZE = 8; /** Abstand zwischen der Zeichnung und dem Absatz darunter. */ const PLAN_ABSATZ_ABSTAND = 14; /** * Verweis auf die gleichwertige Textfassung der Zeichnung. * * Befund S1 (1.1.1 Nicht-Text-Inhalt, A): Die Zeichnung hat keinen * Alternativtext, und dass die Tabelle "Signalzeiten je Signalgruppe" denselben * Inhalt in Textform enthaelt, stand NIRGENDS im Dokument - ein Suchlauf ueber * den ganzen Text fand das Wort "Grafik" genau einmal, in der * Bestandteileliste. * * Der Absatz ist die WCAG-Technik G74 (Alternative im umgebenden Text, mit * Verweis). Er behebt 1.1.1 NICHT: Ohne Strukturbaum gibt es kein Figure, dem * eine Alternative zugeordnet waere. Er macht die Unterlage im Vorlesefall * benutzbar, und er ist die Voraussetzung dafuer, dass der Abschnitt * "Barrierefreiheit dieser Datei" ueberhaupt behaupten darf, die Tabelle sei * die Textfassung der Zeichnung. */ /** * Der gleichbleibende Teil des Verweisabsatzes. * * Der Nachsatz zum Phasenband haengt daran, ob der Abschnitt "Phasen und * Phasenuebergaenge" ueberhaupt ausgegeben wird: Die Zeichnung traegt ueber der * Zeitachse Phasennamen und Uebergangszeiten, und die stehen in der * Signalzeitentabelle NICHT. Ein Verweis auf einen abgewaehlten Abschnitt waere * ein Verweis ins Leere. */ function planVerweisRumpf(mitPhasenabschnitt: boolean): string { return ( 'Über diesem Absatz steht eine Zeichnung: der Signalzeitenplan als ' + 'Balkendiagramm. Jede Zeile ist eine Signalgruppe, die waagerechte Achse ist der Umlauf in ' + 'Sekunden; die farbigen Abschnitte sind die Signalbilder und tragen ihr Kürzel (R = Rot, ' + 'RG = Rot-Gelb, F = Freigabe, GE = Gelb) und ihre Dauer. Dieselben Angaben stehen ' + 'vollständig in der Tabelle „Signalzeiten je Signalgruppe", die auf die Zeichnung folgt; ' + 'diese Tabelle ist die gleichwertige Textfassung der Zeichnung. ' + (mitPhasenabschnitt ? 'Über der Zeitachse steht das Phasenband mit Phasennamen und Übergangszeiten; es ist im ' + 'Abschnitt „Phasen und Phasenübergänge" in Textform aufgeführt.' : 'Über der Zeitachse steht das Phasenband mit Phasennamen und Übergangszeiten; der ' + 'Abschnitt „Phasen und Phasenübergänge", der sie in Textform aufführt, wurde für diese ' + 'Ausgabe abgewählt.') ); } /** Blattangabe vor dem Verweisabsatz - nur, wenn die Zeichnung geteilt ist. */ function planBlattangabe(blatt: number, blaetter: number, gruppen: string): string { return blaetter > 1 ? `Blatt ${blatt} von ${blaetter} der Zeichnung, ${gruppen}. ` : ''; } /** Bezeichnung der Signalgruppen eines Blattes - Einzahl, wenn es nur eine ist. */ function blattGruppen(gruppen: readonly PlannedSignalGroup[]): string { const erste = gruppen[0]?.name ?? '–'; const letzte = gruppen[gruppen.length - 1]?.name ?? '–'; return gruppen.length === 1 ? `Signalgruppe ${erste}` : `Signalgruppen ${erste} bis ${letzte}`; } /** * Laengste Blattangabe, die bei diesem Plan ueberhaupt entstehen kann. * * Obere Schranke fuer die Platzreserve unter der Zeichnung (Befund B1): die * hoechste denkbare Blattzahl - eine Signalgruppe je Blatt - und die beiden * laengsten Namen. Kein Blatt einer tatsaechlichen Aufteilung kann laenger * ausfallen, also genuegt EIN zweiter Anlauf. */ function laengsteBlattangabe(plan: SignalPlan): string { const anzahl = Math.max(2, plan.groups.length); const namen = [...plan.groups.map((g) => g.name)].sort((a, b) => b.length - a.length); const erste = namen[0] ?? '–'; const zweite = namen[1] ?? erste; return `Blatt ${anzahl} von ${anzahl} der Zeichnung, Signalgruppen ${erste} bis ${zweite}. `; } function planVerweisAbsatz( blatt: number, blaetter: number, gruppen: string, mitPhasenabschnitt: boolean, ): string { return `${planBlattangabe(blatt, blaetter, gruppen)}${planVerweisRumpf(mitPhasenabschnitt)}`; } /** * Fussnote zu Abschnitten, die fuer eine Beschriftung zu schmal sind. * * Befund M5 (1.4.1 Verwendung von Farbe, A): In der Zeichnung wird das Kuerzel * weggelassen, wenn der Abschnitt zu schmal ist (signalPlanDrawing.ts:246-250, * kein dritter Zweig). Im Musterplan mit 90 s Umlauf und 1 s Rot-Gelb steht * deshalb in keiner einzigen Signalgruppenzeile ein "RG" - der Abschnitt ist * dort ausschliesslich an seiner orangen Farbe zu erkennen, und im * Schwarzweissausdruck, dem Regelfall bei einer Anordnungsakte, gar nicht. * * ABGRENZUNG ZUR ZEICHNUNG: Die Zeichnung selbst benennt unter ihrer Legende, * WELCHE Signalbilder es diesmal getroffen hat (drawSchmalHinweis in * signalPlanDrawing.ts) - eine Angabe, die nur dort entstehen kann, weil nur * dort gemessen wird, was noch hineinpasst. Diese Fussnote wiederholt das nicht * und beruft sich auch nicht darauf: Sie sagt die Regel und nennt die Tabelle, * in der die Zeiten vollstaendig stehen. So bleibt sie richtig, gleich ob die * Zeichnung ihren Hinweis zeigt oder nicht - eine Fussnote, die ihren * Gegenstand ueberlebt, ist eine falsche Angabe. */ const PLAN_SCHMALE_ABSCHNITTE = 'Die Zeichnung beschriftet jeden Abschnitt mit dem Kürzel des Signalbilds und seiner Dauer, ' + 'soweit die Breite des Abschnitts dafür ausreicht. Sehr kurze Signalbilder - im Regelfall ' + 'Rot-Gelb mit 1 s - ergeben Abschnitte, die dafür zu schmal sind; in ihnen steht dann keine ' + 'Beschriftung. Für diese Abschnitte wie für jeden anderen führt die Tabelle „Signalzeiten je ' + 'Signalgruppe" das Signalbild mit seiner Dauer auf. Bei Farbfehlsichtigkeit und im ' + 'Schwarzweißausdruck ist sie deshalb die verlässliche Quelle.'; /** * Layout der Zeichnung fuer die verfuegbare Hoehe. * * KORREKTUR (Befund M12): Die Hoehe wurde mit Math.min gekappt, die Zeichnung * erfuhr davon nichts - drawSignalPlan rechnet seine Zeilen aus dem Layout, * nicht aus der Flaechenhoehe, und PdfSurface beschneidet nicht. Ab 13 * Signalgruppen lief die Zeichnung damit ueber den Seitenrand hinaus, und weil * die Legende ganz unten steht, fiel sie als Erstes weg: Danach trug in der * Zeichnung nur noch die Farbe, ohne jede Erklaerung - aus einem Layoutfehler * wurde ein Verstoss gegen 1.4.1. * * Gestaucht werden nur die Signalgruppenzeilen. Kopfbereich, Raender und der * Platz fuer Legende und Ersatzwertvermerk bleiben, wie sie sind; sie tragen * gerade den Textschluessel zu den Farben. */ function planGrafikLayout(plan: SignalPlan, zeilen: number, verfuegbar: number): PlanLayout { const jeZeile = DEFAULT_LAYOUT.rowHeight + DEFAULT_LAYOUT.rowGap; // Feste Anteile: Rand, Kopfbereich, Legende und - bei uebersaettigter Anlage - // die Vermerkzeile. planHeight rechnet mindestens eine Zeile, die hier abgeht. const fest = planHeight({ groups: [], cycleResult: plan.cycleResult }, DEFAULT_LAYOUT) - jeZeile; if (fest + zeilen * jeZeile <= verfuegbar) return DEFAULT_LAYOUT; const proZeile = Math.floor((verfuegbar - fest) / zeilen); const rowGap = Math.max( PLAN_MIN_ROW_GAP, Math.min(DEFAULT_LAYOUT.rowGap, Math.round(proZeile / 5)), ); const rowHeight = Math.max( PLAN_MIN_ROW_HEIGHT, Math.min(DEFAULT_LAYOUT.rowHeight, proZeile - rowGap), ); return { ...DEFAULT_LAYOUT, rowHeight, rowGap, // Die Schrift folgt der Zeilenhoehe, sonst steht die Beschriftung ueber dem // Balken, in dem sie liegen soll. Verhaeltnis wie im Grundlayout (22 zu 11). fontSize: Math.max( PLAN_MIN_FONT_SIZE, Math.min(DEFAULT_LAYOUT.fontSize, Math.round(rowHeight / 2)), ), }; } interface PlanAufteilung { readonly layout: PlanLayout; readonly blaetter: readonly (readonly PlannedSignalGroup[])[]; } /** * Layout und Aufteilung auf Blaetter, sodass jedes Blatt vollstaendig auf * seine Seite passt. * * Gesucht wird die kleinste Blattzahl, mit der die Zeichnung noch in den * erlaubten Grenzen darstellbar ist; die Signalgruppen werden darauf * GLEICHMAESSIG verteilt. Sonst entstuende bei 16 Signalgruppen ein Blatt mit * 14 Zeilen und eines mit 2 - fachlich unbrauchbar, weil sich die Zeilen zweier * so verschiedener Blaetter nicht mehr nebeneinanderlegen lassen. * * Jedes Blatt bekommt Zeitachse, Phasenband und Legende von drawSignalPlan * erneut. Deshalb werden hier die GRUPPEN geteilt und nicht die Zeichenflaeche * zerschnitten: Ein Blatt ohne Legende truege nur noch Farbe. */ function planAufteilung(plan: SignalPlan, verfuegbar: number): PlanAufteilung { const anzahl = Math.max(1, plan.groups.length); for (let blaetter = 1; blaetter <= anzahl; blaetter += 1) { const proBlatt = Math.ceil(anzahl / blaetter); const layout = planGrafikLayout(plan, proBlatt, verfuegbar); const hoehe = planHeight( { groups: plan.groups.slice(0, proBlatt), cycleResult: plan.cycleResult }, layout, ); if (hoehe <= verfuegbar) return { layout, blaetter: teileGruppen(plan.groups, proBlatt) }; } // Nicht erreichbar, solange eine einzelne Zeile in das kleinste Layout passt. // Der Rueckfall teilt auf je eine Signalgruppe je Blatt: lieber viele // Blaetter als eine Zeichnung, die ueber den Seitenrand hinauslaeuft. const kleinstes: PlanLayout = { ...DEFAULT_LAYOUT, rowHeight: PLAN_MIN_ROW_HEIGHT, rowGap: PLAN_MIN_ROW_GAP, fontSize: PLAN_MIN_FONT_SIZE, }; return { layout: kleinstes, blaetter: teileGruppen(plan.groups, 1) }; } function teileGruppen( gruppen: readonly PlannedSignalGroup[], proBlatt: number, ): readonly (readonly PlannedSignalGroup[])[] { if (gruppen.length <= proBlatt) return [gruppen]; const blaetter: (readonly PlannedSignalGroup[])[] = []; for (let von = 0; von < gruppen.length; von += proBlatt) { blaetter.push(gruppen.slice(von, von + proBlatt)); } return blaetter; } function drawPlanGraphic(context: Context, mitPhasenabschnitt: boolean): void { const { doc, plan } = context; /* * Der Platz fuer die beiden Absaetze unter der Zeichnung wird VORHER * abgezogen. Sie gehoeren auf dasselbe Blatt wie die Zeichnung, auf die sie * sich beziehen; ein Verweis auf die Textfassung, den man erst auf der * naechsten Seite findet, ist keiner. Der Preis ist eine leicht gestauchte * Zeichnung ab neun Signalgruppen - er ist geringer als der Verlust des * Verweises. */ /** * Hoehe der beiden Absaetze, wie sie mit dieser Blattangabe wirklich * gesetzt werden. * * KORREKTUR (Fassung 5.7.0): Gemessen wurde zuvor * `planVerweisAbsatz(1, 1, '')` - also die Fassung OHNE Blattangabe - und * dazu pauschal 18 pt fuer "eine Zeile und eine halbe zum Abrunden". Gedruckt * wird bei mehreren Blaettern aber "Blatt 1 von 2 der Zeichnung, * Signalgruppen bis ", und Signalgruppennamen sind freier * Anwendertext. Mit Namen, wie sie in einer Anordnungsunterlage vorkommen * ("K1 Zufahrt Nord, Hauptstraße stadteinwärts, Geradeaus und * Rechtsabbieger"), wuchs die Blattangabe ueber die eine reservierte Zeile * hinaus, `writeParagraph` rief `ensureSpace`, und die Fussnote landete auf * der Folgeseite - genau der Zustand, den der Kommentar oben ausschliesst. * Bei kurzen Namen (K1 bis K16) trat es nicht auf; die Pruefstuecke fuehrten * nur solche. */ const unterschriftHoehe = (blattangabe: string): number => // Abstand zwischen Zeichnung und Absatz - derselbe Wert wie unten beim // Setzen. Fehlt er hier, faellt die Fussnote auf die naechste Seite. PLAN_ABSATZ_ABSTAND + absatzHoehe(context, `${blattangabe}${planVerweisRumpf(mitPhasenabschnitt)}`) + absatzHoehe(context, PLAN_SCHMALE_ABSCHNITTE); const verfuegbarBei = (unterschrift: number): number => context.pageHeight - context.cursor - PAGE_MARGIN - FOOTER_HEIGHT - unterschrift; /* * Zwei Anlaeufe statt einer Schaetzung. * * Erster Anlauf ohne Blattangabe: Bleibt es bei EINEM Blatt, wird auch keine * gedruckt, und die Reserve stimmt auf den Punkt. Erst wenn geteilt wird, * kommt die Angabe hinzu - dann wird mit ihrer laengstmoeglichen Fassung * gerechnet und neu geteilt. Laengstmoeglich heisst: die hoechste Blattzahl, * die ueberhaupt entstehen kann (eine Signalgruppe je Blatt), und die beiden * laengsten Namen des Plans. Das ist eine obere Schranke fuer JEDE Aufteilung, * also auch fuer die, die der zweite Anlauf liefert - einen dritten Anlauf * kann es nicht brauchen. */ let aufteilung = planAufteilung(plan, verfuegbarBei(unterschriftHoehe(''))); if (aufteilung.blaetter.length > 1) { aufteilung = planAufteilung(plan, verfuegbarBei(unterschriftHoehe(laengsteBlattangabe(plan)))); } const { layout, blaetter } = aufteilung; blaetter.forEach((gruppen, index) => { if (index > 0) newSection(context, 'Signalzeitenplan (Fortsetzung)'); const blatt: SignalPlan = { ...plan, groups: gruppen }; const hoehe = planHeight(blatt, layout); const surface = new PdfSurface(doc, PAGE_MARGIN, context.cursor, context.contentWidth, hoehe); drawSignalPlan(surface, blatt, { theme: LIGHT_THEME, layout }); context.cursor += hoehe + PLAN_ABSATZ_ABSTAND; writeParagraph( context, planVerweisAbsatz(index + 1, blaetter.length, blattGruppen(gruppen), mitPhasenabschnitt), ); writeParagraph(context, PLAN_SCHMALE_ABSCHNITTE); }); } /** * Umlaufzeit als Drucktext - mit Vermerk, wenn sie kein Bemessungsergebnis ist. * * KORREKTUR (Befund C11): Bei Uebersaettigung traegt der Plan die groesste * zulaessige Umlaufzeit als Ersatzwert (bounded 'uebersaettigt'); Deckblatt und * Signalzeiten druckten "120 s" wie ein Ergebnis. Jede Stelle, die die * Umlaufzeit druckt, laeuft ueber diese Funktion; Wortlaut und Zusammenfuegen * kommen aus dem Fachkern. */ function umlaufzeitText(plan: Pick): string { return umlaufzeitMitVermerk(plan, fmt.seconds(plan.cycleTime)); } /** * Absatz ueber einer Tabelle, deren Zahlen auf der Umlaufzeit stehen, wenn * diese nur ein Ersatzwert ist (Befund C11). Ohne den Absatz staenden Rot- * zeiten, Freigabegrenzen, Phasenbeginne und Kapazitaeten zu einer Zahl, die * kein Ergebnis ist. Steht vor JEDER dieser Tabellen - Signalzeiten, Phasen, * Leistungsfaehigkeit, Rechenweg Wartezeit -, nicht nur vor einigen: Die * CSV-Pendants tragen den Vermerk mit derselben Begruendung. */ function ersatzwertAbsatz(context: Context): void { const { plan } = context; if (!umlaufzeitIstErsatzwert(plan)) return; writeParagraph( context, `Umlaufzeit ${umlaufzeitText(plan)}. Die Anlage ist übersättigt; eine Umlaufzeit lässt sich ` + 'nicht ermitteln. Die folgenden Zeiten beruhen auf dem Ersatzwert und sind kein ' + 'Bemessungsergebnis - siehe Prüfbericht.', ); } function drawSignalTimeTable(context: Context): void { const { doc, plan } = context; doc.addPage(); // Eigene Seite ohne newSection - das Lesezeichen braucht sie trotzdem: Sie // ist die Textfassung der Zeichnung und der Ort, auf den der Verweisabsatz // unter der Zeichnung zeigt. merkeLesezeichen(context, 'Signalzeiten je Signalgruppe'); context.cursor = PAGE_MARGIN + HEADER_HEIGHT; doc.setFont('helvetica', 'bold'); doc.setFontSize(12); doc.text(toWinAnsi('Signalzeiten je Signalgruppe'), PAGE_MARGIN, context.cursor); context.cursor += 18; ersatzwertAbsatz(context); autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 9, cellPadding: 3 }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, head: [ /* * KORREKTUR (Befund C22): Hier standen zwei Spalten "Freigabe von" und * "Freigabe bis", gefuellt aus greens[0] - dem ersten Fenster -, waehrend * "Freigabezeit" die Summe aller Fenster nannte. Eine Signalgruppe in * zwei getrennten Phasen erschien damit mit einem Fenster und einer * Freigabezeit, die dazu nicht passte; das zweite Fenster kam im Ausdruck * ueberhaupt nicht vor. Jetzt eine Spalte mit allen Fenstern * (freigabefensterZelle, gleiche Zeichenkette wie in der Tabellenausgabe) * und eine Summenspalte, die Summe heisst. */ [ 'SG', 'Freigabefenster (von–bis) [s]', 'Freigabezeit (Summe)', 'Rot-Gelb', 'Gelb', 'Rot', ].map(toWinAnsi), ], body: plan.groups.map((group) => { const totals = aspectTotals(group, plan.cycleTime); return [ group.name, freigabefensterZelle(group.greens, plan.cycleTime), fmt.seconds(group.totalGreen), fmt.seconds(totals.rotgelb), fmt.seconds(totals.gelb), fmt.seconds(totals.rot), ].map(toWinAnsi); }), }); context.cursor = lastTableBottom(doc) + 10; // Ohne die Fussnote bliebe offen, warum in einer Zeile zwei Fenster stehen // und was die Summenspalte summiert (Befund C22). writeParagraph(context, FREIGABEFENSTER_FUSSNOTE); context.cursor += 6; } // --- Leistungsfaehigkeit ---------------------------------------------------- /** * Bewertungsverfahren des Plans - dieselbe Quelle wie im Fachkern * (project.settings.serviceLevelScale bezeichnet seit Fassung 5.4.0, * Befund B2, das ganze Verfahren, nicht nur die Stufentafel). */ function bewertungsverfahren(context: Context): Bewertungsverfahren { return context.project.settings.serviceLevelScale; } /** Signalgruppen, die ueber Kapazitaet und mittlere Wartezeit bewertet werden. */ function kfzOepnvGruppen(plan: SignalPlan): readonly PlannedSignalGroup[] { return plan.groups.filter((group) => hatSaettigungsverkehrsstaerke(group.mode)); } /** Signalgruppen, die ueber die maximale Wartezeit bewertet werden. */ function fussRadGruppen(plan: SignalPlan): readonly PlannedSignalGroup[] { return plan.groups.filter((group) => group.mode === 'fuss' || group.mode === 'rad'); } /** * Instationaritaetsfaktor, mit dem TATSAECHLICH gerechnet wurde. * * Aus den Ergebnissen, nicht aus der Vorgabe: Ein ungueltiger Vorgabewert * faellt in delay.ts auf den Regelwert zurueck, und der Absatz unter der * Tabelle muss die Zahl nennen, die in tW eingegangen ist - sonst rechnet der * Pruefer mit der falschen nach. Ohne bewertete Gruppe gilt der Wert der * Vorgabenverwaltung. */ function angesetzterInstationaritaetsfaktor(context: Context): number { for (const group of kfzOepnvGruppen(context.plan)) { const fin = group.delay?.rechenweg.instationaritaetsfaktor; if (fin !== null && fin !== undefined && Number.isFinite(fin)) return fin; } return context.plan.defaults.capacity.instationaritaetsfaktor; } /** * Leistungsfaehigkeit der Kfz- und OePNV-Signalgruppen. * * KORREKTUR (Fassung 5.4.0, Befunde B1 bis B4): Bis dahin behauptete * der Absatz unter dieser Tabelle "Bewertung nach HBS 2015" - waehrend fuer * beide Einstellungen dieselbe HCM-Formel gerechnet und nach der Kfz-Tafel des * HBS 2001 bewertet wurde. Und er erklaerte, Fussgaengersignalgruppen seien * "nicht aufgefuehrt, da ihre Leistungsfaehigkeit ... nicht massgebend ist" - * das HBS 2015 bewertet sie sehr wohl, nur nach einem anderen Kriterium * (maximale Wartezeit, eigene Tabelle unten). Jetzt steht hier das Verfahren, * das gerechnet wurde, mit seiner Formelkette und seiner Tafel, und die Spalte * "Abflusszeit tA" macht die Kapazitaet aus den gedruckten Groessen * nachrechenbar (C = qS · tA/tU). */ function drawPerformanceTable(context: Context, mitSignalgruppentabelle: boolean): void { const { doc, plan, project } = context; const verfahren = bewertungsverfahren(context); const gruppen = kfzOepnvGruppen(plan); ensureSpace(context, 60); doc.setFont('helvetica', 'bold'); doc.setFontSize(11); doc.setTextColor('#1b2230'); doc.text(toWinAnsi('Kraftfahrzeuge und ÖPNV'), PAGE_MARGIN, context.cursor); context.cursor += 16; if (gruppen.length === 0) { writeParagraph(context, 'Es sind keine Kfz- oder ÖPNV-Signalgruppen erfasst.'); return; } // Kapazitaet, Auslastung und Wartezeit rechnen mit der Umlaufzeit. ersatzwertAbsatz(context); const rows = gruppen.map((group) => { const demand = project.demands.find((d) => d.signalGroupId === group.groupId); return [ group.name, group.capacity === null ? '–' : fmt.vehiclesPerHour(group.capacity.saturationFlow), group.capacity === null ? '–' : fmt.seconds(group.capacity.abflusszeit, 1), group.capacity === null ? '–' : fmt.vehiclesPerHour(group.capacity.capacity), // "–" auch bei 0 (Fassung 5.10.0): Ein Datensatz ist noch keine // Zaehlung - wird allein der Schwerverkehrsanteil ausgefuellt, entsteht // ein Datensatz mit `volume: 0`. Der Fachkern liest ihn seit den Befunden // 12 und 18 als "keine Angabe" (signalPlan.ts, erfassteVerkehrsstaerke), // der Auslastungsgrad zwei Spalten weiter steht dann als "–" da und der // Bildschirm zeichnet das Feld seit Befund 58 leer. Hier stand "0 Fz/h": // eine Zaehlung, die niemand vorgenommen hat, neben zwei Strichen // derselben Zeile. Dieselbe Bedingung wie in `schwerverkehrZelle` // daneben. demand === undefined || demand.volume <= 0 ? '–' : fmt.vehiclesPerHour(demand.volume), schwerverkehrZelle(project, group.groupId), // Die Geometrie der Zufahrt: Sie mindert die Saettigungsverkehrsstaerke // der Spalte ganz links, und ohne sie liesse sich diese Zahl aus dem // Fundstellenverzeichnis nicht herleiten - derselbe Grund wie beim // Schwerverkehr daneben. geometrieZelle(project, group.groupId), // Drei Nachkommastellen wie im Rechenweg und im Pruefbericht: Mit zwei // stand bei q = 244 Fz/h und C = 243 Fz/h "1,00" neben "F (ueberlastet)" // - die gedruckte Zahl widersprach der gedruckten Stufe. group.capacity?.degreeOfSaturation === undefined ? '–' : fmt.ratio(group.capacity.degreeOfSaturation, 3), group.delay === null ? '–' : fmt.seconds(group.delay.averageDelay, 1), group.delay === null ? '–' : stufeZelle(group.delay.serviceLevel), ].map(toWinAnsi); }); autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 9, cellPadding: 3 }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, head: [ [ 'SG', 'Sättigungsverkehrsstärke', // Nach HBS 2015 tF + 1 s je Freigabezeitfenster, nach HCM die // Freigabezeit selbst - der Absatz unter der Tabelle sagt, was gilt. 'Abflusszeit tA', 'Kapazität', 'Verkehrsstärke', // Der Anteil mindert die Saettigungsverkehrsstaerke, stand aber in // keiner Ausgabe. Der Pruefer las eine Kapazitaet, die er aus dem // Fundstellenverzeichnis nicht herleiten konnte, und hatte keine // Moeglichkeit, die Differenz aufzuloesen. Seit Schema 13 stehen in // der Zelle drei Zeilen: Anteil, Aufteilung und fSV mit der Gleichung // (siehe schwerverkehrZelle). 'Schwerverkehr (Anteil, Aufteilung, fSV)', 'Geometrie (b, R, s; Faktor)', 'Auslastungsgrad', 'Mittlere Wartezeit', 'Qualitätsstufe', ].map(toWinAnsi), ], body: rows, }); context.cursor = lastTableBottom(doc) + 10; writeParagraph(context, verfahrensAbsatz(context, verfahren, gruppen, mitSignalgruppentabelle)); } /** * Wo n und die Fahrbeziehung stehen, aus denen die gedruckte * Saettigungsverkehrsstaerke entsteht (Fassung 5.10.0; dasselbe Muster wie beim * Anfahransatz, Fassung 5.9.0). * * Der Erlaeuterungsabsatz nennt die Kette qS0 · n · fA / fSV. Die * Leistungstabelle fuehrt keine Fahrstreifenspalte, weil n und die * Fahrbeziehung in der Signalgruppentabelle stehen - eine halbe Wiederholung * dort waere ohne die Fahrbeziehung derselben Zeile kein geschlossenes Glied. * Diese Begruendung faellt jedoch, sobald der Anwender die Tabelle * "Signalgruppen" abwaehlt: Dann stand die Kette in einer Unterlage, in der * weder "Fahrstr." noch "Fahrbeziehung" vorkommt und aus keiner gedruckten * Groesse zu schliessen ist. In diesem Fall traegt der Absatz beides selbst * nach - Name, Fahrstreifenzahl und Fahrbeziehung je Strom der Tabelle. * * Die Fahrstreifenzahl ueber `fmt` und nicht ueber `String` (Befund 55): Eine * Projektdatei mit "lanes": 2.5 wird uebernommen, und die englische * Schreibweise haette sich hier eine zweite Stelle gesucht. */ function fahrstreifenHerkunftSatz( project: Project, gruppen: readonly PlannedSignalGroup[], mitSignalgruppentabelle: boolean, ): string { if (mitSignalgruppentabelle) { return ' n und die Fahrbeziehung stehen je Strom in der Tabelle "Signalgruppen" (Spalten "Fahrstr." und "Fahrbeziehung").'; } // Der Name in Anfuehrungszeichen, damit Name und Zahl in der Aufzaehlung // nicht unverbunden hintereinander stehen - wie beim Anfahransatz. const angaben = gruppen.map((gruppe) => { const group = project.signalGroups.find((g) => g.id === gruppe.groupId); return group === undefined ? `"${gruppe.name}" ohne Angabe` : `"${group.name}" ${fmt.numShort(group.lanes)} Fahrstreifen, ${MOVEMENT_LABELS[group.movement]}`; }); return ( ' Die Tabelle "Signalgruppen", in der n und die Fahrbeziehung stehen, ist für diese Ausgabe ' + `abgewählt; für die Ströme dieser Tabelle gilt: ${angaben.join('; ')}.` ); } /** * Der Absatz, der sagt, WAS gerechnet wurde. Zahlen und Tafeln kommen aus den * Konstanten des Fachkerns (bewertung.ts), nicht aus dem Satz. */ function verfahrensAbsatz( context: Context, verfahren: Bewertungsverfahren, gruppen: readonly PlannedSignalGroup[], mitSignalgruppentabelle: boolean, ): string { const tafeln = stufentafeln(verfahren); const mitOepnv = gruppen.some((g) => g.mode === 'oepnv'); /* * KORREKTUR (Fassung 5.5.0, Frage E6): Hier stand "Der Schwerverkehrsanteil * mindert die Saettigungsverkehrsstaerke (Pkw-Aequivalent 2,0)" - unter der * Ueberschrift "Bewertung nach HBS 2015". Die 2,0 sind der Pkw-Gleichwert des * HCM 2010; das HBS rechnet mit dem Anpassungsfaktor fSV. Der Satz kommt * jetzt aus bewertung.ts und nennt die ganze Kette, mit den Vorgaben dieses * Plans. * * MIT DER DATENLAGE DIESES PLANS (Schema 13): Beide Gleichungen des HBS sind * im Einsatz, und welche gilt, entscheidet der einzelne Strom. Der Absatz * bekommt deshalb die Zaehlung mit - sonst stuende unter einer Tabelle, in * der jeder Strom eine erfasste Aufteilung hat, weiter der Vorbehalt zur * fehlenden Aufteilung. */ const schwerverkehr = schwerverkehrSatz(context.plan.defaults, aufteilungslage(context.project)) + // Wo n und fA in DIESER Ausgabe stehen - der Satz aus bewertung.ts nennt // die Kette, nicht ihre Fundstelle. fahrstreifenHerkunftSatz(context.project, gruppen, mitSignalgruppentabelle); if (verfahren === 'HBS') { const fin = fmt.numShort(angesetzterInstationaritaetsfaktor(context), 2); return ( `Bewertung nach ${verfahrenLabel(verfahren)}: Kapazität C = qS · tA/tU mit der Abflusszeit tA = tF + 1 s je ` + 'Freigabezeitfenster; mittlere Wartezeit tW = tW,G + tW,R (Grundwartezeit und Wartezeit im ' + `Reststau, Betrachtungszeitraum T = 1 h, Instationaritätsfaktor fin = ${fin}); ` + `Qualitätsstufe nach der mittleren Wartezeit (Kfz: ${tafeln.kfz}` + (mitOepnv ? `; ÖPNV: ${tafeln.oepnv}` : '') + '); Stufe F bei Überlastung (q > C), unabhängig von der Wartezeit. ' + schwerverkehr ); } return ( `Bewertung nach ${verfahrenLabel(verfahren)} (Highway Capacity Manual): Kapazität C = qS · g/tU mit der Freigabezeit ` + 'g = tF (Spalte "Abflusszeit tA"); mittlere Wartezeit d = d1 + d2 (gleichmäßiger Anteil und ' + 'Überlastungsanteil, Bemessungszeitraum T = 0,25 h, k = 0,5, I = 1,0); Qualitätsstufe nach ' + `der mittleren Wartezeit (${tafeln.kfz}), Stufe F außerdem bei Überlastung (q > C). ` + schwerverkehr ); } /** * Fussgaenger und Radverkehr. * * KORREKTUR (Fassung 5.4.0, Befund B4): Das HBS 2015 bewertet Fussgaenger und * Radverkehr an Lichtsignalanlagen nach der MAXIMALEN Wartezeit - der laengsten * Sperrzeit im Umlauf - mit eigener Tafel; eine Kapazitaet und ein * Auslastungsgrad werden dafuer nicht gebildet. Bis dahin fehlten die * Fussgaenger im Leistungsabschnitt ganz, und Radgruppen standen mit einer * Kfz-Kapazitaet in der Kfz-Tabelle. Nach HCM gibt es fuer beide keine Stufe; * das steht dann ausdruecklich hier, statt eine Zelle leer zu lassen. */ function drawFussRadTable(context: Context): void { const { doc, plan } = context; const gruppen = fussRadGruppen(plan); if (gruppen.length === 0) return; const verfahren = bewertungsverfahren(context); const tafeln = stufentafeln(verfahren); ensureSpace(context, 90); doc.setFont('helvetica', 'bold'); doc.setFontSize(11); doc.setTextColor('#1b2230'); doc.text(toWinAnsi('Fußgänger und Radverkehr'), PAGE_MARGIN, context.cursor); context.cursor += 16; autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 9, cellPadding: 3 }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, head: [ [ 'SG', 'Verkehrsart', 'Freigabezeit', 'Längste Sperrzeit = maximale Wartezeit', 'Mittlere Wartezeit', 'Qualitätsstufe', ].map(toWinAnsi), ], body: gruppen.map((group) => [ group.name, TRAFFIC_MODE_LABELS[group.mode], fmt.seconds(group.totalGreen), group.delay === null ? '–' : fmt.seconds(group.delay.maximumDelay, 1), group.delay === null ? '–' : fmt.seconds(group.delay.averageDelay, 1), group.delay === null ? '–' : stufeZelle(group.delay.serviceLevel), ].map(toWinAnsi), ), }); context.cursor = lastTableBottom(doc) + 10; const mittlere = 'Die mittlere Wartezeit gilt für über den Umlauf gleichverteilt eintreffende Fußgänger bzw. ' + 'Radfahrer (Summe der Sperrzeitquadrate / (2 · tU)); sie ist eine Kenngröße für den ' + 'Variantenvergleich, keine Bewertungsgröße. Kapazität und Auslastungsgrad werden für ' + 'Fußgänger und Radverkehr nicht gebildet.'; writeParagraph( context, tafeln.fussRad !== null ? 'Nach HBS 2015 werden Fußgänger und Radverkehr nach der maximalen Wartezeit bewertet - ' + `der längsten Sperrzeit im Umlauf: ${tafeln.fussRad}. ` + mittlere : 'Nach HCM wird für Fußgänger und Radverkehr keine Stufe vergeben; die HCM-Fußgängerbewertung ' + 'ist in diesem Programm nicht hinterlegt. Ausgewiesen wird die maximale Wartezeit (längste ' + 'Sperrzeit im Umlauf), nach der das HBS 2015 bewerten würde. ' + mittlere, ); } /** * Rechenweg der mittleren Wartezeit je Kfz- und OePNV-Signalgruppe. * * Grundsatz: Was gedruckt wird, muss sich aus den gedruckten Groessen * nachrechnen lassen. Die Leistungstabelle nennt tW; hier stehen die * Zwischenwerte, aus denen es entsteht (HBS: tA, fA, x, tW,G, NGE, tW,R; HCM: * g, x, d1, d2 mit T, k, I), und darunter die Formeln. */ function drawDelayDetails(context: Context): void { const { doc, plan } = context; const verfahren = bewertungsverfahren(context); const gruppen = kfzOepnvGruppen(plan).filter((group) => group.delay !== null); if (gruppen.length === 0) { writeParagraph( context, 'Für keine Kfz- oder ÖPNV-Signalgruppe ist eine Verkehrsstärke erfasst; eine Wartezeit ' + 'wurde nicht gerechnet.', ); return; } // tU ist bei Uebersaettigung nur der Ersatzwert; das gehoert VOR die // Formelkette, die mit ihm rechnet (Befund C11). Die Spalte selbst bleibt // die Zahl - der Vermerk in vierzehn Spalten je Zeile waere unlesbar. ersatzwertAbsatz(context); const head = verfahren === 'HBS' ? [ 'SG', 'tU', 'tF', 'tA', 'fA = tA/tU', 'C', 'q', 'x = q/C', 'fin', 'tW,G', 'NGE', 'tW,R', 'tW', 'Stufe', ] : [ 'SG', 'tU', 'g = tF', 'g/tU', 'C', 'q', 'x = q/C', 'T', 'k', 'I', 'd1', 'd2', 'd', 'Stufe', ]; const body = gruppen.map((group) => { const delay = group.delay!; const capacity = group.capacity; const rw = delay.rechenweg; const demand = context.project.demands.find((d) => d.signalGroupId === group.groupId); const gemeinsam = [group.name, fmt.seconds(plan.cycleTime)]; const kapazitaet = [ capacity === null ? '–' : fmt.vehiclesPerHour(capacity.capacity), // Dieselbe Bedingung wie in der Leistungstabelle darueber, damit beide // Tabellen dieselbe Aussage machen. Erreichbar ist der Strich hier // nicht: Diese Tabelle fuehrt nur Gruppen mit `delay !== null`, und ein // Wartezeitergebnis entsteht nur aus einer ERFASSTEN Verkehrsstaerke // (signalPlan.ts) - `volume <= 0` gilt dort als keine. demand === undefined || demand.volume <= 0 ? '–' : fmt.vehiclesPerHour(demand.volume), fmt.ratio(delay.degreeOfSaturation, 3), ]; // Stufe mit Kriterium: "F (Ueberlastung (q > C))" oder "B (mittlere // Wartezeit)" - der Zusatz "ueberlastet" aus der Leistungstabelle waere // hier doppelt. const stufe = delay.serviceLevel === null ? '–' : `${delay.serviceLevel.grade} (${KRITERIUM_LABELS[delay.serviceLevel.kriterium]})`; if (verfahren === 'HBS') { return [ ...gemeinsam, fmt.seconds(group.totalGreen, 1), fmt.seconds(rw.abflusszeit, 1), fmt.ratio(rw.abflussanteil, 3), ...kapazitaet, rw.instationaritaetsfaktor === null ? '–' : fmt.numShort(rw.instationaritaetsfaktor, 2), fmt.seconds(delay.uniformDelay, 1), rw.reststau === null ? '–' : fmt.numShort(rw.reststau, 2), fmt.seconds(delay.incrementalDelay, 1), fmt.seconds(delay.averageDelay, 1), stufe, ].map(toWinAnsi); } return [ ...gemeinsam, fmt.seconds(rw.abflusszeit, 1), fmt.ratio(rw.abflussanteil, 3), ...kapazitaet, `${fmt.numShort(rw.betrachtungszeitraum, 2)} h`, rw.hcmK === null ? '–' : fmt.num(rw.hcmK, 1), rw.hcmI === null ? '–' : fmt.num(rw.hcmI, 1), fmt.seconds(delay.uniformDelay, 1), fmt.seconds(delay.incrementalDelay, 1), fmt.seconds(delay.averageDelay, 1), stufe, ].map(toWinAnsi); }); autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 8, cellPadding: 2.5, valign: 'top' }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, head: [head.map(toWinAnsi)], body, }); context.cursor = lastTableBottom(doc) + 10; /* * Formeln in ASCII-nahen Zeichen: Wurzelzeichen und das typografische Minus * liegen ausserhalb des WinAnsi-Vorrats und wuerden von toWinAnsi entfernt - * aus "(x - 1)" wuerde "(x 1)". */ writeParagraph( context, verfahren === 'HBS' ? 'Formeln nach HBS 2015 (Leyn 2016, Gl. 2-7 bis 2-10 und 3-2): tW = tW,G + tW,R; ' + 'tW,G = tU · (1 - fA)² / (2 · (1 - min(1; x) · fA)) mit fA = tA/tU und tA = tF + 1 s je ' + 'Freigabezeitfenster; tW,R = NGE · 3600 / C; NGE = max { 0,58 · T · C/4 · [(fin · x - 1) + ' + 'Wurzel aus ((fin · x - 1)² + 4 · fin · x / (0,58 · T · C))] ; T · C/4 · [(x - 1) + ' + 'Wurzel aus ((x - 1)² + 4 · x / (T · C))] } mit T = 1 h; fin = 1 + ((q15/q) - 1) / 1,5 ' + '(Vorgabewert 1,1 ohne 15-Minuten-Zähldaten). Angaben mit Zwischenrundung auf die ' + 'gedruckten Stellen; die Kapazität C ist ganzzahlig, x = q/C daraus gebildet. ' + /* * Bis hierher endete der Absatz vor der Fassung 5.11.0 - und * ein Pruefer, der die Zeile nach der genannten Gleichung nachrechnet, * kam an rund der Haelfte der Zeilen auf eine andere Zahl als die * gedruckte (im Regelbeispiel K1: 5,7 gegen 5,6 und K4: 3,8 gegen 3,7). * Der Grund steht in delay.ts und ist begruendet: Die Anteile muessen * auf den gedruckten Ziffern aufgehen, sonst widerlegt sich die Zeile * selbst. Ungesagt blieb er trotzdem, waehrend der Zusatz * "Zwischenrundung auf die gedruckten Stellen" ausdruecklich dazu * auffordert, mit ihnen nachzurechnen. Behoben wird deshalb hier und * nicht in delay.ts. */ 'Die Spalte tW,R ist nicht aus NGE und C gerundet, sondern als Differenz tW - tW,G auf ' + 'den ausgegebenen Ziffern gebildet, damit tW,G + tW,R die gedruckte Gesamtwartezeit ' + 'ergibt; gegenüber NGE · 3600 / C kann sie deshalb um bis zu 0,1 s abweichen.' : 'Formeln nach HCM: d = d1 + d2; d1 = 0,5 · tU · (1 - g/tU)² / (1 - min(1; x) · g/tU); ' + 'd2 = 900 · T · [(x - 1) + Wurzel aus ((x - 1)² + 8 · k · I · x / (C · T))] mit ' + 'T = 0,25 h, k = 0,5 (Festzeitsteuerung), I = 1,0 (isolierter Knotenpunkt). Angaben mit ' + 'Zwischenrundung auf die gedruckten Stellen; die Kapazität C ist ganzzahlig, x = q/C ' + 'daraus gebildet.', ); drawRueckstau(context); } /** * Der Rueckstau als Laenge, gehalten gegen den erfassten Stauraum. * * WARUM ER HIER STEHT UND NICHT ALS SPALTE: Die Tabelle darueber fuehrt im * HBS-Verfahren vierzehn Spalten. Eine fuenfzehnte haette sie unlesbar * gemacht, und die Umrechnung gehoert ohnehin nicht in die Formelkette - sie * ist kein Glied der Wartezeitrechnung, sondern eine Auskunft daneben. * * WARUM ER UEBERHAUPT GEDRUCKT WIRD: Die Spalte NGE nennt eine Fahrzeugzahl. * Ob ein Aufstellstreifen ueberlaeuft, entscheidet eine Laenge, und daran * haengt eine Abwaegung nach Paragraf 45 StVO. */ function drawRueckstau(context: Context): void { const { doc, plan } = context; const zeilen = plan.groups.filter((g) => g.rueckstau !== null); if (zeilen.length === 0) return; ensureSpace(context, 90); doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor('#1b2230'); doc.text(toWinAnsi('Rückstau und Stauraum'), PAGE_MARGIN, context.cursor); context.cursor += 14; autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 9, cellPadding: 3 }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, head: [ ['SG', 'NGE', 'Fahrstreifen', 'Rückstau je Fahrstreifen', 'Stauraum', 'Auslastung'].map( toWinAnsi, ), ], body: zeilen.map((group) => { const r = group.rueckstau!; const fahrstreifen = context.project.signalGroups.find((g) => g.id === group.groupId)?.lanes ?? 1; return [ group.name, `${fmt.numShort(r.fahrzeuge, 2)} Kfz`, // NICHT `String(...)`: Eine Fahrstreifenzahl darf gebrochen sein, und // `String(2.5)` schreibt den englischen Punkt. Genau das war ein // Befund, behoben mit Fassung 5.10.0, und sein Waechter hat diese Zeile // beim ersten Lauf gefangen. fmt.numShort(fahrstreifen, 2), fmt.meters(r.laenge, 1), r.stauraum === null ? 'nicht erfasst' : fmt.meters(r.stauraum, 1), r.auslastung === null ? '–' : `${fmt.percent(r.auslastung)}${r.ueberlauf ? ' – erreicht' : ''}`, ].map(toWinAnsi); }), }); context.cursor = lastTableBottom(doc) + 10; writeParagraph( context, `Länge = NGE · ${fmt.meters(STAURAUMBEDARF_JE_FAHRZEUG, 0)} je wartendem Fahrzeug, geteilt ` + 'durch die Fahrstreifen der Signalgruppe. Die Länge je Fahrzeug ist ein Praxiswert; weder ' + 'die RiLSA 2015 noch das HBS 2015 nennen einen, und bei hohem Schwerverkehrsanteil ist ' + 'der wirkliche Rückstau länger. ' + 'VERGLICHEN WIRD DER MITTLERE RÜCKSTAU bei Freigabezeitende über die Betrachtungsstunde: ' + 'In etwa der Hälfte der Umläufe steht mehr, und ein Stauraum, der gerade eben reicht, ' + 'reicht dann nicht. Ein 95-Prozent-Wert wäre die richtige Größe für die Frage, ob ein ' + 'Aufstellstreifen überläuft; dieses Programm führt ihn nicht und bildet ihn auch nicht ' + 'aus einem geschätzten Faktor. Der Stauraum geht in keine Zahl der Bemessung ein.', ); } /** * Schwerverkehr einer Signalgruppe in drei Zeilen: Anteil, Aufteilung, * Anpassungsfaktor fSV mit der Gleichung, die ihn gebildet hat. * * ERGAENZT (Fassung 5.5.0, Frage E6): Frueher stand hier nur * der Anteil. Zwischen ihm und der gedruckten Saettigungsverkehrsstaerke lag * eine Umrechnung, die nirgends stand - und die gedruckte Erlaeuterung nannte * dafuer ein "Pkw-Aequivalent 2,0", das nachweislich nicht der Wert des HBS * ist. Der Faktor daneben ist das Glied, das die Spalte ohne eigene Angabe * nicht hergaebe. * * BERICHTIGT (Fassung 5.10.0): Hier stand die Zusage, mit dem Faktor daneben * lasse sich die Spalte "Saettigungsverkehrsstaerke" aus dem Regelwert je * Fahrstreifen nachrechnen (qS = 3600 / (fSV · tB)). Mit fSV allein geht das * nicht, und das Zeichen qS stand in einem Satz fuer zwei Groessen. Die Spalte * fuehrt qS0 · n · fA / fSV: qS0 ist der Grundwert je Fahrstreifen aus den * Vorgaben, n die Fahrstreifenzahl und fA die Abminderung fuer links und rechts * abbiegende Stroeme. Der Erlaeuterungsabsatz unter der Tabelle nennt diese * Kette vollstaendig (bewertung.ts, schwerverkehrSatz); die gleichlautende * Zusage in der Tabellenausgabe ist nachgezogen (csv.ts). * * WO N UND FA STEHEN: in der Signalgruppentabelle - n in ihrer Spalte * "Fahrstr.", die Fahrbeziehung, aus der fA folgt, in ihrer Spalte * "Fahrbeziehung". Eine eigene Fahrstreifenspalte in der Leistungstabelle * bliebe ohne die Fahrbeziehung derselben Zeile ein halbes Glied und stuende * zugleich ein zweites Mal in der Unterlage. * * SOLANGE ES DIESE TABELLE GIBT: Der Anwender kann sie abwaehlen. Der * Erlaeuterungsabsatz unter der Leistungstabelle nennt deshalb beide Faelle - * mit Tabelle die Spalten, ohne sie die Abwahl und die Werte selbst * (`fahrstreifenHerkunftSatz`). * * AUFTEILUNG UND GLEICHUNG (Schema 13): Seit die Aufteilung des Schwerverkehrs * erfassbar ist, reicht der Anteil zum Nachrechnen nicht mehr aus - zwei * Stroeme mit denselben 12 % tragen verschiedene Faktoren, je nachdem, ob ihre * Lastzuege gezaehlt sind. Die Zelle nennt deshalb auch, WAS erfasst ist und * nach welcher Gleichung gerechnet wurde. Bis Schema 13 bildete diese Funktion den * Faktor mit `schwerverkehrsfaktor(anteil)`, also OHNE die Aufteilung: Der * Ausdruck druckte dann 1,180 neben einer Saettigungsverkehrsstaerke, die zu * 1,300 gehoert. * * Faktor und Worte kommen aus `schwerverkehrsangabe` (bewertung.ts) - eine * zweite Fassung der Gleichung im Ausdruck waere genau die Stelle, an der Zahl * und Nachweis auseinanderlaufen, und andere Worte als in der * Tabellenausgabe waeren die Stelle, an der beide Ausgaben auseinanderlaufen. */ function schwerverkehrZelle(project: Project, groupId: string): string { const demand = project.demands.find((d) => d.signalGroupId === groupId); if (demand === undefined || demand.volume <= 0) return '–'; const angabe = schwerverkehrsangabe(demand); // Der Anteil kommt aus derselben Angabe wie fSV daneben und nicht noch // einmal aus `demand`: Sonst stuende hier der eingetragene und dort der // gerechnete Wert (bewertung.ts, anteilProzent). return ( `${angabe.anteilProzent} %\n${angabe.datenlage}\n` + `fSV = ${fmt.numShort(angabe.fsv, 3)} (${angabe.gleichungKurz})` ); } /** * Die Geometrie einer Zufahrt in einer Zelle - erfasste Werte, angesetzter * Faktor und die beiden Faelle, in denen eine Eingabe nicht wirkt. * * "Standard (angenommen)" und nicht "Standard": Ein leeres Feld heisst nicht, * dass jemand 3,25 m gemessen hat, sondern dass niemand gemessen hat. Der * Unterschied ist derselbe wie bei der Wegherkunft 'unbestimmt' - behauptet * werden darf nur, was bekannt ist. */ function geometrieZelle(project: Project, groupId: string): string { const group = project.signalGroups.find((g) => g.id === groupId); if (group === undefined) return '–'; const faktor = geometriefaktor(fahrstreifengeometrie(group), group.movement); const teile: string[] = []; if (group.fahrstreifenbreite !== undefined) { teile.push(`b = ${fmt.meters(group.fahrstreifenbreite, 2)}`); } if (group.kurvenradius !== undefined) teile.push(`R = ${fmt.meters(group.kurvenradius, 1)}`); if (group.laengsneigung !== undefined) { teile.push(`s = ${fmt.numShort(group.laengsneigung, 1)} %`); } if (teile.length === 0) return 'Standard (angenommen)\nFaktor 1,000'; const zeilen = [teile.join('; '), `Faktor ${fmt.numShort(faktor.gesamt, 3)}`]; if (faktor.gedeckelt) zeilen.push('Gefälle hebt nicht an (gedeckelt)'); if (faktor.radiusVomAbbiegerfaktorGedeckt) zeilen.push('R ohne Wirkung (Abbiegerfaktor)'); return zeilen.join('\n'); } function drawReport(context: Context): void { const { doc, report } = context; if (report.findings.length === 0) { writeParagraph(context, 'Die Prüfung ergab keine Beanstandungen.'); return; } autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 8.5, cellPadding: 3, valign: 'top' }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, columnStyles: { 0: { cellWidth: 55 }, 1: { cellWidth: 90 }, 2: { cellWidth: 150 }, 4: { cellWidth: 130 }, }, head: [['Art', 'Bereich', 'Beanstandung', 'Beschreibung', 'Fundstelle'].map(toWinAnsi)], body: report.findings.map((finding) => [ SEVERITY_LABELS[finding.severity], CATEGORY_LABELS[finding.category], finding.title, `${finding.message}\n\nZu tun: ${finding.suggestion}`, finding.reference, ].map(toWinAnsi), ), }); context.cursor = lastTableBottom(doc) + 16; } /** * Abweichungen vom Regelansatz, die KEIN Zahlenwert sind * (Fassung 5.5.0, Fragen E1 und E2). * * `resolveRilsaSettings` kennt nur Kennwerte - Zahlen mit Regelwert, Einheit * und Schranke. Die beiden neuen Wahlmoeglichkeiten sind keine: Der * Ueberfahrzeit-Ansatz ist die Wahl zwischen zwei Rechenwegen, der enge * Innenradius ein Merkmal einzelner Konfliktbeziehungen. Beide veraendern die * Zwischenzeiten, und beide sind Praxisansaetze ohne Regelwerksbeleg - eine * Unterlage, die sie nicht unter "Abweichungen" fuehrt, verschweigt genau das, * was zu begruenden ist. * * Die Zeilen stehen in derselben Tabelle wie die Kennwerte, weil ein Pruefer * EINE Liste der Abweichungen erwartet und nicht zwei nebeneinander. * * UND KEINE ABWEICHUNG IST, WAS IN KEINE ZAHL EINGEHT (seit 5.43.0): Die Zeile * zum festen Ansatz stand allein nach der Einstellung da - also auch an einer * einstreifigen Verkehrsfuehrung, wo nach RiLSA 2015, Abschnitt 5.2.2 gerechnet * wird, und dort verlangte sie samt Richtungssatz eine Begruendung fuer eine * Wahl ohne Wirkung. Denselben Grundsatz haelt der Aufrufer fuer die Kennwerte * seit Befund C20 ausdruecklich fest; er gilt fuer einen Rechenansatz genauso. * Die Zeile bleibt mit dem Wirkungsvermerk - eine Einstellung, die nicht wirkt, * gehoert genannt -, aber ohne Richtungssatz: Was nicht rechnet, verkuerzt und * verlaengert nichts. */ function rechenansatzAbweichungen( project: Project, plan: SignalPlan, ): readonly (readonly string[])[] { const zeilen: string[][] = []; const defaults = plan.defaults; if (project.settings.ueberfahrzeitAnsatz === 'fest') { const ohneWirkung = ueberfahrzeitAnsatzOhneWirkung(project, plan); zeilen.push([ 'Überfahrzeit des Kraftfahrzeugverkehrs (Rechenansatz, kein Kennwert)', UEBERFAHRZEIT_ANSATZ_LABELS.gelbzeit, ohneWirkung !== null ? `${UEBERFAHRZEIT_ANSATZ_LABELS.fest} – ${ohneWirkung.vermerk}. ` + `${ohneWirkung.begruendung} ${ohneWirkung.zutun}` : `${UEBERFAHRZEIT_ANSATZ_LABELS.fest} – angesetzt sind ` + `${fmt.seconds(defaults.crossingTime.kfzGeradeaus)} geradeaus und ` + // Richtung aus den wirksamen Vorgaben (Fassung 5.5.0, Befund B5): // "VERKUERZT" gilt nur, solange die eingetragenen festen Werte unter // der Gelbzeit liegen; einstellbar sind sie von 1 bis 10 s. `${fmt.seconds(defaults.crossingTime.kfzAbbiegend)} abbiegend. ` + ueberfahrzeitAnsatzRichtungSatz(defaults), '–', ]); } // Nur die gesetzten Merkmale zaehlen, nicht die Beziehungen insgesamt: Die // Zeile soll sagen, WIE OFT vom Regelfall abgewichen wird. // // WIRKSAM heisst: abbiegender Kfz-Strom UND keine Raeumgeschwindigkeit von // Hand (Fassung 5.5.0, Befund B2). Gezaehlt werden beide - die Zeile nennt, was erfasst // ist, und daneben, an wie vielen Beziehungen es tatsaechlich gerechnet hat. // // ABBIEGEND IST DER MASSGEBENDE STROM, nicht die Signalgruppe: // `massgebendeRaeumbeziehung` (signalPlan.ts) sagt an einer Stelle, mit // welcher Fahrbeziehung eine Beziehung raeumt. Mit `from.movement` zaehlte // die Zeile an einer Sammelsignalgruppe "geradeaus UND rechts" null wirksame // Beziehungen, waehrend derselbe Ausdruck in der Spalte "vr" derselben Zeile // die gesenkte Raeumgeschwindigkeit auswies. const mitMerkmal = project.conflicts.filter((c) => c.engerRadius === true).length; if (mitMerkmal > 0) { const wirksam = project.conflicts.filter((c) => { const from = project.signalGroups.find((g) => g.id === c.fromId); return ( from !== undefined && engerRadiusWirktAn({ mode: from.mode, movement: massgebendeRaeumbeziehung(from, c), engerRadius: c.engerRadius === true, ...(c.clearingSpeedOverride !== null ? { clearingSpeedOverride: c.clearingSpeedOverride } : {}), }) ); }).length; zeilen.push([ `Räumgeschwindigkeit Kfz abbiegend, Merkmal „enger Innenradius" an ${mitMerkmal} von ` + `${project.conflicts.length} Konfliktbeziehungen (${ENGER_RADIUS_LABEL})`, fmt.metersPerSecond(RILSA_DEFAULTS.clearingSpeed.kfzTurning, 1), `${fmt.metersPerSecond(defaults.clearingSpeed.kfzTurningEngerRadius, 1)} an den ` + (wirksam === mitMerkmal ? 'gekennzeichneten Beziehungen. ' : `${wirksam} Beziehungen, an denen es wirkt (an den übrigen ist der räumende Strom ` + 'kein abbiegender Kraftfahrzeugstrom oder es steht eine Räumgeschwindigkeit von ' + 'Hand daneben, die Vorrang hat). ') + (defaults.clearingSpeed.kfzTurningEngerRadius < defaults.clearingSpeed.kfzTurning ? 'Die Wahl VERLÄNGERT die Zwischenzeiten.' : 'Die Wahl verlängert die Zwischenzeiten nicht: Der Wert entspricht der ' + 'Räumgeschwindigkeit des Abbiegers ohne dieses Merkmal.'), 'm/s', ]); } return zeilen; } function drawSettingsDeviations(context: Context): void { const { doc, project, plan } = context; // Mit der Anlagenart des Projekts: Die Umlaufzeit-Vorgaben werden gegen // DEREN Schranken aufgeloest (Befund C10). Ohne sie gaelte 'knotenpunkt', // und der Ausdruck einer einstreifigen Fuehrung fuehrte eine Abweichung, die // der Plan selbst nicht kennt - oder liesse eine aus, die er kennt. const resolved = resolveRilsaSettings(project.settings.rilsa, undefined, project.anlagenart); // Mit dem PLAN und nicht nur mit seinen Kennwerten: Die Zeilen nennen die // Kennwerte DIESES Plans, nicht die Regelwerte - wer die feste Ueberfahrzeit // oder die Raeumgeschwindigkeit bei engem Innenradius angepasst hat, bekaeme // sonst eine Abweichung gedruckt, die andere Zahlen nennt als die Rechnung // (Befund C21) -, und sie fragen am Plan nach, ob der Ansatz ueberhaupt // gerechnet hat (seit 5.43.0). const ansaetze = rechenansatzAbweichungen(project, plan); if (resolved.deviations.length === 0 && ansaetze.length === 0) return; newSection(context, 'Abweichungen von den Regelwerten'); const verfahren = bewertungsverfahren(context); // Mit der Anlagenart wie engine.ts checkSettings - fuer den Quellenschluessel // ohne Wirkung, aber der eine Aufrufer ohne sie waere der, der beim naechsten // Feld mit Schranke je Anlagenart falsch liegt. const quelleJeFeld = new Map( settingFields(undefined, project.anlagenart).map((f) => [f.key, f.quellenSchluessel]), ); // Ein Kennwert, der in keine Zahl dieser Planung eingeht, ist keine zu // begruendende Abweichung. Das steht an der Zeile, statt eine Begruendung // fuer eine wirkungslose Zahl zu verlangen. // // Den GRUND liefert der Fachkern ueber wirkungsvermerk (settings.ts, // `wirkungslosigkeit`); vor Fassung 5.4.0 (Befund C20) stand // hier ein fest eingetragenes " (ohne Wirkung: Bewertungsverfahren HCM)", // waehrend die Bedingung bereits jeden wirkungslosen Kennwert traf. Die // Koordinierungs-Kennwerte haetten damit im Ausdruck ein Bewertungsverfahren // als Grund genannt bekommen, das mit ihnen nichts zu tun hat. writeParagraph( context, 'Die folgende Aufstellung nennt, worin dieser Plan von den Regelwerten und vom Regelansatz ' + 'dieses Programms abweicht: angepasste Kennwerte (RiLSA 2015, HBS 2015 oder Planungspraxis, ' + 'siehe Fundstellenverzeichnis) und gewählte Rechenansätze, die kein Kennwert sind. Jede ' + 'wirksame Abweichung ist fachlich zu begründen.', ); autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 9, cellPadding: 3, valign: 'top' }, headStyles: { fillColor: '#fff4e0', textColor: '#1b2230', fontStyle: 'bold' }, head: [['Kennwert', 'Regelwert', 'Angesetzt', 'Einheit'].map(toWinAnsi)], body: [ /* * Die Rechenansaetze zuerst: Sie wirken auf jede Zwischenzeit des Plans, * waehrend ein angepasster Kennwert eine einzelne Groesse betrifft. Wer * die Aufstellung von oben liest, soll die weitreichendere Abweichung * zuerst sehen (Fassung 5.5.0, Fragen E1 und E2). */ ...ansaetze.map((zeile) => zeile.map(toWinAnsi)), ...resolved.deviations.map((d) => [ // Mit dem Ueberfahrzeit-Ansatz dieses Projekts (Fassung 5.5.0, Frage E2): Die // beiden festen Ueberfahrzeiten wirken nur beim Ansatz "fest". Ohne // ihn verlangte der Ausdruck eine Begruendung fuer einen Wert, der // in keine Zahl eingeht (Befund C20). `${d.label}${wirkungsvermerk(quelleJeFeld.get(d.key) ?? 'praxis', verfahren, { ueberfahrzeitAnsatz: project.settings.ueberfahrzeitAnsatz, // Ebenso (Fassung 5.5.0, Frage E1): Ohne eine Beziehung mit dem // Merkmal geht die Raeumgeschwindigkeit bei engem Innenradius in // keine Zahl ein. engerRadiusGesetzt: project.conflicts.some((c) => c.engerRadius === true), koordinierungErfasst: project.koordinierung !== null, })}`, fmt.numShort(d.standardValue, 3), fmt.numShort(d.appliedValue, 3), d.unit, ].map(toWinAnsi), ), ], }); context.cursor = lastTableBottom(doc) + 16; } // --- Nachweis der Kennwerte ------------------------------------------------- /** * Rechtliche Einordnung. * * Fuer die Pruefung der Unterlagen ist entscheidend, dass die StVO selbst keine * Zahlenwerte fuer Signalzeiten enthaelt: Sie regelt die Bedeutung der Signale * und die Zustaendigkeit, die Kennwerte stehen in den Regelwerken der FGSV. * * ANLAGENARTABHAENGIG seit Fassung 5.4.0 (Befund C13): Die Tabelle * nannte unabhaengig von der Anlagenart nur Par. 37, Par. 45, VwV-StVO, RiLSA * und HBS. Fuer eine Baustellenampel fehlten damit genau die Regelwerke, nach * denen sie angeordnet, ausgeschrieben und abgenommen wird (RSA 21, TL und ZTV * transportable Lichtsignalanlagen 2023) - waehrend derselbe Ausdruck im * Pruefbericht laengst RSA- und ZTV-Anforderungen zitierte. Welche Grundlagen * hinzukommen, entscheidet der Fachkern (quellen.ts, rechtsgrundlagenFuer), * damit Ausdruck und Vorgabenansicht dieselbe Liste fuehren. */ function drawLegalBasis(context: Context, mitLeistungsfaehigkeit: boolean): void { const { doc, project } = context; writeParagraph( context, 'Die nachstehenden Angaben weisen aus, worauf die verwendeten Kennwerte beruhen. Die StVO ' + 'enthält selbst keine Zahlenwerte für Signalzeiten; sie regelt die Bedeutung der Signale und ' + 'die Zuständigkeit der Straßenverkehrsbehörde.', ); autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 8.5, cellPadding: 3, valign: 'top' }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, columnStyles: { 0: { cellWidth: 190 }, 2: { cellWidth: 80 } }, head: [['Grundlage', 'Inhalt', 'Prüfstand'].map(toWinAnsi)], body: rechtsgrundlagenFuer(project.anlagenart).map((grundlage) => [ grundlage.bezeichnung, grundlage.hinweis === undefined ? grundlage.inhalt : `${grundlage.inhalt}\n\n${grundlage.hinweis}`, PRUEFSTAND_LABELS[grundlage.pruefstand], ].map(toWinAnsi), ), }); context.cursor = lastTableBottom(doc) + 10; // Die Tabelle nennt das HBS 2015 als Grundlage von Kapazitaet, Wartezeit // und Qualitaetsstufen - allgemein, fuer das Programm. Ob DIESES Projekt // danach bewertet, steht erst hier: Bei HCM stammen aus dem HBS nur die // Eingangsgroessen der Kapazitaet, und wer die Grundlagen liest, ohne den // Leistungsabschnitt daneben zu haben, hielte sonst die ganze Bewertung fuer // eine nach HBS 2015. writeParagraph( context, verfahrensGrundlage(bewertungsverfahren(context), mitLeistungsfaehigkeit), ); context.cursor += 6; } /** * Ein Satz dazu, nach welchem Verfahren dieses Projekt bewertet - fuer die * Grundlagen. * * Der HBS-Zweig schickte den Leser vor der Fassung 5.11.0 unbedingt in * den Abschnitt "Leistungsfaehigkeit" - auch dann, wenn dieser Abschnitt * abgewaehlt war und die Bestandteileuebersicht ihn auf Blatt 2 derselben Datei * bereits als "abgewählt - in dieser Ausgabe nicht enthalten" auswies. Zwei * gegenlaeufige Aussagen in einer Anordnungsunterlage. * * DER SATZ SELBST BLEIBT UNBEDINGT: Er unterscheidet HBS von HCM. Nur sein * Klammerzusatz haengt daran, ob es den genannten Abschnitt in DIESER Ausgabe * gibt - wie an den uebrigen Verweisen dieser Datei. Der HCM-Zweig beschreibt * das Verfahren selbst und verweist auf nichts; er bleibt unveraendert. */ function verfahrensGrundlage( verfahren: Bewertungsverfahren, mitLeistungsfaehigkeit: boolean, ): string { const name = verfahrenLabel(verfahren); if (verfahren === 'HCM') { return ( `Dieses Projekt bewertet nach dem ${name} (Highway Capacity Manual): Kapazität mit ` + 'g = tF, Wartezeit d = d1 + d2, Level of Service. Aus dem HBS 2015 stammen hier nur ' + 'Sättigungsverkehrsstärke und Abbiegerfaktor.' ); } return mitLeistungsfaehigkeit ? `Dieses Projekt rechnet und bewertet nach dem ${name} (Verfahren siehe Abschnitt ` + 'Leistungsfähigkeit).' : `Dieses Projekt rechnet und bewertet nach dem ${name}; der Abschnitt ` + '"Leistungsfähigkeit", der das Verfahren zeigt, ist für diese Ausgabe abgewählt.'; } /** * Schranken und Pruefregeln, die an der Anlagenart haengen. * * Ohne diesen Abschnitt stuende im Ausdruck eine Umlaufzeit, deren zulaessiger * Bereich nirgends benannt ist - und die Angabe, wonach sie zu beurteilen ist, * fehlte genau dort, wo der Ausdruck sonst jede Zahl auf ein Regelwerk * zurueckfuehrt. * * KORREKTUR (Fassung 5.4.0, Befund B8): Bis dahin schrieb diese Tabelle in JEDE * Zeile dieselbe Quelle der Anlagenart - "RiLSA 2015, gesichert" auch neben die * Wartezeitschwellen 90/120 s, den vollen Namen der RSA 21 auch neben die * Umlaufzeit von 30 bis 300 s. Weder die eine noch die andere Zahl steht im * jeweils genannten Regelwerk (Fassung 5.4.0, Fragen E4 und E7). Jede Zeile * bezieht ihre Grundlage und ihren Pruefstand jetzt aus `grenzen.herkunft` - * derselben Angabe, aus der auch der Pruefbericht seine Fundstelle nimmt * (Befund B9). Das Programm hatte den Sachverhalt intern laengst richtig * kommentiert; nur die gedruckte Spalte sagte etwas anderes. * * DIE SPALTE "ANGESETZT": Sie las danach trotzdem weiter aus * ANLAGENART_GRENZEN - also die Werte der Anlagenart und nicht die dieses * Plans. Wirksam sind aber die aus den Vorgaben abgeleiteten Grenzen * (`anlagenartGrenzen` in signalPlan.ts): Der Pruefbericht misst gegen * `plan.grenzen.cycleTime`, die Umlaufzeitermittlung rechnet damit, und die * Aufstellung "Abweichungen von den Regelwerten" fuehrt dieselbe Vorgabe als * Abweichung. Bei eingetragenen 45 bis 100 s stand hier "30 s bis 120 s" mit * dem Pruefstand "gesichert" - zwei Zahlenwerke in einer Anordnungsunterlage, * und quellen.ts verweist im selben Dokument auf genau diesen Abschnitt als die * Stelle, die die WIRKSAME Schranke nennt. * * DIE TABELLE UND DER SATZ DARUNTER FUEHREN DESHALB VERSCHIEDENE WERTE, und das * ist kein Versehen: In der Spalte "Angesetzt" steht, wonach dieser Plan * beurteilt wird; im Satz bleibt die Obergrenze der RiLSA stehen, weil ihre * Begruendung ("Wer so lange vor einem Signal steht, faehrt bei Rot") am * Regelwerkswert haengt. Eine selbst gesetzte Schranke ist eine * Betriebsentscheidung und traegt diese Begruendung nicht - sie wird daneben * benannt, nicht an ihre Stelle gesetzt. * * GRUNDLAGE UND PRUEFSTAND: Sie kommen weiter aus `grenzen.herkunft`, und * `anlagenartGrenzen` reicht die Herkunft der Anlagenart unveraendert durch. * Neben einem eingeengten Wert stand damit "RiLSA 2015 - Umlaufzeit am * Knotenpunkt (30 bis 120 s)" und der Pruefstand "gesichert" - die Verbindung * aus selbst gesetzter Schwelle und "gesichert", die dieselbe Datei fuer das * Fundstellenverzeichnis schon einmal aufgeloest hat (Fassung 5.9.0). * Aufgeloest wird sie hier auf demselben Weg: Der Vermerk haengt an der * Kennwertzelle, Grundlage und Pruefstand bleiben beim Regelwert und sind so * die Gegenprobe zum angesetzten Wert; der Einleitungsabsatz sagt es fuer die * ganze Tabelle. */ function drawAnlagenartGrenzen(context: Context): void { const { doc, project } = context; const grenzen = context.plan.grenzen; const regelwerk = ANLAGENART_GRENZEN[project.anlagenart]; ensureSpace(context, 140); doc.setFont('helvetica', 'bold'); doc.setFontSize(11); doc.setTextColor('#1b2230'); doc.text(toWinAnsi('Schranken der Anlagenart'), PAGE_MARGIN, context.cursor); context.cursor += 16; writeParagraph( context, `Dieser Plan ist als "${ANLAGENART_LABELS[project.anlagenart]}" angelegt. Daran hängen die ` + 'Schranken der Umlaufzeit, der größte eintragbare Räumweg und die Wartezeiten, ab denen die ' + 'Prüfung warnt oder beanstandet. Die Angaben gelten für den gesamten Plan. Die Spalte ' + '"Grundlage" sagt je Zeile, woher der Zahlenwert stammt - nicht, welches Regelwerk die ' + 'Anlagenart im Allgemeinen behandelt: Mehrere dieser Schwellen setzt dieses Programm selbst, ' + 'und sie sind mit der Straßenverkehrsbehörde abzustimmen statt in einem Regelwerk ' + 'nachzuschlagen. Steht unter "Angesetzt" eine eigene Vorgabe aus der Vorgabenverwaltung, ist ' + 'das an der Zeile vermerkt: Grundlage und Prüfstand nennen dann weiterhin den Regelwert, von ' + 'dem die Vorgabe abweicht, und nicht die Herkunft des angesetzten Wertes.', ); /** Grundlage und Pruefstand einer Zeile - beide aus derselben Herkunft. */ const herkunft = (h: SchrankenHerkunft): readonly [string, string] => [ h.text, PRUEFSTAND_LABELS[h.pruefstand], ]; /* * Der Vermerk an der Kennwertzelle - dieselbe Stelle, an der das * Fundstellenverzeichnis "(angesetzt: X, abweichend vom Regelwert)" fuehrt. * Ohne ihn stuende die eigene Vorgabe neben der Grundlage und dem Pruefstand * des Regelwerks, als waere sie dort belegt. * * "abweichend" und nicht "enger": Der zulaessige Bereich kann nur eingeengt * werden (Math.max/Math.min in anlagenartGrenzen), der empfohlene Bereich * aber auch aufgeweitet - und er verschiebt sich schon dann, wenn allein * eine eigene Obergrenze eingetragen ist und clamp() ihn in den zulaessigen * Bereich legt. Ausgeloest ist beides von einer eigenen Vorgabe; ohne eine * solche sind angesetzter Wert und Regelwert gleich. */ const abweichungsvermerk = (angesetzt: string, regelwert: string): string => angesetzt === regelwert ? '' : ` (eigene Vorgabe, abweichend vom Regelwert ${regelwert})`; const spanne = (g: AnlagenartGrenzen, welche: 'zulaessig' | 'regelbereich'): string => welche === 'zulaessig' ? `${fmt.seconds(g.cycleTime.min)} bis ${fmt.seconds(g.cycleTime.max)}` : `${fmt.seconds(g.cycleTime.recommendedMin)} bis ${fmt.seconds(g.cycleTime.recommendedMax)}`; const zulaessig = spanne(grenzen, 'zulaessig'); const regelbereich = spanne(grenzen, 'regelbereich'); autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', styles: { fontSize: 8.5, cellPadding: 3, valign: 'top' }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, columnStyles: { 0: { cellWidth: 190, fontStyle: 'bold' }, 2: { cellWidth: 260 }, 3: { cellWidth: 80 }, }, head: [['Schranke', 'Angesetzt', 'Grundlage', 'Prüfstand'].map(toWinAnsi)], body: [ [ `Umlaufzeit, zulässig${abweichungsvermerk(zulaessig, spanne(regelwerk, 'zulaessig'))}`, zulaessig, ...herkunft(grenzen.herkunft.umlaufzeit), ], [ 'Umlaufzeit, empfohlener Bereich' + abweichungsvermerk(regelbereich, spanne(regelwerk, 'regelbereich')), regelbereich, ...herkunft(grenzen.herkunft.regelbereich), ], // Kein Regelwerkswert, sondern die Eingabegrenze dieses Programms - und // deshalb ausdruecklich so benannt. Sie gehoert trotzdem in die // Unterlage: Sie sagt, dass ein laengerer Raeumweg gar nicht erfassbar // gewesen waere, und erklaert damit, warum der Plan so aussieht, wie er // aussieht. Diese Zeile war die einzige, die ihre Herkunft schon vor // Befund B8 richtig fuehrte; jetzt fuehren sie alle so. [ 'Größter eintragbarer Räumweg', fmt.meters(grenzen.maxClearingDistance, 0), ...herkunft(grenzen.herkunft.raeumweg), ], [ 'Wartezeit: Warnung / Beanstandung', `ab ${fmt.seconds(grenzen.warteZeitWarnung)} / ab ${fmt.seconds(grenzen.warteZeitFehler)}`, ...herkunft(grenzen.herkunft.wartezeit), ], // Nur bei einstreifiger Fuehrung: die Laengen, ab denen der Pruefbericht // auf Abstimmungsbedarf hinweist (Befund C5). BEHOERDENPRAXIS, KEIN // REGELWERKSWERT - so benannt, damit ein Pruefer die 400 m nicht in der // RSA sucht. ...(project.anlagenart === 'einstreifig' ? [ [ 'Engstellenlänge: Hinweis / Warnung', `ab ${fmt.meters(ENGSTELLE_PRAXIS.hinweisAb, 0)} / ab ${fmt.meters(ENGSTELLE_PRAXIS.warnungAb, 0)}`, 'Behördenpraxis (z. B. Hessen Mobil), kein Regelwerkswert', PRUEFSTAND_LABELS.abzugleichen, ], ] : []), ].map((row) => row.map((cell) => toWinAnsi(String(cell)))), }); context.cursor = lastTableBottom(doc) + 12; /* * Die Zahlen im Fliesstext kommen aus dem REGELWERK der Anlagenart, nicht aus * der Tabelle darueber. Ausgeschriebene Werte in einem Absatz sind genau die * Stelle, an der ein Ausdruck unbemerkt veraltet - deshalb sind sie * gerechnet und nicht getippt; welche der beiden Groessen sie nennen, ist * eine fachliche Entscheidung (siehe Kopf dieser Funktion, Befund 7). */ const obergrenze = fmt.seconds(regelwerk.cycleTime.max); const knotenpunktGrenze = fmt.seconds(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.max); // Hat der Anwender enger vorgegeben, steht das daneben - sonst behauptete der // Satz eine Obergrenze, nach der dieser Plan gar nicht beurteilt wird. const eigeneObergrenze = grenzen.cycleTime.max < regelwerk.cycleTime.max ? ` Für diesen Plan ist eine engere Obergrenze von ${fmt.seconds(grenzen.cycleTime.max)} ` + 'vorgegeben; sie ist eine Betriebsentscheidung dieses Projekts und trägt die vorstehende ' + 'Begründung nicht. Beurteilt wird der Plan nach ihr.' : ''; writeParagraph( context, (grenzen.quelle === 'rilsa-umlaufzeit' ? `Die Obergrenze der Umlaufzeit von ${obergrenze} ist eine Sicherheitsgrenze und keine ` + 'Bequemlichkeitsschwelle: Wer so lange vor einem Signal steht, hält die Anlage für ' + 'gestört und fährt bei Rot. Der empfohlene Bereich liegt deutlich darunter; ihn zu ' + 'überschreiten ist in den Planunterlagen zu begründen.' : /* * KORREKTUR (Fassung 5.4.0, Hinweis D1): Die Herleitung rechnete * mit RILSA_DEFAULTS statt mit den Vorgaben DIESES Plans. Bei einer * abweichenden Raeumgeschwindigkeit druckte der Ausdruck damit eine * Zwischenzeit- und Umlaufzahl, die im selben Dokument nirgends vorkommt - * und genau diese Zahl ist hier die BEGRUENDUNG der Umlaufzeitobergrenze. */ herleitungEinstreifig(regelwerk, obergrenze, knotenpunktGrenze, context.plan.defaults)) + eigeneObergrenze, ); } /** * Herleitung der Umlaufzeitobergrenze fuer eine einstreifige Verkehrsfuehrung. * * Die Zahlen werden GERECHNET und stehen nicht im Satz. Zuvor trug der Text * feste 125 s und 290 s neben einer gerechneten Engstellenlaenge - und beide * waren falsch: Aus 1000 m folgen mit den Kennwerten dieses Programms 104 s * Zwischenzeit je Richtung. Eine Zahl, die in eine Planunterlage gedruckt wird * und sich aus keiner Rechnung des Programms ergibt, ist als Begruendung * wertlos - ein Pruefer, der sie nachrechnet, findet etwas anderes. */ function herleitungEinstreifig( grenzen: AnlagenartGrenzen, obergrenze: string, knotenpunktGrenze: string, defaults: RilsaDefaults, ): string { const laenge = grenzen.maxClearingDistance; // 50 km/h und 20 s Freigabe je Richtung: der Fall, den man an einer // innerstaedtischen Arbeitsstelle tatsaechlich anordnen wuerde. Die // Untergrenze aus engstellenFolgen unterstellt 5 s und taugt als Begruendung // nicht - sie beschriebe eine Schaltung, die niemand vorsieht. const freigabeJeRichtung = 20; // `defaults` sind die Kennwerte DIESES Plans (Hinweis D1) - nicht die // Regelwerte: Die beiden Zahlen unten muessen sich mit dem Rest des // Ausdrucks nachrechnen lassen. const { zwischenzeit } = engstellenFolgen(laenge, 50, defaults); const umlauf = umlaufBeiFreigabe(laenge, 50, freigabeJeRichtung, defaults); return ( 'Die Umlaufzeitgrenzen dieses Plans stammen NICHT aus der RiLSA. Die RiLSA regelt den ' + `Knotenpunkt; dort ist die Obergrenze von ${knotenpunktGrenze} eine Sicherheitsgrenze, ` + 'weil längere Umläufe zu Rotlichtverstößen führen. An einer Arbeitsstelle gilt sie ' + 'nicht: Der Räumweg ist dort die gesamte Engstelle, die Umlaufzeit folgt zwangsläufig ' + 'aus ihrer Länge, und maßgebend ist stattdessen die zumutbare Wartezeit. Die hier ' + `angesetzte Obergrenze von ${obergrenze} ist aus den übrigen Schranken hergeleitet und ` + 'keine Zahl aus einem Regelwerk - bei der größten eintragbaren Engstelle von ' + `${fmt.meters(laenge, 0)} ergeben sich ${fmt.seconds(zwischenzeit)} Zwischenzeit je ` + `Richtung und mit zwei Übergängen sowie ${fmt.seconds(freigabeJeRichtung)} Freigabe je ` + `Richtung ${fmt.seconds(umlauf)} Umlaufzeit. Wer länger absperrt, braucht ein anderes ` + `Verfahren; die RSA 21 sieht dann vor: ${RSA_ALTERNATIVEN_ZUR_SIGNALREGELUNG}. Die Werte ` + 'sind vor dem Einsatz gegen die eigene Ausgabe der RSA abzugleichen und in den ' + 'Planunterlagen zu begründen. Unabhängig davon stellen Straßenbaubehörden Baustellenampeln ' + `üblicherweise nur bis ${fmt.meters(ENGSTELLE_PRAXIS.hinweisAb, 0)} auf, ausnahmsweise und mit ` + `Zustimmung bis ${fmt.meters(ENGSTELLE_PRAXIS.warnungAb, 0)} – Behördenpraxis (z. B. Hessen ` + 'Mobil), kein Regelwerkswert; der Prüfbericht weist ab diesen Längen auf den Abstimmungsbedarf hin.' ); } /** Fundstellenverzeichnis: jeder Kennwert mit Regelwerk, Sachgebiet und Pruefstand. */ function drawSourceRegister(context: Context, options: PdfOptions): void { const { doc, plan, project } = context; ensureSpace(context, 120); doc.setFont('helvetica', 'bold'); doc.setFontSize(11); doc.setTextColor('#1b2230'); doc.text(toWinAnsi('Fundstellenverzeichnis'), PAGE_MARGIN, context.cursor); context.cursor += 16; writeParagraph(context, FUNDSTELLEN_HINWEIS); /* * Die Fundstelle der EIGENEN Ausgabe geht der hinterlegten vor (5.27.0). * Bis dahin gab es nur die eigene, weil das Programm keine Nummer kannte - * jetzt kennt es die der RiLSA 2015, an der gekauften Ausgabe * nachgeschlagen. Wer eine andere Ausgabe hat, hat eine andere Gliederung * und traegt sie ein; deshalb die Reihenfolge und nicht umgekehrt. */ const fundstelle = (schluessel: QuellenSchluessel): string => { const eigene = options.fundstellen[schluessel] ?? ''; if (eigene !== '') return eigene; return QUELLEN[schluessel].fundstelle ?? 'nicht eingetragen'; }; const verfahren = bewertungsverfahren(context); const rows: RowInput[] = []; const verbunden = (text: string): CellDef => ({ content: toWinAnsi(text), colSpan: 2 }); /* * Die Zeilen entstanden vor der Fassung 5.9.0 aus * `settingFieldsByQuelle(plan.defaults, ...)` - das sind die AUFGELOESTEN * Kennwerte einschliesslich aller Vorgaben des Anwenders. In der Spalte * "Regelwert" stand damit bei gesetzter Vorgabe der selbst gesetzte Wert, * daneben das Regelwerk und der Pruefstand "gesichert". Derselbe Ausdruck * widersprach sich zweifach: Der Abschnitt "Abweichungen von den * Regelwerten" nennt fuer denselben Kennwert den echten Regelwert aus * RILSA_DEFAULTS, und der VwV-StVO-Eintrag DIESES Verzeichnisses haengt bei * abweichender Vorgabe ausdruecklich "abweichend vom Regelwert ..." an. Der * mitgedruckte Quellenhinweis "Die hier genannten Regelwerte sind die Werte * dieser Zeile" wurde von der Zelle daneben widerlegt. * * Die Spalte fuehrt deshalb den Regelwert. Verschwiegen wird der angesetzte * Wert nicht: Er steht mit dem Vermerk der Abweichung an der Kennwertzelle, * dort, wo auch der Wirkungsvermerk steht. * * NACHGEZOGEN (Fassung 5.12.0): WELCHER Wert der Regelwert ist und WANN eine * Vorgabe davon abweicht, entschied diese Stelle bis dahin selbst - aus * `settingFields` und einem Wertvergleich daneben. Bei den beiden * Regelbereichsfeldern der Umlaufzeit misst die Abweichungsliste aber gegen * den Wert, der ohne Eintrag fuer die ANLAGENART gilt, und an einer * Fussgaengerschutzanlage nannten die beiden Aufstellungen desselben * Ausdrucks damit verschiedene Regelwerte (40 gegen 60 s). Beides kommt jetzt * aus `fundstellenkennwerte` und damit aus derselben Quelle wie die * Aufstellung "Abweichungen von den Regelwerten": aus `resolveRilsaSettings`. * Der Rueckfall daneben nimmt den `regelwert` der Vorgabenzeile - seit der * Fassung 5.12.0 dieselbe Zahl, gebildet an derselben Stelle (settings.ts, * `regelwertVon`); mit `standard` stand dort wieder der Knotenpunktwert. */ const kennwerte = fundstellenkennwerte(project.settings.rilsa, plan.anlagenart); for (const gruppe of settingFieldsByQuelle(RILSA_DEFAULTS, plan.anlagenart)) { for (const [index, feld] of gruppe.felder.entries()) { const kennwert = kennwerte.get(feld.key); const regelwert = kennwert?.regelwert ?? feld.regelwert; const angesetzt = kennwert?.angesetzt ?? null; const abweichung = angesetzt === null ? '' : ` (angesetzt: ${fmt.numShort(angesetzt, 3)} ${feld.unit}, abweichend vom Regelwert)`; rows.push( [ index === 0 ? gruppe.quelle.kurz : '', // Mit dem Wirkungskontext dieses Projekts: Beim Regelansatz // "Ueberfahrzeit = Gelbzeit" gehen die beiden festen Ueberfahrzeiten // in keine Zahl ein, und ohne eine Beziehung mit dem Merkmal "enger // Innenradius" auch die zugehoerige Raeumgeschwindigkeit nicht. Das // Verzeichnis sagt es an der Zeile (Fassung 5.5.0, Fragen E1 und E2, Muster von // Befund C20). `${feld.label}${wirkungsvermerk(gruppe.schluessel, verfahren, { ueberfahrzeitAnsatz: project.settings.ueberfahrzeitAnsatz, engerRadiusGesetzt: project.conflicts.some((c) => c.engerRadius === true), koordinierungErfasst: project.koordinierung !== null, })}${abweichung}`, `${fmt.numShort(regelwert, 3)} ${feld.unit}`, index === 0 ? gruppe.quelle.sachgebiet : '', index === 0 ? fundstelle(gruppe.schluessel) : '', index === 0 ? PRUEFSTAND_LABELS[gruppe.quelle.pruefstand] : '', ].map(toWinAnsi), ); } /* * "Zu beachten" auch fuer Feldgruppen, nicht nur fuer feldlose Quellen: Der * Hinweis der Quelle traegt den Vorbehalt des Pruefstands - bei * 'rilsa-signalzeiten' etwa, dass die Furtregel am Wortlaut und am Bezug * auf DIN 32981 abzugleichen ist. Vor der Fassung 5.4.0 stand er nur am * Bildschirm; der Ausdruck nannte "gesichert" ohne den Vorbehalt. */ if (gruppe.quelle.hinweis !== undefined) { rows.push(['', verbunden(`Zu beachten: ${gruppe.quelle.hinweis}`), '', '', '']); } } /* * Quellen ohne Vorgabenfeld - die Stufentafeln der Qualitaetsstufen, die * VwV-StVO und (bei einstreifiger Fuehrung) die RSA 21. Sie fehlten hier * ganz, weil das Verzeichnis nur aus den Vorgabenfeldern entsteht: Die * gedruckten Tafeln des Leistungsabschnitts hatten im Nachweis weder Herkunft * noch Pruefstand, und die Grundlagen der Pruefregeln C2/C3/C6 ebenso wenig. * Kennwert und Regelwert stehen in einer verbundenen Zelle: Eine Tafel ist * kein einzelner Zahlenwert und passte nicht in die schmale Regelwertspalte. */ // Mit den Kennwerten DIESES Plans (Befund C21): Der VwV-StVO-Eintrag nennt // Rot-Gelb-Zeit und Gelbzeitstaffel, und beide sind einstellbar. for (const eintrag of quellenOhneFeld( verfahren, plan.anlagenart, plan.defaults, options.includeCycleComparison, )) { const { quelle } = eintrag; rows.push([ toWinAnsi(quelle.kurz), verbunden(`${eintrag.bezeichnung}\n\n${quelle.verwendung}`), toWinAnsi(quelle.sachgebiet), toWinAnsi(fundstelle(eintrag.schluessel)), toWinAnsi(PRUEFSTAND_LABELS[quelle.pruefstand]), ]); for (const kennwert of eintrag.kennwerte) { rows.push(['', verbunden(`${kennwert.label}: ${kennwert.wert}`), '', '', '']); } if (quelle.hinweis !== undefined) { rows.push(['', verbunden(`Zu beachten: ${quelle.hinweis}`), '', '', '']); } } autoTable(doc, { startY: context.cursor, margin: TABELLENRAND, theme: 'grid', /* * Eine Zeile dieses Verzeichnisses ist EINE Aussage - "Stufentafel * Fussgaenger und Radverkehr (maximale Wartezeit): A bis 30 s, ...". * Bricht sie mitten im Satz auf die naechste Seite um, stehen zwischen * ihren Haelften Fusszeile, Kopfzeile und Tabellenkopf, und ein Pruefer * muss die Aussage aus zwei Seiten zusammensetzen. Sie wandert deshalb * lieber ganz auf die naechste Seite. Aufgefallen, als der neue Abschnitt * "Bestandteile der Anordnungsunterlage" (Befund C14) den Satzspiegel * verschob - der Umbruch war vorher nur zufaellig guenstig. */ rowPageBreak: 'avoid', styles: { fontSize: 8, cellPadding: 2.5, valign: 'top' }, headStyles: { fillColor: '#eef1f6', textColor: '#1b2230', fontStyle: 'bold' }, /* * Die Spalte "Fundstelle" bekommt eine feste Breite (Fassung 5.5.0, Fragen E1/E2). * Sie war die einzige neben "Kennwert" ohne, und autoTable teilt den Rest * nach Inhaltsbreite auf: Mit den neuen Kennwertbezeichnungen samt * Wirkungsvermerk ("Überfahrzeit Kfz geradeaus (Ansatz „feste * Überfahrzeit") (ohne Wirkung: Überfahrzeit = Gelbzeit)") zog die * Kennwertspalte so viel Platz an sich, dass die selbst eingetragene * Fundstelle "Abschnitt 3.4, Tabelle 6" als "Abschnitt 3.4, Tabelle" / * "6" umbrach - eine Fundstelle, die ueber zwei Zeilen zerrissen ist, * laesst sich nicht mehr abschreiben. */ columnStyles: { 0: { cellWidth: 70, fontStyle: 'bold' }, 2: { cellWidth: 62, halign: 'right' }, 3: { cellWidth: 175 }, 4: { cellWidth: 110 }, 5: { cellWidth: 62 }, }, head: [ ['Regelwerk', 'Kennwert', 'Regelwert', 'Sachgebiet', 'Fundstelle', 'Prüfstand'].map( toWinAnsi, ), ], body: rows, }); context.cursor = lastTableBottom(doc) + 12; /* * Die Legende kommt aus PRUEFSTAND_ERLAEUTERUNG und nicht aus einem eigenen * Satz: Vor der Fassung 5.4.0 stand hier eine zweite Fassung, die * "abzugleichen" nur als "am Original zu pruefen" erklaerte - waehrend die * Schrankentabelle desselben Ausdrucks dasselbe Wort fuer selbst gesetzte * Schwellen verwendet, fuer die es kein Original gibt. Zwei Fassungen einer * Erklaerung laufen genau so auseinander. */ writeParagraph( context, PRUEFSTAND_REIHENFOLGE.map( (stand) => `Prüfstand "${PRUEFSTAND_LABELS[stand]}": ${PRUEFSTAND_ERLAEUTERUNG[stand]}`, ).join(' '), ); } // --- Hilfsfunktionen -------------------------------------------------------- /** Zeilenabstand eines Absatzes (9 pt in Grau). */ const ABSATZ_ZEILENHOEHE = 12; /** Abstand zum naechsten Block, den ein Absatz hinter sich laesst. */ const ABSATZ_NACHLAUF = 8; /** * Ein Absatz in 9 pt Grau - gemessen gesetzt, mit so vielen Folgeblaettern wie * noetig. * * Hier stand vor der Fassung 5.11.0 `ensureSpace` und danach * ein einzelnes `doc.text(lines, ...)`. `ensureSpace` legt genau EIN Folgeblatt * an; die Zeilen liefen danach fortlaufend nach unten - erst in den Fusssteg, * dann ueber die Fusszeile, die paintHeadersAndFooters spaeter darueber setzt, * und schliesslich aus dem Blatt. Ohne Hinweis. * * Drei Aufrufer fuehren Anwendertext ohne Laengengrenze: der Furtregelabsatz * (Rechenweg der Mindestfreigabezeit je Fussgaengergruppe, mit dem * Mittelinselvermerk am Ende), der Fahrstreifensatz der Leistungstabelle und * der Vmax-Nachsatz des Anfahransatzes. Bei den beiden letzten ist der Absatz * der EINZIGE Traeger seiner Nachweisgroesse, sobald die Tabelle * "Signalgruppen" abgewaehlt ist. * * Gesetzt wird deshalb ueber `setzeZeilenMitUmbruch` - denselben Weg, den * Deckblatttitel (Befund 43) und Bemerkung (Befund 18) schon gehen. Das * beseitigt zugleich, dass hier mit 12 pt je Zeile gerechnet, aber mit den * 10,35 pt des Feldsatzes gezeichnet wurde. * * DIE RESERVE BLEIBT: Passt der ganze Absatz noch auf dieses Blatt, beginnt er * dort; sonst beginnt er auf einem frischen. Mehr als ein volles Blatt wird * nicht verlangt - sonst legte ein ueberlanger Absatz auch dann ein Blatt an, * wenn er ohnehin oben beginnt, und liesse ein leeres zurueck. */ function writeParagraph(context: Context, text: string): void { context.doc.setFont('helvetica', 'normal'); context.doc.setFontSize(9); const lines = context.doc.splitTextToSize(toWinAnsi(text), context.contentWidth) as string[]; // Mit der tatsaechlichen Hoehe statt pauschal 40 pt: Die Absaetze der // Barrierefreiheitserklaerung sind laenger als drei Zeilen und liefen sonst // unter die Fusszeile. ensureSpace(context, Math.min(absatzHoehe(context, text), nutzhoeheJeBlatt(context))); context.doc.setTextColor('#5b6577'); setzeZeilenMitUmbruch(context, lines, ABSATZ_ZEILENHOEHE); context.cursor += ABSATZ_NACHLAUF; context.doc.setTextColor('#1b2230'); } /** * Kuerzt einen Text auf die gemessene Breite, mit Auslassungszeichen. * * Der Text muss bereits durch `toWinAnsi` gegangen sein, und Schrift und * Schriftgroesse muessen am Dokument gesetzt sein - gemessen wird mit dem, was * danach gezeichnet wird. */ function gekuerztAufBreite(doc: jsPDF, text: string, breite: number): string { if (doc.getTextWidth(text) <= breite) return text; // toWinAnsi macht aus dem Auslassungszeichen drei Punkte; Latin-1 kennt es // nicht, und ein unsichtbar verschlucktes Zeichen waere hier das Gegenteil // eines Hinweises auf die Kuerzung. const auslassung = '...'; // Halbierend statt zeichenweise: Die Kopfzeile wird fuer JEDE Seite neu // gesetzt, und ein Projektname hat keine Laengengrenze. let unten = 0; let oben = text.length; while (unten < oben) { const mitte = Math.ceil((unten + oben) / 2); if (doc.getTextWidth(`${text.slice(0, mitte)}${auslassung}`) <= breite) unten = mitte; else oben = mitte - 1; } return `${text.slice(0, unten).trimEnd()}${auslassung}`; } /** * Setzt bereits umbrochene Zeilen mit fester Schrittweite und legt dabei so * viele Folgeblaetter an, wie noetig sind. * * Schrift, Groesse und Farbe setzt der Aufrufer - gemessen und gezeichnet wird * mit dem, was am Dokument steht. Danach steht der Cursor eine Schrittweite * unter der letzten Grundlinie. * * Deckblatttitel (Befund 43) und Bemerkung (Befund 18) liefen ohne diese * Rechnung in den Fusssteg, ueber die Fusszeile und schliesslich aus dem * Blatt - ohne Hinweis. Dasselbe galt fuer jeden Absatz aus `writeParagraph`, * sobald Signalgruppennamen hineinliefen. Deshalb ein Weg fuer alle drei: * Laeuft die Rechnung auseinander, laeuft auch der gezeichnete Abstand * auseinander. */ function setzeZeilenMitUmbruch( context: Context, zeilen: readonly string[], zeilenhoehe: number, ): void { const { doc } = context; const unterkante = context.pageHeight - PAGE_MARGIN - FOOTER_HEIGHT; let offen = [...zeilen]; while (offen.length > 0) { // Sichert, dass wenigstens eine Zeile auf dieses Blatt passt; danach // ergibt die Rechnung darunter mindestens zwei. ensureSpace(context, zeilenhoehe); const platz = Math.floor((unterkante - context.cursor) / zeilenhoehe) + 1; const jetzt = offen.slice(0, Math.max(1, platz)); // Jede Zeile mit eigener Grundlinie statt als Feld: Nur so ist der // gezeichnete Abstand derselbe, mit dem hier gerechnet wird. for (const [i, zeile] of jetzt.entries()) { doc.text(zeile, PAGE_MARGIN, context.cursor + i * zeilenhoehe); } context.cursor += jetzt.length * zeilenhoehe; offen = offen.slice(jetzt.length); } } /** Zeilenabstand der Bemerkung - dieselbe Schrittweite wie in writeParagraph. */ const BEMERKUNG_ZEILENHOEHE = 12; /** * Die Bemerkung des Anwenders - gemessen gesetzt, mit so vielen Folgeblaettern * wie noetig. * * FASSUNG 5.9.0, BEFUND 18: Hier stand ein einzelnes * `doc.text(beschreibung, x, y, { maxWidth })` - eine von zwei Textstellen * dieser Datei, die ihre Hoehe nicht gemessen haben; die andere ist der * Deckblatttitel (Befund 43). `maxWidth` bricht in jsPDF nur um; ein Folgeblatt * legt es nicht an und beschnitten wird auch nichts. Die Zeilen liefen * fortlaufend nach unten: ab der 43. ueber die Fusszeile, die * paintHeadersAndFooters danach zusaetzlich darueber zeichnet, ab der 47. * ausserhalb des Blattes. Der Anwender bekam keinen Hinweis, dass seine * Bemerkung im Ausdruck fehlt - und das Feld hat keine Laengengrenze: Beim * Einlesen einer 4.x-Datei haengt die Migration "Anordnungsgrund" und * "Anordnungsnummer" an das Ende genau dieses Textes. * * WARUM NICHT writeParagraph: Das setzt 9 pt in Grau, die Bemerkung behaelt * ihre Auszeichnung (9,5 pt, #1b2230). Den Umbruch ueber mehrere Blaetter * teilen sich beide inzwischen (setzeZeilenMitUmbruch); vor der Fassung 5.11.0 * war er der zweite Grund - writeParagraph legte damals hoechstens EIN * Folgeblatt an. */ function writeBemerkung(context: Context, text: string): void { const { doc } = context; doc.setFont('helvetica', 'normal'); doc.setFontSize(9.5); doc.setTextColor('#1b2230'); setzeZeilenMitUmbruch( context, doc.splitTextToSize(toWinAnsi(text), context.contentWidth) as string[], BEMERKUNG_ZEILENHOEHE, ); } /** * Hoehe, die writeParagraph fuer diesen Text braucht - ohne ihn zu drucken. * * Damit laesst sich Platz reservieren, BEVOR eine Zeichnung gesetzt wird, die * den Rest der Seite fuellt (Befund M12). Die Rechnung ist dieselbe wie in * writeParagraph; steht sie auseinander, stimmt die Reserve nicht mehr. */ function absatzHoehe(context: Context, text: string): number { context.doc.setFont('helvetica', 'normal'); context.doc.setFontSize(9); const lines = context.doc.splitTextToSize(toWinAnsi(text), context.contentWidth) as string[]; return lines.length * ABSATZ_ZEILENHOEHE + ABSATZ_NACHLAUF; } function lastTableBottom(doc: jsPDF): number { const result = (doc as unknown as { lastAutoTable?: { finalY?: number } }).lastAutoTable; return result?.finalY ?? PAGE_MARGIN + HEADER_HEIGHT; } function methodLabel(method: Project['program']['method']): string { switch (method) { case 'webster': return 'Webster (wartezeitminimal)'; case 'akcelik': return 'Akcelik (wartezeitminimal)'; case 'hbs': return 'HBS (kapazitätsorientiert)'; case 'hcm': return 'HCM (kapazitätsorientiert)'; case 'manuell': return 'feste Vorgabe'; } }