@opendata-ai/openchart-vanilla 7.6.0 → 7.7.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.
@@ -0,0 +1,29 @@
1
+ import { ThemeConfig, DarkMode, ChartSpec, LayerSpec } from '@opendata-ai/openchart-core';
2
+
3
+ interface StaticRenderOptions {
4
+ width?: number;
5
+ height?: number;
6
+ theme?: ThemeConfig;
7
+ /**
8
+ * Dark mode setting. In static rendering `'auto'` resolves to light mode
9
+ * since there is no `matchMedia` to query. Use `'force'` for dark output.
10
+ */
11
+ darkMode?: DarkMode;
12
+ watermark?: boolean;
13
+ }
14
+ /**
15
+ * Render a chart spec to a standalone SVG string without a browser DOM.
16
+ *
17
+ * Requires `happy-dom` as a peer dependency (`bun add happy-dom`).
18
+ *
19
+ * Not safe for concurrent invocation: the render pipeline relies on a global
20
+ * SVG ID counter and a temporary global `document` swap, both of which are
21
+ * single-threaded. In a server handling parallel requests, serialize calls
22
+ * through a queue or mutex.
23
+ *
24
+ * The entire render pipeline is synchronous; the global swap is safe as long
25
+ * as no code schedules microtasks that outlive the call.
26
+ */
27
+ declare function renderStaticSVG(spec: ChartSpec | LayerSpec, options?: StaticRenderOptions): string;
28
+
29
+ export { type StaticRenderOptions, renderStaticSVG };
package/dist/static.js ADDED
@@ -0,0 +1,177 @@
1
+ import {
2
+ SVG_NS,
3
+ renderChartSVG,
4
+ resetSvgIdCounter
5
+ } from "./chunk-PSMDJEXK.js";
6
+
7
+ // src/static.ts
8
+ import { createRequire } from "module";
9
+ import { adaptForLightLineStroke, isLayerSpec } from "@opendata-ai/openchart-core";
10
+ import { compileChart, compileLayer } from "@opendata-ai/openchart-engine";
11
+ var esmRequire = createRequire(import.meta.url);
12
+ var cachedWindow;
13
+ function getHappyDomWindow() {
14
+ if (cachedWindow) return cachedWindow;
15
+ try {
16
+ ({ Window: cachedWindow } = esmRequire("happy-dom"));
17
+ } catch {
18
+ throw new Error(
19
+ "renderStaticSVG requires 'happy-dom' as a peer dependency. Install it with: npm add happy-dom"
20
+ );
21
+ }
22
+ return cachedWindow;
23
+ }
24
+ var rendering = false;
25
+ function resolveStaticDarkMode(mode) {
26
+ if (mode === "force") return true;
27
+ return false;
28
+ }
29
+ function buildThemeStyleBlock(theme) {
30
+ const accent = theme.colors.categorical[0] ?? "#06b6d4";
31
+ const bg = theme.colors.background === "transparent" ? theme.isDark ? "#09090b" : "#ffffff" : theme.colors.background;
32
+ const props = [
33
+ `--oc-font-family: ${theme.fonts.family}`,
34
+ `--oc-font-mono: ${theme.fonts.mono}`,
35
+ `--oc-title-size: ${theme.chrome.title.fontSize}px`,
36
+ `--oc-title-weight: ${theme.chrome.title.fontWeight}`,
37
+ `--oc-title-tracking: -0.022em`,
38
+ // sync with tokens.css
39
+ `--oc-subtitle-size: ${theme.chrome.subtitle.fontSize}px`,
40
+ `--oc-subtitle-weight: ${theme.chrome.subtitle.fontWeight}`,
41
+ `--oc-source-size: ${theme.chrome.source.fontSize}px`,
42
+ `--oc-source-weight: ${theme.chrome.source.fontWeight}`,
43
+ `--oc-body-size: ${theme.fonts.sizes.body}px`,
44
+ `--oc-eyebrow-size: ${theme.chrome.eyebrow.fontSize}px`,
45
+ `--oc-eyebrow-weight: ${theme.chrome.eyebrow.fontWeight}`,
46
+ `--oc-eyebrow-tracking: 0.08em`,
47
+ // sync with tokens.css
48
+ `--oc-bg: ${bg}`,
49
+ `--oc-text: ${theme.colors.text}`,
50
+ `--oc-text-muted: ${theme.colors.axis}`,
51
+ `--oc-text-faint: ${theme.isDark ? "#52525b" : "#d4d4d8"}`,
52
+ `--oc-gridline: ${theme.colors.gridline}`,
53
+ `--oc-axis: ${theme.colors.axis}`,
54
+ `--oc-border-radius: ${theme.borderRadius}px`,
55
+ `--oc-accent: ${accent}`,
56
+ `--oc-accent-strong: ${adaptForLightLineStroke(accent)}`,
57
+ `--oc-positive: ${theme.colors.positive}`,
58
+ `--oc-negative: ${theme.colors.negative}`,
59
+ `--oc-legend-text: ${theme.isDark ? "#d0d6e0" : "#3f3f46"}`,
60
+ `--oc-space-2: ${theme.spacing.chromeGap * 2}px`,
61
+ `--oc-space-4: ${theme.spacing.padding}px`
62
+ ];
63
+ const rules = [
64
+ `svg.oc-chart { ${props.join("; ")}; }`,
65
+ `.oc-chrome { font-family: var(--oc-font-family); }`,
66
+ `.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
+ `.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
+ `.oc-subtitle { font-size: var(--oc-subtitle-size); font-weight: var(--oc-subtitle-weight); fill: var(--oc-text-muted); }`,
69
+ `.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); }`,
71
+ `.oc-brand-dot { fill: var(--oc-accent); }`,
72
+ `.oc-eyebrow-dot { fill: var(--oc-accent); }`,
73
+ `.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; }`,
78
+ `.oc-axis-tick-inline { font-size: 11px; font-weight: 400; fill: var(--oc-text-muted); }`,
79
+ `.oc-endpoint-labels { font-family: var(--oc-font-family); }`,
80
+ `.oc-endpoint-label { fill: var(--oc-endpoint-label-color, var(--oc-text)); }`,
81
+ `.oc-endpoint-value { fill: var(--oc-endpoint-value-color, var(--oc-text-muted)); }`,
82
+ `.oc-endpoint-leader { stroke: var(--oc-endpoint-leader-color, currentColor); }`,
83
+ `.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; }`,
85
+ `.oc-legend { font-family: var(--oc-font-family); font-size: var(--oc-body-size); }`,
86
+ `.oc-legend-entry { cursor: default; }`,
87
+ `.oc-legend text { fill: var(--oc-legend-text); }`
88
+ ];
89
+ return rules.join("\n");
90
+ }
91
+ function stripInteractiveElements(svg) {
92
+ const selectors = ["[data-voronoi-overlay]", "[data-crosshair]", "[data-snap-dots]"];
93
+ for (const selector of selectors) {
94
+ const els = svg.querySelectorAll(selector);
95
+ for (const el of els) {
96
+ el.parentNode?.removeChild(el);
97
+ }
98
+ }
99
+ }
100
+ function renderStaticSVG(spec, options) {
101
+ if (rendering) {
102
+ throw new Error("renderStaticSVG is not reentrant \u2014 serialize calls through a queue or mutex");
103
+ }
104
+ rendering = true;
105
+ const Window = getHappyDomWindow();
106
+ const win = new Window({ url: "about:blank" });
107
+ const prevDocument = globalThis.document;
108
+ const prevWindow = globalThis.window;
109
+ try {
110
+ globalThis.document = win.document;
111
+ globalThis.window = win;
112
+ resetSvgIdCounter();
113
+ const width = options?.width ?? 640;
114
+ const height = options?.height ?? 420;
115
+ const darkMode = resolveStaticDarkMode(options?.darkMode);
116
+ const compileOpts = {
117
+ width,
118
+ height,
119
+ theme: options?.theme,
120
+ darkMode,
121
+ watermark: options?.watermark
122
+ };
123
+ let layout;
124
+ if (isLayerSpec(spec)) {
125
+ layout = compileLayer(spec, compileOpts);
126
+ } else {
127
+ layout = compileChart(spec, compileOpts);
128
+ }
129
+ const container = win.document.createElement("div");
130
+ Object.defineProperty(container, "getBoundingClientRect", {
131
+ value: () => ({
132
+ width,
133
+ height,
134
+ top: 0,
135
+ left: 0,
136
+ right: width,
137
+ bottom: height,
138
+ x: 0,
139
+ y: 0,
140
+ toJSON: () => ({})
141
+ })
142
+ });
143
+ const svg = renderChartSVG(layout, container, {
144
+ animate: false,
145
+ crosshair: false
146
+ });
147
+ stripInteractiveElements(svg);
148
+ const doc = win.document;
149
+ let defs = svg.querySelector("defs");
150
+ if (!defs) {
151
+ defs = doc.createElementNS(SVG_NS, "defs");
152
+ svg.insertBefore(defs, svg.firstChild);
153
+ }
154
+ const styleEl = doc.createElementNS(SVG_NS, "style");
155
+ styleEl.textContent = buildThemeStyleBlock(layout.theme);
156
+ defs.insertBefore(styleEl, defs.firstChild);
157
+ const serializer = new win.XMLSerializer();
158
+ return serializer.serializeToString(svg);
159
+ } finally {
160
+ rendering = false;
161
+ if (prevDocument !== void 0) {
162
+ globalThis.document = prevDocument;
163
+ } else {
164
+ delete globalThis.document;
165
+ }
166
+ if (prevWindow !== void 0) {
167
+ globalThis.window = prevWindow;
168
+ } else {
169
+ delete globalThis.window;
170
+ }
171
+ win.close();
172
+ }
173
+ }
174
+ export {
175
+ renderStaticSVG
176
+ };
177
+ //# sourceMappingURL=static.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/static.ts"],"sourcesContent":["// Node.js only — do not import from browser code.\nimport { createRequire } from 'node:module';\nimport type {\n ChartLayout,\n ChartSpec,\n CompileOptions,\n DarkMode,\n LayerSpec,\n ResolvedTheme,\n ThemeConfig,\n} from '@opendata-ai/openchart-core';\nimport { adaptForLightLineStroke, isLayerSpec } from '@opendata-ai/openchart-core';\nimport { compileChart, compileLayer } from '@opendata-ai/openchart-engine';\nimport { SVG_NS } from './renderers/svg-dom';\nimport { resetSvgIdCounter } from './svg-ids';\nimport { renderChartSVG } from './svg-renderer';\n\nconst esmRequire = createRequire(import.meta.url);\n\nlet cachedWindow: typeof import('happy-dom').Window | undefined;\nfunction getHappyDomWindow(): typeof import('happy-dom').Window {\n if (cachedWindow) return cachedWindow;\n try {\n ({ Window: cachedWindow } = esmRequire('happy-dom') as typeof import('happy-dom'));\n } catch {\n throw new Error(\n \"renderStaticSVG requires 'happy-dom' as a peer dependency. Install it with: npm add happy-dom\",\n );\n }\n return cachedWindow;\n}\n\nlet rendering = false;\n\nexport interface StaticRenderOptions {\n width?: number;\n height?: number;\n theme?: ThemeConfig;\n /**\n * Dark mode setting. In static rendering `'auto'` resolves to light mode\n * since there is no `matchMedia` to query. Use `'force'` for dark output.\n */\n darkMode?: DarkMode;\n watermark?: boolean;\n}\n\nfunction resolveStaticDarkMode(mode?: DarkMode): boolean {\n if (mode === 'force') return true;\n return false;\n}\n\nfunction buildThemeStyleBlock(theme: ResolvedTheme): string {\n const accent = theme.colors.categorical[0] ?? '#06b6d4';\n const bg =\n theme.colors.background === 'transparent'\n ? theme.isDark\n ? '#09090b'\n : '#ffffff'\n : theme.colors.background;\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: -0.022em`, // sync with tokens.css\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: 0.08em`, // sync with tokens.css\n `--oc-bg: ${bg}`,\n `--oc-text: ${theme.colors.text}`,\n `--oc-text-muted: ${theme.colors.axis}`,\n `--oc-text-faint: ${theme.isDark ? '#52525b' : '#d4d4d8'}`,\n `--oc-gridline: ${theme.colors.gridline}`,\n `--oc-axis: ${theme.colors.axis}`,\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 ? '#d0d6e0' : '#3f3f46'}`,\n `--oc-space-2: ${theme.spacing.chromeGap * 2}px`,\n `--oc-space-4: ${theme.spacing.padding}px`,\n ];\n\n const rules = [\n `svg.oc-chart { ${props.join('; ')}; }`,\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: 510; 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: 10px; font-weight: 510; letter-spacing: 0.08em; text-transform: uppercase; fill: var(--oc-text-muted); }`,\n `.oc-metric-value { font-size: 22px; font-weight: 510; letter-spacing: -0.01em; fill: var(--oc-text); font-variant-numeric: tabular-nums; }`,\n `.oc-metric-delta-up { fill: var(--oc-positive); font-size: 12px; font-weight: 510; }`,\n `.oc-metric-delta-down { fill: var(--oc-negative); font-size: 12px; font-weight: 510; }`,\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-positive); 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\nfunction stripInteractiveElements(svg: Element): void {\n const selectors = ['[data-voronoi-overlay]', '[data-crosshair]', '[data-snap-dots]'];\n for (const selector of selectors) {\n const els = svg.querySelectorAll(selector);\n for (const el of els) {\n el.parentNode?.removeChild(el);\n }\n }\n}\n\n/**\n * Render a chart spec to a standalone SVG string without a browser DOM.\n *\n * Requires `happy-dom` as a peer dependency (`bun add happy-dom`).\n *\n * Not safe for concurrent invocation: the render pipeline relies on a global\n * SVG ID counter and a temporary global `document` swap, both of which are\n * single-threaded. In a server handling parallel requests, serialize calls\n * through a queue or mutex.\n *\n * The entire render pipeline is synchronous; the global swap is safe as long\n * as no code schedules microtasks that outlive the call.\n */\nexport function renderStaticSVG(\n spec: ChartSpec | LayerSpec,\n options?: StaticRenderOptions,\n): string {\n if (rendering) {\n throw new Error('renderStaticSVG is not reentrant — serialize calls through a queue or mutex');\n }\n rendering = true;\n\n const Window = getHappyDomWindow();\n const win = new Window({ url: 'about:blank' });\n\n const prevDocument = globalThis.document;\n const prevWindow = (globalThis as Record<string, unknown>).window;\n\n try {\n (globalThis as Record<string, unknown>).document = win.document;\n (globalThis as Record<string, unknown>).window = win;\n\n resetSvgIdCounter();\n\n const width = options?.width ?? 640;\n const height = options?.height ?? 420;\n const darkMode = resolveStaticDarkMode(options?.darkMode);\n\n const compileOpts: CompileOptions = {\n width,\n height,\n theme: options?.theme,\n darkMode,\n watermark: options?.watermark,\n };\n\n let layout: ChartLayout;\n if (isLayerSpec(spec)) {\n layout = compileLayer(spec, compileOpts);\n } else {\n layout = compileChart(spec, compileOpts);\n }\n\n const container = win.document.createElement('div');\n Object.defineProperty(container, 'getBoundingClientRect', {\n value: () => ({\n width,\n height,\n top: 0,\n left: 0,\n right: width,\n bottom: height,\n x: 0,\n y: 0,\n toJSON: () => ({}),\n }),\n });\n\n // renderChartSVG appends to container; the container and SVG are owned by\n // the happy-dom Window which is closed in the finally block.\n const svg = renderChartSVG(layout, container as unknown as HTMLElement, {\n animate: false,\n crosshair: false,\n });\n\n stripInteractiveElements(svg);\n\n const doc = win.document as unknown as Document;\n let defs = svg.querySelector('defs');\n if (!defs) {\n defs = doc.createElementNS(SVG_NS, 'defs');\n svg.insertBefore(defs as unknown as Node, svg.firstChild);\n }\n const styleEl = doc.createElementNS(SVG_NS, 'style');\n styleEl.textContent = buildThemeStyleBlock(layout.theme);\n defs.insertBefore(styleEl as unknown as Node, defs.firstChild);\n\n const serializer = new (\n win as unknown as { XMLSerializer: typeof XMLSerializer }\n ).XMLSerializer();\n return serializer.serializeToString(svg as unknown as Node);\n } finally {\n rendering = false;\n if (prevDocument !== undefined) {\n (globalThis as Record<string, unknown>).document = prevDocument;\n } else {\n delete (globalThis as Record<string, unknown>).document;\n }\n if (prevWindow !== undefined) {\n (globalThis as Record<string, unknown>).window = prevWindow;\n } else {\n delete (globalThis as Record<string, unknown>).window;\n }\n win.close();\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-vanilla",
3
- "version": "7.6.0",
3
+ "version": "7.7.0",
4
4
  "description": "Vanilla JS renderer for openchart: SVG charts, HTML tables, force-directed graphs",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -25,6 +25,10 @@
25
25
  "types": "./dist/index.d.ts",
26
26
  "import": "./dist/index.js"
27
27
  },
28
+ "./static": {
29
+ "types": "./dist/static.d.ts",
30
+ "import": "./dist/static.js"
31
+ },
28
32
  "./styles.css": "./dist/styles.css",
29
33
  "./simulation-worker": "./dist/simulation-worker.js"
30
34
  },
@@ -50,11 +54,19 @@
50
54
  },
51
55
  "dependencies": {
52
56
  "@floating-ui/dom": "^1.7.6",
53
- "@opendata-ai/openchart-core": "7.6.0",
54
- "@opendata-ai/openchart-engine": "7.6.0",
57
+ "@opendata-ai/openchart-core": "7.7.0",
58
+ "@opendata-ai/openchart-engine": "7.7.0",
55
59
  "d3-force": "^3.0.0",
56
60
  "d3-quadtree": "^3.0.1"
57
61
  },
62
+ "peerDependencies": {
63
+ "happy-dom": ">=14.0.0"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "happy-dom": {
67
+ "optional": true
68
+ }
69
+ },
58
70
  "devDependencies": {
59
71
  "@types/d3-force": "^3.0.10",
60
72
  "@types/d3-quadtree": "^3.0.6"
@@ -0,0 +1,141 @@
1
+ import type { LayerSpec } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { barSpec, lineSpec, pieSpec } from '../__test-fixtures__/specs';
4
+ import { renderStaticSVG } from '../static';
5
+
6
+ describe('renderStaticSVG', () => {
7
+ it('returns a valid SVG string', () => {
8
+ const svg = renderStaticSVG(lineSpec);
9
+ expect(svg).toContain('<svg');
10
+ expect(svg).toContain('</svg>');
11
+ expect(svg).toContain('viewBox');
12
+ });
13
+
14
+ it('produces byte-identical output for the same spec', () => {
15
+ const first = renderStaticSVG(lineSpec, { width: 640, height: 420 });
16
+ const second = renderStaticSVG(lineSpec, { width: 640, height: 420 });
17
+ expect(first).toBe(second);
18
+ });
19
+
20
+ it('includes a11y attributes', () => {
21
+ const svg = renderStaticSVG(lineSpec);
22
+ expect(svg).toContain('role=');
23
+ expect(svg).toContain('aria-label=');
24
+ });
25
+
26
+ it('does not contain interactive elements', () => {
27
+ const svg = renderStaticSVG(lineSpec);
28
+ expect(svg).not.toContain('data-voronoi-overlay');
29
+ expect(svg).not.toContain('data-crosshair');
30
+ expect(svg).not.toContain('data-snap-dots');
31
+ });
32
+
33
+ it('does not contain animation classes', () => {
34
+ const svg = renderStaticSVG(lineSpec);
35
+ expect(svg).not.toContain('oc-animate');
36
+ });
37
+
38
+ it('includes the inlined style block with theme values', () => {
39
+ const svg = renderStaticSVG(lineSpec);
40
+ expect(svg).toContain('<style');
41
+ expect(svg).toContain('.oc-brand-dot');
42
+ });
43
+
44
+ it('scopes custom properties to svg.oc-chart, not :root', () => {
45
+ const svg = renderStaticSVG(lineSpec);
46
+ expect(svg).toContain('svg.oc-chart');
47
+ expect(svg).not.toContain(':root');
48
+ });
49
+
50
+ it('uses var() references in class rules for theme overridability', () => {
51
+ const svg = renderStaticSVG(lineSpec);
52
+ expect(svg).toContain('fill: var(--oc-accent)');
53
+ expect(svg).toContain('fill: var(--oc-text)');
54
+ expect(svg).toContain('fill: var(--oc-text-muted)');
55
+ expect(svg).toContain('font-family: var(--oc-font-family)');
56
+ expect(svg).not.toMatch(/\.oc-brand-dot \{ fill: #[0-9a-f]{6}/i);
57
+ });
58
+
59
+ it('renders bar charts', () => {
60
+ const svg = renderStaticSVG(barSpec);
61
+ expect(svg).toContain('oc-mark-rect');
62
+ });
63
+
64
+ it('renders pie charts', () => {
65
+ const svg = renderStaticSVG(pieSpec);
66
+ expect(svg).toContain('oc-mark-arc');
67
+ });
68
+
69
+ it('renders layer specs', () => {
70
+ const layerSpec: LayerSpec = {
71
+ layer: [
72
+ {
73
+ mark: 'line',
74
+ data: [
75
+ { x: '2020', y: 10 },
76
+ { x: '2021', y: 20 },
77
+ ],
78
+ encoding: {
79
+ x: { field: 'x', type: 'temporal' },
80
+ y: { field: 'y', type: 'quantitative' },
81
+ },
82
+ },
83
+ {
84
+ mark: 'point',
85
+ data: [
86
+ { x: '2020', y: 10 },
87
+ { x: '2021', y: 20 },
88
+ ],
89
+ encoding: {
90
+ x: { field: 'x', type: 'temporal' },
91
+ y: { field: 'y', type: 'quantitative' },
92
+ },
93
+ },
94
+ ],
95
+ };
96
+ const svg = renderStaticSVG(layerSpec);
97
+ expect(svg).toContain('<svg');
98
+ expect(svg).toContain('oc-mark-line');
99
+ });
100
+
101
+ it('respects explicit width and height', () => {
102
+ const svg = renderStaticSVG(lineSpec, { width: 800, height: 500 });
103
+ expect(svg).toContain('viewBox="0 0 800 500"');
104
+ });
105
+
106
+ it('renders dark mode when darkMode is "force"', () => {
107
+ const light = renderStaticSVG(lineSpec, { darkMode: 'off' });
108
+ const dark = renderStaticSVG(lineSpec, { darkMode: 'force' });
109
+ expect(light).not.toBe(dark);
110
+ });
111
+
112
+ it('renders chrome elements', () => {
113
+ const svg = renderStaticSVG(lineSpec);
114
+ expect(svg).toContain('GDP Growth');
115
+ expect(svg).toContain('US vs UK over time');
116
+ expect(svg).toContain('World Bank');
117
+ });
118
+
119
+ it('produces deterministic IDs across calls', () => {
120
+ const first = renderStaticSVG(lineSpec, { width: 640, height: 420 });
121
+ const second = renderStaticSVG(lineSpec, { width: 640, height: 420 });
122
+ const idPattern = /id="oc-[a-z]+-\d+"/g;
123
+ const firstIds = first.match(idPattern) ?? [];
124
+ const secondIds = second.match(idPattern) ?? [];
125
+ expect(firstIds).toEqual(secondIds);
126
+ expect(firstIds.length).toBeGreaterThan(0);
127
+ });
128
+
129
+ it('does not pollute global document or window after rendering', () => {
130
+ const docBefore = globalThis.document;
131
+ const winBefore = globalThis.window;
132
+ renderStaticSVG(lineSpec);
133
+ expect(globalThis.document).toBe(docBefore);
134
+ expect(globalThis.window).toBe(winBefore);
135
+ });
136
+
137
+ it('suppresses watermark when watermark is false', () => {
138
+ const svg = renderStaticSVG(lineSpec, { watermark: false });
139
+ expect(svg).not.toContain('oc-brand-text');
140
+ });
141
+ });
@@ -525,16 +525,22 @@ describe('axis rendering', () => {
525
525
  expect(labels.length).toBeGreaterThan(0);
526
526
  });
527
527
 
528
- it('x-axis has a baseline line element', () => {
529
- const { svg } = renderSpec(lineSpec);
528
+ it('x-axis has a baseline line element for grounded marks', () => {
529
+ const { svg } = renderSpec(barSpec);
530
530
  const xAxis = svg.querySelector('.oc-axis-x');
531
531
  const line = xAxis!.querySelector('line');
532
- // The renderer draws an axis line for x-axis
533
532
  expect(line).not.toBeNull();
534
533
  });
535
534
 
535
+ it('x-axis hides baseline for non-grounded marks', () => {
536
+ const { svg } = renderSpec(lineSpec);
537
+ const xAxis = svg.querySelector('.oc-axis-x');
538
+ const line = xAxis!.querySelector('line');
539
+ expect(line).toBeNull();
540
+ });
541
+
536
542
  it('x-tick labels hang below the axis line by the label padding (no hugging)', () => {
537
- const { svg, layout } = renderSpec(lineSpec);
543
+ const { svg, layout } = renderSpec(barSpec);
538
544
  const xAxis = svg.querySelector('.oc-axis-x')!;
539
545
  const axisLineY = Number(xAxis.querySelector('line')!.getAttribute('y2'));
540
546
  const labels = Array.from(xAxis.querySelectorAll('text.oc-axis-tick'));
package/src/mount.ts CHANGED
@@ -20,7 +20,7 @@ import type {
20
20
  LayerSpec,
21
21
  ThemeConfig,
22
22
  } from '@opendata-ai/openchart-core';
23
- import { isLayerSpec } from '@opendata-ai/openchart-core';
23
+ import { isGraphSpec, isLayerSpec } from '@opendata-ai/openchart-core';
24
24
  import { compileChart, compileLayer } from '@opendata-ai/openchart-engine';
25
25
  import { cancelAnimations, setupAnimationCleanup } from './animation';
26
26
  import {
@@ -57,6 +57,7 @@ import { createMeasureText, resolveFontFamily, scheduleFontReload } from './meas
57
57
  import { observeResize } from './resize-observer';
58
58
  import { renderChartSVG } from './svg-renderer';
59
59
  import { createTextEditOverlay } from './text-edit-overlay';
60
+ import { stampThemeProperties } from './theme-tokens';
60
61
  import { createTooltipManager, type TooltipManager } from './tooltip';
61
62
 
62
63
  // ---------------------------------------------------------------------------
@@ -114,6 +115,8 @@ export interface ChartInstance {
114
115
  deselect(): void;
115
116
  /** Whether inline text editing is active. */
116
117
  readonly isEditing: boolean;
118
+ /** Set highlight values on the color encoding and re-render. Pass null to clear. */
119
+ setHighlight(values: string[] | null): void;
117
120
  }
118
121
 
119
122
  // ---------------------------------------------------------------------------
@@ -789,6 +792,10 @@ export function createChart<TData extends DataRow = DataRow>(
789
792
  container.classList.remove('oc-dark');
790
793
  }
791
794
 
795
+ // Stamp resolved theme as CSS custom properties on the container
796
+ // so CSS consumers read from the same source of truth as the JS engine.
797
+ stampThemeProperties(container, currentLayout.theme);
798
+
792
799
  // Set up animation cleanup on first render only
793
800
  if (shouldAnimate && svgElement) {
794
801
  cleanupAnimations = setupAnimationCleanup(svgElement, () => {
@@ -849,6 +856,28 @@ export function createChart<TData extends DataRow = DataRow>(
849
856
  render();
850
857
  }
851
858
 
859
+ function setHighlight(values: string[] | null): void {
860
+ if (destroyed) return;
861
+ if (isLayerSpec(currentSpec) || isGraphSpec(currentSpec as unknown as Record<string, unknown>))
862
+ return;
863
+ const spec = currentSpec as ChartSpec;
864
+ const colorEnc = spec.encoding?.color;
865
+ if (!colorEnc || typeof colorEnc !== 'object' || !('field' in colorEnc)) return;
866
+ const current = (colorEnc as { highlight?: string | string[] }).highlight;
867
+ if (!values?.length && !current) return;
868
+ const updatedColor = { ...colorEnc };
869
+ if (values && values.length > 0) {
870
+ updatedColor.highlight = values;
871
+ } else {
872
+ delete updatedColor.highlight;
873
+ }
874
+ currentSpec = {
875
+ ...spec,
876
+ encoding: { ...spec.encoding, color: updatedColor },
877
+ } as ChartSpec;
878
+ render();
879
+ }
880
+
852
881
  function doExport(format: 'svg'): string;
853
882
  function doExport(format: 'svg-with-fonts', exportOptions?: SVGExportOptions): Promise<string>;
854
883
  function doExport(format: 'png', exportOptions?: ExportOptions): Promise<Blob>;
@@ -985,5 +1014,6 @@ export function createChart<TData extends DataRow = DataRow>(
985
1014
  get isEditing(): boolean {
986
1015
  return isTextEditingActive;
987
1016
  },
1017
+ setHighlight,
988
1018
  };
989
1019
  }
@@ -121,6 +121,49 @@ function renderAnnotation(
121
121
  g.appendChild(line);
122
122
  }
123
123
 
124
+ // Footnote marker: annotation was demoted by auto-thinning. Render a numbered
125
+ // dot at the data point instead of the full label text.
126
+ if (annotation.footnoteIndex != null && annotation.label) {
127
+ const cx =
128
+ annotation.label.connector?.endpoint?.x ??
129
+ annotation.label.connector?.to.x ??
130
+ annotation.label.x;
131
+ const cy =
132
+ annotation.label.connector?.endpoint?.y ??
133
+ annotation.label.connector?.to.y ??
134
+ annotation.label.y;
135
+ const r = 8;
136
+ const circle = createSVGElement('circle');
137
+ circle.setAttribute('class', 'oc-annotation-footnote-marker');
138
+ setAttrs(circle, {
139
+ cx,
140
+ cy,
141
+ r,
142
+ fill: bgColor ?? '#ffffff',
143
+ stroke: annotation.label.style.fill ?? '#666',
144
+ 'stroke-width': 1.5,
145
+ });
146
+ g.appendChild(circle);
147
+
148
+ const num = createSVGElement('text');
149
+ num.setAttribute('class', 'oc-annotation-footnote-number');
150
+ setAttrs(num, {
151
+ x: cx,
152
+ y: cy,
153
+ 'dominant-baseline': 'central',
154
+ 'text-anchor': 'middle',
155
+ });
156
+ applyTextStyle(num, {
157
+ ...annotation.label.style,
158
+ fontSize: 9,
159
+ fontWeight: 600,
160
+ });
161
+ num.textContent = String(annotation.footnoteIndex);
162
+ g.appendChild(num);
163
+ parent.appendChild(g);
164
+ return;
165
+ }
166
+
124
167
  // Label with optional connector line
125
168
  if (annotation.label?.visible) {
126
169
  // Render connector first (behind the label text)
@@ -91,10 +91,41 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void {
91
91
  }
92
92
 
93
93
  const bottomOffset = layout.chrome.bottomAnchorY ?? layout.area.y + layout.area.height;
94
+
95
+ // Footnotes from auto-thinned annotations, rendered above source/byline.
96
+ // Each footnote gets its own line to avoid horizontal overflow.
97
+ let footnoteBandHeight = 0;
98
+ if (chrome.footnotes && chrome.footnotes.length > 0) {
99
+ const fontSize = layout.theme.fonts.sizes.small;
100
+ const pad = layout.theme.spacing.padding;
101
+ const lineHeight = fontSize * 1.3;
102
+ const style = {
103
+ fontFamily: layout.theme.fonts.family,
104
+ fontSize,
105
+ fontWeight: layout.theme.fonts.weights.normal,
106
+ fill: layout.theme.chrome.source.color,
107
+ lineHeight: 1.3,
108
+ textAnchor: 'start' as const,
109
+ };
110
+
111
+ for (let i = 0; i < chrome.footnotes.length; i++) {
112
+ const f = chrome.footnotes[i];
113
+ const y =
114
+ bottomOffset + layout.theme.spacing.chartToFooter + textAscent(fontSize) + i * lineHeight;
115
+ const el = createSVGElement('text');
116
+ el.setAttribute('class', 'oc-footnotes');
117
+ setAttrs(el, { x: pad, y });
118
+ applyTextStyle(el, style);
119
+ el.textContent = `${f.index}. ${f.text}`;
120
+ g.appendChild(el);
121
+ }
122
+ footnoteBandHeight = chrome.footnotes.length * lineHeight + 4;
123
+ }
124
+
94
125
  if (chrome.source) {
95
126
  renderChromeElement(
96
127
  g,
97
- { ...chrome.source, y: bottomOffset + chrome.source.y },
128
+ { ...chrome.source, y: bottomOffset + chrome.source.y + footnoteBandHeight },
98
129
  'oc-source',
99
130
  'source',
100
131
  measureText,
@@ -103,7 +134,7 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void {
103
134
  if (chrome.byline) {
104
135
  renderChromeElement(
105
136
  g,
106
- { ...chrome.byline, y: bottomOffset + chrome.byline.y },
137
+ { ...chrome.byline, y: bottomOffset + chrome.byline.y + footnoteBandHeight },
107
138
  'oc-byline',
108
139
  'byline',
109
140
  measureText,
@@ -112,14 +143,14 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void {
112
143
  if (chrome.footer) {
113
144
  renderChromeElement(
114
145
  g,
115
- { ...chrome.footer, y: bottomOffset + chrome.footer.y },
146
+ { ...chrome.footer, y: bottomOffset + chrome.footer.y + footnoteBandHeight },
116
147
  'oc-footer',
117
148
  'footer',
118
149
  measureText,
119
150
  );
120
151
  }
121
152
  if (chrome.brand) {
122
- const brandY = bottomOffset + chrome.brand.y;
153
+ const brandY = bottomOffset + chrome.brand.y + footnoteBandHeight;
123
154
  renderChromeElement(g, { ...chrome.brand, y: brandY }, 'oc-brand', 'brand', measureText);
124
155
  // Accent dot to the left of the brand text. text-anchor=end means
125
156
  // brand.x is the right edge, so the dot sits 12px left of the measured