@opendata-ai/openchart-vanilla 7.6.1 → 7.8.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.
@@ -1,68 +1,58 @@
1
1
  /**
2
- * Endpoint-labels rendering: right-side per-series label column for multi-series
3
- * line/area charts. Renders, per entry:
4
- * - a chip+bar swatch matching the traditional legend (rounded surface chip
5
- * with a colored bar through its midline)
6
- * - the colored series label (with wrap support via tspans)
7
- * - a muted formatted value below the label
8
- * - an optional thin leader line back to the data point's true y
9
- * - an optional open-ring marker on the line at the chart's right edge
2
+ * Endpoint-labels rendering: per-series label columns for multi-series
3
+ * line/area charts. Supports trailing (right) and optional leading (left)
4
+ * columns when `ends: 'both'` is set.
10
5
  *
11
6
  * The engine resolves all positions, colors, and styles. This renderer is dumb:
12
7
  * it reads `layout.endpointLabels` and stamps SVG. Suppression logic lives in
13
8
  * the engine — when entries is empty, the renderer is a no-op.
14
9
  */
15
10
 
16
- import type { ChartLayout } from '@opendata-ai/openchart-core';
11
+ import type {
12
+ ChartLayout,
13
+ EndpointLabelEntry,
14
+ EndpointLabelsLayout,
15
+ Rect,
16
+ } from '@opendata-ai/openchart-core';
17
17
  import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
18
18
 
19
- // Swatch→label and label→value gaps both come from the engine layout
20
- // (`ep.gap` and `ep.valueGap`), so the renderer never has to keep its own
21
- // copies in sync. Leader styling is renderer-only — the engine doesn't
22
- // model stroke width or opacity for the optional connector.
23
19
  const LEADER_STROKE_WIDTH = 1;
24
20
  const LEADER_OPACITY = 0.45;
25
21
 
26
- export function renderEndpointLabels(parent: SVGElement, layout: ChartLayout): void {
27
- const ep = layout.endpointLabels;
28
- if (!ep || ep.entries.length === 0) return;
29
-
30
- const chartArea = layout.area;
31
- const chartRightX = chartArea.x + chartArea.width;
32
-
33
- const root = createSVGElement('g');
34
- root.setAttribute('class', 'oc-endpoint-labels');
35
- root.setAttribute('role', 'list');
36
- root.setAttribute('aria-label', 'Endpoint labels');
37
-
22
+ function renderColumn(
23
+ root: SVGElement,
24
+ entries: EndpointLabelEntry[],
25
+ bounds: Rect,
26
+ ep: EndpointLabelsLayout,
27
+ leaderAnchorX: number,
28
+ side: 'trailing' | 'leading',
29
+ ): void {
38
30
  const labelFontSize = ep.labelStyle.fontSize ?? 11;
39
31
  const labelLineHeight = labelFontSize * (ep.labelStyle.lineHeight ?? 1.25);
40
32
  const valueFontSize = ep.valueStyle.fontSize ?? 11;
41
33
 
42
- // The column starts at ep.bounds.x; the chip sits flush-left in the column,
43
- // the label/value text starts after the chip + gap.
44
- const chipX = ep.bounds.x;
34
+ const chipX = bounds.x;
45
35
  const chipWidth = ep.swatchSize;
46
36
  const textX = chipX + chipWidth + ep.gap;
47
37
 
48
- for (let i = 0; i < ep.entries.length; i++) {
49
- const entry = ep.entries[i];
38
+ for (let i = 0; i < entries.length; i++) {
39
+ const entry = entries[i];
50
40
 
51
41
  const entryG = createSVGElement('g');
52
42
  entryG.setAttribute('class', 'oc-endpoint-label-entry');
53
43
  entryG.setAttribute('role', 'listitem');
54
44
  entryG.setAttribute('data-endpoint-index', String(i));
55
45
  entryG.setAttribute('data-endpoint-key', entry.seriesKey);
46
+ entryG.setAttribute('data-endpoint-side', side);
56
47
  entryG.setAttribute('aria-label', `${entry.seriesKey}: ${entry.value}`);
57
48
 
58
- // Leader line: drawn first so swatch/text sit on top.
59
49
  if (entry.showLeader) {
60
50
  const leader = createSVGElement('line');
61
51
  leader.setAttribute('class', 'oc-endpoint-leader');
62
52
  setAttrs(leader, {
63
53
  x1: chipX,
64
54
  y1: entry.labelY + labelFontSize / 2,
65
- x2: chartRightX,
55
+ x2: leaderAnchorX,
66
56
  y2: entry.dataY,
67
57
  stroke: entry.color,
68
58
  'stroke-width': LEADER_STROKE_WIDTH,
@@ -71,7 +61,6 @@ export function renderEndpointLabels(parent: SVGElement, layout: ChartLayout): v
71
61
  entryG.appendChild(leader);
72
62
  }
73
63
 
74
- // Swatch: bare colored line segment matching the legend style.
75
64
  const rowY = entry.labelY + labelFontSize / 2;
76
65
  const line = createSVGElement('line');
77
66
  line.setAttribute('class', 'oc-endpoint-swatch-line');
@@ -86,13 +75,10 @@ export function renderEndpointLabels(parent: SVGElement, layout: ChartLayout): v
86
75
  });
87
76
  entryG.appendChild(line);
88
77
 
89
- // Label text. Multi-line via tspans when wrapped.
90
78
  const label = createSVGElement('text');
91
79
  label.setAttribute('class', 'oc-endpoint-label');
92
80
  setAttrs(label, { x: textX, y: entry.labelY + labelFontSize });
93
81
  applyTextStyle(label, ep.labelStyle);
94
- // Engine-resolved color always wins so theme overrides at the CSS layer
95
- // don't fight per-series colors.
96
82
  (label as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', entry.color);
97
83
 
98
84
  if (entry.labelLines.length <= 1) {
@@ -107,7 +93,6 @@ export function renderEndpointLabels(parent: SVGElement, layout: ChartLayout): v
107
93
  }
108
94
  entryG.appendChild(label);
109
95
 
110
- // Value text directly underneath the last label line.
111
96
  const lineCount = Math.max(entry.labelLines.length, 1);
112
97
  const valueY =
113
98
  entry.labelY +
@@ -122,7 +107,6 @@ export function renderEndpointLabels(parent: SVGElement, layout: ChartLayout): v
122
107
  value.textContent = entry.value;
123
108
  entryG.appendChild(value);
124
109
 
125
- // Marker: open-ring circle at the chart's right edge on the line.
126
110
  if (entry.marker) {
127
111
  const marker = createSVGElement('circle');
128
112
  marker.setAttribute('class', 'oc-endpoint-marker');
@@ -139,6 +123,28 @@ export function renderEndpointLabels(parent: SVGElement, layout: ChartLayout): v
139
123
 
140
124
  root.appendChild(entryG);
141
125
  }
126
+ }
127
+
128
+ export function renderEndpointLabels(parent: SVGElement, layout: ChartLayout): void {
129
+ const ep = layout.endpointLabels;
130
+ if (!ep || ep.entries.length === 0) return;
131
+
132
+ const chartArea = layout.area;
133
+
134
+ const root = createSVGElement('g');
135
+ root.setAttribute('class', 'oc-endpoint-labels');
136
+ root.setAttribute('role', 'list');
137
+ root.setAttribute('aria-label', 'Endpoint labels');
138
+
139
+ // Trailing column (right side).
140
+ const chartRightX = chartArea.x + chartArea.width;
141
+ renderColumn(root, ep.entries, ep.bounds, ep, chartRightX, 'trailing');
142
+
143
+ // Leading column (left side) when `ends: 'both'`.
144
+ if (ep.leading && ep.leading.length > 0 && ep.leadingBounds) {
145
+ const chartLeftX = chartArea.x;
146
+ renderColumn(root, ep.leading, ep.leadingBounds, ep, chartLeftX, 'leading');
147
+ }
142
148
 
143
149
  parent.appendChild(root);
144
150
  }
package/src/static.ts ADDED
@@ -0,0 +1,246 @@
1
+ // Node.js only — do not import from browser code.
2
+ import { createRequire } from 'node:module';
3
+ import type {
4
+ ChartLayout,
5
+ ChartSpec,
6
+ CompileOptions,
7
+ DarkMode,
8
+ LayerSpec,
9
+ ResolvedTheme,
10
+ ThemeConfig,
11
+ TileMapSpec,
12
+ } from '@opendata-ai/openchart-core';
13
+ import { adaptForLightLineStroke, isLayerSpec, isTileMapSpec } from '@opendata-ai/openchart-core';
14
+ import { compileChart, compileLayer, compileTileMap } from '@opendata-ai/openchart-engine';
15
+ import { SVG_NS } from './renderers/svg-dom';
16
+ import { resetSvgIdCounter } from './svg-ids';
17
+ import { renderChartSVG } from './svg-renderer';
18
+ import { renderTileMapSVG } from './tilemap-renderer';
19
+
20
+ const esmRequire = createRequire(import.meta.url);
21
+
22
+ let cachedWindow: typeof import('happy-dom').Window | undefined;
23
+ function getHappyDomWindow(): typeof import('happy-dom').Window {
24
+ if (cachedWindow) return cachedWindow;
25
+ try {
26
+ ({ Window: cachedWindow } = esmRequire('happy-dom') as typeof import('happy-dom'));
27
+ } catch {
28
+ throw new Error(
29
+ "renderStaticSVG requires 'happy-dom' as a peer dependency. Install it with: npm add happy-dom",
30
+ );
31
+ }
32
+ return cachedWindow;
33
+ }
34
+
35
+ let rendering = false;
36
+
37
+ export interface StaticRenderOptions {
38
+ width?: number;
39
+ height?: number;
40
+ theme?: ThemeConfig;
41
+ /**
42
+ * Dark mode setting. In static rendering `'auto'` resolves to light mode
43
+ * since there is no `matchMedia` to query. Use `'force'` for dark output.
44
+ */
45
+ darkMode?: DarkMode;
46
+ watermark?: boolean;
47
+ }
48
+
49
+ function resolveStaticDarkMode(mode?: DarkMode): boolean {
50
+ if (mode === 'force') return true;
51
+ return false;
52
+ }
53
+
54
+ function buildThemeStyleBlock(theme: ResolvedTheme): string {
55
+ const accent = theme.colors.categorical[0] ?? '#06b6d4';
56
+ const bg =
57
+ theme.colors.background === 'transparent'
58
+ ? theme.isDark
59
+ ? '#09090b'
60
+ : '#ffffff'
61
+ : theme.colors.background;
62
+
63
+ const props = [
64
+ `--oc-font-family: ${theme.fonts.family}`,
65
+ `--oc-font-mono: ${theme.fonts.mono}`,
66
+ `--oc-title-size: ${theme.chrome.title.fontSize}px`,
67
+ `--oc-title-weight: ${theme.chrome.title.fontWeight}`,
68
+ `--oc-title-tracking: -0.022em`, // sync with tokens.css
69
+ `--oc-subtitle-size: ${theme.chrome.subtitle.fontSize}px`,
70
+ `--oc-subtitle-weight: ${theme.chrome.subtitle.fontWeight}`,
71
+ `--oc-source-size: ${theme.chrome.source.fontSize}px`,
72
+ `--oc-source-weight: ${theme.chrome.source.fontWeight}`,
73
+ `--oc-body-size: ${theme.fonts.sizes.body}px`,
74
+ `--oc-eyebrow-size: ${theme.chrome.eyebrow.fontSize}px`,
75
+ `--oc-eyebrow-weight: ${theme.chrome.eyebrow.fontWeight}`,
76
+ `--oc-eyebrow-tracking: 0.08em`, // sync with tokens.css
77
+ `--oc-bg: ${bg}`,
78
+ `--oc-text: ${theme.colors.text}`,
79
+ `--oc-text-muted: ${theme.colors.axis}`,
80
+ `--oc-text-faint: ${theme.isDark ? '#52525b' : '#d4d4d8'}`,
81
+ `--oc-gridline: ${theme.colors.gridline}`,
82
+ `--oc-axis: ${theme.colors.axis}`,
83
+ `--oc-border-radius: ${theme.borderRadius}px`,
84
+ `--oc-accent: ${accent}`,
85
+ `--oc-accent-strong: ${adaptForLightLineStroke(accent)}`,
86
+ `--oc-positive: ${theme.colors.positive}`,
87
+ `--oc-negative: ${theme.colors.negative}`,
88
+ `--oc-legend-text: ${theme.isDark ? '#d0d6e0' : '#3f3f46'}`,
89
+ `--oc-space-2: ${theme.spacing.chromeGap * 2}px`,
90
+ `--oc-space-4: ${theme.spacing.padding}px`,
91
+ ];
92
+
93
+ const rules = [
94
+ `svg.oc-chart { ${props.join('; ')}; }`,
95
+ `.oc-chrome { font-family: var(--oc-font-family); }`,
96
+ `.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); }`,
97
+ `.oc-title { font-size: var(--oc-title-size); font-weight: var(--oc-title-weight); letter-spacing: var(--oc-title-tracking); fill: var(--oc-text); }`,
98
+ `.oc-subtitle { font-size: var(--oc-subtitle-size); font-weight: var(--oc-subtitle-weight); fill: var(--oc-text-muted); }`,
99
+ `.oc-source, .oc-byline, .oc-footer { font-size: var(--oc-source-size); font-weight: var(--oc-source-weight); fill: var(--oc-text-muted); }`,
100
+ `.oc-brand { font-size: 11px; font-weight: 510; letter-spacing: 0.02em; fill: var(--oc-text-faint); }`,
101
+ `.oc-brand-dot { fill: var(--oc-accent); }`,
102
+ `.oc-eyebrow-dot { fill: var(--oc-accent); }`,
103
+ `.oc-metrics { font-family: var(--oc-font-family); }`,
104
+ `.oc-metric-label { font-size: 10px; font-weight: 510; letter-spacing: 0.08em; text-transform: uppercase; fill: var(--oc-text-muted); }`,
105
+ `.oc-metric-value { font-size: 22px; font-weight: 510; letter-spacing: -0.01em; fill: var(--oc-text); font-variant-numeric: tabular-nums; }`,
106
+ `.oc-metric-delta-up { fill: var(--oc-positive); font-size: 12px; font-weight: 510; }`,
107
+ `.oc-metric-delta-down { fill: var(--oc-negative); font-size: 12px; font-weight: 510; }`,
108
+ `.oc-axis-tick-inline { font-size: 11px; font-weight: 400; fill: var(--oc-text-muted); }`,
109
+ `.oc-endpoint-labels { font-family: var(--oc-font-family); }`,
110
+ `.oc-endpoint-label { fill: var(--oc-endpoint-label-color, var(--oc-text)); }`,
111
+ `.oc-endpoint-value { fill: var(--oc-endpoint-value-color, var(--oc-text-muted)); }`,
112
+ `.oc-endpoint-leader { stroke: var(--oc-endpoint-leader-color, currentColor); }`,
113
+ `.oc-annotation-subtitle { fill: var(--oc-annotation-subtitle-color, var(--oc-text-muted)); }`,
114
+ `.oc-metric-secondary { fill: var(--oc-positive); font-size: 12px; font-weight: 400; }`,
115
+ `.oc-legend { font-family: var(--oc-font-family); font-size: var(--oc-body-size); }`,
116
+ `.oc-legend-entry { cursor: default; }`,
117
+ `.oc-legend text { fill: var(--oc-legend-text); }`,
118
+ ];
119
+
120
+ return rules.join('\n');
121
+ }
122
+
123
+ function stripInteractiveElements(svg: Element): void {
124
+ const selectors = ['[data-voronoi-overlay]', '[data-crosshair]', '[data-snap-dots]'];
125
+ for (const selector of selectors) {
126
+ const els = svg.querySelectorAll(selector);
127
+ for (const el of els) {
128
+ el.parentNode?.removeChild(el);
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Render a chart spec to a standalone SVG string without a browser DOM.
135
+ *
136
+ * Requires `happy-dom` as a peer dependency (`bun add happy-dom`).
137
+ *
138
+ * Not safe for concurrent invocation: the render pipeline relies on a global
139
+ * SVG ID counter and a temporary global `document` swap, both of which are
140
+ * single-threaded. In a server handling parallel requests, serialize calls
141
+ * through a queue or mutex.
142
+ *
143
+ * The entire render pipeline is synchronous; the global swap is safe as long
144
+ * as no code schedules microtasks that outlive the call.
145
+ */
146
+ export function renderStaticSVG(
147
+ spec: ChartSpec | LayerSpec | TileMapSpec,
148
+ options?: StaticRenderOptions,
149
+ ): string {
150
+ if (rendering) {
151
+ throw new Error('renderStaticSVG is not reentrant — serialize calls through a queue or mutex');
152
+ }
153
+ rendering = true;
154
+
155
+ const Window = getHappyDomWindow();
156
+ const win = new Window({ url: 'about:blank' });
157
+
158
+ const prevDocument = globalThis.document;
159
+ const prevWindow = (globalThis as Record<string, unknown>).window;
160
+
161
+ try {
162
+ (globalThis as Record<string, unknown>).document = win.document;
163
+ (globalThis as Record<string, unknown>).window = win;
164
+
165
+ resetSvgIdCounter();
166
+
167
+ const width = options?.width ?? 640;
168
+ const height = options?.height ?? 420;
169
+ const darkMode = resolveStaticDarkMode(options?.darkMode);
170
+
171
+ const compileOpts: CompileOptions = {
172
+ width,
173
+ height,
174
+ theme: options?.theme,
175
+ darkMode,
176
+ watermark: options?.watermark,
177
+ };
178
+
179
+ let svg: SVGElement;
180
+ let themeForStyle: ResolvedTheme;
181
+
182
+ if (isTileMapSpec(spec)) {
183
+ const tileMapLayout = compileTileMap(spec, compileOpts);
184
+ svg = renderTileMapSVG(tileMapLayout, { animate: false });
185
+ themeForStyle = tileMapLayout.theme;
186
+ } else {
187
+ let layout: ChartLayout;
188
+ if (isLayerSpec(spec)) {
189
+ layout = compileLayer(spec, compileOpts);
190
+ } else {
191
+ layout = compileChart(spec, compileOpts);
192
+ }
193
+
194
+ const container = win.document.createElement('div');
195
+ Object.defineProperty(container, 'getBoundingClientRect', {
196
+ value: () => ({
197
+ width,
198
+ height,
199
+ top: 0,
200
+ left: 0,
201
+ right: width,
202
+ bottom: height,
203
+ x: 0,
204
+ y: 0,
205
+ toJSON: () => ({}),
206
+ }),
207
+ });
208
+
209
+ svg = renderChartSVG(layout, container as unknown as HTMLElement, {
210
+ animate: false,
211
+ crosshair: false,
212
+ });
213
+
214
+ stripInteractiveElements(svg);
215
+ themeForStyle = layout.theme;
216
+ }
217
+
218
+ const doc = win.document as unknown as Document;
219
+ let defs = svg.querySelector('defs');
220
+ if (!defs) {
221
+ defs = doc.createElementNS(SVG_NS, 'defs');
222
+ svg.insertBefore(defs as unknown as Node, svg.firstChild);
223
+ }
224
+ const styleEl = doc.createElementNS(SVG_NS, 'style');
225
+ styleEl.textContent = buildThemeStyleBlock(themeForStyle);
226
+ defs.insertBefore(styleEl as unknown as Node, defs.firstChild);
227
+
228
+ const serializer = new (
229
+ win as unknown as { XMLSerializer: typeof XMLSerializer }
230
+ ).XMLSerializer();
231
+ return serializer.serializeToString(svg as unknown as Node);
232
+ } finally {
233
+ rendering = false;
234
+ if (prevDocument !== undefined) {
235
+ (globalThis as Record<string, unknown>).document = prevDocument;
236
+ } else {
237
+ delete (globalThis as Record<string, unknown>).document;
238
+ }
239
+ if (prevWindow !== undefined) {
240
+ (globalThis as Record<string, unknown>).window = prevWindow;
241
+ } else {
242
+ delete (globalThis as Record<string, unknown>).window;
243
+ }
244
+ win.close();
245
+ }
246
+ }
package/src/svg-ids.ts CHANGED
@@ -16,3 +16,8 @@ let counter = 0;
16
16
  export function nextSvgId(prefix: string): string {
17
17
  return `${prefix}-${counter++}`;
18
18
  }
19
+
20
+ /** @internal Used by static rendering only. Never call from browser-facing code. */
21
+ export function resetSvgIdCounter(): void {
22
+ counter = 0;
23
+ }