@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.
- package/dist/chunk-PSMDJEXK.js +1680 -0
- package/dist/chunk-PSMDJEXK.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +364 -1839
- package/dist/index.js.map +1 -1
- package/dist/static.d.ts +29 -0
- package/dist/static.js +177 -0
- package/dist/static.js.map +1 -0
- package/package.json +15 -3
- package/src/__tests__/static.test.ts +141 -0
- package/src/__tests__/svg-renderer.test.ts +10 -4
- package/src/mount.ts +31 -1
- package/src/renderers/annotations.ts +43 -0
- package/src/renderers/chrome.ts +35 -4
- package/src/renderers/endpoint-labels.ts +44 -38
- package/src/static.ts +236 -0
- package/src/svg-ids.ts +5 -0
- package/src/svg-renderer.ts +157 -98
- package/src/theme-tokens.ts +51 -0
- package/src/tilemap-renderer.ts +7 -2
package/src/svg-renderer.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* own module under `./renderers/`.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { ChartLayout, RectMark } from '@opendata-ai/openchart-core';
|
|
12
|
+
import type { ChartLayout, FacetPanelLayout, RectMark } from '@opendata-ai/openchart-core';
|
|
13
13
|
import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
|
|
14
14
|
import { buildGradientDefs } from './gradient-utils';
|
|
15
15
|
import { renderAnnotations } from './renderers/annotations';
|
|
@@ -161,111 +161,90 @@ export function renderChartSVG(
|
|
|
161
161
|
// starts with a clean slate.
|
|
162
162
|
setMarkRenderState({ animation, gradientMap });
|
|
163
163
|
try {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
if (layout.facet) {
|
|
165
|
+
// Faceted rendering: per-panel axes, marks, annotations
|
|
166
|
+
renderFacetedPanels(svg, layout, layout.facet.panels, defs);
|
|
167
|
+
} else {
|
|
168
|
+
// Standard (non-faceted) rendering
|
|
169
|
+
// Axes render outside clip (labels extend beyond chart area)
|
|
170
|
+
renderAxes(svg, layout);
|
|
167
171
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
// Decorative point marks on line/area: route pointer events to the
|
|
183
|
-
// overlay so the snap-tooltip wins instead of competing per-point hover.
|
|
184
|
-
const pointEls = clippedGroup.querySelectorAll('circle.oc-mark-point');
|
|
185
|
-
for (const el of pointEls) {
|
|
186
|
-
el.setAttribute('pointer-events', 'none');
|
|
187
|
-
}
|
|
172
|
+
// Marks are clipped to chart area so area fills don't cover chrome
|
|
173
|
+
const clippedGroup = createSVGElement('g');
|
|
174
|
+
clippedGroup.setAttribute('clip-path', `url(#${clipId})`);
|
|
175
|
+
const markLabelsOverlay = renderMarks(clippedGroup, layout);
|
|
176
|
+
|
|
177
|
+
// Add transparent overlay rect for line/area charts to enable voronoi tooltip lookup.
|
|
178
|
+
const hasLineOrAreaWithDataPoints = layout.marks.some(
|
|
179
|
+
(m) => (m.type === 'line' || m.type === 'area') && m.dataPoints && m.dataPoints.length > 0,
|
|
180
|
+
);
|
|
181
|
+
if (hasLineOrAreaWithDataPoints) {
|
|
182
|
+
const pointEls = clippedGroup.querySelectorAll('circle.oc-mark-point');
|
|
183
|
+
for (const el of pointEls) {
|
|
184
|
+
el.setAttribute('pointer-events', 'none');
|
|
185
|
+
}
|
|
188
186
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
});
|
|
197
|
-
overlay.setAttribute('class', 'oc-voronoi-overlay');
|
|
198
|
-
overlay.setAttribute('data-voronoi-overlay', 'true');
|
|
199
|
-
clippedGroup.appendChild(overlay);
|
|
200
|
-
|
|
201
|
-
// Crosshair line: vertical line that tracks the snapped data point x.
|
|
202
|
-
// Gated on `opts.crosshair` because the dashed line is the optional bit;
|
|
203
|
-
// the snap-dots layer below ships regardless so the multi-series
|
|
204
|
-
// hover-tooltip stays useful even when the user opts out of the line.
|
|
205
|
-
if (opts?.crosshair) {
|
|
206
|
-
const crosshairLine = createSVGElement('line');
|
|
207
|
-
crosshairLine.setAttribute('data-crosshair', 'true');
|
|
208
|
-
crosshairLine.setAttribute('class', 'oc-crosshair');
|
|
209
|
-
setAttrs(crosshairLine, {
|
|
210
|
-
x1: 0,
|
|
211
|
-
y1: layout.area.y,
|
|
212
|
-
x2: 0,
|
|
213
|
-
y2: layout.area.y + layout.area.height,
|
|
214
|
-
stroke: layout.theme.colors.axis,
|
|
215
|
-
'stroke-opacity': '0.4',
|
|
216
|
-
'stroke-dasharray': '3,3',
|
|
217
|
-
'stroke-width': '1',
|
|
218
|
-
'pointer-events': 'none',
|
|
187
|
+
const overlay = createSVGElement('rect');
|
|
188
|
+
setAttrs(overlay, {
|
|
189
|
+
x: layout.area.x,
|
|
190
|
+
y: layout.area.y,
|
|
191
|
+
width: layout.area.width,
|
|
192
|
+
height: layout.area.height,
|
|
193
|
+
fill: 'transparent',
|
|
219
194
|
});
|
|
220
|
-
|
|
221
|
-
|
|
195
|
+
overlay.setAttribute('class', 'oc-voronoi-overlay');
|
|
196
|
+
overlay.setAttribute('data-voronoi-overlay', 'true');
|
|
197
|
+
clippedGroup.appendChild(overlay);
|
|
198
|
+
|
|
199
|
+
if (opts?.crosshair) {
|
|
200
|
+
const crosshairLine = createSVGElement('line');
|
|
201
|
+
crosshairLine.setAttribute('data-crosshair', 'true');
|
|
202
|
+
crosshairLine.setAttribute('class', 'oc-crosshair');
|
|
203
|
+
setAttrs(crosshairLine, {
|
|
204
|
+
x1: 0,
|
|
205
|
+
y1: layout.area.y,
|
|
206
|
+
x2: 0,
|
|
207
|
+
y2: layout.area.y + layout.area.height,
|
|
208
|
+
stroke: layout.theme.colors.axis,
|
|
209
|
+
'stroke-opacity': '0.4',
|
|
210
|
+
'stroke-dasharray': '3,3',
|
|
211
|
+
'stroke-width': '1',
|
|
212
|
+
'pointer-events': 'none',
|
|
213
|
+
});
|
|
214
|
+
crosshairLine.style.display = 'none';
|
|
215
|
+
clippedGroup.appendChild(crosshairLine);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const dotsGroup = createSVGElement('g');
|
|
219
|
+
dotsGroup.setAttribute('data-snap-dots', 'true');
|
|
220
|
+
dotsGroup.setAttribute('class', 'oc-snap-dots');
|
|
221
|
+
dotsGroup.setAttribute('pointer-events', 'none');
|
|
222
|
+
clippedGroup.appendChild(dotsGroup);
|
|
222
223
|
}
|
|
223
224
|
|
|
224
|
-
|
|
225
|
-
// snapped x on hover. Always emitted so the merged tooltip has its
|
|
226
|
-
// anchors regardless of `opts.crosshair`.
|
|
227
|
-
const dotsGroup = createSVGElement('g');
|
|
228
|
-
dotsGroup.setAttribute('data-snap-dots', 'true');
|
|
229
|
-
dotsGroup.setAttribute('class', 'oc-snap-dots');
|
|
230
|
-
dotsGroup.setAttribute('pointer-events', 'none');
|
|
231
|
-
clippedGroup.appendChild(dotsGroup);
|
|
232
|
-
}
|
|
225
|
+
svg.appendChild(clippedGroup);
|
|
233
226
|
|
|
234
|
-
|
|
227
|
+
if (markLabelsOverlay) {
|
|
228
|
+
svg.appendChild(markLabelsOverlay);
|
|
229
|
+
}
|
|
235
230
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (markLabelsOverlay) {
|
|
239
|
-
svg.appendChild(markLabelsOverlay);
|
|
240
|
-
}
|
|
231
|
+
renderAnnotations(svg, layout);
|
|
232
|
+
renderEndpointLabels(svg, layout);
|
|
241
233
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (epEntries.length > 0) {
|
|
257
|
-
const pointEls = clippedGroup.querySelectorAll<SVGCircleElement>('circle.oc-mark-point');
|
|
258
|
-
for (const entry of epEntries) {
|
|
259
|
-
if (!entry.marker) continue;
|
|
260
|
-
// dataX is the original line endpoint; marker.x is offset by radius.
|
|
261
|
-
// Point marks are placed at data coordinates, so compare against dataX.
|
|
262
|
-
const mx = entry.marker.dataX;
|
|
263
|
-
const my = entry.marker.y;
|
|
264
|
-
for (const el of pointEls) {
|
|
265
|
-
const cx = Number(el.getAttribute('cx'));
|
|
266
|
-
const cy = Number(el.getAttribute('cy'));
|
|
267
|
-
if (Math.abs(cx - mx) < 0.5 && Math.abs(cy - my) < 0.5) {
|
|
268
|
-
el.setAttribute('opacity', '0');
|
|
234
|
+
// Suppress decorative point marks under endpoint markers
|
|
235
|
+
const epEntries = layout.endpointLabels?.entries ?? [];
|
|
236
|
+
if (epEntries.length > 0) {
|
|
237
|
+
const pointEls = clippedGroup.querySelectorAll<SVGCircleElement>('circle.oc-mark-point');
|
|
238
|
+
for (const entry of epEntries) {
|
|
239
|
+
if (!entry.marker) continue;
|
|
240
|
+
const mx = entry.marker.dataX;
|
|
241
|
+
const my = entry.marker.y;
|
|
242
|
+
for (const el of pointEls) {
|
|
243
|
+
const cx = Number(el.getAttribute('cx'));
|
|
244
|
+
const cy = Number(el.getAttribute('cy'));
|
|
245
|
+
if (Math.abs(cx - mx) < 0.5 && Math.abs(cy - my) < 0.5) {
|
|
246
|
+
el.setAttribute('opacity', '0');
|
|
247
|
+
}
|
|
269
248
|
}
|
|
270
249
|
}
|
|
271
250
|
}
|
|
@@ -290,3 +269,83 @@ export function renderChartSVG(
|
|
|
290
269
|
container.appendChild(svg);
|
|
291
270
|
return svg;
|
|
292
271
|
}
|
|
272
|
+
|
|
273
|
+
function renderFacetedPanels(
|
|
274
|
+
svg: SVGElement,
|
|
275
|
+
layout: ChartLayout,
|
|
276
|
+
panels: FacetPanelLayout[],
|
|
277
|
+
defs: SVGElement,
|
|
278
|
+
): void {
|
|
279
|
+
for (const panel of panels) {
|
|
280
|
+
const g = createSVGElement('g');
|
|
281
|
+
g.setAttribute('class', 'oc-facet-panel');
|
|
282
|
+
g.setAttribute('data-facet', panel.key);
|
|
283
|
+
|
|
284
|
+
// Panel background for visual grouping
|
|
285
|
+
const bg = createSVGElement('rect');
|
|
286
|
+
setAttrs(bg, {
|
|
287
|
+
x: panel.area.x,
|
|
288
|
+
y: panel.header.y - panel.header.fontSize * 0.6,
|
|
289
|
+
width: panel.area.width,
|
|
290
|
+
height: panel.area.height + panel.header.fontSize + 4,
|
|
291
|
+
rx: 3,
|
|
292
|
+
fill: layout.theme.isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)',
|
|
293
|
+
});
|
|
294
|
+
g.appendChild(bg);
|
|
295
|
+
|
|
296
|
+
// Panel header label
|
|
297
|
+
const headerText = createSVGElement('text');
|
|
298
|
+
setAttrs(headerText, {
|
|
299
|
+
x: panel.header.x,
|
|
300
|
+
y: panel.header.y,
|
|
301
|
+
'text-anchor': 'middle',
|
|
302
|
+
'font-family': layout.theme.fonts.family,
|
|
303
|
+
'font-size': panel.header.fontSize,
|
|
304
|
+
'font-weight': panel.header.fontWeight,
|
|
305
|
+
fill: layout.theme.colors.text,
|
|
306
|
+
});
|
|
307
|
+
headerText.textContent = panel.header.text;
|
|
308
|
+
g.appendChild(headerText);
|
|
309
|
+
|
|
310
|
+
// Build a per-panel synthetic layout for the existing renderers
|
|
311
|
+
const panelLayout: ChartLayout = {
|
|
312
|
+
...layout,
|
|
313
|
+
area: panel.area,
|
|
314
|
+
axes: panel.axes,
|
|
315
|
+
marks: panel.marks,
|
|
316
|
+
annotations: panel.annotations,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Panel axes (ticks already suppressed for inner panels by the engine)
|
|
320
|
+
renderAxes(g, panelLayout);
|
|
321
|
+
|
|
322
|
+
// Panel clip path
|
|
323
|
+
const panelClipId = nextSvgId('oc-facet-clip');
|
|
324
|
+
const clipPath = createSVGElement('clipPath');
|
|
325
|
+
clipPath.setAttribute('id', panelClipId);
|
|
326
|
+
const clipRect = createSVGElement('rect');
|
|
327
|
+
setAttrs(clipRect, {
|
|
328
|
+
x: panel.area.x,
|
|
329
|
+
y: panel.area.y,
|
|
330
|
+
width: panel.area.width,
|
|
331
|
+
height: panel.area.height,
|
|
332
|
+
});
|
|
333
|
+
clipPath.appendChild(clipRect);
|
|
334
|
+
defs.appendChild(clipPath);
|
|
335
|
+
|
|
336
|
+
// Panel marks (clipped)
|
|
337
|
+
const clippedGroup = createSVGElement('g');
|
|
338
|
+
clippedGroup.setAttribute('clip-path', `url(#${panelClipId})`);
|
|
339
|
+
const panelLabelsOverlay = renderMarks(clippedGroup, panelLayout);
|
|
340
|
+
g.appendChild(clippedGroup);
|
|
341
|
+
|
|
342
|
+
if (panelLabelsOverlay) {
|
|
343
|
+
g.appendChild(panelLabelsOverlay);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Panel annotations
|
|
347
|
+
renderAnnotations(g, panelLayout);
|
|
348
|
+
|
|
349
|
+
svg.appendChild(g);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate CSS custom properties from a resolved JS theme.
|
|
3
|
+
*
|
|
4
|
+
* Stamped on the .oc-root container at mount time so CSS consumers
|
|
5
|
+
* read from the same source of truth as the JS engine. The static
|
|
6
|
+
* values in tokens.css / dark.css serve as fallback defaults for
|
|
7
|
+
* contexts where JS hasn't mounted (SSR, static HTML).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ResolvedTheme } from '@opendata-ai/openchart-core';
|
|
11
|
+
import { adaptForLightLineStroke } from '@opendata-ai/openchart-core';
|
|
12
|
+
|
|
13
|
+
export function stampThemeProperties(el: HTMLElement, theme: ResolvedTheme): void {
|
|
14
|
+
const accent = theme.colors.categorical[0] ?? '#06b6d4';
|
|
15
|
+
const bg =
|
|
16
|
+
theme.colors.background === 'transparent'
|
|
17
|
+
? theme.isDark
|
|
18
|
+
? '#09090b'
|
|
19
|
+
: '#ffffff'
|
|
20
|
+
: theme.colors.background;
|
|
21
|
+
|
|
22
|
+
const props: [string, string][] = [
|
|
23
|
+
['--oc-font-family', theme.fonts.family],
|
|
24
|
+
['--oc-font-mono', theme.fonts.mono],
|
|
25
|
+
['--oc-title-size', `${theme.chrome.title.fontSize}px`],
|
|
26
|
+
['--oc-title-weight', `${theme.chrome.title.fontWeight}`],
|
|
27
|
+
['--oc-subtitle-size', `${theme.chrome.subtitle.fontSize}px`],
|
|
28
|
+
['--oc-subtitle-weight', `${theme.chrome.subtitle.fontWeight}`],
|
|
29
|
+
['--oc-source-size', `${theme.chrome.source.fontSize}px`],
|
|
30
|
+
['--oc-source-weight', `${theme.chrome.source.fontWeight}`],
|
|
31
|
+
['--oc-body-size', `${theme.fonts.sizes.body}px`],
|
|
32
|
+
['--oc-eyebrow-size', `${theme.chrome.eyebrow.fontSize}px`],
|
|
33
|
+
['--oc-eyebrow-weight', `${theme.chrome.eyebrow.fontWeight}`],
|
|
34
|
+
['--oc-bg', bg],
|
|
35
|
+
['--oc-text', theme.colors.text],
|
|
36
|
+
['--oc-text-muted', theme.colors.axis],
|
|
37
|
+
['--oc-gridline', theme.colors.gridline],
|
|
38
|
+
['--oc-axis', theme.colors.axis],
|
|
39
|
+
['--oc-border-radius', `${theme.borderRadius}px`],
|
|
40
|
+
['--oc-accent', accent],
|
|
41
|
+
['--oc-accent-strong', adaptForLightLineStroke(accent)],
|
|
42
|
+
['--oc-positive', theme.colors.positive],
|
|
43
|
+
['--oc-negative', theme.colors.negative],
|
|
44
|
+
['--oc-space-2', `${theme.spacing.chromeGap * 2}px`],
|
|
45
|
+
['--oc-space-4', `${theme.spacing.padding}px`],
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
for (const [name, value] of props) {
|
|
49
|
+
el.style.setProperty(name, value);
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/tilemap-renderer.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
TileMapTileMark,
|
|
13
13
|
} from '@opendata-ai/openchart-core';
|
|
14
14
|
import { textAscent } from '@opendata-ai/openchart-core';
|
|
15
|
+
import { renderLegend } from './renderers/legend';
|
|
15
16
|
|
|
16
17
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
17
18
|
const XLINK_NS = 'http://www.w3.org/1999/xlink';
|
|
@@ -438,8 +439,12 @@ export function renderTileMapSVG(
|
|
|
438
439
|
// Render tiles
|
|
439
440
|
renderTiles(svg, tiles, animate ? animation : undefined);
|
|
440
441
|
|
|
441
|
-
// Render gradient
|
|
442
|
-
|
|
442
|
+
// Render legend (gradient for quantitative, categorical for nominal)
|
|
443
|
+
if (layout.categoricalLegend) {
|
|
444
|
+
renderLegend(svg, layout.categoricalLegend);
|
|
445
|
+
} else {
|
|
446
|
+
renderGradientLegend(svg, layout);
|
|
447
|
+
}
|
|
443
448
|
|
|
444
449
|
// Render watermark
|
|
445
450
|
if (watermark) {
|