lsa-planer

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

/ src services export blattbild.ts

8,7 KB Rohdatei
src/services/export/blattbild.ts — 228 Zeilen
1 import type { LageplanBild } from '@/domain/geometrie/lageplan';
2 import { bildzuschnitt, type Planblatt } from '@/domain/geometrie/planblatt';
3
4 /**
5 * Das Luftbild fuer das Lageplanblatt: zugeschnitten, verkleinert, verdichtet.
6 *
7 * WOZU (aus dem Betrieb, 11.09.2026): Das gedruckte Lageplanblatt zeigte die
8 * Fahrlinien ohne das Luftbild darunter und trug keine einzige Angabe zur
9 * Verortung. Wer es allein vor sich hatte, konnte nicht sagen, wo auf der Welt
10 * die Zeichnung liegt.
11 *
12 * WARUM DAS BILD NICHT EINFACH MITGEGEBEN WIRD. Eine Aufnahme im Projekt ist
13 * bis zu 24 MiB gross (siehe `schema.ts`), und ein PDF, das sie mitfuehrt, ist
14 * es auch. Sie wird deshalb auf das gebracht, was das Blatt ueberhaupt
15 * aufnehmen kann, und dabei zweimal kleiner:
16 *
17 * ZUSCHNEIDEN Das Blatt zeigt nur den gezeichneten Bereich. Wer 200 m
18 * geholt und einen Knotenpunkt von 60 m gezeichnet hat,
19 * braucht ein Zehntel der Flaeche.
20 * VERKLEINERN Auf die Aufloesung des Blattes. Alles darueber druckt nichts
21 * Zusaetzliches.
22 *
23 * Das Wichtige daran ist nicht der Faktor, sondern woran die Groesse danach
24 * haengt: an der Blattgroesse und nicht mehr an der Vorlage. Eine
25 * Ortsdurchfahrt mit 2000 m Kantenlaenge kostet dasselbe wie ein einzelner
26 * Knotenpunkt.
27 *
28 * WAS DAS BILD NICHT WIRD: eine Messgrundlage. Gemessen wird am
29 * Massstabsbalken und an den Linien. Die Aufloesung genuegt fuer die Verortung
30 * und den Augenschein; abgegriffen wird nicht auf Papier.
31 */
32 export interface Blattbild {
33 /** Die verdichtete Aufnahme als Daten-URL. */
34 readonly datenUrl: string;
35 /** Groesse der erzeugten Grafik in Bildpunkten. */
36 readonly breite: number;
37 readonly hoehe: number;
38 /** Wohin sie auf dem Blatt gehoert - in Blattpunkten. */
39 readonly zielX: number;
40 readonly zielY: number;
41 readonly zielBreite: number;
42 readonly zielHoehe: number;
43 }
44
45 /**
46 * Aufloesung, mit der das Blatt gerastert wird.
47 *
48 * 200 Punkte je Zoll: Bei 300 Blattpunkten Hoehe sind das 833 Bildpunkte. Ein
49 * Bueroausdruck loest 300 bis 600 auf, sieht davon bei einer Luftaufnahme aber
50 * nichts mehr - die Vorlage selbst hat 10 bis 20 cm Bodenaufloesung, und bei
51 * 1:250 sind 10 cm rund 0,4 mm. Mehr Bildpunkte kosteten Groesse ohne Gewinn.
52 */
53 export const BLATTBILD_DPI = 200;
54
55 /**
56 * Obergrenze der Bilddaten je Blatt.
57 *
58 * KEINE HARTE SCHRANKE, sondern das Ziel der Verdichtungsstufen: Wird sie auch
59 * auf der letzten Stufe verfehlt, geht das Bild trotzdem mit. Eine
60 * Planunterlage am Erzeugen zu hindern, weil eine Aufnahme sich schlecht
61 * verdichtet, waere das falsche Verhaeltnis von Anlass und Folge.
62 */
63 export const BLATTBILD_ZIELBYTES = 700 * 1024;
64
65 /**
66 * Verdichtungsstufen, von der besten abwaerts.
67 *
68 * Die erste ist dieselbe Regelstufe, mit der ein eingelesenes Bild in die
69 * Projektdatei geht. Darunter drei Notstufen; die letzte wird genommen, wenn
70 * keine das Ziel erreicht.
71 */
72 const JPEG_STUFEN: readonly number[] = [0.72, 0.6, 0.45, 0.32];
73
74 /** Grenze gegen ein unsinnig grosses Zwischenbild. */
75 const MAX_KANTE = 4000;
76
77 /**
78 * Die Groesse der Grafik in Bildpunkten, die ein Blatt dieser Groesse braucht.
79 *
80 * Eigene Funktion, weil sie die einzige Stelle ist, an der aus Blattpunkten
81 * Bildpunkte werden - und weil sie sich ohne Zeichenflaeche pruefen laesst.
82 */
83 export function blattbildGroesse(
84 blattBreite: number,
85 blattHoehe: number,
86 dpi: number = BLATTBILD_DPI,
87 ): { readonly breite: number; readonly hoehe: number } | null {
88 if (!Number.isFinite(blattBreite) || !Number.isFinite(blattHoehe)) return null;
89 if (blattBreite <= 0 || blattHoehe <= 0 || dpi <= 0) return null;
90 const faktor = dpi / 72;
91 const breite = Math.min(MAX_KANTE, Math.max(1, Math.round(blattBreite * faktor)));
92 const hoehe = Math.min(MAX_KANTE, Math.max(1, Math.round(blattHoehe * faktor)));
93 return { breite, hoehe };
94 }
95
96 /**
97 * Bereitet das Luftbild fuer das Blatt vor.
98 *
99 * `null`, wo es kein Blattbild geben kann: kein Bild im Projekt, keine
100 * Zeichenflaeche, unlesbare Daten-URL, oder ein Blatt, das gar nichts vom Bild
101 * zeigt. Keiner dieser Faelle ist ein Fehler - die Unterlage entsteht dann
102 * ohne das zweite Blatt, und der Ausdruck sagt es an Ort und Stelle.
103 *
104 * ASYNCHRON, UND DER AUSDRUCK BLEIBT ES NICHT. Entschluesseln und Verkleinern
105 * gehen nur asynchron; `buildProjectPdf` ist synchron und soll es bleiben - an
106 * ihm haengen ueber dreissig Prueffaelle und der Durchlauf am gebauten Stand.
107 * Deshalb wird das Blattbild VORHER erzeugt und der fertigen Grafik uebergeben.
108 */
109 export async function blattbildVorbereiten(
110 bild: LageplanBild,
111 blatt: Planblatt,
112 ): Promise<Blattbild | null> {
113 const groesse = blattbildGroesse(blatt.breite, blatt.hoehe);
114 if (groesse === null) return null;
115
116 const zuschnitt = bildzuschnitt(blatt, bild.breite, bild.hoehe, groesse.breite, groesse.hoehe);
117 if (zuschnitt === null) return null;
118
119 const quelle = await ladeBild(bild.datenUrl);
120 if (quelle === null) return null;
121
122 try {
123 const flaeche = document.createElement('canvas');
124 flaeche.width = groesse.breite;
125 flaeche.height = groesse.hoehe;
126 const stift = flaeche.getContext('2d');
127 if (stift === null) return null;
128
129 /*
130 * DECKEND WEISS UNTERLEGEN, BEVOR GEZEICHNET WIRD - derselbe Grund wie beim
131 * Einlesen eines Bildes (`ui/lageplan/bildImport.ts`): JPEG kennt keinen
132 * Alphakanal, und eine durchsichtige Zeichenflaeche wird dabei auf SCHWARZ
133 * komponiert. Hier trifft es nicht nur den Sonderfall eines durchsichtigen
134 * Bildes, sondern den Regelfall: Ragt die Zeichnung ueber den Bildrand
135 * hinaus, bleibt ein Teil der Flaeche leer. Ohne die Fuellung waere das
136 * ein schwarzer Rand um die Aufnahme.
137 */
138 stift.fillStyle = '#ffffff';
139 stift.fillRect(0, 0, groesse.breite, groesse.hoehe);
140 stift.imageSmoothingQuality = 'high';
141 stift.drawImage(
142 quelle,
143 zuschnitt.quelleX,
144 zuschnitt.quelleY,
145 zuschnitt.quelleBreite,
146 zuschnitt.quelleHoehe,
147 zuschnitt.zielX,
148 zuschnitt.zielY,
149 zuschnitt.zielBreite,
150 zuschnitt.zielHoehe,
151 );
152
153 const datenUrl = verdichte(flaeche);
154 if (datenUrl === null) return null;
155
156 /*
157 * Das ZIEL steht in Blattpunkten, nicht in Bildpunkten: Die erzeugte
158 * Grafik deckt das ganze Blatt ab - der Zuschnitt liegt schon in ihr
159 * drin -, und sie gehoert deshalb auf das ganze Blatt.
160 */
161 return {
162 datenUrl,
163 breite: groesse.breite,
164 hoehe: groesse.hoehe,
165 zielX: 0,
166 zielY: 0,
167 zielBreite: blatt.breite,
168 zielHoehe: blatt.hoehe,
169 };
170 } finally {
171 quelle.close();
172 }
173 }
174
175 /** Die erste Stufe, die das Ziel erreicht - sonst die letzte. */
176 function verdichte(flaeche: HTMLCanvasElement): string | null {
177 let letzte: string | null = null;
178 for (const stufe of JPEG_STUFEN) {
179 const versuch = flaeche.toDataURL('image/jpeg', stufe);
180 if (!versuch.startsWith('data:image/jpeg')) return null;
181 letzte = versuch;
182 if (datenBytes(versuch) <= BLATTBILD_ZIELBYTES) return versuch;
183 }
184 return letzte;
185 }
186
187 /**
188 * Die Zahl der Bytes hinter einer Daten-URL mit Base64.
189 *
190 * Drei Bytes werden zu vier Zeichen; die Auffuellzeichen am Ende stehen fuer
191 * keines. Gerechnet und nicht entschluesselt: Es geht um die Groesse, nicht um
192 * den Inhalt, und die Aufnahme ein zweites Mal in den Speicher zu legen waere
193 * fuer eine Zahl zu teuer.
194 */
195 export function datenBytes(datenUrl: string): number {
196 const komma = datenUrl.indexOf(',');
197 if (komma < 0) return 0;
198 const nutzlast = datenUrl.length - komma - 1;
199 const auffuellung = datenUrl.endsWith('==') ? 2 : datenUrl.endsWith('=') ? 1 : 0;
200 return Math.max(0, Math.floor((nutzlast * 3) / 4) - auffuellung);
201 }
202
203 /**
204 * Entschluesselt eine Daten-URL zu einem Bild.
205 *
206 * UEBER EINEN BLOB UND NICHT UEBER `new Image()` MIT DER DATEN-URL: Der zweite
207 * Weg haengt an der Inhaltssicherheitsrichtlinie (`img-src data:`) und liesse
208 * sich mit einer Aenderung dort stillschweigend abschalten. `createImageBitmap`
209 * greift auf nichts zu, was eine Richtlinie regelt, und gibt das Bild wieder
210 * frei, sobald `close` gerufen ist - bei 24 MiB ist das kein Nebenpunkt.
211 */
212 async function ladeBild(datenUrl: string): Promise<ImageBitmap | null> {
213 const treffer = /^data:([a-z]+\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/=]*)$/i.exec(datenUrl);
214 if (treffer === null) return null;
215 try {
216 const roh = atob(treffer[2]!);
217 const bytes = new Uint8Array(roh.length);
218 for (let i = 0; i < roh.length; i += 1) bytes[i] = roh.charCodeAt(i);
219 return await createImageBitmap(new Blob([bytes], { type: treffer[1]! }));
220 } catch {
221 /*
222 * Eine unlesbare Aufnahme darf den Ausdruck nicht verhindern. Sie ist die
223 * Beigabe des Blattes, nicht sein Gegenstand - die Fahrlinien sind der
224 * Nachweis, und die stehen ohne sie.
225 */
226 return null;
227 }
228 }