Skip to content

Commit 6fa3649

Browse files
committed
timeline icons
1 parent 76cc78c commit 6fa3649

3 files changed

Lines changed: 271 additions & 49 deletions

File tree

‎src/routes/timeline-categorical/+page.svelte‎

Lines changed: 88 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { loadArticles } from "$lib/utils/loadArticles";
66
import { parseDateLoose } from "$lib/utils/parseDate";
77
import { detectRegion } from "$lib/utils/detectRegion";
8+
import { normalizeDistrict } from "$lib/constants/districts";
89
import { lang, setLang, availableLangs } from "$lib/i18n";
910
import { translateDE_EN } from "$lib/utils/translate";
1011
import CatPanel from "./CatPanel.svelte";
@@ -14,7 +15,7 @@
1415
import CategoryMarkers from "./CategoryMarkers.svelte";
1516
import {
1617
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,
1819
DEFAULT_SHOW_BILANZ, DEFAULT_SHOW_BERLIN, DEFAULT_SHOW_BRANDENBURG,
1920
DEFAULT_REVERSED, DEFAULT_DISPLAY_MODE, DEFAULT_TEXT_ALIGN,
2021
} from "./config.js";
@@ -130,7 +131,7 @@
130131
for (const cat of categories) {
131132
const catItems = parsed
132133
.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)) }));
134135
newCounts[cat.id] = catItems.length;
135136
catItems.forEach((it) => preItems.push(it));
136137
}
@@ -301,6 +302,35 @@
301302
let exporting = $state(false);
302303
let exportingPng = $state(false);
303304
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+
304334
/** Appends a category legend to a cloned SVG; returns added height. */
305335
function appendLegend(clone) {
306336
const ns = "http://www.w3.org/2000/svg";
@@ -339,14 +369,15 @@
339369
return 16 + rows * (chipH + gap) + 10;
340370
}
341371
342-
function exportSVG() {
372+
async function exportSVG() {
343373
if (!svgEl) return;
344374
exporting = true;
345375
const clone = /** @type {SVGSVGElement} */ (svgEl.cloneNode(true));
346376
clone.setAttribute("width", String(dataSvgW));
347377
clone.querySelector(".zoom-group")?.setAttribute("transform", "");
348378
const legendH = appendLegend(clone);
349379
clone.setAttribute("height", String(svgH + legendH));
380+
await injectFontStyle(clone);
350381
const blob = new Blob([new XMLSerializer().serializeToString(clone)], {
351382
type: "image/svg+xml",
352383
});
@@ -368,26 +399,66 @@
368399
const legendH = appendLegend(clone);
369400
const totalH = svgH + legendH;
370401
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+
371430
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);
376433
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;
380438
});
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");
381445
if (ctx) {
382446
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);
385449
}
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");
389461
});
390-
a.click();
391462
exportingPng = false;
392463
}
393464
</script>
@@ -402,7 +473,7 @@
402473
<svg bind:this={svgEl}>
403474
<g class="zoom-group" transform={zoomTransform}>
404475
<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} />
406477
<CategoryMarkers {catMarkers} {counts} baseline={baseline()} {translatedMap} lang={$lang} />
407478
</g>
408479
</svg>

‎src/routes/timeline-categorical/TimelineItems.svelte‎

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,83 @@
33
<script>
44
import { FS, CHAR_W } from "./config.js";
55
6-
let { placed, baseline, translatedMap, lang, textAlign = "start" } = $props();
6+
let {
7+
placed,
8+
baseline,
9+
translatedMap,
10+
lang,
11+
textAlign = "start",
12+
locationIcons = {},
13+
} = $props();
714
</script>
815

916
{#each placed as item}
1017
{@const catColor = item.color ?? "#999"}
1118
{@const op = item.active ? 1 : 0.18}
12-
{@const fillOp = op}
13-
{@const textOp = op}
14-
{@const displayLabel = lang === "en" ? (translatedMap[item.label] ?? item.label) : item.label}
19+
{@const catIcon = item.icon ?? ""}
20+
{@const locIcon =
21+
item.district && locationIcons[item.district]
22+
? `[${locationIcons[item.district]}]`
23+
: ""}
24+
{@const prefix = `${catIcon}${locIcon} `}
25+
{@const rawLabel =
26+
lang === "en" ? (translatedMap[item.label] ?? item.label) : item.label}
27+
{@const displayLabel = `${prefix}${rawLabel}`}
1528
{@const textY = baseline - item.y - 1}
1629
{@const tw = Math.ceil(displayLabel.length * CHAR_W)}
17-
{@const rectX = textAlign === "middle" ? item.x - tw / 2 - 2 : textAlign === "end" ? item.x - tw - 2 : item.x - 2}
30+
{@const rectX =
31+
textAlign === "middle"
32+
? item.x - tw / 2 - 2
33+
: textAlign === "end"
34+
? item.x - tw - 2
35+
: item.x - 2}
1836
{#if item.raw?.URL}
1937
<a href={item.raw.URL} target="_blank" rel="noreferrer" class="item-link">
20-
<rect x={rectX} y={textY - FS} width={tw + 4} height={FS + 3}
21-
fill={catColor} fill-opacity={fillOp}
22-
stroke="none" stroke-width={0} />
23-
<text x={item.x} y={textY} style="font-family: var(--font-mono)"font-size={FS}
24-
text-anchor={textAlign} fill="#000" opacity={textOp} class="item">
25-
{displayLabel}
38+
<rect
39+
x={rectX}
40+
y={textY - FS}
41+
width={tw + 4}
42+
height={FS + 3}
43+
fill={catColor}
44+
fill-opacity={op}
45+
stroke="none"
46+
stroke-width={0}
47+
/>
48+
<text
49+
x={item.x}
50+
y={textY}
51+
style="font-family: var(--font-mono)"
52+
font-size={FS}
53+
text-anchor={textAlign}
54+
fill="#000"
55+
opacity={op}
56+
class="item"
57+
>
58+
<tspan font-weight="900" font-size={FS / 2} dy={-(FS - FS / 2) / 4}>{prefix}</tspan><tspan dy={(FS - FS / 2) / 4}>{rawLabel}</tspan>
2659
</text>
2760
</a>
2861
{:else}
29-
<rect x={rectX} y={textY - FS} width={tw + 4} height={FS + 3}
30-
fill={catColor} fill-opacity={fillOp}
31-
stroke="none" stroke-width={0} />
32-
<text x={item.x} y={textY} style="font-family: var(--font-mono)"font-size={FS}
33-
text-anchor={textAlign} fill="#000" opacity={textOp} class="item">
34-
{displayLabel}
62+
<rect
63+
x={rectX}
64+
y={textY - FS}
65+
width={tw + 4}
66+
height={FS + 3}
67+
fill={catColor}
68+
fill-opacity={op}
69+
stroke="none"
70+
stroke-width={0}
71+
/>
72+
<text
73+
x={item.x}
74+
y={textY}
75+
style="font-family: var(--font-mono)"
76+
font-size={FS}
77+
text-anchor={textAlign}
78+
fill="#000"
79+
opacity={op}
80+
class="item"
81+
>
82+
<tspan font-weight="900" font-size={FS / 2} dy={-(FS - FS / 2) / 4}>{prefix}</tspan><tspan dy={(FS - FS / 2) / 4}>{rawLabel}</tspan>
3583
</text>
3684
{/if}
3785
{/each}

0 commit comments

Comments
 (0)