@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.
@@ -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
- // Render layers in order (back to front)
165
- // Axes render outside clip (labels extend beyond chart area)
166
- renderAxes(svg, layout);
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
- // Marks are clipped to chart area so area fills don't cover chrome
169
- const clippedGroup = createSVGElement('g');
170
- clippedGroup.setAttribute('clip-path', `url(#${clipId})`);
171
- const markLabelsOverlay = renderMarks(clippedGroup, layout);
172
-
173
- // Add transparent overlay rect for line/area charts to enable voronoi tooltip lookup.
174
- // Always emitted for line/area with dataPoints — the overlay-driven snap tooltip
175
- // with crosshair is the canonical interaction for these chart types. When point
176
- // marks coexist (e.g. mark.point: true), they still render decoratively but
177
- // pointer events route to the overlay so the snap behavior wins.
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
- // 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
- const overlay = createSVGElement('rect');
190
- setAttrs(overlay, {
191
- x: layout.area.x,
192
- y: layout.area.y,
193
- width: layout.area.width,
194
- height: layout.area.height,
195
- fill: 'transparent',
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
- crosshairLine.style.display = 'none';
221
- clippedGroup.appendChild(crosshairLine);
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
- // Snap-dot layer: mount.ts populates one circle per series at the
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
- svg.appendChild(clippedGroup);
227
+ if (markLabelsOverlay) {
228
+ svg.appendChild(markLabelsOverlay);
229
+ }
235
230
 
236
- // Value label overlay sits outside the clip path so labels above near-full-height
237
- // bars aren't clipped. Coordinates are in absolute SVG space, no transform needed.
238
- if (markLabelsOverlay) {
239
- svg.appendChild(markLabelsOverlay);
240
- }
231
+ renderAnnotations(svg, layout);
232
+ renderEndpointLabels(svg, layout);
241
233
 
242
- renderAnnotations(svg, layout);
243
-
244
- // Endpoint labels render after marks/annotations so they sit on top of any
245
- // chart-edge content, but before the traditional legend so chrome wins on
246
- // collision. The engine handles all suppression — when entries is empty,
247
- // renderEndpointLabels is a no-op.
248
- renderEndpointLabels(svg, layout);
249
-
250
- // Suppress decorative point marks that sit underneath an endpoint marker
251
- // (mark.point: true + endpoint marker on produces a double-circle at the
252
- // line's right terminus). The endpoint marker is the canonical terminator
253
- // when present, so the point mark hides via opacity (not removal) so the
254
- // SVG DOM stays diff-friendly for animated re-renders.
255
- const epEntries = layout.endpointLabels?.entries ?? [];
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
+ }
@@ -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 legend
442
- renderGradientLegend(svg, layout);
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) {