@opendata-ai/openchart-vanilla 8.3.0 → 8.4.0

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.
@@ -5,10 +5,10 @@ import {
5
5
  getSVGBackgroundColor,
6
6
  getSVGDimensions,
7
7
  rasterizeSVGToCanvas
8
- } from "./chunk-T32UHMZA.js";
8
+ } from "./chunk-UOB422LY.js";
9
9
  import {
10
10
  injectThemeStyleBlock
11
- } from "./chunk-RM5XS6NR.js";
11
+ } from "./chunk-HGRNXLDF.js";
12
12
 
13
13
  // src/gif-encode.ts
14
14
  function readCanvasSRGB(canvas) {
@@ -231,4 +231,4 @@ export {
231
231
  applyFrameState,
232
232
  exportGIF
233
233
  };
234
- //# sourceMappingURL=chunk-DCODCLWI.js.map
234
+ //# sourceMappingURL=chunk-FETYAVTZ.js.map
@@ -30,7 +30,7 @@ function applyTextStyle(el, style) {
30
30
  import { adaptForLightLineStroke, cssTokenDefault } from "@opendata-ai/openchart-core";
31
31
  function buildThemeStyleBlock(theme) {
32
32
  const accent = theme.colors.categorical[0] ?? cssTokenDefault("--oc-accent", "light");
33
- const bg = theme.colors.background === "transparent" ? theme.isDark ? cssTokenDefault("--oc-bg", "dark") : cssTokenDefault("--oc-bg", "light") : theme.colors.background;
33
+ const bg = theme.colors.neutral.surface;
34
34
  const props = [
35
35
  `--oc-font-family: ${theme.fonts.family}`,
36
36
  `--oc-font-mono: ${theme.fonts.mono}`,
@@ -48,9 +48,12 @@ function buildThemeStyleBlock(theme) {
48
48
  `--oc-bg: ${bg}`,
49
49
  `--oc-text: ${theme.colors.text}`,
50
50
  `--oc-text-muted: ${theme.colors.axis}`,
51
- `--oc-text-faint: ${theme.isDark ? cssTokenDefault("--oc-text-faint", "dark") : cssTokenDefault("--oc-text-faint", "light")}`,
51
+ `--oc-text-secondary: ${theme.colors.neutral.secondary}`,
52
+ `--oc-text-faint: ${theme.colors.neutral.faint}`,
53
+ `--oc-border: ${theme.colors.neutral.border}`,
52
54
  `--oc-gridline: ${theme.colors.gridline}`,
53
- `--oc-axis: ${theme.colors.axis}`,
55
+ // Hairline, not the tick-label ink.
56
+ `--oc-axis: ${theme.colors.hairline}`,
54
57
  `--oc-border-radius: ${theme.borderRadius}px`,
55
58
  `--oc-accent: ${accent}`,
56
59
  `--oc-accent-strong: ${adaptForLightLineStroke(accent)}`,
@@ -61,27 +64,29 @@ function buildThemeStyleBlock(theme) {
61
64
  `--oc-space-4: ${theme.spacing.padding}px`
62
65
  ];
63
66
  const rules = [
64
- `svg.oc-chart { ${props.join("; ")}; }`,
67
+ // Tabular figures everywhere in the chart's own text: axis ticks, value
68
+ // labels and legend entries all line up column-wise.
69
+ `svg.oc-chart { ${props.join("; ")}; font-variant-numeric: tabular-nums; }`,
65
70
  `.oc-chrome { font-family: var(--oc-font-family); }`,
66
71
  `.oc-eyebrow { font-size: var(--oc-eyebrow-size); font-weight: var(--oc-eyebrow-weight); letter-spacing: var(--oc-eyebrow-tracking); text-transform: uppercase; fill: var(--oc-accent); }`,
67
72
  `.oc-title { font-size: var(--oc-title-size); font-weight: var(--oc-title-weight); letter-spacing: var(--oc-title-tracking); fill: var(--oc-text); }`,
68
73
  `.oc-subtitle { font-size: var(--oc-subtitle-size); font-weight: var(--oc-subtitle-weight); fill: var(--oc-text-muted); }`,
69
74
  `.oc-source, .oc-byline, .oc-footer { font-size: var(--oc-source-size); font-weight: var(--oc-source-weight); fill: var(--oc-text-muted); }`,
70
- `.oc-brand { font-size: 11px; font-weight: 510; letter-spacing: 0.02em; fill: var(--oc-text-faint); }`,
75
+ `.oc-brand { font-size: 11px; font-weight: 500; letter-spacing: 0.02em; fill: var(--oc-text-faint); }`,
71
76
  `.oc-brand-dot { fill: var(--oc-accent); }`,
72
77
  `.oc-eyebrow-dot { fill: var(--oc-accent); }`,
73
78
  `.oc-metrics { font-family: var(--oc-font-family); }`,
74
- `.oc-metric-label { font-size: 10px; font-weight: 510; letter-spacing: 0.08em; text-transform: uppercase; fill: var(--oc-text-muted); }`,
75
- `.oc-metric-value { font-size: 22px; font-weight: 510; letter-spacing: -0.01em; fill: var(--oc-text); font-variant-numeric: tabular-nums; }`,
76
- `.oc-metric-delta-up { fill: var(--oc-positive); font-size: 12px; font-weight: 510; }`,
77
- `.oc-metric-delta-down { fill: var(--oc-negative); font-size: 12px; font-weight: 510; }`,
79
+ `.oc-metric-label { font-size: 11px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; fill: var(--oc-text-muted); }`,
80
+ `.oc-metric-value { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; fill: var(--oc-text); font-variant-numeric: tabular-nums; }`,
81
+ `.oc-metric-delta-up { fill: var(--oc-positive); font-size: 12px; font-weight: 500; }`,
82
+ `.oc-metric-delta-down { fill: var(--oc-negative); font-size: 12px; font-weight: 500; }`,
78
83
  `.oc-axis-tick-inline { font-size: 11px; font-weight: 400; fill: var(--oc-text-muted); }`,
79
84
  `.oc-endpoint-labels { font-family: var(--oc-font-family); }`,
80
85
  `.oc-endpoint-label { fill: var(--oc-endpoint-label-color, var(--oc-text)); }`,
81
86
  `.oc-endpoint-value { fill: var(--oc-endpoint-value-color, var(--oc-text-muted)); }`,
82
87
  `.oc-endpoint-leader { stroke: var(--oc-endpoint-leader-color, currentColor); }`,
83
88
  `.oc-annotation-subtitle { fill: var(--oc-annotation-subtitle-color, var(--oc-text-muted)); }`,
84
- `.oc-metric-secondary { fill: var(--oc-positive); font-size: 12px; font-weight: 400; }`,
89
+ `.oc-metric-secondary { fill: var(--oc-text-muted); font-size: 12px; font-weight: 400; }`,
85
90
  `.oc-legend { font-family: var(--oc-font-family); font-size: var(--oc-body-size); }`,
86
91
  `.oc-legend-entry { cursor: default; }`,
87
92
  `.oc-legend text { fill: var(--oc-legend-text); }`
@@ -110,4 +115,4 @@ export {
110
115
  buildThemeStyleBlock,
111
116
  injectThemeStyleBlock
112
117
  };
113
- //# sourceMappingURL=chunk-RM5XS6NR.js.map
118
+ //# sourceMappingURL=chunk-HGRNXLDF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/renderers/svg-dom.ts","../src/theme-style-block.ts"],"sourcesContent":["/**\n * Shared SVG DOM helpers used across the per-concern renderers.\n *\n * Pure, stateless utilities. No layout/theme knowledge.\n */\n\nimport type { TextStyle } from '@opendata-ai/openchart-core';\n\nexport const SVG_NS = 'http://www.w3.org/2000/svg';\nexport const XLINK_NS = 'http://www.w3.org/1999/xlink';\n\nexport function createSVGElement(tag: string): SVGElement {\n return document.createElementNS(SVG_NS, tag);\n}\n\nexport function 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\nexport function applyTextStyle(el: SVGElement, style: TextStyle): void {\n // Use inline styles so engine-computed values take priority over CSS class\n // defaults (e.g. .oc-title { font-size: var(--oc-title-size) } would otherwise\n // override the responsive scaling applied by the chrome layout).\n const inline = (el as SVGElement & ElementCSSInlineStyle).style;\n inline.setProperty('fill', style.fill);\n inline.setProperty('font-size', `${style.fontSize}px`);\n inline.setProperty('font-weight', String(style.fontWeight));\n inline.setProperty('font-family', style.fontFamily);\n if (style.textAnchor) {\n el.setAttribute('text-anchor', style.textAnchor);\n }\n if (style.dominantBaseline) {\n el.setAttribute('dominant-baseline', style.dominantBaseline);\n }\n if (style.fontVariant) {\n el.setAttribute('font-variant', style.fontVariant);\n }\n}\n","/**\n * Theme `<style>` block for self-contained SVG export.\n *\n * Charts render most fills as inline SVG attributes, but a handful of chrome\n * elements (metric cells, the brand watermark dot, legend text, endpoint labels)\n * take their fill from CSS classes in `chrome.css` via `--oc-*` variables. That\n * works on-screen because the live SVG inherits the page stylesheet — but a\n * serialized/rasterized export (PNG/JPG/GIF) is detached from the page, so those\n * class-based fills vanish. This module builds a `<style>` block that resolves\n * every such rule against a concrete `ResolvedTheme`, so injecting it into an\n * export clone makes the SVG fully self-contained.\n *\n * Both the headless renderer (`static.ts`) and the browser export path use this,\n * so class-based styling round-trips identically no matter which path produced\n * the SVG. Browser-safe (no Node imports) so the browser exporters can import it.\n */\n\nimport type { ResolvedTheme } from '@opendata-ai/openchart-core';\nimport { adaptForLightLineStroke, cssTokenDefault } from '@opendata-ai/openchart-core';\nimport { SVG_NS } from './renderers/svg-dom';\n\n/**\n * Build the theme `<style>` block CSS text for a resolved theme. Sets the\n * `--oc-*` custom properties on `svg.oc-chart` and defines every class-based\n * chrome rule against them.\n */\nexport function buildThemeStyleBlock(theme: ResolvedTheme): string {\n const accent = theme.colors.categorical[0] ?? cssTokenDefault('--oc-accent', 'light');\n const bg = theme.colors.neutral.surface;\n\n const props = [\n `--oc-font-family: ${theme.fonts.family}`,\n `--oc-font-mono: ${theme.fonts.mono}`,\n `--oc-title-size: ${theme.chrome.title.fontSize}px`,\n `--oc-title-weight: ${theme.chrome.title.fontWeight}`,\n `--oc-title-tracking: ${cssTokenDefault('--oc-title-tracking', 'light')}`,\n `--oc-subtitle-size: ${theme.chrome.subtitle.fontSize}px`,\n `--oc-subtitle-weight: ${theme.chrome.subtitle.fontWeight}`,\n `--oc-source-size: ${theme.chrome.source.fontSize}px`,\n `--oc-source-weight: ${theme.chrome.source.fontWeight}`,\n `--oc-body-size: ${theme.fonts.sizes.body}px`,\n `--oc-eyebrow-size: ${theme.chrome.eyebrow.fontSize}px`,\n `--oc-eyebrow-weight: ${theme.chrome.eyebrow.fontWeight}`,\n `--oc-eyebrow-tracking: ${cssTokenDefault('--oc-eyebrow-tracking', 'light')}`,\n `--oc-bg: ${bg}`,\n `--oc-text: ${theme.colors.text}`,\n `--oc-text-muted: ${theme.colors.axis}`,\n `--oc-text-secondary: ${theme.colors.neutral.secondary}`,\n `--oc-text-faint: ${theme.colors.neutral.faint}`,\n `--oc-border: ${theme.colors.neutral.border}`,\n `--oc-gridline: ${theme.colors.gridline}`,\n // Hairline, not the tick-label ink.\n `--oc-axis: ${theme.colors.hairline}`,\n `--oc-border-radius: ${theme.borderRadius}px`,\n `--oc-accent: ${accent}`,\n `--oc-accent-strong: ${adaptForLightLineStroke(accent)}`,\n `--oc-positive: ${theme.colors.positive}`,\n `--oc-negative: ${theme.colors.negative}`,\n `--oc-legend-text: ${theme.isDark ? cssTokenDefault('--oc-legend-text', 'dark') : cssTokenDefault('--oc-legend-text', 'light')}`,\n `--oc-space-2: ${theme.spacing.chromeGap * 2}px`,\n `--oc-space-4: ${theme.spacing.padding}px`,\n ];\n\n const rules = [\n // Tabular figures everywhere in the chart's own text: axis ticks, value\n // labels and legend entries all line up column-wise.\n `svg.oc-chart { ${props.join('; ')}; font-variant-numeric: tabular-nums; }`,\n `.oc-chrome { font-family: var(--oc-font-family); }`,\n `.oc-eyebrow { font-size: var(--oc-eyebrow-size); font-weight: var(--oc-eyebrow-weight); letter-spacing: var(--oc-eyebrow-tracking); text-transform: uppercase; fill: var(--oc-accent); }`,\n `.oc-title { font-size: var(--oc-title-size); font-weight: var(--oc-title-weight); letter-spacing: var(--oc-title-tracking); fill: var(--oc-text); }`,\n `.oc-subtitle { font-size: var(--oc-subtitle-size); font-weight: var(--oc-subtitle-weight); fill: var(--oc-text-muted); }`,\n `.oc-source, .oc-byline, .oc-footer { font-size: var(--oc-source-size); font-weight: var(--oc-source-weight); fill: var(--oc-text-muted); }`,\n `.oc-brand { font-size: 11px; font-weight: 500; letter-spacing: 0.02em; fill: var(--oc-text-faint); }`,\n `.oc-brand-dot { fill: var(--oc-accent); }`,\n `.oc-eyebrow-dot { fill: var(--oc-accent); }`,\n `.oc-metrics { font-family: var(--oc-font-family); }`,\n `.oc-metric-label { font-size: 11px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; fill: var(--oc-text-muted); }`,\n `.oc-metric-value { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; fill: var(--oc-text); font-variant-numeric: tabular-nums; }`,\n `.oc-metric-delta-up { fill: var(--oc-positive); font-size: 12px; font-weight: 500; }`,\n `.oc-metric-delta-down { fill: var(--oc-negative); font-size: 12px; font-weight: 500; }`,\n `.oc-axis-tick-inline { font-size: 11px; font-weight: 400; fill: var(--oc-text-muted); }`,\n `.oc-endpoint-labels { font-family: var(--oc-font-family); }`,\n `.oc-endpoint-label { fill: var(--oc-endpoint-label-color, var(--oc-text)); }`,\n `.oc-endpoint-value { fill: var(--oc-endpoint-value-color, var(--oc-text-muted)); }`,\n `.oc-endpoint-leader { stroke: var(--oc-endpoint-leader-color, currentColor); }`,\n `.oc-annotation-subtitle { fill: var(--oc-annotation-subtitle-color, var(--oc-text-muted)); }`,\n `.oc-metric-secondary { fill: var(--oc-text-muted); font-size: 12px; font-weight: 400; }`,\n `.oc-legend { font-family: var(--oc-font-family); font-size: var(--oc-body-size); }`,\n `.oc-legend-entry { cursor: default; }`,\n `.oc-legend text { fill: var(--oc-legend-text); }`,\n ];\n\n return rules.join('\\n');\n}\n\n/**\n * Inject the theme style block into an SVG clone's `<defs>` so class-based fills\n * survive serialization. Idempotent-ish: adds one `<style data-oc-theme>` at the\n * front of `<defs>`; call once per clone before serializing. No-op if `theme` is\n * undefined (nothing to resolve against).\n */\nexport function injectThemeStyleBlock(svg: SVGElement, theme: ResolvedTheme | undefined): void {\n if (!theme) return;\n let defs = svg.querySelector('defs');\n if (!defs) {\n defs = document.createElementNS(SVG_NS, 'defs');\n svg.insertBefore(defs, svg.firstChild);\n }\n const style = document.createElementNS(SVG_NS, 'style');\n style.setAttribute('data-oc-theme', '');\n style.textContent = buildThemeStyleBlock(theme);\n defs.insertBefore(style, defs.firstChild);\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-P32LUJFK.js";
6
+ } from "./chunk-YGYMB7KB.js";
7
7
 
8
8
  // src/tilemap-renderer.ts
9
9
  var SVG_NS = "http://www.w3.org/2000/svg";
@@ -154,7 +154,10 @@ function renderTiles(parent, tiles, animation) {
154
154
  "dominant-baseline": "central",
155
155
  "font-family": tile.label.style.fontFamily,
156
156
  "font-size": tile.label.style.fontSize,
157
- "font-weight": tile.label.style.fontWeight
157
+ "font-weight": tile.label.style.fontWeight,
158
+ // A hair of tracking is what keeps a two-letter code legible at 8px now
159
+ // that it is set at 600 instead of 700.
160
+ "letter-spacing": "0.02em"
158
161
  });
159
162
  codeLabel.style.setProperty(
160
163
  "fill",
@@ -221,8 +224,8 @@ function renderGradientLegend(parent, layout) {
221
224
  y: gradientLegend.bounds.y,
222
225
  width: gradientLegend.bounds.width,
223
226
  height: barHeight,
224
- rx: barHeight / 2,
225
- fill: `url(#${gradientId})`
227
+ fill: `url(#${gradientId})`,
228
+ "shape-rendering": "crispEdges"
226
229
  });
227
230
  g.appendChild(bar);
228
231
  const minText = createSVGElement("text");
@@ -255,6 +258,35 @@ function renderGradientLegend(parent, layout) {
255
258
  );
256
259
  maxText.textContent = gradientLegend.maxLabel;
257
260
  g.appendChild(maxText);
261
+ if (gradientLegend.noData) {
262
+ const nd = gradientLegend.noData;
263
+ const swatch = createSVGElement("rect");
264
+ swatch.setAttribute("class", "oc-legend-nodata");
265
+ setAttrs(swatch, {
266
+ x: nd.x,
267
+ y: nd.y,
268
+ width: nd.size,
269
+ height: nd.size,
270
+ fill: nd.fill,
271
+ "shape-rendering": "crispEdges"
272
+ });
273
+ g.appendChild(swatch);
274
+ const ndLabel = createSVGElement("text");
275
+ setAttrs(ndLabel, {
276
+ x: nd.labelX,
277
+ y: nd.labelY,
278
+ "text-anchor": "start",
279
+ "font-family": gradientLegend.labelStyle.fontFamily,
280
+ "font-size": gradientLegend.labelStyle.fontSize,
281
+ "font-weight": gradientLegend.labelStyle.fontWeight
282
+ });
283
+ ndLabel.style.setProperty(
284
+ "fill",
285
+ gradientLegend.labelStyle.fill
286
+ );
287
+ ndLabel.textContent = nd.label;
288
+ g.appendChild(ndLabel);
289
+ }
258
290
  parent.appendChild(g);
259
291
  }
260
292
  function renderTileMapSVG(layout, opts) {
@@ -295,4 +327,4 @@ function renderTileMapSVG(layout, opts) {
295
327
  export {
296
328
  renderTileMapSVG
297
329
  };
298
- //# sourceMappingURL=chunk-737VNBU6.js.map
330
+ //# sourceMappingURL=chunk-LNPRDSK3.js.map
@@ -0,0 +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,6 +1,6 @@
1
1
  import {
2
2
  injectThemeStyleBlock
3
- } from "./chunk-RM5XS6NR.js";
3
+ } from "./chunk-HGRNXLDF.js";
4
4
 
5
5
  // src/export.ts
6
6
  function getSVGDimensions(svg) {
@@ -279,4 +279,4 @@ export {
279
279
  exportJPG,
280
280
  exportCSV
281
281
  };
282
- //# sourceMappingURL=chunk-T32UHMZA.js.map
282
+ //# sourceMappingURL=chunk-UOB422LY.js.map
@@ -4,7 +4,7 @@ import {
4
4
  applyTextStyle,
5
5
  createSVGElement,
6
6
  setAttrs
7
- } from "./chunk-RM5XS6NR.js";
7
+ } from "./chunk-HGRNXLDF.js";
8
8
 
9
9
  // src/svg-ids.ts
10
10
  var counter = 0;
@@ -195,7 +195,11 @@ function renderLineMark(mark, index) {
195
195
  d,
196
196
  fill: "none",
197
197
  stroke: mark.stroke,
198
- "stroke-width": mark.strokeWidth
198
+ "stroke-width": mark.strokeWidth,
199
+ // Round joins/caps: at 2px a mitre on a sharp data spike renders as a
200
+ // spur past the vertex, and a butt cap leaves a squared-off line end.
201
+ "stroke-linejoin": "round",
202
+ "stroke-linecap": "round"
199
203
  });
200
204
  if (mark.strokeDasharray) {
201
205
  path.setAttribute("stroke-dasharray", mark.strokeDasharray);
@@ -253,7 +257,9 @@ function renderAreaMark(mark, index) {
253
257
  d: mark.topPath,
254
258
  fill: "none",
255
259
  stroke: mark.stroke,
256
- "stroke-width": mark.strokeWidth ?? 1
260
+ "stroke-width": mark.strokeWidth ?? 1,
261
+ "stroke-linejoin": "round",
262
+ "stroke-linecap": "round"
257
263
  });
258
264
  g.appendChild(strokePath);
259
265
  }
@@ -311,6 +317,9 @@ function renderRectMark(mark, index) {
311
317
  if (mark.strokeWidth) {
312
318
  shapeEl.setAttribute("stroke-width", String(mark.strokeWidth));
313
319
  }
320
+ if (mark.shapeRendering) {
321
+ shapeEl.setAttribute("shape-rendering", mark.shapeRendering);
322
+ }
314
323
  g.appendChild(shapeEl);
315
324
  return g;
316
325
  }
@@ -402,6 +411,9 @@ function renderRuleMark(mark, index) {
402
411
  if (mark.strokeDasharray) {
403
412
  line.setAttribute("stroke-dasharray", mark.strokeDasharray);
404
413
  }
414
+ if (mark.strokeOpacity != null) {
415
+ line.setAttribute("stroke-opacity", String(mark.strokeOpacity));
416
+ }
405
417
  if (mark.opacity != null) {
406
418
  line.setAttribute("opacity", String(mark.opacity));
407
419
  }
@@ -454,6 +466,10 @@ function getMarkSeries(mark) {
454
466
  if (mark.type === "line" || mark.type === "area") {
455
467
  return mark.seriesKey;
456
468
  }
469
+ const explicitSeries = mark.seriesKey;
470
+ if (explicitSeries) {
471
+ return explicitSeries;
472
+ }
457
473
  if (mark.type === "arc") {
458
474
  const label = mark.aria.label;
459
475
  if (!label) return void 0;
@@ -819,7 +835,6 @@ function renderAnnotations(parent, layout) {
819
835
  import {
820
836
  axisTitleOffset,
821
837
  estimateTextWidth,
822
- getAxisTitleOffset,
823
838
  TICK_LABEL_OFFSET,
824
839
  textAscent
825
840
  } from "@opendata-ai/openchart-core";
@@ -1053,7 +1068,19 @@ function renderAxis(parent, axis, orientation, layout, opts) {
1053
1068
  "text-anchor": "middle"
1054
1069
  });
1055
1070
  } else if (isRight) {
1056
- const titleOffset = getAxisTitleOffset(layout.dimensions.width);
1071
+ const maxTickLabelWidth = axis.ticks.reduce((max, t) => {
1072
+ const w = estimateTextWidth(
1073
+ t.label,
1074
+ axis.tickLabelStyle.fontSize,
1075
+ axis.tickLabelStyle.fontWeight ?? 400
1076
+ );
1077
+ return Math.max(max, w);
1078
+ }, 0);
1079
+ const titleOffset = axisTitleOffset(
1080
+ maxTickLabelWidth,
1081
+ axis.labelStyle.fontSize,
1082
+ layout.dimensions.width
1083
+ );
1057
1084
  const titleX = area.x + area.width + titleOffset;
1058
1085
  setAttrs(axisLabel, {
1059
1086
  x: titleX,
@@ -1161,7 +1188,22 @@ function renderChrome(parent, layout) {
1161
1188
  const g = createSVGElement("g");
1162
1189
  g.setAttribute("class", "oc-chrome");
1163
1190
  const { chrome, measureText } = layout;
1164
- if (chrome.eyebrow) {
1191
+ if (chrome.rule) {
1192
+ const rule = createSVGElement("rect");
1193
+ rule.setAttribute("class", "oc-chrome-rule");
1194
+ setAttrs(rule, {
1195
+ x: chrome.rule.x,
1196
+ y: chrome.rule.y,
1197
+ width: chrome.rule.width,
1198
+ height: chrome.rule.thickness
1199
+ });
1200
+ rule.setAttribute("fill", chrome.rule.color);
1201
+ rule.setAttribute("shape-rendering", "crispEdges");
1202
+ g.appendChild(rule);
1203
+ }
1204
+ if (chrome.eyebrow && chrome.rule) {
1205
+ renderChromeElement(g, chrome.eyebrow, "oc-eyebrow", "eyebrow", measureText, true);
1206
+ } else if (chrome.eyebrow) {
1165
1207
  const eyebrow = chrome.eyebrow;
1166
1208
  const dotR = 3;
1167
1209
  const dotGap = 8;
@@ -1386,6 +1428,8 @@ function renderEndpointLabels(parent, layout) {
1386
1428
 
1387
1429
  // src/renderers/legend.ts
1388
1430
  import { estimateTextWidth as estimateTextWidth2 } from "@opendata-ai/openchart-core";
1431
+ var LEGEND_HIT_HEIGHT = 24;
1432
+ var LEGEND_HIT_PAD = 4;
1389
1433
  function isCategorical(legend) {
1390
1434
  return !legend.type || legend.type === "categorical";
1391
1435
  }
@@ -1435,7 +1479,8 @@ function renderContinuousLegend(parent, legend) {
1435
1479
  });
1436
1480
  g.appendChild(bar);
1437
1481
  } else {
1438
- for (const bin of legend.bins) {
1482
+ for (let i = 0; i < legend.bins.length; i++) {
1483
+ const bin = legend.bins[i];
1439
1484
  const rect = createSVGElement("rect");
1440
1485
  rect.setAttribute("class", "oc-legend-bin");
1441
1486
  setAttrs(rect, {
@@ -1443,11 +1488,21 @@ function renderContinuousLegend(parent, legend) {
1443
1488
  y: legend.bar.y,
1444
1489
  width: bin.width,
1445
1490
  height: legend.bar.height,
1446
- fill: bin.color
1491
+ fill: bin.color,
1492
+ "shape-rendering": "crispEdges"
1447
1493
  });
1494
+ rect.setAttribute("data-bin-index", String(i));
1448
1495
  g.appendChild(rect);
1449
1496
  }
1450
1497
  }
1498
+ if (legend.title && legend.titleY !== void 0) {
1499
+ const title = createSVGElement("text");
1500
+ title.setAttribute("class", "oc-legend-title");
1501
+ setAttrs(title, { x: legend.bar.x, y: legend.titleY, "text-anchor": "start" });
1502
+ applyTextStyle(title, legend.titleStyle ?? legend.labelStyle);
1503
+ title.textContent = legend.title;
1504
+ g.appendChild(title);
1505
+ }
1451
1506
  for (const tick of legend.ticks) {
1452
1507
  const label = createSVGElement("text");
1453
1508
  setAttrs(label, { x: tick.x, y: legend.labelY, "text-anchor": tick.anchor });
@@ -1455,6 +1510,28 @@ function renderContinuousLegend(parent, legend) {
1455
1510
  label.textContent = tick.label;
1456
1511
  g.appendChild(label);
1457
1512
  }
1513
+ if (legend.noData) {
1514
+ const swatch = createSVGElement("rect");
1515
+ swatch.setAttribute("class", "oc-legend-nodata");
1516
+ setAttrs(swatch, {
1517
+ x: legend.noData.x,
1518
+ y: legend.noData.y,
1519
+ width: legend.noData.size,
1520
+ height: legend.noData.size,
1521
+ fill: legend.noData.fill,
1522
+ "shape-rendering": "crispEdges"
1523
+ });
1524
+ g.appendChild(swatch);
1525
+ const label = createSVGElement("text");
1526
+ setAttrs(label, {
1527
+ x: legend.noData.labelX,
1528
+ y: legend.noData.labelY,
1529
+ "text-anchor": "start"
1530
+ });
1531
+ applyTextStyle(label, legend.labelStyle);
1532
+ label.textContent = legend.noData.label;
1533
+ g.appendChild(label);
1534
+ }
1458
1535
  parent.appendChild(g);
1459
1536
  }
1460
1537
  function renderSizeLegend(parent, legend) {
@@ -1466,6 +1543,14 @@ function renderSizeLegend(parent, legend) {
1466
1543
  const oy = legend.bounds.y;
1467
1544
  const labelX = ox + legend.circles[0].cx + legend.circles[0].radius + 8;
1468
1545
  const leaderX = ox + legend.circles[0].cx + legend.circles[0].radius + 4;
1546
+ if (legend.title && legend.titleY !== void 0) {
1547
+ const title = createSVGElement("text");
1548
+ title.setAttribute("class", "oc-legend-title");
1549
+ setAttrs(title, { x: ox, y: oy + legend.titleY, "text-anchor": "start" });
1550
+ applyTextStyle(title, legend.titleStyle ?? legend.labelStyle);
1551
+ title.textContent = legend.title;
1552
+ g.appendChild(title);
1553
+ }
1469
1554
  for (const circle of legend.circles) {
1470
1555
  const c = createSVGElement("circle");
1471
1556
  setAttrs(c, {
@@ -1511,7 +1596,7 @@ function renderLegend(parent, legend) {
1511
1596
  if (!isCategorical(legend) || legend.entries.length === 0) return;
1512
1597
  const g = createSVGElement("g");
1513
1598
  g.setAttribute("class", "oc-legend");
1514
- g.setAttribute("role", "list");
1599
+ g.setAttribute("role", "group");
1515
1600
  g.setAttribute("aria-label", "Chart legend");
1516
1601
  const isHorizontal = legend.position === "top" || legend.position === "top-left" || legend.position === "bottom";
1517
1602
  const positions = "entryPositions" in legend ? legend.entryPositions : void 0;
@@ -1537,7 +1622,6 @@ function renderLegend(parent, legend) {
1537
1622
  }
1538
1623
  const entryG = createSVGElement("g");
1539
1624
  entryG.setAttribute("class", "oc-legend-entry");
1540
- entryG.setAttribute("role", "listitem");
1541
1625
  entryG.setAttribute("data-legend-index", String(i));
1542
1626
  entryG.setAttribute("data-legend-label", entry.label);
1543
1627
  if (entry.overflow) {
@@ -1545,14 +1629,32 @@ function renderLegend(parent, legend) {
1545
1629
  entryG.setAttribute("aria-label", entry.label);
1546
1630
  entryG.setAttribute("opacity", "0.5");
1547
1631
  } else {
1548
- entryG.setAttribute(
1549
- "aria-label",
1550
- `${entry.label}: ${entry.active !== false ? "visible" : "hidden"}`
1551
- );
1632
+ const visible = entry.active !== false;
1633
+ entryG.setAttribute("role", "button");
1634
+ entryG.setAttribute("tabindex", "0");
1635
+ entryG.setAttribute("aria-pressed", String(visible));
1636
+ entryG.setAttribute("data-legend-active", String(visible));
1637
+ entryG.setAttribute("aria-label", `${entry.label}: ${visible ? "visible" : "hidden"}`);
1552
1638
  entryG.setAttribute("style", "cursor: pointer");
1553
- if (entry.active === false) {
1639
+ if (!visible) {
1554
1640
  entryG.setAttribute("opacity", "0.3");
1555
1641
  }
1642
+ const labelWidth = estimateTextWidth2(
1643
+ entry.label,
1644
+ legend.labelStyle.fontSize,
1645
+ legend.labelStyle.fontWeight
1646
+ );
1647
+ const hit = createSVGElement("rect");
1648
+ hit.setAttribute("class", "oc-legend-hit");
1649
+ setAttrs(hit, {
1650
+ x: offsetX - LEGEND_HIT_PAD,
1651
+ y: offsetY + legend.swatchSize / 2 - LEGEND_HIT_HEIGHT / 2,
1652
+ width: legend.swatchSize + legend.swatchGap + labelWidth + LEGEND_HIT_PAD * 2,
1653
+ height: LEGEND_HIT_HEIGHT,
1654
+ rx: 2,
1655
+ fill: "transparent"
1656
+ });
1657
+ entryG.appendChild(hit);
1556
1658
  }
1557
1659
  const midX = offsetX + legend.swatchSize / 2;
1558
1660
  const midY = offsetY + legend.swatchSize / 2;
@@ -1941,4 +2043,4 @@ export {
1941
2043
  renderSingleMark,
1942
2044
  renderChartSVG
1943
2045
  };
1944
- //# sourceMappingURL=chunk-P32LUJFK.js.map
2046
+ //# sourceMappingURL=chunk-YGYMB7KB.js.map