@opendata-ai/openchart-vanilla 8.3.1 → 8.4.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.
@@ -9,6 +9,14 @@ function setAttrs(el, attrs) {
9
9
  el.setAttribute(key, String(value));
10
10
  }
11
11
  }
12
+ function applyKnockoutHalo(el, color, fontSize) {
13
+ setAttrs(el, {
14
+ stroke: color,
15
+ "stroke-width": Math.round(fontSize * 0.3),
16
+ "stroke-linejoin": "round",
17
+ "paint-order": "stroke"
18
+ });
19
+ }
12
20
  function applyTextStyle(el, style) {
13
21
  const inline = el.style;
14
22
  inline.setProperty("fill", style.fill);
@@ -30,7 +38,7 @@ function applyTextStyle(el, style) {
30
38
  import { adaptForLightLineStroke, cssTokenDefault } from "@opendata-ai/openchart-core";
31
39
  function buildThemeStyleBlock(theme) {
32
40
  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;
41
+ const bg = theme.colors.neutral.surface;
34
42
  const props = [
35
43
  `--oc-font-family: ${theme.fonts.family}`,
36
44
  `--oc-font-mono: ${theme.fonts.mono}`,
@@ -48,9 +56,12 @@ function buildThemeStyleBlock(theme) {
48
56
  `--oc-bg: ${bg}`,
49
57
  `--oc-text: ${theme.colors.text}`,
50
58
  `--oc-text-muted: ${theme.colors.axis}`,
51
- `--oc-text-faint: ${theme.isDark ? cssTokenDefault("--oc-text-faint", "dark") : cssTokenDefault("--oc-text-faint", "light")}`,
59
+ `--oc-text-secondary: ${theme.colors.neutral.secondary}`,
60
+ `--oc-text-faint: ${theme.colors.neutral.faint}`,
61
+ `--oc-border: ${theme.colors.neutral.border}`,
52
62
  `--oc-gridline: ${theme.colors.gridline}`,
53
- `--oc-axis: ${theme.colors.axis}`,
63
+ // Hairline, not the tick-label ink.
64
+ `--oc-axis: ${theme.colors.hairline}`,
54
65
  `--oc-border-radius: ${theme.borderRadius}px`,
55
66
  `--oc-accent: ${accent}`,
56
67
  `--oc-accent-strong: ${adaptForLightLineStroke(accent)}`,
@@ -61,27 +72,29 @@ function buildThemeStyleBlock(theme) {
61
72
  `--oc-space-4: ${theme.spacing.padding}px`
62
73
  ];
63
74
  const rules = [
64
- `svg.oc-chart { ${props.join("; ")}; }`,
75
+ // Tabular figures everywhere in the chart's own text: axis ticks, value
76
+ // labels and legend entries all line up column-wise.
77
+ `svg.oc-chart { ${props.join("; ")}; font-variant-numeric: tabular-nums; }`,
65
78
  `.oc-chrome { font-family: var(--oc-font-family); }`,
66
79
  `.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
80
  `.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
81
  `.oc-subtitle { font-size: var(--oc-subtitle-size); font-weight: var(--oc-subtitle-weight); fill: var(--oc-text-muted); }`,
69
82
  `.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); }`,
83
+ `.oc-brand { font-size: 11px; font-weight: 500; letter-spacing: 0.02em; fill: var(--oc-text-faint); }`,
71
84
  `.oc-brand-dot { fill: var(--oc-accent); }`,
72
85
  `.oc-eyebrow-dot { fill: var(--oc-accent); }`,
73
86
  `.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; }`,
87
+ `.oc-metric-label { font-size: 11px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; fill: var(--oc-text-muted); }`,
88
+ `.oc-metric-value { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; fill: var(--oc-text); font-variant-numeric: tabular-nums; }`,
89
+ `.oc-metric-delta-up { fill: var(--oc-positive); font-size: 12px; font-weight: 500; }`,
90
+ `.oc-metric-delta-down { fill: var(--oc-negative); font-size: 12px; font-weight: 500; }`,
78
91
  `.oc-axis-tick-inline { font-size: 11px; font-weight: 400; fill: var(--oc-text-muted); }`,
79
92
  `.oc-endpoint-labels { font-family: var(--oc-font-family); }`,
80
93
  `.oc-endpoint-label { fill: var(--oc-endpoint-label-color, var(--oc-text)); }`,
81
94
  `.oc-endpoint-value { fill: var(--oc-endpoint-value-color, var(--oc-text-muted)); }`,
82
95
  `.oc-endpoint-leader { stroke: var(--oc-endpoint-leader-color, currentColor); }`,
83
96
  `.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; }`,
97
+ `.oc-metric-secondary { fill: var(--oc-text-muted); font-size: 12px; font-weight: 400; }`,
85
98
  `.oc-legend { font-family: var(--oc-font-family); font-size: var(--oc-body-size); }`,
86
99
  `.oc-legend-entry { cursor: default; }`,
87
100
  `.oc-legend text { fill: var(--oc-legend-text); }`
@@ -106,8 +119,9 @@ export {
106
119
  XLINK_NS,
107
120
  createSVGElement,
108
121
  setAttrs,
122
+ applyKnockoutHalo,
109
123
  applyTextStyle,
110
124
  buildThemeStyleBlock,
111
125
  injectThemeStyleBlock
112
126
  };
113
- //# sourceMappingURL=chunk-RM5XS6NR.js.map
127
+ //# sourceMappingURL=chunk-5RX6ZQGZ.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\n/**\n * Cut a knockout halo behind text so it stays legible where it crosses\n * gridlines, marks, or a neighbouring series: a surface-colored stroke painted\n * under the glyphs.\n *\n * Presentation attributes, not inline styles, so a stylesheet can still\n * override the halo. The width scales with the font size -- a fixed width rings\n * small glyphs and disappears behind large ones.\n */\nexport function applyKnockoutHalo(el: SVGElement, color: string, fontSize: number): void {\n setAttrs(el, {\n stroke: color,\n 'stroke-width': Math.round(fontSize * 0.3),\n 'stroke-linejoin': 'round',\n 'paint-order': 'stroke',\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":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  injectThemeStyleBlock
3
- } from "./chunk-RM5XS6NR.js";
3
+ } from "./chunk-5RX6ZQGZ.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-6B5OBGIO.js.map
@@ -1,10 +1,11 @@
1
1
  import {
2
2
  SVG_NS,
3
3
  XLINK_NS,
4
+ applyKnockoutHalo,
4
5
  applyTextStyle,
5
6
  createSVGElement,
6
7
  setAttrs
7
- } from "./chunk-RM5XS6NR.js";
8
+ } from "./chunk-5RX6ZQGZ.js";
8
9
 
9
10
  // src/svg-ids.ts
10
11
  var counter = 0;
@@ -156,15 +157,22 @@ function resolvePatternFill(pattern, patternMap) {
156
157
  var currentAnimation;
157
158
  var currentGradientMap = /* @__PURE__ */ new Map();
158
159
  var currentPatternMap = /* @__PURE__ */ new Map();
160
+ var currentSurface = "";
159
161
  function setMarkRenderState(state) {
160
162
  currentAnimation = state.animation;
161
163
  currentGradientMap = state.gradientMap;
162
164
  currentPatternMap = state.patternMap ?? /* @__PURE__ */ new Map();
165
+ currentSurface = state.surface ?? "";
163
166
  }
164
167
  function resetMarkRenderState() {
165
168
  currentAnimation = void 0;
166
169
  currentGradientMap = /* @__PURE__ */ new Map();
167
170
  currentPatternMap = /* @__PURE__ */ new Map();
171
+ currentSurface = "";
172
+ }
173
+ function applyLabelHalo(text, label) {
174
+ if (!currentSurface || label.halo === false) return;
175
+ applyKnockoutHalo(text, currentSurface, label.style.fontSize);
168
176
  }
169
177
  function resolveFillOrPattern(fill, pattern) {
170
178
  if (pattern) return resolvePatternFill(pattern, currentPatternMap);
@@ -195,7 +203,11 @@ function renderLineMark(mark, index) {
195
203
  d,
196
204
  fill: "none",
197
205
  stroke: mark.stroke,
198
- "stroke-width": mark.strokeWidth
206
+ "stroke-width": mark.strokeWidth,
207
+ // Round joins/caps: at 2px a mitre on a sharp data spike renders as a
208
+ // spur past the vertex, and a butt cap leaves a squared-off line end.
209
+ "stroke-linejoin": "round",
210
+ "stroke-linecap": "round"
199
211
  });
200
212
  if (mark.strokeDasharray) {
201
213
  path.setAttribute("stroke-dasharray", mark.strokeDasharray);
@@ -213,6 +225,7 @@ function renderLineMark(mark, index) {
213
225
  }
214
226
  setAttrs(label, { x: mark.label.x, y: mark.label.y });
215
227
  applyTextStyle(label, mark.label.style);
228
+ applyLabelHalo(label, mark.label);
216
229
  label.textContent = mark.label.text;
217
230
  g.appendChild(label);
218
231
  if (mark.label.connector) {
@@ -253,7 +266,9 @@ function renderAreaMark(mark, index) {
253
266
  d: mark.topPath,
254
267
  fill: "none",
255
268
  stroke: mark.stroke,
256
- "stroke-width": mark.strokeWidth ?? 1
269
+ "stroke-width": mark.strokeWidth ?? 1,
270
+ "stroke-linejoin": "round",
271
+ "stroke-linecap": "round"
257
272
  });
258
273
  g.appendChild(strokePath);
259
274
  }
@@ -311,6 +326,9 @@ function renderRectMark(mark, index) {
311
326
  if (mark.strokeWidth) {
312
327
  shapeEl.setAttribute("stroke-width", String(mark.strokeWidth));
313
328
  }
329
+ if (mark.shapeRendering) {
330
+ shapeEl.setAttribute("shape-rendering", mark.shapeRendering);
331
+ }
314
332
  g.appendChild(shapeEl);
315
333
  return g;
316
334
  }
@@ -336,6 +354,7 @@ function renderArcMark(mark, index) {
336
354
  y: mark.label.y - mark.center.y
337
355
  });
338
356
  applyTextStyle(label, mark.label.style);
357
+ applyLabelHalo(label, mark.label);
339
358
  label.textContent = mark.label.text;
340
359
  g.appendChild(label);
341
360
  }
@@ -402,6 +421,9 @@ function renderRuleMark(mark, index) {
402
421
  if (mark.strokeDasharray) {
403
422
  line.setAttribute("stroke-dasharray", mark.strokeDasharray);
404
423
  }
424
+ if (mark.strokeOpacity != null) {
425
+ line.setAttribute("stroke-opacity", String(mark.strokeOpacity));
426
+ }
405
427
  if (mark.opacity != null) {
406
428
  line.setAttribute("opacity", String(mark.opacity));
407
429
  }
@@ -454,6 +476,10 @@ function getMarkSeries(mark) {
454
476
  if (mark.type === "line" || mark.type === "area") {
455
477
  return mark.seriesKey;
456
478
  }
479
+ const explicitSeries = mark.seriesKey;
480
+ if (explicitSeries) {
481
+ return explicitSeries;
482
+ }
457
483
  if (mark.type === "arc") {
458
484
  const label = mark.aria.label;
459
485
  if (!label) return void 0;
@@ -513,6 +539,7 @@ function renderMarks(parent, layout, opts) {
513
539
  label.setAttribute("class", "oc-mark-label");
514
540
  setAttrs(label, { x: rect.label.x, y: rect.label.y });
515
541
  applyTextStyle(label, rect.label.style);
542
+ applyLabelHalo(label, rect.label);
516
543
  label.textContent = rect.label.text;
517
544
  if (currentAnimation?.enter) {
518
545
  const idx = rect.animationIndex ?? i;
@@ -558,6 +585,61 @@ import {
558
585
  DEFAULT_LINE_HEIGHT,
559
586
  parseAnnotationSpans
560
587
  } from "@opendata-ai/openchart-engine";
588
+
589
+ // src/theme-tokens.ts
590
+ import { adaptForLightLineStroke, cssTokenDefault } from "@opendata-ai/openchart-core";
591
+ function resolvedSurface(theme) {
592
+ return theme.colors.neutral.surface;
593
+ }
594
+ function stampThemeProperties(el, theme) {
595
+ const accent = theme.colors.categorical[0] ?? cssTokenDefault("--oc-accent", "light");
596
+ const bg = resolvedSurface(theme);
597
+ const n = theme.colors.neutral;
598
+ const props = [
599
+ ["--oc-font-family", theme.fonts.family],
600
+ ["--oc-font-mono", theme.fonts.mono],
601
+ ["--oc-title-size", `${theme.chrome.title.fontSize}px`],
602
+ ["--oc-title-weight", `${theme.chrome.title.fontWeight}`],
603
+ ["--oc-subtitle-size", `${theme.chrome.subtitle.fontSize}px`],
604
+ ["--oc-subtitle-weight", `${theme.chrome.subtitle.fontWeight}`],
605
+ ["--oc-source-size", `${theme.chrome.source.fontSize}px`],
606
+ ["--oc-source-weight", `${theme.chrome.source.fontWeight}`],
607
+ ["--oc-body-size", `${theme.fonts.sizes.body}px`],
608
+ ["--oc-eyebrow-size", `${theme.chrome.eyebrow.fontSize}px`],
609
+ ["--oc-eyebrow-weight", `${theme.chrome.eyebrow.fontWeight}`],
610
+ ["--oc-bg", bg],
611
+ ["--oc-text", theme.colors.text],
612
+ ["--oc-text-muted", theme.colors.axis],
613
+ // Secondary grays are derived from the theme's own text/background pair
614
+ // (ResolvedTheme.colors.neutral), so a warm or cool theme gets warm or
615
+ // cool grays instead of zinc. One direction only: theme -> CSS.
616
+ ["--oc-text-secondary", n.secondary],
617
+ ["--oc-text-faint", n.faint],
618
+ ["--oc-border", n.border],
619
+ ["--oc-gray-100", n[100]],
620
+ ["--oc-gray-200", n[200]],
621
+ ["--oc-gray-300", n[300]],
622
+ ["--oc-gray-400", n[400]],
623
+ ["--oc-gray-600", n[600]],
624
+ ["--oc-gray-800", n[800]],
625
+ ["--oc-gridline", theme.colors.gridline],
626
+ // --oc-axis is the hairline the axis line is drawn with, not the ink its
627
+ // tick labels take (that is --oc-text-muted, above).
628
+ ["--oc-axis", theme.colors.hairline],
629
+ ["--oc-border-radius", `${theme.borderRadius}px`],
630
+ ["--oc-accent", accent],
631
+ ["--oc-accent-strong", adaptForLightLineStroke(accent)],
632
+ ["--oc-positive", theme.colors.positive],
633
+ ["--oc-negative", theme.colors.negative],
634
+ ["--oc-space-2", `${theme.spacing.chromeGap * 2}px`],
635
+ ["--oc-space-4", `${theme.spacing.padding}px`]
636
+ ];
637
+ for (const [name, value] of props) {
638
+ el.style.setProperty(name, value);
639
+ }
640
+ }
641
+
642
+ // src/renderers/annotations.ts
561
643
  var CONNECTOR_STROKE_WIDTH = 1.25;
562
644
  function renderArrowhead(parent, tipX, tipY, tangentX, tangentY, stroke) {
563
645
  const head = computeArrowheadPoints(tipX, tipY, tangentX, tangentY);
@@ -785,10 +867,7 @@ function renderAnnotation(parent, annotation, index, bgColor) {
785
867
  });
786
868
  g.appendChild(bgRect);
787
869
  } else if (bgColor && annotation.label.halo !== false) {
788
- text.style.paintOrder = "stroke";
789
- text.style.stroke = bgColor;
790
- text.style.strokeWidth = `${Math.round(fontSize * 0.3)}px`;
791
- text.style.strokeLinejoin = "round";
870
+ applyKnockoutHalo(text, bgColor, fontSize);
792
871
  }
793
872
  g.appendChild(text);
794
873
  if (annotation.subtitle) {
@@ -808,7 +887,7 @@ function renderAnnotations(parent, layout) {
808
887
  if (layout.annotations.length === 0) return;
809
888
  const g = createSVGElement("g");
810
889
  g.setAttribute("class", "oc-annotations");
811
- const bgColor = layout.theme.colors.background;
890
+ const bgColor = resolvedSurface(layout.theme);
812
891
  for (let i = 0; i < layout.annotations.length; i++) {
813
892
  renderAnnotation(g, layout.annotations[i], i, bgColor);
814
893
  }
@@ -1172,7 +1251,22 @@ function renderChrome(parent, layout) {
1172
1251
  const g = createSVGElement("g");
1173
1252
  g.setAttribute("class", "oc-chrome");
1174
1253
  const { chrome, measureText } = layout;
1175
- if (chrome.eyebrow) {
1254
+ if (chrome.rule) {
1255
+ const rule = createSVGElement("rect");
1256
+ rule.setAttribute("class", "oc-chrome-rule");
1257
+ setAttrs(rule, {
1258
+ x: chrome.rule.x,
1259
+ y: chrome.rule.y,
1260
+ width: chrome.rule.width,
1261
+ height: chrome.rule.thickness
1262
+ });
1263
+ rule.setAttribute("fill", chrome.rule.color);
1264
+ rule.setAttribute("shape-rendering", "crispEdges");
1265
+ g.appendChild(rule);
1266
+ }
1267
+ if (chrome.eyebrow && chrome.rule) {
1268
+ renderChromeElement(g, chrome.eyebrow, "oc-eyebrow", "eyebrow", measureText, true);
1269
+ } else if (chrome.eyebrow) {
1176
1270
  const eyebrow = chrome.eyebrow;
1177
1271
  const dotR = 3;
1178
1272
  const dotGap = 8;
@@ -1397,9 +1491,34 @@ function renderEndpointLabels(parent, layout) {
1397
1491
 
1398
1492
  // src/renderers/legend.ts
1399
1493
  import { estimateTextWidth as estimateTextWidth2 } from "@opendata-ai/openchart-core";
1494
+ var LEGEND_HIT_HEIGHT = 24;
1495
+ var LEGEND_HIT_PAD = 4;
1400
1496
  function isCategorical(legend) {
1401
1497
  return !legend.type || legend.type === "categorical";
1402
1498
  }
1499
+ function renderNoDataSwatch(parent, noData, labelStyle, chip) {
1500
+ const swatch = createSVGElement("rect");
1501
+ swatch.setAttribute("class", "oc-legend-nodata");
1502
+ setAttrs(swatch, {
1503
+ x: noData.x,
1504
+ y: noData.y,
1505
+ width: noData.size,
1506
+ height: noData.size,
1507
+ fill: noData.fill,
1508
+ ...chip ? { rx: chip.rx, ry: chip.rx } : { "shape-rendering": "crispEdges" }
1509
+ });
1510
+ parent.appendChild(swatch);
1511
+ const label = createSVGElement("text");
1512
+ setAttrs(label, {
1513
+ x: noData.labelX,
1514
+ y: noData.labelY,
1515
+ "text-anchor": "start",
1516
+ ...chip ? { "dominant-baseline": chip.baseline } : {}
1517
+ });
1518
+ applyTextStyle(label, labelStyle);
1519
+ label.textContent = noData.label;
1520
+ parent.appendChild(label);
1521
+ }
1403
1522
  function renderContinuousLegend(parent, legend) {
1404
1523
  if (legend.bounds.width <= 0 || legend.bounds.height <= 0) return;
1405
1524
  const g = createSVGElement("g");
@@ -1446,7 +1565,8 @@ function renderContinuousLegend(parent, legend) {
1446
1565
  });
1447
1566
  g.appendChild(bar);
1448
1567
  } else {
1449
- for (const bin of legend.bins) {
1568
+ for (let i = 0; i < legend.bins.length; i++) {
1569
+ const bin = legend.bins[i];
1450
1570
  const rect = createSVGElement("rect");
1451
1571
  rect.setAttribute("class", "oc-legend-bin");
1452
1572
  setAttrs(rect, {
@@ -1454,11 +1574,21 @@ function renderContinuousLegend(parent, legend) {
1454
1574
  y: legend.bar.y,
1455
1575
  width: bin.width,
1456
1576
  height: legend.bar.height,
1457
- fill: bin.color
1577
+ fill: bin.color,
1578
+ "shape-rendering": "crispEdges"
1458
1579
  });
1580
+ rect.setAttribute("data-bin-index", String(i));
1459
1581
  g.appendChild(rect);
1460
1582
  }
1461
1583
  }
1584
+ if (legend.title && legend.titleY !== void 0) {
1585
+ const title = createSVGElement("text");
1586
+ title.setAttribute("class", "oc-legend-title");
1587
+ setAttrs(title, { x: legend.bar.x, y: legend.titleY, "text-anchor": "start" });
1588
+ applyTextStyle(title, legend.titleStyle ?? legend.labelStyle);
1589
+ title.textContent = legend.title;
1590
+ g.appendChild(title);
1591
+ }
1462
1592
  for (const tick of legend.ticks) {
1463
1593
  const label = createSVGElement("text");
1464
1594
  setAttrs(label, { x: tick.x, y: legend.labelY, "text-anchor": tick.anchor });
@@ -1466,6 +1596,9 @@ function renderContinuousLegend(parent, legend) {
1466
1596
  label.textContent = tick.label;
1467
1597
  g.appendChild(label);
1468
1598
  }
1599
+ if (legend.noData) {
1600
+ renderNoDataSwatch(g, legend.noData, legend.labelStyle);
1601
+ }
1469
1602
  parent.appendChild(g);
1470
1603
  }
1471
1604
  function renderSizeLegend(parent, legend) {
@@ -1477,6 +1610,14 @@ function renderSizeLegend(parent, legend) {
1477
1610
  const oy = legend.bounds.y;
1478
1611
  const labelX = ox + legend.circles[0].cx + legend.circles[0].radius + 8;
1479
1612
  const leaderX = ox + legend.circles[0].cx + legend.circles[0].radius + 4;
1613
+ if (legend.title && legend.titleY !== void 0) {
1614
+ const title = createSVGElement("text");
1615
+ title.setAttribute("class", "oc-legend-title");
1616
+ setAttrs(title, { x: ox, y: oy + legend.titleY, "text-anchor": "start" });
1617
+ applyTextStyle(title, legend.titleStyle ?? legend.labelStyle);
1618
+ title.textContent = legend.title;
1619
+ g.appendChild(title);
1620
+ }
1480
1621
  for (const circle of legend.circles) {
1481
1622
  const c = createSVGElement("circle");
1482
1623
  setAttrs(c, {
@@ -1522,7 +1663,7 @@ function renderLegend(parent, legend) {
1522
1663
  if (!isCategorical(legend) || legend.entries.length === 0) return;
1523
1664
  const g = createSVGElement("g");
1524
1665
  g.setAttribute("class", "oc-legend");
1525
- g.setAttribute("role", "list");
1666
+ g.setAttribute("role", "group");
1526
1667
  g.setAttribute("aria-label", "Chart legend");
1527
1668
  const isHorizontal = legend.position === "top" || legend.position === "top-left" || legend.position === "bottom";
1528
1669
  const positions = "entryPositions" in legend ? legend.entryPositions : void 0;
@@ -1548,7 +1689,6 @@ function renderLegend(parent, legend) {
1548
1689
  }
1549
1690
  const entryG = createSVGElement("g");
1550
1691
  entryG.setAttribute("class", "oc-legend-entry");
1551
- entryG.setAttribute("role", "listitem");
1552
1692
  entryG.setAttribute("data-legend-index", String(i));
1553
1693
  entryG.setAttribute("data-legend-label", entry.label);
1554
1694
  if (entry.overflow) {
@@ -1556,14 +1696,32 @@ function renderLegend(parent, legend) {
1556
1696
  entryG.setAttribute("aria-label", entry.label);
1557
1697
  entryG.setAttribute("opacity", "0.5");
1558
1698
  } else {
1559
- entryG.setAttribute(
1560
- "aria-label",
1561
- `${entry.label}: ${entry.active !== false ? "visible" : "hidden"}`
1562
- );
1699
+ const visible = entry.active !== false;
1700
+ entryG.setAttribute("role", "button");
1701
+ entryG.setAttribute("tabindex", "0");
1702
+ entryG.setAttribute("aria-pressed", String(visible));
1703
+ entryG.setAttribute("data-legend-active", String(visible));
1704
+ entryG.setAttribute("aria-label", `${entry.label}: ${visible ? "visible" : "hidden"}`);
1563
1705
  entryG.setAttribute("style", "cursor: pointer");
1564
- if (entry.active === false) {
1706
+ if (!visible) {
1565
1707
  entryG.setAttribute("opacity", "0.3");
1566
1708
  }
1709
+ const labelWidth = estimateTextWidth2(
1710
+ entry.label,
1711
+ legend.labelStyle.fontSize,
1712
+ legend.labelStyle.fontWeight
1713
+ );
1714
+ const hit = createSVGElement("rect");
1715
+ hit.setAttribute("class", "oc-legend-hit");
1716
+ setAttrs(hit, {
1717
+ x: offsetX - LEGEND_HIT_PAD,
1718
+ y: offsetY + legend.swatchSize / 2 - LEGEND_HIT_HEIGHT / 2,
1719
+ width: legend.swatchSize + legend.swatchGap + labelWidth + LEGEND_HIT_PAD * 2,
1720
+ height: LEGEND_HIT_HEIGHT,
1721
+ rx: 2,
1722
+ fill: "transparent"
1723
+ });
1724
+ entryG.appendChild(hit);
1567
1725
  }
1568
1726
  const midX = offsetX + legend.swatchSize / 2;
1569
1727
  const midY = offsetY + legend.swatchSize / 2;
@@ -1629,6 +1787,9 @@ function renderLegend(parent, legend) {
1629
1787
  }
1630
1788
  }
1631
1789
  }
1790
+ if (legend.noData) {
1791
+ renderNoDataSwatch(g, legend.noData, legend.labelStyle, { rx: 2, baseline: "central" });
1792
+ }
1632
1793
  parent.appendChild(g);
1633
1794
  }
1634
1795
 
@@ -1763,7 +1924,12 @@ function renderChartSVG(layout, container, opts) {
1763
1924
  defs
1764
1925
  );
1765
1926
  svg.appendChild(defs);
1766
- setMarkRenderState({ animation, gradientMap, patternMap });
1927
+ setMarkRenderState({
1928
+ animation,
1929
+ gradientMap,
1930
+ patternMap,
1931
+ surface: resolvedSurface(layout.theme)
1932
+ });
1767
1933
  try {
1768
1934
  if (layout.facet) {
1769
1935
  renderFacetedPanels(svg, layout, layout.facet.panels, defs);
@@ -1837,10 +2003,10 @@ function renderChartSVG(layout, container, opts) {
1837
2003
  clippedGroup.appendChild(dotsGroup);
1838
2004
  }
1839
2005
  svg.appendChild(clipGroup);
2006
+ renderAnnotations(svg, layout);
1840
2007
  if (markLabelsOverlay) {
1841
2008
  svg.appendChild(markLabelsOverlay);
1842
2009
  }
1843
- renderAnnotations(svg, layout);
1844
2010
  renderEndpointLabels(svg, layout);
1845
2011
  const epEntries = layout.endpointLabels?.entries ?? [];
1846
2012
  if (epEntries.length > 0) {
@@ -1931,10 +2097,10 @@ function renderFacetedPanels(svg, layout, panels, defs) {
1931
2097
  clippedGroup.setAttribute("clip-path", `url(#${panelClipId})`);
1932
2098
  const panelLabelsOverlay = renderMarks(clippedGroup, panelLayout);
1933
2099
  g.appendChild(clippedGroup);
2100
+ renderAnnotations(g, panelLayout);
1934
2101
  if (panelLabelsOverlay) {
1935
2102
  g.appendChild(panelLabelsOverlay);
1936
2103
  }
1937
- renderAnnotations(g, panelLayout);
1938
2104
  svg.appendChild(g);
1939
2105
  }
1940
2106
  }
@@ -1945,6 +2111,10 @@ export {
1945
2111
  resetSvgIdCounter,
1946
2112
  buildGradientDefs,
1947
2113
  resolveMarkFill,
2114
+ buildPatternDefs,
2115
+ resolvePatternFill,
2116
+ resolvedSurface,
2117
+ stampThemeProperties,
1948
2118
  renderChromeElement,
1949
2119
  renderLegend,
1950
2120
  registerMarkRenderer,
@@ -1952,4 +2122,4 @@ export {
1952
2122
  renderSingleMark,
1953
2123
  renderChartSVG
1954
2124
  };
1955
- //# sourceMappingURL=chunk-W76C53MC.js.map
2125
+ //# sourceMappingURL=chunk-KANNWAEA.js.map