@opendata-ai/openchart-vanilla 8.8.0 → 8.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/{chunk-SXTHPXG6.js → chunk-APMUBW72.js} +174 -65
  2. package/dist/chunk-APMUBW72.js.map +1 -0
  3. package/dist/{chunk-QBTRAQAX.js → chunk-CCRFYGHU.js} +2299 -1766
  4. package/dist/chunk-CCRFYGHU.js.map +1 -0
  5. package/dist/{chunk-5IMVUHVC.js → chunk-GX6BWMJD.js} +23 -21
  6. package/dist/chunk-GX6BWMJD.js.map +1 -0
  7. package/dist/{chunk-DFKLAB67.js → chunk-MGFXAROR.js} +20 -8
  8. package/dist/chunk-MGFXAROR.js.map +1 -0
  9. package/dist/{chunk-EZQDCLZN.js → chunk-Q7YEEF2X.js} +3 -2
  10. package/dist/{chunk-EZQDCLZN.js.map → chunk-Q7YEEF2X.js.map} +1 -1
  11. package/dist/{chunk-I73B3DJI.js → chunk-R5TZHNTG.js} +23 -5
  12. package/dist/chunk-R5TZHNTG.js.map +1 -0
  13. package/dist/{chunk-2OJZBMXM.js → chunk-REH5UQCL.js} +46 -46
  14. package/dist/{chunk-2OJZBMXM.js.map → chunk-REH5UQCL.js.map} +1 -1
  15. package/dist/{chunk-NG7CVCI2.js → chunk-UC4IHEUO.js} +16 -1
  16. package/dist/chunk-UC4IHEUO.js.map +1 -0
  17. package/dist/export-gif.d.ts +2 -2
  18. package/dist/export-gif.js +2 -2
  19. package/dist/graph-3d/index.d.ts +0 -27
  20. package/dist/graph-3d/index.js +9 -5
  21. package/dist/graph-3d/index.js.map +1 -1
  22. package/dist/index.d.ts +27 -5
  23. package/dist/index.js +635 -214
  24. package/dist/index.js.map +1 -1
  25. package/dist/{mount-WiY9BIr9.d.ts → mount-TEjIdF0m.d.ts} +16 -1
  26. package/dist/static.js +3 -3
  27. package/dist/story/index.d.ts +2 -2
  28. package/dist/story/index.js +5 -5
  29. package/dist/styles.css +1 -1
  30. package/dist/styles.css.map +1 -1
  31. package/package.json +4 -4
  32. package/dist/chunk-5IMVUHVC.js.map +0 -1
  33. package/dist/chunk-DFKLAB67.js.map +0 -1
  34. package/dist/chunk-I73B3DJI.js.map +0 -1
  35. package/dist/chunk-NG7CVCI2.js.map +0 -1
  36. package/dist/chunk-QBTRAQAX.js.map +0 -1
  37. package/dist/chunk-SXTHPXG6.js.map +0 -1
@@ -5,11 +5,16 @@ import {
5
5
  getSVGBackgroundColor,
6
6
  getSVGDimensions,
7
7
  rasterizeSVGToCanvas
8
- } from "./chunk-I73B3DJI.js";
8
+ } from "./chunk-R5TZHNTG.js";
9
9
  import {
10
10
  injectThemeStyleBlock
11
11
  } from "./chunk-SDJHUELU.js";
12
12
 
13
+ // src/export-gif.ts
14
+ import {
15
+ sampleMotion
16
+ } from "@opendata-ai/openchart-core";
17
+
13
18
  // src/gif-encode.ts
14
19
  function readCanvasSRGB(canvas) {
15
20
  const ctx = canvas.getContext("2d");
@@ -52,6 +57,11 @@ function ease(p, _kind) {
52
57
  }
53
58
  function classifyTarget(groupOrEl) {
54
59
  const cls = groupOrEl.getAttribute("class") ?? "";
60
+ if (cls.includes("oc-stack-reveal"))
61
+ return {
62
+ el: groupOrEl,
63
+ kind: groupOrEl.getAttribute("data-orient") === "horizontal" ? "bar-horizontal" : "bar-vertical"
64
+ };
55
65
  if (cls.includes("oc-mark-rect") || cls.includes("oc-mark-bar")) {
56
66
  const rect = groupOrEl.querySelector("rect") ?? groupOrEl.querySelector("path");
57
67
  if (!rect) return null;
@@ -70,6 +80,7 @@ function collectTargets(clone, enterDurationMs, staggerMs) {
70
80
  const els = clone.querySelectorAll("[data-animation-index]");
71
81
  const targets = [];
72
82
  for (const el of els) {
83
+ if (!el.classList.contains("oc-stack-reveal") && el.closest(".oc-stack-reveal")) continue;
73
84
  const classified = classifyTarget(el);
74
85
  if (!classified) continue;
75
86
  const idx = parseInt(el.getAttribute("data-animation-index") ?? "0", 10) || 0;
@@ -105,27 +116,28 @@ function resolveRepeat(loop) {
105
116
  if (typeof loop === "number") return loop;
106
117
  return -1;
107
118
  }
108
- function applyFrameState(target, nowMs, easeKind) {
119
+ function applyFrameState(target, nowMs, easeKind, phase) {
109
120
  const p = clamp01((nowMs - target.startMs) / target.durationMs);
110
- const eased = ease(p, easeKind);
121
+ const progress = (fraction) => phase?.spring ? sampleMotion(phase, clamp01(fraction) * phase.duration).position : ease(clamp01(fraction), easeKind);
122
+ const eased = progress(p);
111
123
  const style = target.el.style;
112
124
  switch (target.kind) {
113
125
  case "bar-vertical": {
114
126
  const inset = (1 - eased) * 100;
115
127
  style.clipPath = `inset(${inset}% 0 0 0)`;
116
- style.opacity = String(clamp01(p / 0.75));
128
+ style.opacity = String(progress(p / 0.75));
117
129
  break;
118
130
  }
119
131
  case "bar-horizontal": {
120
132
  const inset = (1 - eased) * 100;
121
133
  style.clipPath = `inset(0 ${inset}% 0 0)`;
122
- style.opacity = String(clamp01(p / 0.75));
134
+ style.opacity = String(progress(p / 0.75));
123
135
  break;
124
136
  }
125
137
  case "line-area": {
126
138
  const inset = (1 - eased) * 100;
127
139
  style.clipPath = `inset(0 ${inset}% 0 0)`;
128
- style.opacity = String(clamp01(p / 0.15));
140
+ style.opacity = String(progress(p / 0.15));
129
141
  break;
130
142
  }
131
143
  case "fade": {
@@ -194,7 +206,7 @@ async function exportGIF(svgElement, animation, options) {
194
206
  if (nowMs === null) {
195
207
  for (const t of targets) clearFrameState(t);
196
208
  } else {
197
- for (const t of targets) applyFrameState(t, nowMs, easeKind);
209
+ for (const t of targets) applyFrameState(t, nowMs, easeKind, enter || void 0);
198
210
  }
199
211
  const svgString = ensureSVGDimensions(exportSVG(clone), width, height);
200
212
  const canvas = await rasterizeSVGToCanvas(svgString, width, height, dpi, fillBackground);
@@ -231,4 +243,4 @@ export {
231
243
  applyFrameState,
232
244
  exportGIF
233
245
  };
234
- //# sourceMappingURL=chunk-DFKLAB67.js.map
246
+ //# sourceMappingURL=chunk-MGFXAROR.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/export-gif.ts","../src/gif-encode.ts"],"sourcesContent":["/**\n * Animated GIF export.\n *\n * Unlike PNG/JPG (single static frame), GIF export must reproduce the chart's\n * entrance animation across many frames. OpenChart entrance animations are pure\n * CSS keyframes that mutate `clip-path`/`opacity`/`transform` in *computed*\n * style, which `XMLSerializer` does not capture — so serializing the live,\n * mid-animation SVG yields the base markup on every frame (a static GIF).\n *\n * This module sidesteps that by *synthesizing* frames deterministically: it\n * takes the settled, final-state SVG plus the resolved animation config,\n * computes each animated element's value at time `t` in JS, stamps\n * `clip-path`/`opacity` as INLINE attributes on a clone (inline values DO\n * serialize), rasterizes that clone, and encodes each frame with gifenc.\n *\n * This mirrors the entrance keyframes in `packages/core/src/styles/*.css`:\n * bars clip bottom-up / left-right, line/area clip left-right, arcs and points\n * fade. Only the entrance is reproduced; the on-screen CSS animation is\n * untouched. `gifenc` is an optional peer dependency, loaded dynamically.\n */\n\nimport {\n type AnimationEase,\n type MotionPhase,\n type ResolvedAnimation,\n type ResolvedTheme,\n sampleMotion,\n} from '@opendata-ai/openchart-core';\nimport {\n embedFonts,\n ensureSVGDimensions,\n exportSVG,\n getSVGBackgroundColor,\n getSVGDimensions,\n rasterizeSVGToCanvas,\n} from './export';\nimport { readCanvasSRGB } from './gif-encode';\nimport { injectThemeStyleBlock } from './theme-style-block';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface GIFExportOptions {\n /** Frames per second to capture. Defaults to 25. */\n fps?: number;\n /** DPI scaling factor, matching PNG export. Defaults to 2. */\n dpi?: number;\n /**\n * Loop behavior. Defaults to play-once (hold the final frame):\n * - `false` / omitted: play once, then hold the final frame\n * - `true`: loop forever\n * - a number: loop that many times, then hold\n */\n loop?: boolean | number;\n /**\n * Explicit capture duration in ms. Defaults to the resolved animation total\n * (enter duration + last element's stagger delay + a small tail).\n */\n durationMs?: number;\n /** Embed fonts as base64 data URIs so text matches on-screen. Defaults to true. */\n embedFonts?: boolean;\n /**\n * Opaque background fill. GIF can't carry partial alpha, so a transparent\n * chart would composite onto black. Defaults to the chart's own background\n * color (or white if the chart is transparent).\n */\n backgroundColor?: string;\n /**\n * Resolved theme to inline as a `<style>` block so class-based fills (metric\n * cells, eyebrow dot, legend text) survive serialization. Omit to skip.\n */\n theme?: ResolvedTheme;\n}\n\n/** Which visual property an element's entrance keyframe animates. */\nexport type AnimationKind = 'bar-vertical' | 'bar-horizontal' | 'line-area' | 'fade';\n\nexport interface AnimatedTarget {\n /** The element in the CLONE that receives per-frame inline styles. */\n el: SVGElement;\n kind: AnimationKind;\n /** Start offset in ms (stagger * mark index). */\n startMs: number;\n /** Duration of this element's own animation in ms. */\n durationMs: number;\n}\n\n// ---------------------------------------------------------------------------\n// Easing — replicate --oc-ease-smooth (CSS linear() sample points)\n// ---------------------------------------------------------------------------\n\n/**\n * Sample points of the `--oc-ease-smooth` token (see tokens.css). CSS\n * `linear()` interpolates piecewise-linearly between evenly-spaced samples, so\n * we reproduce it exactly rather than approximating with a cubic-bezier.\n */\nexport const EASE_SMOOTH_SAMPLES = [\n 0, 0.157, 0.438, 0.64, 0.766, 0.85, 0.906, 0.941, 0.964, 0.978, 0.988, 0.994, 0.998, 1,\n];\n\n/** Piecewise-linear evaluation of a CSS linear() sample array at t in [0,1]. */\nexport function evalLinearSamples(samples: number[], t: number): number {\n if (t <= 0) return samples[0];\n if (t >= 1) return samples[samples.length - 1];\n const scaled = t * (samples.length - 1);\n const i = Math.floor(scaled);\n const frac = scaled - i;\n return samples[i] + (samples[i + 1] - samples[i]) * frac;\n}\n\n/** Ease a normalized progress value. Only 'smooth' is sample-accurate today. */\nfunction ease(p: number, _kind: AnimationEase): number {\n // The entrance rules use --oc-ease-smooth (or fall back to it) in all cases\n // GIF export handles today. Snappy/others could add their own samples later.\n return evalLinearSamples(EASE_SMOOTH_SAMPLES, p);\n}\n\n// ---------------------------------------------------------------------------\n// Target discovery\n// ---------------------------------------------------------------------------\n\n/**\n * Classify an animated element (identified by `data-animation-index`) into the\n * keyframe it plays, and return the element that should receive inline styles.\n * Returns null only when a bar group has no shape child to clip. Unrecognized\n * mark types (map/tilemap/table/text) fall back to a plain opacity fade rather\n * than a hard pop-in — see the generic case at the end.\n */\nexport function classifyTarget(\n groupOrEl: SVGElement,\n): { el: SVGElement; kind: AnimationKind } | null {\n const cls = groupOrEl.getAttribute('class') ?? '';\n\n if (cls.includes('oc-stack-reveal'))\n return {\n el: groupOrEl,\n kind:\n groupOrEl.getAttribute('data-orient') === 'horizontal' ? 'bar-horizontal' : 'bar-vertical',\n };\n\n // Bars: CSS animates the child <rect>; clip + fade. Orient on the group.\n if (cls.includes('oc-mark-rect') || cls.includes('oc-mark-bar')) {\n const rect = groupOrEl.querySelector('rect') ?? groupOrEl.querySelector('path');\n if (!rect) return null;\n const horizontal = groupOrEl.getAttribute('data-orient') === 'horizontal';\n return { el: rect as SVGElement, kind: horizontal ? 'bar-horizontal' : 'bar-vertical' };\n }\n\n // Line/area: the whole group clips left-to-right + fades.\n if (cls.includes('oc-mark-line') || cls.includes('oc-mark-area')) {\n return { el: groupOrEl, kind: 'line-area' };\n }\n\n // Arcs and points: fade only (scale breaks arc/point positioning, so the CSS\n // uses a plain opacity fade too).\n if (\n cls.includes('oc-mark-arc') ||\n cls.includes('oc-mark-point') ||\n cls.includes('oc-mark-circle')\n ) {\n return { el: groupOrEl, kind: 'fade' };\n }\n\n // Everything else (text labels, rules, ticks, map/tilemap/table marks) is a\n // plain fade; treat generically so the frame isn't a hard pop-in.\n return { el: groupOrEl, kind: 'fade' };\n}\n\n/**\n * Build the list of animated targets from a cloned SVG, pairing each element\n * with its timing. Elements carry `data-animation-index` (the mark index that\n * drives stagger) stamped by the renderer.\n */\nfunction collectTargets(\n clone: SVGElement,\n enterDurationMs: number,\n staggerMs: number,\n): AnimatedTarget[] {\n const els = clone.querySelectorAll<SVGElement>('[data-animation-index]');\n const targets: AnimatedTarget[] = [];\n\n for (const el of els) {\n if (!el.classList.contains('oc-stack-reveal') && el.closest('.oc-stack-reveal')) continue;\n const classified = classifyTarget(el);\n if (!classified) continue;\n const idx = parseInt(el.getAttribute('data-animation-index') ?? '0', 10) || 0;\n // Points animate at 40% of the base duration (matches animation.css).\n const isPoint =\n classified.kind === 'fade' && /oc-mark-(point|circle)/.test(el.getAttribute('class') ?? '');\n // Points on line/area charts start 35% of a duration in, so they pop as the\n // line draws through them (the `.oc-mark-line ~ circle.oc-mark-point` rule).\n const lineOffset = isPoint && hasLineAreaSibling(el) ? enterDurationMs * 0.35 : 0;\n targets.push({\n el: classified.el,\n kind: classified.kind,\n startMs: staggerMs * idx + lineOffset,\n durationMs: isPoint ? enterDurationMs * 0.4 : enterDurationMs,\n });\n }\n\n return targets;\n}\n\n/**\n * Replicate the CSS sibling combinator `.oc-mark-line ~ circle.oc-mark-point`:\n * true when a line or area mark precedes this point among its siblings.\n */\nfunction hasLineAreaSibling(point: SVGElement): boolean {\n let sib = point.previousElementSibling;\n while (sib) {\n const cls = sib.getAttribute('class') ?? '';\n if (cls.includes('oc-mark-line') || cls.includes('oc-mark-area')) return true;\n sib = sib.previousElementSibling;\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Per-frame interpolation\n// ---------------------------------------------------------------------------\n\nconst clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);\n\n/**\n * Distinguish \"the optional gifenc peer isn't installed\" from a real error\n * thrown while loading it. Node/bundlers use ERR_MODULE_NOT_FOUND /\n * MODULE_NOT_FOUND / \"Cannot find module\" for the former.\n */\nexport function isModuleNotFound(err: unknown): boolean {\n const code = (err as { code?: string })?.code;\n if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') return true;\n const message = (err as { message?: string })?.message ?? '';\n return /cannot find (module|package)|failed to (resolve|fetch)/i.test(message);\n}\n\n/**\n * Map the `loop` option to gifenc's `repeat` value:\n * `true` → 0 (loop forever), a number → that count, otherwise → -1 (play once,\n * hold the final frame — the default and the common editorial case).\n */\nexport function resolveRepeat(loop: boolean | number | undefined): number {\n if (loop === true) return 0;\n if (typeof loop === 'number') return loop;\n return -1;\n}\n\n/**\n * Apply the interpolated entrance state for a target at absolute time `nowMs`,\n * writing inline `clip-path`/`opacity` so it survives serialization.\n */\nexport function applyFrameState(\n target: AnimatedTarget,\n nowMs: number,\n easeKind: AnimationEase,\n phase?: MotionPhase,\n): void {\n const p = clamp01((nowMs - target.startMs) / target.durationMs);\n const progress = (fraction: number) =>\n phase?.spring\n ? sampleMotion(phase, clamp01(fraction) * phase.duration).position\n : ease(clamp01(fraction), easeKind);\n const eased = progress(p);\n const style = target.el.style;\n\n switch (target.kind) {\n case 'bar-vertical': {\n // oc-enter-bar: clip-path inset(100%→0 from top) + opacity (full by 75%).\n const inset = (1 - eased) * 100;\n style.clipPath = `inset(${inset}% 0 0 0)`;\n style.opacity = String(progress(p / 0.75));\n break;\n }\n case 'bar-horizontal': {\n // oc-enter-bar-h: clip-path inset(0 100%→0 from right) + opacity.\n const inset = (1 - eased) * 100;\n style.clipPath = `inset(0 ${inset}% 0 0)`;\n style.opacity = String(progress(p / 0.75));\n break;\n }\n case 'line-area': {\n // oc-enter-line: clip-path inset(0 100%→0 from right); opacity full by 15%.\n const inset = (1 - eased) * 100;\n style.clipPath = `inset(0 ${inset}% 0 0)`;\n style.opacity = String(progress(p / 0.15));\n break;\n }\n case 'fade': {\n // oc-enter-fade-only: opacity 0→1.\n style.opacity = String(eased);\n break;\n }\n }\n}\n\n/** Clear any inline entrance styles so the settled frame renders at rest. */\nfunction clearFrameState(target: AnimatedTarget): void {\n const style = target.el.style;\n style.clipPath = '';\n style.opacity = '';\n}\n\n// ---------------------------------------------------------------------------\n// Export\n// ---------------------------------------------------------------------------\n\n/**\n * Render a chart's entrance animation to an animated GIF Blob.\n *\n * @param svgElement - The settled, fully-rendered chart SVG (animation complete).\n * @param animation - The chart's resolved animation config (for exact timing).\n * When undefined, timing falls back to sensible defaults.\n * @param options - fps, dpi, loop, duration, font-embedding overrides.\n * @returns A Promise resolving to the GIF Blob.\n */\nexport async function exportGIF(\n svgElement: SVGElement,\n animation: ResolvedAnimation | undefined,\n options?: GIFExportOptions,\n): Promise<Blob> {\n let gifenc: typeof import('gifenc');\n try {\n gifenc = await import('gifenc');\n } catch (err) {\n // Only a genuine \"module not found\" means the optional peer is absent. Any\n // other failure (a real bug in gifenc's module eval, a corrupt install) must\n // surface with its context, not the misleading install hint.\n if (isModuleNotFound(err)) {\n throw new Error(\n \"GIF export requires the optional 'gifenc' peer dependency. Install it: npm install gifenc\",\n );\n }\n throw new Error('GIF export failed to load the gifenc encoder', { cause: err });\n }\n const { GIFEncoder, quantize, applyPalette } = gifenc;\n\n const fps = options?.fps ?? 25;\n const dpi = options?.dpi ?? 2;\n const shouldEmbed = options?.embedFonts ?? true;\n const { width, height } = getSVGDimensions(svgElement);\n if (!width || !height) {\n throw new Error(`SVG has zero dimensions (width=${width}, height=${height})`);\n }\n\n // Resolve timing from the config (exact) with defaults as a fallback.\n const enter = animation?.enter;\n const enterDurationMs = enter?.duration ?? 600;\n const staggerMs = enter?.staggerDelay ?? 0;\n const easeKind: AnimationEase = enter?.ease ?? 'smooth';\n\n // Build one font-embedded clone that represents the FINAL frame; every frame\n // derives from it, so fonts are embedded exactly once.\n const clone = svgElement.cloneNode(true) as SVGElement;\n // Inline the theme style block so class-based chrome fills survive the detach\n // from the page stylesheet (metrics, eyebrow dot, legend). No-op when undefined.\n injectThemeStyleBlock(clone, options?.theme);\n if (shouldEmbed) {\n await embedFonts(clone);\n }\n const targets = collectTargets(clone, enterDurationMs, staggerMs);\n\n // Total capture window: explicit override, else the animation total. Mirrors\n // computeAnimationDuration (animation.ts): the window must cover the last\n // mark's stagger + its own duration AND the annotation reveal, which animates\n // at enterDuration + annotationDelay. Without the annotationDelay term, charts\n // with annotations get cut off before the annotations finish fading in.\n const annotationDelay = animation?.annotationDelay ?? 0;\n const lastMarkEnd = targets.reduce(\n (m, t) => Math.max(m, t.startMs + t.durationMs),\n enterDurationMs,\n );\n const lastEnd = Math.max(lastMarkEnd, enterDurationMs + annotationDelay);\n const totalMs = options?.durationMs ?? lastEnd + 300;\n const frameDelay = Math.round(1000 / fps);\n const totalFrames = Math.max(1, Math.round((totalMs / 1000) * fps));\n\n // Loop → gifenc repeat: play-once (-1), forever (0), or explicit count.\n const repeat = resolveRepeat(options?.loop);\n\n // GIF can't carry partial alpha, so fill an opaque background before drawing\n // each frame (else a transparent chart composites onto black). Runs before\n // ctx.scale, so paint the raw device-pixel canvas size.\n // The theme background is the fallback: canvas mark mode suppresses the SVG\n // background rect, so there's nothing in the markup to read it from.\n const bgColor =\n options?.backgroundColor ??\n getSVGBackgroundColor(svgElement, options?.theme?.colors.background);\n const fillBackground = (ctx: CanvasRenderingContext2D, cv: HTMLCanvasElement): void => {\n ctx.fillStyle = bgColor;\n ctx.fillRect(0, 0, cv.width, cv.height);\n };\n\n const gif = GIFEncoder();\n\n // Derive ONE global palette from the settled frame (most complete colors)\n // and reuse it for every frame, so colors stay stable and the file stays\n // small. gifenc writes the global color table from the palette on the first\n // frame; later frames reuse it.\n let globalPalette: number[][] = [];\n await seedPalette(clone, targets, width, height, dpi, fillBackground, (data) => {\n globalPalette = quantize(data, 256);\n });\n\n let frameWritten = 0;\n const writeFrame = async (nowMs: number | null): Promise<void> => {\n if (nowMs === null) {\n for (const t of targets) clearFrameState(t);\n } else {\n for (const t of targets) applyFrameState(t, nowMs, easeKind, enter || undefined);\n }\n const svgString = ensureSVGDimensions(exportSVG(clone), width, height);\n const canvas = await rasterizeSVGToCanvas(svgString, width, height, dpi, fillBackground);\n const index = applyPalette(readCanvasSRGB(canvas), globalPalette);\n // `repeat` is only meaningful on the first frame (it writes the loop\n // extension); passing it later is ignored by gifenc.\n gif.writeFrame(index, canvas.width, canvas.height, {\n palette: globalPalette,\n delay: frameDelay,\n ...(frameWritten === 0 ? { repeat } : {}),\n });\n frameWritten++;\n };\n\n for (let i = 0; i < totalFrames; i++) {\n await writeFrame((i / fps) * 1000);\n }\n // Final settled frame the GIF holds on.\n await writeFrame(null);\n\n gif.finish();\n // gifenc types bytes() as Uint8Array<ArrayBufferLike>; BlobPart wants a view\n // over a plain ArrayBuffer. The runtime value is a valid BufferSource, so the\n // cast is safe — it only bridges the ArrayBufferLike/ArrayBuffer lib mismatch.\n return new Blob([gif.bytes() as BlobPart], { type: 'image/gif' });\n}\n\n/**\n * Render the settled clone once (no per-frame state) to derive the palette\n * source, without writing a frame. Restores frame state afterward.\n */\nasync function seedPalette(\n clone: SVGElement,\n targets: AnimatedTarget[],\n width: number,\n height: number,\n dpi: number,\n prepare: (ctx: CanvasRenderingContext2D, cv: HTMLCanvasElement) => void,\n use: (data: Uint8ClampedArray) => void,\n): Promise<void> {\n for (const t of targets) clearFrameState(t);\n const svgString = ensureSVGDimensions(exportSVG(clone), width, height);\n const canvas = await rasterizeSVGToCanvas(svgString, width, height, dpi, prepare);\n use(readCanvasSRGB(canvas));\n}\n","/**\n * Shared gifenc frame-encoding primitives.\n *\n * Both GIF exporters (`export-gif.ts`, entrance animation; `export-sequence.ts`,\n * spec-swap keyframes) need the same low-level steps: read a canvas back in\n * sRGB (the export canvas may be display-p3, but gifenc's quantizer assumes sRGB\n * bytes), quantize a shared palette, and write indexed frames. They differ only\n * in how they PRODUCE canvases — synthesized per time-step vs. one per spec — so\n * that stays in each caller; only these primitives are shared, which keeps the\n * sRGB-readback subtlety in exactly one place.\n */\n\n/**\n * Read a canvas's pixels back in sRGB. The export canvas may be display-p3 (so\n * PNG/JPG can embed a wide-gamut profile), but GIF is sRGB-only and gifenc's\n * quantizer assumes sRGB bytes — feeding it P3-encoded data would shift and\n * desaturate the GIF. The browser converts P3→sRGB on readback.\n */\nexport function readCanvasSRGB(canvas: HTMLCanvasElement): Uint8ClampedArray {\n const ctx = canvas.getContext('2d');\n if (!ctx) throw new Error('Canvas 2D context not available');\n return ctx.getImageData(0, 0, canvas.width, canvas.height, {\n colorSpace: 'srgb',\n }).data;\n}\n\n/** Quantize a 256-color palette from a canvas's sRGB pixels. */\nexport function paletteFromCanvas(\n canvas: HTMLCanvasElement,\n quantize: (data: Uint8ClampedArray, maxColors: number) => number[][],\n): number[][] {\n return quantize(readCanvasSRGB(canvas), 256);\n}\n"],"mappings":"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","names":[]}
@@ -3,7 +3,7 @@ import {
3
3
  renderChromeElement,
4
4
  renderLegend,
5
5
  stampAnimationVars
6
- } from "./chunk-SXTHPXG6.js";
6
+ } from "./chunk-APMUBW72.js";
7
7
 
8
8
  // src/tilemap-renderer.ts
9
9
  var SVG_NS = "http://www.w3.org/2000/svg";
@@ -304,6 +304,7 @@ function renderTileMapSVG(layout, opts) {
304
304
  const stagger = Math.max(5, Math.round(800 / Math.max(tiles.length, 1)));
305
305
  stampAnimationVars(svg, {
306
306
  duration: animation.enter.duration,
307
+ spring: animation.enter.spring,
307
308
  stagger,
308
309
  annotationDelay: animation.annotationDelay,
309
310
  ease: animation.enter.ease
@@ -327,4 +328,4 @@ function renderTileMapSVG(layout, opts) {
327
328
  export {
328
329
  renderTileMapSVG
329
330
  };
330
- //# sourceMappingURL=chunk-EZQDCLZN.js.map
331
+ //# sourceMappingURL=chunk-Q7YEEF2X.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/tilemap-renderer.ts"],"sourcesContent":["/**\n * TileMap SVG renderer: converts a TileMapLayout into SVG DOM elements.\n *\n * Creates an <svg> with tile rectangles, state code labels, value labels,\n * gradient legend, and chrome. All styling via inline SVG attributes from\n * layout data. Animation is pure CSS, driven by data attributes.\n */\n\nimport type {\n ResolvedAnimation,\n TileMapLayout,\n TileMapTileMark,\n} from '@opendata-ai/openchart-core';\nimport { stampAnimationVars } from './animation-vars';\nimport { renderChromeElement } from './renderers/chrome';\nimport { renderLegend } from './renderers/legend';\nimport { nextSvgId } from './svg-ids';\n\nconst SVG_NS = 'http://www.w3.org/2000/svg';\nconst XLINK_NS = 'http://www.w3.org/1999/xlink';\nconst BRAND_URL = 'https://tryopendata.ai';\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction createSVGElement(tag: string): SVGElement {\n return document.createElementNS(SVG_NS, tag);\n}\n\nfunction setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {\n for (const [key, value] of Object.entries(attrs)) {\n el.setAttribute(key, String(value));\n }\n}\n\n// ---------------------------------------------------------------------------\n// Chrome rendering (delegates to the shared helper for text wrapping)\n// ---------------------------------------------------------------------------\n\nfunction renderChrome(parent: SVGElement, layout: TileMapLayout): void {\n const g = createSVGElement('g');\n g.setAttribute('class', 'oc-chrome');\n\n const { chrome, measureText } = layout;\n const bottomOffset = layout.area.y + layout.area.height;\n\n if (chrome.title) {\n renderChromeElement(g, chrome.title, 'oc-title', 'title', measureText);\n }\n if (chrome.subtitle) {\n renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);\n }\n if (chrome.source) {\n renderChromeElement(\n g,\n { ...chrome.source, y: bottomOffset + chrome.source.y },\n 'oc-source',\n 'source',\n measureText,\n );\n }\n if (chrome.byline) {\n renderChromeElement(\n g,\n { ...chrome.byline, y: bottomOffset + chrome.byline.y },\n 'oc-byline',\n 'byline',\n measureText,\n );\n }\n if (chrome.footer) {\n renderChromeElement(\n g,\n { ...chrome.footer, y: bottomOffset + chrome.footer.y },\n 'oc-footer',\n 'footer',\n measureText,\n );\n }\n\n parent.appendChild(g);\n}\n\n// ---------------------------------------------------------------------------\n// Watermark rendering\n// ---------------------------------------------------------------------------\n\nfunction renderWatermark(parent: SVGElement, layout: TileMapLayout): void {\n if (layout.width < 480) return; // Don't render if too narrow\n\n const { width, height } = layout;\n const { theme } = layout;\n const padding = theme.spacing.padding;\n const rightEdge = width - padding;\n const bottomEdge = height - padding;\n const fill = theme.colors.axis;\n\n const a = createSVGElement('a');\n a.setAttribute('href', BRAND_URL);\n a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);\n a.setAttribute('target', '_blank');\n a.setAttribute('rel', 'noopener');\n a.setAttribute('class', 'oc-chrome-ref');\n\n const text = createSVGElement('text');\n setAttrs(text, {\n x: rightEdge,\n y: bottomEdge,\n 'dominant-baseline': 'alphabetic',\n 'text-anchor': 'end',\n 'font-family': theme.fonts.family,\n 'font-size': 12,\n 'fill-opacity': 0.55,\n });\n (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);\n\n const trySpan = createSVGElement('tspan');\n setAttrs(trySpan, { 'font-weight': 500 });\n trySpan.textContent = 'try';\n\n const openDataSpan = createSVGElement('tspan');\n setAttrs(openDataSpan, { 'font-weight': 600, 'font-size': 16 });\n openDataSpan.textContent = 'OpenData';\n\n const aiSpan = createSVGElement('tspan');\n setAttrs(aiSpan, { 'font-weight': 500 });\n aiSpan.textContent = '.ai';\n\n text.appendChild(trySpan);\n text.appendChild(openDataSpan);\n text.appendChild(aiSpan);\n a.appendChild(text);\n parent.appendChild(a);\n}\n\n// ---------------------------------------------------------------------------\n// Tiles rendering\n// ---------------------------------------------------------------------------\n\nfunction renderTiles(\n parent: SVGElement,\n tiles: TileMapTileMark[],\n animation?: ResolvedAnimation,\n): void {\n const g = createSVGElement('g');\n g.setAttribute('class', 'oc-tilemap-tiles');\n g.setAttribute('role', 'list');\n\n // Compute per-tile delays with jitter for organic feel.\n // Base delay spreads tiles across ~800ms window, jitter adds +-40% variation\n // so some tiles pop in clusters while others have longer gaps.\n const tileDelays: number[] = [];\n if (animation?.enter) {\n const baseStagger = 800 / Math.max(tiles.length, 1);\n let seed = 17;\n for (let i = 0; i < tiles.length; i++) {\n const idx = tiles[i].animationIndex ?? i;\n seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n const jitter = ((seed % 1000) / 1000 - 0.5) * 0.8;\n tileDelays.push(Math.max(0, Math.round(idx * baseStagger * (1 + jitter))));\n }\n }\n\n for (let i = 0; i < tiles.length; i++) {\n const tile = tiles[i];\n const tileGroup = createSVGElement('g');\n tileGroup.setAttribute('class', 'oc-tilemap-tile');\n tileGroup.setAttribute('data-state', tile.stateCode);\n tileGroup.setAttribute('role', 'listitem');\n if (tile.aria?.label) {\n tileGroup.setAttribute('aria-label', tile.aria.label);\n }\n\n if (animation?.enter) {\n const idx = tile.animationIndex ?? i;\n tileGroup.setAttribute('data-animation-index', String(idx));\n (tileGroup as SVGElement & ElementCSSInlineStyle).style.setProperty(\n '--oc-mark-index',\n String(idx),\n );\n (tileGroup as SVGElement & ElementCSSInlineStyle).style.setProperty(\n '--oc-tile-delay',\n `${tileDelays[i]}ms`,\n );\n }\n\n // Tile background rect\n const rect = createSVGElement('rect');\n setAttrs(rect, {\n x: tile.x,\n y: tile.y,\n width: tile.size,\n height: tile.size,\n rx: tile.cornerRadius,\n fill: tile.fill,\n 'fill-opacity': tile.fillOpacity ?? 1,\n stroke: tile.stroke,\n 'stroke-width': tile.strokeWidth,\n });\n tileGroup.appendChild(rect);\n\n // State code label\n const codeLabel = createSVGElement('text');\n setAttrs(codeLabel, {\n x: tile.label.x,\n y: tile.label.y,\n 'text-anchor': 'middle',\n 'dominant-baseline': 'central',\n 'font-family': tile.label.style.fontFamily,\n 'font-size': tile.label.style.fontSize,\n 'font-weight': tile.label.style.fontWeight,\n // A hair of tracking is what keeps a two-letter code legible at 8px now\n // that it is set at 600 instead of 700.\n 'letter-spacing': '0.02em',\n });\n (codeLabel as SVGElement & ElementCSSInlineStyle).style.setProperty(\n 'fill',\n tile.label.style.fill,\n );\n codeLabel.textContent = tile.label.text;\n tileGroup.appendChild(codeLabel);\n\n // Value label (if visible)\n if (tile.valueLabel.visible && tile.valueLabel.text) {\n const valueLabel = createSVGElement('text');\n setAttrs(valueLabel, {\n x: tile.valueLabel.x,\n y: tile.valueLabel.y,\n 'text-anchor': 'middle',\n 'dominant-baseline': 'central',\n 'font-family': tile.valueLabel.style.fontFamily,\n 'font-size': tile.valueLabel.style.fontSize,\n 'font-weight': tile.valueLabel.style.fontWeight,\n });\n (valueLabel as SVGElement & ElementCSSInlineStyle).style.setProperty(\n 'fill',\n tile.valueLabel.style.fill,\n );\n valueLabel.textContent = tile.valueLabel.text;\n tileGroup.appendChild(valueLabel);\n }\n\n g.appendChild(tileGroup);\n }\n\n parent.appendChild(g);\n}\n\n// ---------------------------------------------------------------------------\n// Gradient legend rendering\n// ---------------------------------------------------------------------------\n\nfunction renderGradientLegend(parent: SVGElement, layout: TileMapLayout): void {\n if (!layout.gradientLegend) return;\n\n const { gradientLegend } = layout;\n const g = createSVGElement('g');\n g.setAttribute('class', 'oc-tilemap-legend');\n\n // Build linear gradient in defs\n const defs = parent.querySelector('defs') || createSVGElement('defs');\n const exists = parent.querySelector('defs');\n if (!exists) {\n parent.insertBefore(defs, parent.firstChild);\n }\n\n const gradientId = nextSvgId('oc-tilemap-legend-gradient');\n const grad = createSVGElement('linearGradient');\n grad.id = gradientId;\n grad.setAttribute('x1', '0%');\n grad.setAttribute('y1', '0%');\n grad.setAttribute('x2', '100%');\n grad.setAttribute('y2', '0%');\n\n for (const stop of gradientLegend.colorStops) {\n const s = createSVGElement('stop');\n const attrs: Record<string, string | number> = {\n offset: `${stop.offset * 100}%`,\n 'stop-color': stop.color,\n };\n if (stop.opacity !== undefined) {\n attrs['stop-opacity'] = stop.opacity;\n }\n setAttrs(s, attrs);\n grad.appendChild(s);\n }\n\n (defs as SVGElement).appendChild(grad);\n\n // Gradient bar: square-cornered, so it reads as a color scale rather than a\n // pill-shaped UI control.\n const barHeight = gradientLegend.bounds.height;\n const bar = createSVGElement('rect');\n setAttrs(bar, {\n x: gradientLegend.bounds.x,\n y: gradientLegend.bounds.y,\n width: gradientLegend.bounds.width,\n height: barHeight,\n fill: `url(#${gradientId})`,\n 'shape-rendering': 'crispEdges',\n });\n g.appendChild(bar);\n\n // Min label\n const minText = createSVGElement('text');\n setAttrs(minText, {\n x: gradientLegend.bounds.x,\n y: gradientLegend.bounds.y + gradientLegend.bounds.height + 14,\n 'text-anchor': 'start',\n 'font-family': gradientLegend.labelStyle.fontFamily,\n 'font-size': gradientLegend.labelStyle.fontSize,\n 'font-weight': gradientLegend.labelStyle.fontWeight,\n });\n (minText as SVGElement & ElementCSSInlineStyle).style.setProperty(\n 'fill',\n gradientLegend.labelStyle.fill,\n );\n minText.textContent = gradientLegend.minLabel;\n g.appendChild(minText);\n\n // Max label\n const maxText = createSVGElement('text');\n setAttrs(maxText, {\n x: gradientLegend.bounds.x + gradientLegend.bounds.width,\n y: gradientLegend.bounds.y + gradientLegend.bounds.height + 14,\n 'text-anchor': 'end',\n 'font-family': gradientLegend.labelStyle.fontFamily,\n 'font-size': gradientLegend.labelStyle.fontSize,\n 'font-weight': gradientLegend.labelStyle.fontWeight,\n });\n (maxText as SVGElement & ElementCSSInlineStyle).style.setProperty(\n 'fill',\n gradientLegend.labelStyle.fill,\n );\n maxText.textContent = gradientLegend.maxLabel;\n g.appendChild(maxText);\n\n // Detached \"no data\" swatch, keyed beside the ramp rather than at its low end.\n if (gradientLegend.noData) {\n const nd = gradientLegend.noData;\n const swatch = createSVGElement('rect');\n swatch.setAttribute('class', 'oc-legend-nodata');\n setAttrs(swatch, {\n x: nd.x,\n y: nd.y,\n width: nd.size,\n height: nd.size,\n fill: nd.fill,\n 'shape-rendering': 'crispEdges',\n });\n g.appendChild(swatch);\n\n const ndLabel = createSVGElement('text');\n setAttrs(ndLabel, {\n x: nd.labelX,\n y: nd.labelY,\n 'text-anchor': 'start',\n 'font-family': gradientLegend.labelStyle.fontFamily,\n 'font-size': gradientLegend.labelStyle.fontSize,\n 'font-weight': gradientLegend.labelStyle.fontWeight,\n });\n (ndLabel as SVGElement & ElementCSSInlineStyle).style.setProperty(\n 'fill',\n gradientLegend.labelStyle.fill,\n );\n ndLabel.textContent = nd.label;\n g.appendChild(ndLabel);\n }\n\n parent.appendChild(g);\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Render a TileMapLayout to an SVG element.\n */\nexport function renderTileMapSVG(\n layout: TileMapLayout,\n opts?: { animate?: boolean },\n): SVGSVGElement {\n const { width, height, tiles, a11y, watermark, animation } = layout;\n const animate = opts?.animate && !!animation?.enter;\n\n const svg = createSVGElement('svg') as SVGSVGElement;\n svg.setAttribute('viewBox', `0 0 ${width} ${height}`);\n svg.setAttribute('role', 'img');\n if (a11y.altText) {\n svg.setAttribute('aria-label', a11y.altText);\n }\n\n const classes = animate ? 'oc-tilemap oc-animate' : 'oc-tilemap';\n svg.setAttribute('class', classes);\n\n if (animate && animation?.enter) {\n // Target ~1s total: stagger window ~800ms + per-tile pop ~200ms\n const stagger = Math.max(5, Math.round(800 / Math.max(tiles.length, 1)));\n stampAnimationVars(svg, {\n duration: animation.enter.duration,\n stagger,\n annotationDelay: animation.annotationDelay,\n ease: animation.enter.ease,\n });\n }\n\n // Empty defs element (will be filled by gradient legend)\n const defs = createSVGElement('defs');\n svg.appendChild(defs);\n\n // Render chrome\n renderChrome(svg, layout);\n\n // Render tiles\n renderTiles(svg, tiles, animate ? animation : undefined);\n\n // Render legend (gradient for quantitative, categorical for nominal)\n if (layout.categoricalLegend) {\n renderLegend(svg, layout.categoricalLegend);\n } else {\n renderGradientLegend(svg, layout);\n }\n\n // Render watermark\n if (watermark) {\n renderWatermark(svg, layout);\n }\n\n return svg;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/tilemap-renderer.ts"],"sourcesContent":["/**\n * TileMap SVG renderer: converts a TileMapLayout into SVG DOM elements.\n *\n * Creates an <svg> with tile rectangles, state code labels, value labels,\n * gradient legend, and chrome. All styling via inline SVG attributes from\n * layout data. Animation is pure CSS, driven by data attributes.\n */\n\nimport type {\n ResolvedAnimation,\n TileMapLayout,\n TileMapTileMark,\n} from '@opendata-ai/openchart-core';\nimport { stampAnimationVars } from './animation-vars';\nimport { renderChromeElement } from './renderers/chrome';\nimport { renderLegend } from './renderers/legend';\nimport { nextSvgId } from './svg-ids';\n\nconst SVG_NS = 'http://www.w3.org/2000/svg';\nconst XLINK_NS = 'http://www.w3.org/1999/xlink';\nconst BRAND_URL = 'https://tryopendata.ai';\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction createSVGElement(tag: string): SVGElement {\n return document.createElementNS(SVG_NS, tag);\n}\n\nfunction setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {\n for (const [key, value] of Object.entries(attrs)) {\n el.setAttribute(key, String(value));\n }\n}\n\n// ---------------------------------------------------------------------------\n// Chrome rendering (delegates to the shared helper for text wrapping)\n// ---------------------------------------------------------------------------\n\nfunction renderChrome(parent: SVGElement, layout: TileMapLayout): void {\n const g = createSVGElement('g');\n g.setAttribute('class', 'oc-chrome');\n\n const { chrome, measureText } = layout;\n const bottomOffset = layout.area.y + layout.area.height;\n\n if (chrome.title) {\n renderChromeElement(g, chrome.title, 'oc-title', 'title', measureText);\n }\n if (chrome.subtitle) {\n renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);\n }\n if (chrome.source) {\n renderChromeElement(\n g,\n { ...chrome.source, y: bottomOffset + chrome.source.y },\n 'oc-source',\n 'source',\n measureText,\n );\n }\n if (chrome.byline) {\n renderChromeElement(\n g,\n { ...chrome.byline, y: bottomOffset + chrome.byline.y },\n 'oc-byline',\n 'byline',\n measureText,\n );\n }\n if (chrome.footer) {\n renderChromeElement(\n g,\n { ...chrome.footer, y: bottomOffset + chrome.footer.y },\n 'oc-footer',\n 'footer',\n measureText,\n );\n }\n\n parent.appendChild(g);\n}\n\n// ---------------------------------------------------------------------------\n// Watermark rendering\n// ---------------------------------------------------------------------------\n\nfunction renderWatermark(parent: SVGElement, layout: TileMapLayout): void {\n if (layout.width < 480) return; // Don't render if too narrow\n\n const { width, height } = layout;\n const { theme } = layout;\n const padding = theme.spacing.padding;\n const rightEdge = width - padding;\n const bottomEdge = height - padding;\n const fill = theme.colors.axis;\n\n const a = createSVGElement('a');\n a.setAttribute('href', BRAND_URL);\n a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);\n a.setAttribute('target', '_blank');\n a.setAttribute('rel', 'noopener');\n a.setAttribute('class', 'oc-chrome-ref');\n\n const text = createSVGElement('text');\n setAttrs(text, {\n x: rightEdge,\n y: bottomEdge,\n 'dominant-baseline': 'alphabetic',\n 'text-anchor': 'end',\n 'font-family': theme.fonts.family,\n 'font-size': 12,\n 'fill-opacity': 0.55,\n });\n (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);\n\n const trySpan = createSVGElement('tspan');\n setAttrs(trySpan, { 'font-weight': 500 });\n trySpan.textContent = 'try';\n\n const openDataSpan = createSVGElement('tspan');\n setAttrs(openDataSpan, { 'font-weight': 600, 'font-size': 16 });\n openDataSpan.textContent = 'OpenData';\n\n const aiSpan = createSVGElement('tspan');\n setAttrs(aiSpan, { 'font-weight': 500 });\n aiSpan.textContent = '.ai';\n\n text.appendChild(trySpan);\n text.appendChild(openDataSpan);\n text.appendChild(aiSpan);\n a.appendChild(text);\n parent.appendChild(a);\n}\n\n// ---------------------------------------------------------------------------\n// Tiles rendering\n// ---------------------------------------------------------------------------\n\nfunction renderTiles(\n parent: SVGElement,\n tiles: TileMapTileMark[],\n animation?: ResolvedAnimation,\n): void {\n const g = createSVGElement('g');\n g.setAttribute('class', 'oc-tilemap-tiles');\n g.setAttribute('role', 'list');\n\n // Compute per-tile delays with jitter for organic feel.\n // Base delay spreads tiles across ~800ms window, jitter adds +-40% variation\n // so some tiles pop in clusters while others have longer gaps.\n const tileDelays: number[] = [];\n if (animation?.enter) {\n const baseStagger = 800 / Math.max(tiles.length, 1);\n let seed = 17;\n for (let i = 0; i < tiles.length; i++) {\n const idx = tiles[i].animationIndex ?? i;\n seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n const jitter = ((seed % 1000) / 1000 - 0.5) * 0.8;\n tileDelays.push(Math.max(0, Math.round(idx * baseStagger * (1 + jitter))));\n }\n }\n\n for (let i = 0; i < tiles.length; i++) {\n const tile = tiles[i];\n const tileGroup = createSVGElement('g');\n tileGroup.setAttribute('class', 'oc-tilemap-tile');\n tileGroup.setAttribute('data-state', tile.stateCode);\n tileGroup.setAttribute('role', 'listitem');\n if (tile.aria?.label) {\n tileGroup.setAttribute('aria-label', tile.aria.label);\n }\n\n if (animation?.enter) {\n const idx = tile.animationIndex ?? i;\n tileGroup.setAttribute('data-animation-index', String(idx));\n (tileGroup as SVGElement & ElementCSSInlineStyle).style.setProperty(\n '--oc-mark-index',\n String(idx),\n );\n (tileGroup as SVGElement & ElementCSSInlineStyle).style.setProperty(\n '--oc-tile-delay',\n `${tileDelays[i]}ms`,\n );\n }\n\n // Tile background rect\n const rect = createSVGElement('rect');\n setAttrs(rect, {\n x: tile.x,\n y: tile.y,\n width: tile.size,\n height: tile.size,\n rx: tile.cornerRadius,\n fill: tile.fill,\n 'fill-opacity': tile.fillOpacity ?? 1,\n stroke: tile.stroke,\n 'stroke-width': tile.strokeWidth,\n });\n tileGroup.appendChild(rect);\n\n // State code label\n const codeLabel = createSVGElement('text');\n setAttrs(codeLabel, {\n x: tile.label.x,\n y: tile.label.y,\n 'text-anchor': 'middle',\n 'dominant-baseline': 'central',\n 'font-family': tile.label.style.fontFamily,\n 'font-size': tile.label.style.fontSize,\n 'font-weight': tile.label.style.fontWeight,\n // A hair of tracking is what keeps a two-letter code legible at 8px now\n // that it is set at 600 instead of 700.\n 'letter-spacing': '0.02em',\n });\n (codeLabel as SVGElement & ElementCSSInlineStyle).style.setProperty(\n 'fill',\n tile.label.style.fill,\n );\n codeLabel.textContent = tile.label.text;\n tileGroup.appendChild(codeLabel);\n\n // Value label (if visible)\n if (tile.valueLabel.visible && tile.valueLabel.text) {\n const valueLabel = createSVGElement('text');\n setAttrs(valueLabel, {\n x: tile.valueLabel.x,\n y: tile.valueLabel.y,\n 'text-anchor': 'middle',\n 'dominant-baseline': 'central',\n 'font-family': tile.valueLabel.style.fontFamily,\n 'font-size': tile.valueLabel.style.fontSize,\n 'font-weight': tile.valueLabel.style.fontWeight,\n });\n (valueLabel as SVGElement & ElementCSSInlineStyle).style.setProperty(\n 'fill',\n tile.valueLabel.style.fill,\n );\n valueLabel.textContent = tile.valueLabel.text;\n tileGroup.appendChild(valueLabel);\n }\n\n g.appendChild(tileGroup);\n }\n\n parent.appendChild(g);\n}\n\n// ---------------------------------------------------------------------------\n// Gradient legend rendering\n// ---------------------------------------------------------------------------\n\nfunction renderGradientLegend(parent: SVGElement, layout: TileMapLayout): void {\n if (!layout.gradientLegend) return;\n\n const { gradientLegend } = layout;\n const g = createSVGElement('g');\n g.setAttribute('class', 'oc-tilemap-legend');\n\n // Build linear gradient in defs\n const defs = parent.querySelector('defs') || createSVGElement('defs');\n const exists = parent.querySelector('defs');\n if (!exists) {\n parent.insertBefore(defs, parent.firstChild);\n }\n\n const gradientId = nextSvgId('oc-tilemap-legend-gradient');\n const grad = createSVGElement('linearGradient');\n grad.id = gradientId;\n grad.setAttribute('x1', '0%');\n grad.setAttribute('y1', '0%');\n grad.setAttribute('x2', '100%');\n grad.setAttribute('y2', '0%');\n\n for (const stop of gradientLegend.colorStops) {\n const s = createSVGElement('stop');\n const attrs: Record<string, string | number> = {\n offset: `${stop.offset * 100}%`,\n 'stop-color': stop.color,\n };\n if (stop.opacity !== undefined) {\n attrs['stop-opacity'] = stop.opacity;\n }\n setAttrs(s, attrs);\n grad.appendChild(s);\n }\n\n (defs as SVGElement).appendChild(grad);\n\n // Gradient bar: square-cornered, so it reads as a color scale rather than a\n // pill-shaped UI control.\n const barHeight = gradientLegend.bounds.height;\n const bar = createSVGElement('rect');\n setAttrs(bar, {\n x: gradientLegend.bounds.x,\n y: gradientLegend.bounds.y,\n width: gradientLegend.bounds.width,\n height: barHeight,\n fill: `url(#${gradientId})`,\n 'shape-rendering': 'crispEdges',\n });\n g.appendChild(bar);\n\n // Min label\n const minText = createSVGElement('text');\n setAttrs(minText, {\n x: gradientLegend.bounds.x,\n y: gradientLegend.bounds.y + gradientLegend.bounds.height + 14,\n 'text-anchor': 'start',\n 'font-family': gradientLegend.labelStyle.fontFamily,\n 'font-size': gradientLegend.labelStyle.fontSize,\n 'font-weight': gradientLegend.labelStyle.fontWeight,\n });\n (minText as SVGElement & ElementCSSInlineStyle).style.setProperty(\n 'fill',\n gradientLegend.labelStyle.fill,\n );\n minText.textContent = gradientLegend.minLabel;\n g.appendChild(minText);\n\n // Max label\n const maxText = createSVGElement('text');\n setAttrs(maxText, {\n x: gradientLegend.bounds.x + gradientLegend.bounds.width,\n y: gradientLegend.bounds.y + gradientLegend.bounds.height + 14,\n 'text-anchor': 'end',\n 'font-family': gradientLegend.labelStyle.fontFamily,\n 'font-size': gradientLegend.labelStyle.fontSize,\n 'font-weight': gradientLegend.labelStyle.fontWeight,\n });\n (maxText as SVGElement & ElementCSSInlineStyle).style.setProperty(\n 'fill',\n gradientLegend.labelStyle.fill,\n );\n maxText.textContent = gradientLegend.maxLabel;\n g.appendChild(maxText);\n\n // Detached \"no data\" swatch, keyed beside the ramp rather than at its low end.\n if (gradientLegend.noData) {\n const nd = gradientLegend.noData;\n const swatch = createSVGElement('rect');\n swatch.setAttribute('class', 'oc-legend-nodata');\n setAttrs(swatch, {\n x: nd.x,\n y: nd.y,\n width: nd.size,\n height: nd.size,\n fill: nd.fill,\n 'shape-rendering': 'crispEdges',\n });\n g.appendChild(swatch);\n\n const ndLabel = createSVGElement('text');\n setAttrs(ndLabel, {\n x: nd.labelX,\n y: nd.labelY,\n 'text-anchor': 'start',\n 'font-family': gradientLegend.labelStyle.fontFamily,\n 'font-size': gradientLegend.labelStyle.fontSize,\n 'font-weight': gradientLegend.labelStyle.fontWeight,\n });\n (ndLabel as SVGElement & ElementCSSInlineStyle).style.setProperty(\n 'fill',\n gradientLegend.labelStyle.fill,\n );\n ndLabel.textContent = nd.label;\n g.appendChild(ndLabel);\n }\n\n parent.appendChild(g);\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Render a TileMapLayout to an SVG element.\n */\nexport function renderTileMapSVG(\n layout: TileMapLayout,\n opts?: { animate?: boolean },\n): SVGSVGElement {\n const { width, height, tiles, a11y, watermark, animation } = layout;\n const animate = opts?.animate && !!animation?.enter;\n\n const svg = createSVGElement('svg') as SVGSVGElement;\n svg.setAttribute('viewBox', `0 0 ${width} ${height}`);\n svg.setAttribute('role', 'img');\n if (a11y.altText) {\n svg.setAttribute('aria-label', a11y.altText);\n }\n\n const classes = animate ? 'oc-tilemap oc-animate' : 'oc-tilemap';\n svg.setAttribute('class', classes);\n\n if (animate && animation?.enter) {\n // Target ~1s total: stagger window ~800ms + per-tile pop ~200ms\n const stagger = Math.max(5, Math.round(800 / Math.max(tiles.length, 1)));\n stampAnimationVars(svg, {\n duration: animation.enter.duration,\n spring: animation.enter.spring,\n stagger,\n annotationDelay: animation.annotationDelay,\n ease: animation.enter.ease,\n });\n }\n\n // Empty defs element (will be filled by gradient legend)\n const defs = createSVGElement('defs');\n svg.appendChild(defs);\n\n // Render chrome\n renderChrome(svg, layout);\n\n // Render tiles\n renderTiles(svg, tiles, animate ? animation : undefined);\n\n // Render legend (gradient for quantitative, categorical for nominal)\n if (layout.categoricalLegend) {\n renderLegend(svg, layout.categoricalLegend);\n } else {\n renderGradientLegend(svg, layout);\n }\n\n // Render watermark\n if (watermark) {\n renderWatermark(svg, layout);\n }\n\n return svg;\n}\n"],"mappings":"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","names":[]}
@@ -20,12 +20,30 @@ function ensureSVGDimensions(svgString, width, height) {
20
20
  if (/^<svg[^>]*\swidth\s*=/.test(svgString)) return svgString;
21
21
  return svgString.replace(/^(<svg)/, `$1 width="${width}" height="${height}"`);
22
22
  }
23
+ function inheritedFontProperty(element, root, property) {
24
+ let current = element;
25
+ while (current) {
26
+ const value = current.style.getPropertyValue(property) || current.getAttribute(property);
27
+ const keyword = value?.trim().toLowerCase();
28
+ if (keyword === "initial") return property === "font-weight" ? "400" : "";
29
+ if (value && keyword !== "inherit" && keyword !== "unset") return value;
30
+ if (current === root) break;
31
+ current = current.parentElement;
32
+ }
33
+ return "";
34
+ }
35
+ function normalizeFontWeight(weight) {
36
+ const value = weight.trim();
37
+ return value === "normal" ? "400" : value === "bold" ? "700" : value;
38
+ }
23
39
  function collectUsedFonts(svgElement) {
24
40
  const fonts = /* @__PURE__ */ new Map();
25
- const textElements = svgElement.querySelectorAll("text");
41
+ const textElements = svgElement.querySelectorAll("text, tspan");
26
42
  for (const el of textElements) {
27
- const family = el.getAttribute("font-family");
28
- const weight = el.getAttribute("font-weight") || "400";
43
+ const family = inheritedFontProperty(el, svgElement, "font-family");
44
+ const weight = normalizeFontWeight(
45
+ inheritedFontProperty(el, svgElement, "font-weight") || "400"
46
+ );
29
47
  if (family) {
30
48
  const primary = family.split(",")[0].trim().replace(/["']/g, "");
31
49
  if (!fonts.has(primary)) fonts.set(primary, /* @__PURE__ */ new Set());
@@ -47,7 +65,7 @@ function findFontFaceRules(usedFonts) {
47
65
  for (const rule of rules) {
48
66
  if (!(rule instanceof CSSFontFaceRule)) continue;
49
67
  const familyRaw = rule.style.getPropertyValue("font-family").replace(/["']/g, "").trim();
50
- const weight = rule.style.getPropertyValue("font-weight") || "400";
68
+ const weight = normalizeFontWeight(rule.style.getPropertyValue("font-weight") || "400");
51
69
  const style = rule.style.getPropertyValue("font-style") || "normal";
52
70
  const src = rule.style.getPropertyValue("src");
53
71
  const weights = usedFonts.get(familyRaw);
@@ -279,4 +297,4 @@ export {
279
297
  exportJPG,
280
298
  exportCSV
281
299
  };
282
- //# sourceMappingURL=chunk-I73B3DJI.js.map
300
+ //# sourceMappingURL=chunk-R5TZHNTG.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/export.ts"],"sourcesContent":["/**\n * Export utilities: serialize charts to SVG, PNG, JPG, or CSV.\n *\n * - SVG: serializes the rendered DOM element via XMLSerializer\n * - SVG with fonts: async version that embeds @font-face data URIs\n * - PNG: renders SVG to canvas, then extracts as Blob\n * - JPG: same as PNG but with JPEG compression and background fill\n * - CSV: converts a data array to comma-separated text\n */\n\nimport type { ResolvedTheme } from '@opendata-ai/openchart-core';\nimport { injectThemeStyleBlock } from './theme-style-block';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface SVGExportOptions {\n /** Embed fonts as base64 data URIs in the SVG. Defaults to true. */\n embedFonts?: boolean;\n /**\n * Resolved theme to inline as a `<style>` block, so class-based fills (metric\n * cells, eyebrow dot, legend text, endpoint labels) survive\n * serialization away from the page stylesheet. Omit to skip — a chart whose\n * chrome is all inline-filled exports fine without it.\n */\n theme?: ResolvedTheme;\n}\n\nexport interface PNGExportOptions extends SVGExportOptions {\n /** DPI scaling factor. Defaults to 2 for retina-quality output. */\n dpi?: number;\n}\n\nexport interface JPGExportOptions extends PNGExportOptions {\n /** JPEG quality from 0 to 1. Defaults to 0.92. */\n quality?: number;\n}\n\ninterface FontFaceData {\n family: string;\n weight: string;\n style: string;\n base64: string;\n format: string;\n}\n\n// ---------------------------------------------------------------------------\n// Dimension parsing\n// ---------------------------------------------------------------------------\n\n/**\n * Extract dimensions from an SVG element, trying width/height attributes\n * first, then falling back to viewBox.\n */\nexport function getSVGDimensions(svg: SVGElement): { width: number; height: number } {\n const w = parseFloat(svg.getAttribute('width') || '');\n const h = parseFloat(svg.getAttribute('height') || '');\n if (w && h) return { width: w, height: h };\n\n const vb = svg.getAttribute('viewBox');\n if (vb) {\n const parts = vb.split(/[\\s,]+/).map(Number);\n if (parts.length >= 4 && parts[2] && parts[3]) {\n return { width: parts[2], height: parts[3] };\n }\n }\n\n return { width: 600, height: 400 };\n}\n\n/**\n * Ensure an SVG string has explicit width/height attributes.\n *\n * When an SVG only has a viewBox (no width/height), browsers loading it as\n * an Image blob may use 300x150 as the intrinsic size instead of the viewBox\n * dimensions. This causes clipping at non-1x DPI scaling. Injecting explicit\n * width/height into the root <svg> tag fixes the intrinsic size.\n */\nexport function ensureSVGDimensions(svgString: string, width: number, height: number): string {\n // If the <svg> already has a width attribute, leave it alone\n if (/^<svg[^>]*\\swidth\\s*=/.test(svgString)) return svgString;\n // Inject width and height right after <svg\n return svgString.replace(/^(<svg)/, `$1 width=\"${width}\" height=\"${height}\"`);\n}\n\n// ---------------------------------------------------------------------------\n// Font embedding\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve an authored font declaration, including SVG inheritance. Inline\n * styles win over presentation attributes, as they do in the live renderer.\n * This also works on detached terminal scenes used for exports.\n */\nfunction inheritedFontProperty(element: SVGElement, root: SVGElement, property: string): string {\n let current: SVGElement | null = element;\n while (current) {\n const value = current.style.getPropertyValue(property) || current.getAttribute(property);\n const keyword = value?.trim().toLowerCase();\n // Unlike inheritance, initial resets to normal weight or the browser's\n // default family, which has no authored font face to embed.\n if (keyword === 'initial') return property === 'font-weight' ? '400' : '';\n if (value && keyword !== 'inherit' && keyword !== 'unset') return value;\n if (current === root) break;\n current = current.parentElement as SVGElement | null;\n }\n return '';\n}\n\nfunction normalizeFontWeight(weight: string): string {\n const value = weight.trim();\n return value === 'normal' ? '400' : value === 'bold' ? '700' : value;\n}\n\n/** Collect font families and weights, including styled spans within labels. */\nfunction collectUsedFonts(svgElement: SVGElement): Map<string, Set<string>> {\n const fonts = new Map<string, Set<string>>();\n const textElements = svgElement.querySelectorAll<SVGElement>('text, tspan');\n\n for (const el of textElements) {\n const family = inheritedFontProperty(el, svgElement, 'font-family');\n const weight = normalizeFontWeight(\n inheritedFontProperty(el, svgElement, 'font-weight') || '400',\n );\n if (family) {\n // Take the first font in the stack (e.g., \"Inter, sans-serif\" → \"Inter\")\n const primary = family.split(',')[0].trim().replace(/[\"']/g, '');\n if (!fonts.has(primary)) fonts.set(primary, new Set());\n fonts.get(primary)!.add(String(weight));\n }\n }\n\n return fonts;\n}\n\n/**\n * Find @font-face rules in document stylesheets that match the requested fonts.\n * Returns the src URLs for .woff2 files.\n */\nfunction findFontFaceRules(\n usedFonts: Map<string, Set<string>>,\n): Array<{ family: string; weight: string; style: string; url: string; format: string }> {\n const results: Array<{\n family: string;\n weight: string;\n style: string;\n url: string;\n format: string;\n }> = [];\n\n try {\n for (const sheet of document.styleSheets) {\n let rules: CSSRuleList;\n try {\n rules = sheet.cssRules;\n } catch {\n // Cross-origin stylesheet, skip\n continue;\n }\n\n for (const rule of rules) {\n if (!(rule instanceof CSSFontFaceRule)) continue;\n\n const familyRaw = rule.style.getPropertyValue('font-family').replace(/[\"']/g, '').trim();\n const weight = normalizeFontWeight(rule.style.getPropertyValue('font-weight') || '400');\n const style = rule.style.getPropertyValue('font-style') || 'normal';\n const src = rule.style.getPropertyValue('src');\n\n const weights = usedFonts.get(familyRaw);\n if (!weights) continue;\n\n // Check if this weight is used (handle ranges like \"100 900\")\n const weightMatch = weights.has(weight) || weight.includes(' ');\n if (!weightMatch) continue;\n\n // Extract woff2 URL from src descriptor\n const woff2Match = src.match(/url\\([\"']?([^\"')]+\\.woff2[^\"')]*?)[\"']?\\)/);\n if (woff2Match) {\n results.push({\n family: familyRaw,\n weight,\n style,\n url: woff2Match[1],\n format: 'woff2',\n });\n }\n }\n }\n } catch {\n // Stylesheet access failed entirely, return empty\n }\n\n return results;\n}\n\n/**\n * Fetch font files and convert to base64 data URIs.\n */\nasync function fetchFontsAsBase64(\n fontRules: Array<{ family: string; weight: string; style: string; url: string; format: string }>,\n): Promise<FontFaceData[]> {\n const results: FontFaceData[] = [];\n\n const fetches = fontRules.map(async (rule) => {\n try {\n const response = await fetch(rule.url);\n if (!response.ok) return;\n const buffer = await response.arrayBuffer();\n const base64 = arrayBufferToBase64(buffer);\n results.push({\n family: rule.family,\n weight: rule.weight,\n style: rule.style,\n base64,\n format: rule.format,\n });\n } catch {\n // Font fetch failed (CORS, network, etc.) - skip this font\n }\n });\n\n await Promise.all(fetches);\n return results;\n}\n\nfunction arrayBufferToBase64(buffer: ArrayBuffer): string {\n let binary = '';\n const bytes = new Uint8Array(buffer);\n for (let i = 0; i < bytes.byteLength; i++) {\n binary += String.fromCharCode(bytes[i]);\n }\n return btoa(binary);\n}\n\n/**\n * Inject @font-face rules with base64 data URIs into the SVG's <defs>.\n */\nfunction injectFontsIntoSVG(svgElement: SVGElement, fonts: FontFaceData[]): void {\n if (fonts.length === 0) return;\n\n const cssRules = fonts\n .map(\n (f) =>\n `@font-face { font-family: '${f.family}'; font-weight: ${f.weight}; font-style: ${f.style}; src: url(data:font/${f.format};base64,${f.base64}) format('${f.format}'); }`,\n )\n .join('\\n');\n\n const ns = 'http://www.w3.org/2000/svg';\n let defs = svgElement.querySelector('defs');\n if (!defs) {\n defs = document.createElementNS(ns, 'defs');\n svgElement.insertBefore(defs, svgElement.firstChild);\n }\n\n const styleEl = document.createElementNS(ns, 'style');\n styleEl.textContent = cssRules;\n defs.insertBefore(styleEl, defs.firstChild);\n}\n\n/**\n * Embed fonts into an SVG element by finding matching @font-face rules\n * in the page's stylesheets, fetching the font files, and injecting\n * them as base64 data URIs.\n *\n * Modifies the SVG element in place. Call this before serialization.\n * If font fetching fails for any font, that font is silently skipped\n * and the export proceeds with system font fallback for that face.\n */\nexport async function embedFonts(svgElement: SVGElement): Promise<void> {\n const usedFonts = collectUsedFonts(svgElement);\n if (usedFonts.size === 0) return;\n\n const fontRules = findFontFaceRules(usedFonts);\n if (fontRules.length === 0) return;\n\n const fontData = await fetchFontsAsBase64(fontRules);\n injectFontsIntoSVG(svgElement, fontData);\n}\n\n// ---------------------------------------------------------------------------\n// SVG background color\n// ---------------------------------------------------------------------------\n\n/**\n * Read the chart's background color from its first rect element. Falls back to\n * `fallback` (white when omitted) when the chart has no opaque background,\n * which matters for formats that can't carry alpha (JPEG, GIF).\n *\n * Pass the resolved theme's `colors.background` as `fallback` when the caller\n * has one: canvas mark mode suppresses the SVG background rect (the canvas\n * paints it), so without it a dark chart would flood-fill white.\n */\nexport function getSVGBackgroundColor(svgElement: SVGElement, fallback?: string): string {\n const firstRect = svgElement.querySelector('rect');\n const fill = firstRect?.getAttribute('fill');\n if (!fill || fill === 'none' || fill === 'transparent') return fallback ?? '#ffffff';\n return fill;\n}\n\n// ---------------------------------------------------------------------------\n// SVG export\n// ---------------------------------------------------------------------------\n\n/**\n * Serialize an SVG element to an XML string.\n *\n * @param svgElement - The rendered SVG element to serialize.\n * @returns The SVG markup as a string.\n */\nexport function exportSVG(svgElement: SVGElement): string {\n if (!svgElement.getAttribute('xmlns')) {\n svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg');\n }\n const serializer = new XMLSerializer();\n return serializer.serializeToString(svgElement);\n}\n\n/**\n * Serialize an SVG element with embedded fonts to an XML string.\n *\n * Collects font-family declarations from the SVG's text elements,\n * finds matching @font-face rules in the page's stylesheets, fetches\n * the font files, and embeds them as base64 data URIs. The resulting\n * SVG is self-contained and renders correctly without external fonts.\n *\n * @param svgElement - The rendered SVG element to serialize.\n * @param options - Export options.\n * @returns A Promise resolving to the SVG markup as a string.\n */\nexport async function exportSVGWithFonts(\n svgElement: SVGElement,\n options?: SVGExportOptions,\n): Promise<string> {\n const clone = svgElement.cloneNode(true) as SVGElement;\n const shouldEmbed = options?.embedFonts ?? true;\n injectThemeStyleBlock(clone, options?.theme);\n if (shouldEmbed) {\n await embedFonts(clone);\n }\n return exportSVG(clone);\n}\n\n// ---------------------------------------------------------------------------\n// Raster export (PNG / JPG)\n// ---------------------------------------------------------------------------\n\n/**\n * Cache the one-time probe for Display P3 canvas support. Rendering raster\n * exports into a display-p3 canvas makes toBlob embed a wide-gamut color profile\n * (verified: an sRGB PNG has no iCCP chunk; a display-p3 PNG does — and JPEG\n * upgrades from an sRGB to a P3 profile), so the export matches the on-screen\n * chart instead of reading as an untagged/sRGB image that looks washed out on\n * P3 displays.\n */\nlet p3Supported: boolean | undefined;\n\nfunction supportsDisplayP3(): boolean {\n if (p3Supported !== undefined) return p3Supported;\n try {\n const probe = document.createElement('canvas');\n const ctx = probe.getContext('2d', { colorSpace: 'display-p3' }) as\n | (CanvasRenderingContext2D & { getContextAttributes?: () => { colorSpace?: string } })\n | null;\n p3Supported = !!ctx && ctx.getContextAttributes?.().colorSpace === 'display-p3';\n } catch {\n p3Supported = false;\n }\n return p3Supported;\n}\n\n/**\n * Create a 2D context in the widest color space the runtime supports. A\n * display-p3 canvas is what makes toBlob embed the profile. Callers that read\n * pixels back for a non-color-managed sink (GIF quantization) must NOT rely on\n * this — they must read back in sRGB explicitly.\n */\nfunction getExportContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D {\n const ctx = supportsDisplayP3()\n ? canvas.getContext('2d', { colorSpace: 'display-p3' })\n : canvas.getContext('2d');\n if (!ctx) {\n throw new Error('Canvas 2D context not available');\n }\n return ctx;\n}\n\n/**\n * Draw a serialized SVG string onto a canvas at DPI scaling.\n *\n * Shared by PNG/JPG export and per-frame GIF rendering: loads the SVG string\n * as an Image via an object URL, draws it into a `width*dpi × height*dpi`\n * canvas, and hands the canvas back once painted. The caller decides how to\n * read the result (toBlob for PNG/JPG, getImageData for GIF frames).\n *\n * An optional `prepare` hook runs before drawing the image, used by JPG export\n * to fill an opaque background so transparency doesn't render as black.\n *\n * @param svgString - Serialized SVG markup (should already carry width/height).\n * @param width - Logical width in CSS pixels.\n * @param height - Logical height in CSS pixels.\n * @param dpi - Device pixel ratio scaling factor.\n * @param prepare - Optional pre-draw hook receiving the scaled 2D context.\n * @returns A Promise resolving to the painted canvas.\n */\nexport function rasterizeSVGToCanvas(\n svgString: string,\n width: number,\n height: number,\n dpi: number,\n prepare?: (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement) => void,\n): Promise<HTMLCanvasElement> {\n if (!width || !height) {\n throw new Error(`SVG has zero dimensions (width=${width}, height=${height})`);\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width * dpi;\n canvas.height = height * dpi;\n\n const ctx = getExportContext(canvas);\n\n prepare?.(ctx, canvas);\n ctx.scale(dpi, dpi);\n\n const img = new Image();\n const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });\n const url = URL.createObjectURL(blob);\n\n return new Promise<HTMLCanvasElement>((resolve, reject) => {\n img.onload = () => {\n ctx.drawImage(img, 0, 0, width, height);\n URL.revokeObjectURL(url);\n resolve(canvas);\n };\n\n img.onerror = () => {\n URL.revokeObjectURL(url);\n reject(\n new Error(\n `Failed to load SVG as image (width=${width}, height=${height}, svgLength=${svgString.length})`,\n ),\n );\n };\n\n img.src = url;\n });\n}\n\n/**\n * Serialize an SVG element (optionally embedding fonts) into a canvas-ready\n * string with explicit width/height. Shared by the raster exporters.\n */\nasync function prepareRasterSVG(\n svgElement: SVGElement,\n shouldEmbed: boolean,\n theme?: ResolvedTheme,\n): Promise<{ svgString: string; width: number; height: number }> {\n const { width, height } = getSVGDimensions(svgElement);\n const clone = svgElement.cloneNode(true) as SVGElement;\n // Inline the theme style block so class-based fills survive the detach from\n // the page stylesheet. No-op when theme is undefined.\n injectThemeStyleBlock(clone, theme);\n if (shouldEmbed) {\n await embedFonts(clone);\n }\n const svgString = ensureSVGDimensions(exportSVG(clone), width, height);\n return { svgString, width, height };\n}\n\n/**\n * Render an SVG element to a PNG Blob via a canvas.\n *\n * Embeds fonts by default so the exported image matches on-screen rendering.\n * Set `embedFonts: false` to skip font embedding for faster exports.\n *\n * @param svgElement - The rendered SVG element.\n * @param options - Optional DPI scaling and font embedding.\n * @returns A Promise resolving to the PNG Blob.\n */\nexport async function exportPNG(svgElement: SVGElement, options?: PNGExportOptions): Promise<Blob> {\n const dpi = options?.dpi ?? 2;\n const shouldEmbed = options?.embedFonts ?? true;\n const { svgString, width, height } = await prepareRasterSVG(\n svgElement,\n shouldEmbed,\n options?.theme,\n );\n const canvas = await rasterizeSVGToCanvas(svgString, width, height, dpi);\n\n return new Promise<Blob>((resolve, reject) => {\n canvas.toBlob((result) => {\n if (result) {\n resolve(result);\n } else {\n reject(new Error('Canvas toBlob returned null'));\n }\n }, 'image/png');\n });\n}\n\n/**\n * Render an SVG element to a JPEG Blob via a canvas.\n *\n * Same pipeline as exportPNG but outputs JPEG with configurable quality.\n * The canvas is filled with the chart's background color before drawing\n * to avoid transparent backgrounds rendering as black in JPEG format.\n *\n * @param svgElement - The rendered SVG element.\n * @param options - Optional DPI scaling, JPEG quality, and font embedding.\n * @returns A Promise resolving to the JPEG Blob.\n */\nexport async function exportJPG(svgElement: SVGElement, options?: JPGExportOptions): Promise<Blob> {\n const dpi = options?.dpi ?? 2;\n const quality = options?.quality ?? 0.92;\n const shouldEmbed = options?.embedFonts ?? true;\n const backgroundColor = getSVGBackgroundColor(svgElement);\n const { svgString, width, height } = await prepareRasterSVG(\n svgElement,\n shouldEmbed,\n options?.theme,\n );\n\n // Fill an opaque background before drawing so transparency doesn't render\n // as black in the JPEG. Runs before ctx.scale, so use the raw canvas size.\n const canvas = await rasterizeSVGToCanvas(svgString, width, height, dpi, (ctx, cv) => {\n ctx.fillStyle = backgroundColor;\n ctx.fillRect(0, 0, cv.width, cv.height);\n });\n\n return new Promise<Blob>((resolve, reject) => {\n canvas.toBlob(\n (result) => {\n if (result) {\n resolve(result);\n } else {\n reject(new Error('Canvas toBlob returned null'));\n }\n },\n 'image/jpeg',\n quality,\n );\n });\n}\n\n// ---------------------------------------------------------------------------\n// CSV export\n// ---------------------------------------------------------------------------\n\n/**\n * Convert an array of data objects to a CSV string.\n *\n * Uses the keys from the first row as column headers.\n * Values are quoted if they contain commas, quotes, or newlines.\n *\n * @param data - Array of row objects.\n * @returns CSV-formatted string.\n */\nexport function exportCSV(data: Record<string, unknown>[]): string {\n if (data.length === 0) return '';\n\n const headers = Object.keys(data[0]);\n const rows = [headers.map(csvEscape).join(',')];\n\n for (const row of data) {\n const values = headers.map((h) => csvEscape(String(row[h] ?? '')));\n rows.push(values.join(','));\n }\n\n return rows.join('\\n');\n}\n\nfunction csvEscape(value: string): string {\n if (value.includes(',') || value.includes('\"') || value.includes('\\n') || value.includes('\\r')) {\n return `\"${value.replace(/\"/g, '\"\"')}\"`;\n }\n return value;\n}\n"],"mappings":"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","names":[]}
@@ -232,51 +232,6 @@ var AnimationScheduler = class {
232
232
  }
233
233
  };
234
234
 
235
- // src/motion/tween.ts
236
- function resolveEase(ease) {
237
- return ease === "snappy" ? cubicOut : cubicInOut;
238
- }
239
- function prefersReducedMotion() {
240
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
241
- try {
242
- return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
243
- } catch {
244
- return false;
245
- }
246
- }
247
- function createTween(opts) {
248
- const { duration, ease, apply, onDone } = opts;
249
- let startTime = null;
250
- let finished = false;
251
- function settleAt(t) {
252
- apply(ease(t));
253
- }
254
- return {
255
- tick(now) {
256
- if (finished) return false;
257
- if (startTime === null) startTime = now;
258
- const elapsed = now - startTime;
259
- const raw = duration <= 0 ? 1 : Math.min(1, elapsed / duration);
260
- settleAt(raw);
261
- if (raw >= 1) {
262
- finished = true;
263
- onDone?.();
264
- return false;
265
- }
266
- return true;
267
- },
268
- finish() {
269
- if (finished) return;
270
- finished = true;
271
- settleAt(1);
272
- onDone?.();
273
- },
274
- cancel() {
275
- finished = true;
276
- }
277
- };
278
- }
279
-
280
235
  // src/spatial-index.ts
281
236
  import { quadtree } from "d3-quadtree";
282
237
  var SpatialIndex = class {
@@ -359,6 +314,51 @@ var SpatialIndex = class {
359
314
  }
360
315
  };
361
316
 
317
+ // src/motion/tween.ts
318
+ function resolveEase(ease) {
319
+ return ease === "snappy" ? cubicOut : cubicInOut;
320
+ }
321
+ function prefersReducedMotion() {
322
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
323
+ try {
324
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
325
+ } catch {
326
+ return false;
327
+ }
328
+ }
329
+ function createTween(opts) {
330
+ const { duration, ease, apply, onDone } = opts;
331
+ let startTime = null;
332
+ let finished = false;
333
+ function settleAt(t) {
334
+ apply(ease(t));
335
+ }
336
+ return {
337
+ tick(now) {
338
+ if (finished) return false;
339
+ if (startTime === null) startTime = now;
340
+ const elapsed = now - startTime;
341
+ const raw = duration <= 0 ? 1 : Math.min(1, elapsed / duration);
342
+ settleAt(raw);
343
+ if (raw >= 1) {
344
+ finished = true;
345
+ onDone?.();
346
+ return false;
347
+ }
348
+ return true;
349
+ },
350
+ finish() {
351
+ if (finished) return;
352
+ finished = true;
353
+ settleAt(1);
354
+ onDone?.();
355
+ },
356
+ cancel() {
357
+ finished = true;
358
+ }
359
+ };
360
+ }
361
+
362
362
  export {
363
363
  observeResize,
364
364
  resolveDarkMode,
@@ -374,4 +374,4 @@ export {
374
374
  prefersReducedMotion,
375
375
  createTween
376
376
  };
377
- //# sourceMappingURL=chunk-2OJZBMXM.js.map
377
+ //# sourceMappingURL=chunk-REH5UQCL.js.map