|
5 | 5 | import { loadArticles } from "$lib/utils/loadArticles"; |
6 | 6 | import { parseDateLoose } from "$lib/utils/parseDate"; |
7 | 7 | import { detectRegion } from "$lib/utils/detectRegion"; |
| 8 | + import { normalizeDistrict } from "$lib/constants/districts"; |
8 | 9 | import { lang, setLang, availableLangs } from "$lib/i18n"; |
9 | 10 | import { translateDE_EN } from "$lib/utils/translate"; |
10 | 11 | import CatPanel from "./CatPanel.svelte"; |
|
14 | 15 | import CategoryMarkers from "./CategoryMarkers.svelte"; |
15 | 16 | import { |
16 | 17 | TOP_PAD, H_PAD, PX_PER_DAY, LINE_H, MARKER_LABEL_FS, MARKER_DESC_FS, BASELINE_GAP, |
17 | | - DEFAULT_CATEGORIES, |
| 18 | + DEFAULT_CATEGORIES, DISTRICT_ICONS, |
18 | 19 | DEFAULT_SHOW_BILANZ, DEFAULT_SHOW_BERLIN, DEFAULT_SHOW_BRANDENBURG, |
19 | 20 | DEFAULT_REVERSED, DEFAULT_DISPLAY_MODE, DEFAULT_TEXT_ALIGN, |
20 | 21 | } from "./config.js"; |
|
130 | 131 | for (const cat of categories) { |
131 | 132 | const catItems = parsed |
132 | 133 | .filter((p) => matchesCategory(p.raw, cat)) |
133 | | - .map((p) => ({ ...p, catId: cat.id, color: cat.color, active: cat.on })); |
| 134 | + .map((p) => ({ ...p, catId: cat.id, color: cat.color, active: cat.on, icon: cat.icon ?? "", district: normalizeDistrict(p.raw.ExtractedDistrict, detectRegion(p.raw)) })); |
134 | 135 | newCounts[cat.id] = catItems.length; |
135 | 136 | catItems.forEach((it) => preItems.push(it)); |
136 | 137 | } |
|
301 | 302 | let exporting = $state(false); |
302 | 303 | let exportingPng = $state(false); |
303 | 304 |
|
| 305 | + /** Cached base64 font data so we only fetch once per session. */ |
| 306 | + let _fontB64 = /** @type {string|null} */ (null); |
| 307 | +
|
| 308 | + async function injectFontStyle(clone) { |
| 309 | + if (!_fontB64) { |
| 310 | + const buf = await (await fetch('/fonts/Pitch_Semibold.otf')).arrayBuffer(); |
| 311 | + const bytes = new Uint8Array(buf); |
| 312 | + let bin = ''; |
| 313 | + for (const b of bytes) bin += String.fromCharCode(b); |
| 314 | + _fontB64 = btoa(bin); |
| 315 | + } |
| 316 | + const ns = 'http://www.w3.org/2000/svg'; |
| 317 | + let defs = clone.querySelector('defs'); |
| 318 | + if (!defs) { |
| 319 | + defs = document.createElementNS(ns, 'defs'); |
| 320 | + clone.insertBefore(defs, clone.firstChild); |
| 321 | + } |
| 322 | + const style = document.createElementNS(ns, 'style'); |
| 323 | + style.textContent = [ |
| 324 | + ':root, svg { --font-mono: "Pitch Sans", Courier, monospace; }', |
| 325 | + '@font-face {', |
| 326 | + ' font-family: "Pitch Sans";', |
| 327 | + ` src: url("data:font/otf;base64,${_fontB64}") format("opentype");`, |
| 328 | + ' font-weight: 600; font-style: normal;', |
| 329 | + '}', |
| 330 | + ].join('\n'); |
| 331 | + defs.insertBefore(style, defs.firstChild); |
| 332 | + } |
| 333 | +
|
304 | 334 | /** Appends a category legend to a cloned SVG; returns added height. */ |
305 | 335 | function appendLegend(clone) { |
306 | 336 | const ns = "http://www.w3.org/2000/svg"; |
|
339 | 369 | return 16 + rows * (chipH + gap) + 10; |
340 | 370 | } |
341 | 371 |
|
342 | | - function exportSVG() { |
| 372 | + async function exportSVG() { |
343 | 373 | if (!svgEl) return; |
344 | 374 | exporting = true; |
345 | 375 | const clone = /** @type {SVGSVGElement} */ (svgEl.cloneNode(true)); |
346 | 376 | clone.setAttribute("width", String(dataSvgW)); |
347 | 377 | clone.querySelector(".zoom-group")?.setAttribute("transform", ""); |
348 | 378 | const legendH = appendLegend(clone); |
349 | 379 | clone.setAttribute("height", String(svgH + legendH)); |
| 380 | + await injectFontStyle(clone); |
350 | 381 | const blob = new Blob([new XMLSerializer().serializeToString(clone)], { |
351 | 382 | type: "image/svg+xml", |
352 | 383 | }); |
|
368 | 399 | const legendH = appendLegend(clone); |
369 | 400 | const totalH = svgH + legendH; |
370 | 401 | clone.setAttribute("height", String(totalH)); |
| 402 | +
|
| 403 | + // For PNG we only need the CSS variable resolved — the browser already has |
| 404 | + // Pitch Sans cached from the page, so we skip the heavy base64 font blob. |
| 405 | + { |
| 406 | + const ns = "http://www.w3.org/2000/svg"; |
| 407 | + let defs = clone.querySelector("defs"); |
| 408 | + if (!defs) { defs = document.createElementNS(ns, "defs"); clone.insertBefore(defs, clone.firstChild); } |
| 409 | + const style = document.createElementNS(ns, "style"); |
| 410 | + style.textContent = ':root, svg { --font-mono: "Pitch Sans", Courier, monospace; }'; |
| 411 | + defs.insertBefore(style, defs.firstChild); |
| 412 | + } |
| 413 | +
|
| 414 | + // Scale up to 3× but stay within Chrome's canvas limits: |
| 415 | + // 32 767 px per side, 268 M px total area. |
| 416 | + const MAX_DIM = 32767; |
| 417 | + const MAX_AREA = 268_000_000; |
| 418 | + const scale = Math.min( |
| 419 | + 3, |
| 420 | + MAX_DIM / dataSvgW, |
| 421 | + MAX_DIM / totalH, |
| 422 | + Math.sqrt(MAX_AREA / (dataSvgW * totalH)), |
| 423 | + ); |
| 424 | + const outW = Math.round(dataSvgW * scale); |
| 425 | + const outH = Math.round(totalH * scale); |
| 426 | + clone.setAttribute("viewBox", `0 0 ${dataSvgW} ${totalH}`); |
| 427 | + clone.setAttribute("width", String(outW)); |
| 428 | + clone.setAttribute("height", String(outH)); |
| 429 | +
|
371 | 430 | const s = new XMLSerializer().serializeToString(clone); |
372 | | - const canvas = document.createElement("canvas"); |
373 | | - canvas.width = dataSvgW; |
374 | | - canvas.height = totalH; |
375 | | - const ctx = canvas.getContext("2d"); |
| 431 | + const svgBlob = new Blob([s], { type: "image/svg+xml" }); |
| 432 | + const svgUrl = URL.createObjectURL(svgBlob); |
376 | 433 | const img = new Image(); |
377 | | - await new Promise((r) => { |
378 | | - img.onload = r; |
379 | | - img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(s); |
| 434 | + await new Promise((resolve, reject) => { |
| 435 | + img.onload = resolve; |
| 436 | + img.onerror = reject; |
| 437 | + img.src = svgUrl; |
380 | 438 | }); |
| 439 | + URL.revokeObjectURL(svgUrl); |
| 440 | +
|
| 441 | + const canvas = document.createElement("canvas"); |
| 442 | + canvas.width = outW; |
| 443 | + canvas.height = outH; |
| 444 | + const ctx = canvas.getContext("2d"); |
381 | 445 | if (ctx) { |
382 | 446 | ctx.fillStyle = "#ffffff"; |
383 | | - ctx.fillRect(0, 0, canvas.width, canvas.height); |
384 | | - ctx.drawImage(img, 0, 0); |
| 447 | + ctx.fillRect(0, 0, outW, outH); |
| 448 | + ctx.drawImage(img, 0, 0, outW, outH); |
385 | 449 | } |
386 | | - const a = Object.assign(document.createElement("a"), { |
387 | | - href: canvas.toDataURL("image/png"), |
388 | | - download: "timeline-categories.png", |
| 450 | +
|
| 451 | + await new Promise((resolve) => { |
| 452 | + canvas.toBlob((pngBlob) => { |
| 453 | + if (!pngBlob) { resolve(); return; } |
| 454 | + const pngUrl = URL.createObjectURL(pngBlob); |
| 455 | + Object.assign(document.createElement("a"), { |
| 456 | + href: pngUrl, download: "timeline-categories.png", |
| 457 | + }).click(); |
| 458 | + setTimeout(() => URL.revokeObjectURL(pngUrl), 1000); |
| 459 | + resolve(); |
| 460 | + }, "image/png"); |
389 | 461 | }); |
390 | | - a.click(); |
391 | 462 | exportingPng = false; |
392 | 463 | } |
393 | 464 | </script> |
|
402 | 473 | <svg bind:this={svgEl}> |
403 | 474 | <g class="zoom-group" transform={zoomTransform}> |
404 | 475 | <TimelineGrid {ticks} baseline={baseline()} {dataSvgW} /> |
405 | | - <TimelineItems {placed} baseline={baseline()} {translatedMap} lang={$lang} {textAlign} /> |
| 476 | + <TimelineItems {placed} baseline={baseline()} {translatedMap} lang={$lang} {textAlign} locationIcons={DISTRICT_ICONS} /> |
406 | 477 | <CategoryMarkers {catMarkers} {counts} baseline={baseline()} {translatedMap} lang={$lang} /> |
407 | 478 | </g> |
408 | 479 | </svg> |
|
0 commit comments