lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ src services export blattbild.ts
| 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 | } |