@opendata-ai/openchart-vanilla 6.10.0 → 6.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-vanilla",
3
- "version": "6.10.0",
3
+ "version": "6.11.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",
@@ -50,8 +50,8 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@floating-ui/dom": "^1.7.6",
53
- "@opendata-ai/openchart-core": "6.10.0",
54
- "@opendata-ai/openchart-engine": "6.10.0",
53
+ "@opendata-ai/openchart-core": "6.11.0",
54
+ "@opendata-ai/openchart-engine": "6.11.0",
55
55
  "d3-force": "^3.0.0",
56
56
  "d3-quadtree": "^3.0.1"
57
57
  },
@@ -0,0 +1,125 @@
1
+ import type { GradientDef } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { buildGradientDefs, resolveMarkFill } from '../gradient-utils';
4
+
5
+ const SVG_NS = 'http://www.w3.org/2000/svg';
6
+
7
+ function createDefs(): SVGElement {
8
+ return document.createElementNS(SVG_NS, 'defs') as SVGElement;
9
+ }
10
+
11
+ const linearGrad: GradientDef = {
12
+ gradient: 'linear',
13
+ stops: [
14
+ { offset: 0, color: '#f00' },
15
+ { offset: 1, color: '#00f' },
16
+ ],
17
+ };
18
+
19
+ const radialGrad: GradientDef = {
20
+ gradient: 'radial',
21
+ stops: [
22
+ { offset: 0, color: '#fff' },
23
+ { offset: 1, color: '#000' },
24
+ ],
25
+ };
26
+
27
+ describe('buildGradientDefs', () => {
28
+ it('creates a linearGradient element for a linear gradient fill', () => {
29
+ const defs = createDefs();
30
+ buildGradientDefs([{ fill: linearGrad }], defs);
31
+ const el = defs.querySelector('linearGradient');
32
+ expect(el).not.toBeNull();
33
+ });
34
+
35
+ it('creates a radialGradient element for a radial gradient fill', () => {
36
+ const defs = createDefs();
37
+ buildGradientDefs([{ fill: radialGrad }], defs);
38
+ const el = defs.querySelector('radialGradient');
39
+ expect(el).not.toBeNull();
40
+ });
41
+
42
+ it('deduplicates identical gradients', () => {
43
+ const defs = createDefs();
44
+ buildGradientDefs([{ fill: linearGrad }, { fill: linearGrad }], defs);
45
+ const els = defs.querySelectorAll('linearGradient');
46
+ expect(els.length).toBe(1);
47
+ });
48
+
49
+ it('creates separate gradient elements for different gradients', () => {
50
+ const defs = createDefs();
51
+ buildGradientDefs([{ fill: linearGrad }, { fill: radialGrad }], defs);
52
+ expect(defs.querySelector('linearGradient')).not.toBeNull();
53
+ expect(defs.querySelector('radialGradient')).not.toBeNull();
54
+ expect(defs.children.length).toBe(2);
55
+ });
56
+
57
+ it('skips marks with string fills', () => {
58
+ const defs = createDefs();
59
+ buildGradientDefs([{ fill: '#ff0000' }, { fill: 'steelblue' }], defs);
60
+ expect(defs.children.length).toBe(0);
61
+ });
62
+
63
+ it('handles empty marks array', () => {
64
+ const defs = createDefs();
65
+ const map = buildGradientDefs([], defs);
66
+ expect(map.size).toBe(0);
67
+ expect(defs.children.length).toBe(0);
68
+ });
69
+
70
+ it('sets correct default attributes on linearGradient', () => {
71
+ const defs = createDefs();
72
+ buildGradientDefs([{ fill: linearGrad }], defs);
73
+ const el = defs.querySelector('linearGradient')!;
74
+ expect(el.getAttribute('x1')).toBe('0');
75
+ expect(el.getAttribute('y1')).toBe('0');
76
+ expect(el.getAttribute('x2')).toBe('0');
77
+ expect(el.getAttribute('y2')).toBe('1');
78
+ });
79
+
80
+ it('sets correct stop attributes', () => {
81
+ const grad: GradientDef = {
82
+ gradient: 'linear',
83
+ stops: [
84
+ { offset: 0, color: '#f00', opacity: 0.5 },
85
+ { offset: 1, color: '#00f' },
86
+ ],
87
+ };
88
+ const defs = createDefs();
89
+ buildGradientDefs([{ fill: grad }], defs);
90
+ const stops = defs.querySelectorAll('stop');
91
+ expect(stops.length).toBe(2);
92
+ expect(stops[0].getAttribute('offset')).toBe('0');
93
+ expect(stops[0].getAttribute('stop-color')).toBe('#f00');
94
+ expect(stops[0].getAttribute('stop-opacity')).toBe('0.5');
95
+ expect(stops[1].getAttribute('offset')).toBe('1');
96
+ expect(stops[1].getAttribute('stop-color')).toBe('#00f');
97
+ expect(stops[1].hasAttribute('stop-opacity')).toBe(false);
98
+ });
99
+
100
+ it('sets gradientUnits="objectBoundingBox"', () => {
101
+ const defs = createDefs();
102
+ buildGradientDefs([{ fill: linearGrad }], defs);
103
+ const el = defs.querySelector('linearGradient')!;
104
+ expect(el.getAttribute('gradientUnits')).toBe('objectBoundingBox');
105
+ });
106
+ });
107
+
108
+ describe('resolveMarkFill', () => {
109
+ it('returns the string directly for string fills', () => {
110
+ const map = new Map<string, string>();
111
+ expect(resolveMarkFill('#ff0000', map)).toBe('#ff0000');
112
+ });
113
+
114
+ it('returns url(#id) for gradient fills that are in the map', () => {
115
+ const defs = createDefs();
116
+ const map = buildGradientDefs([{ fill: linearGrad }], defs);
117
+ const result = resolveMarkFill(linearGrad, map);
118
+ expect(result).toMatch(/^url\(#oc-grad-\d+\)$/);
119
+ });
120
+
121
+ it('returns #000000 for gradient fills not in the map', () => {
122
+ const map = new Map<string, string>();
123
+ expect(resolveMarkFill(linearGrad, map)).toBe('#000000');
124
+ });
125
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * SVG gradient utilities: creates <linearGradient> and <radialGradient>
3
+ * elements from GradientDef specs and resolves mark fills to url(#id) refs.
4
+ */
5
+
6
+ import type { GradientDef, LinearGradient, RadialGradient } from '@opendata-ai/openchart-core';
7
+ import { isGradientDef } from '@opendata-ai/openchart-core';
8
+
9
+ const SVG_NS = 'http://www.w3.org/2000/svg';
10
+
11
+ /**
12
+ * Produce a stable, deterministic key for a GradientDef.
13
+ * Used for deduplication so identical gradients share one SVG element.
14
+ * Recursively sorts object keys for stability across property order variations.
15
+ */
16
+ function gradientKey(def: GradientDef): string {
17
+ return sortedStringify(def);
18
+ }
19
+
20
+ function sortedStringify(value: unknown): string {
21
+ if (value === null || value === undefined) return String(value);
22
+ if (Array.isArray(value)) return `[${value.map(sortedStringify).join(',')}]`;
23
+ if (typeof value === 'object') {
24
+ const sorted = Object.keys(value)
25
+ .sort()
26
+ .map((k) => `${JSON.stringify(k)}:${sortedStringify((value as Record<string, unknown>)[k])}`);
27
+ return `{${sorted.join(',')}}`;
28
+ }
29
+ return JSON.stringify(value);
30
+ }
31
+
32
+ /**
33
+ * Create a single SVG gradient element (<linearGradient> or <radialGradient>).
34
+ * Uses gradientUnits="objectBoundingBox" so coordinates are in [0,1] space.
35
+ */
36
+ function createGradientElement(def: GradientDef, id: string): SVGElement {
37
+ if (def.gradient === 'linear') {
38
+ return createLinearGradient(def, id);
39
+ }
40
+ return createRadialGradient(def, id);
41
+ }
42
+
43
+ function createLinearGradient(def: LinearGradient, id: string): SVGElement {
44
+ const el = document.createElementNS(SVG_NS, 'linearGradient');
45
+ el.setAttribute('id', id);
46
+ el.setAttribute('gradientUnits', 'objectBoundingBox');
47
+ el.setAttribute('x1', String(def.x1 ?? 0));
48
+ el.setAttribute('y1', String(def.y1 ?? 0));
49
+ el.setAttribute('x2', String(def.x2 ?? 0));
50
+ el.setAttribute('y2', String(def.y2 ?? 1));
51
+
52
+ for (const stop of def.stops) {
53
+ appendStop(el, stop);
54
+ }
55
+
56
+ return el;
57
+ }
58
+
59
+ function createRadialGradient(def: RadialGradient, id: string): SVGElement {
60
+ const el = document.createElementNS(SVG_NS, 'radialGradient');
61
+ el.setAttribute('id', id);
62
+ el.setAttribute('gradientUnits', 'objectBoundingBox');
63
+ // SVG radialGradient: cx/cy/r = outer circle, fx/fy/fr = focal (inner) circle
64
+ // Spec: x2/y2/r2 = outer, x1/y1/r1 = inner (focal)
65
+ el.setAttribute('cx', String(def.x2 ?? 0.5));
66
+ el.setAttribute('cy', String(def.y2 ?? 0.5));
67
+ el.setAttribute('r', String(def.r2 ?? 0.5));
68
+ el.setAttribute('fx', String(def.x1 ?? 0.5));
69
+ el.setAttribute('fy', String(def.y1 ?? 0.5));
70
+ el.setAttribute('fr', String(def.r1 ?? 0));
71
+
72
+ for (const stop of def.stops) {
73
+ appendStop(el, stop);
74
+ }
75
+
76
+ return el;
77
+ }
78
+
79
+ function appendStop(
80
+ parent: SVGElement,
81
+ stop: { offset: number; color: string; opacity?: number },
82
+ ): void {
83
+ const stopEl = document.createElementNS(SVG_NS, 'stop');
84
+ stopEl.setAttribute('offset', String(stop.offset));
85
+ stopEl.setAttribute('stop-color', stop.color);
86
+ if (stop.opacity !== undefined) {
87
+ stopEl.setAttribute('stop-opacity', String(stop.opacity));
88
+ }
89
+ parent.appendChild(stopEl);
90
+ }
91
+
92
+ /**
93
+ * Scan all marks for GradientDef fill values, create SVG gradient elements
94
+ * in the provided <defs> node, and return a map from gradient key to element ID.
95
+ *
96
+ * Identical gradients (by key) share a single SVG element.
97
+ */
98
+ export function buildGradientDefs(
99
+ marks: Array<{ fill?: unknown }>,
100
+ defs: SVGElement,
101
+ ): Map<string, string> {
102
+ const map = new Map<string, string>();
103
+ let counter = 0;
104
+
105
+ for (const mark of marks) {
106
+ const fill = mark.fill;
107
+ if (fill && isGradientDef(fill)) {
108
+ const key = gradientKey(fill);
109
+ if (!map.has(key)) {
110
+ const id = `oc-grad-${counter++}`;
111
+ const el = createGradientElement(fill, id);
112
+ defs.appendChild(el);
113
+ map.set(key, id);
114
+ }
115
+ }
116
+ }
117
+
118
+ return map;
119
+ }
120
+
121
+ /**
122
+ * Resolve a mark's fill value to a CSS/SVG fill string.
123
+ * Returns the color string directly for plain fills,
124
+ * or "url(#gradientId)" for gradient fills.
125
+ */
126
+ export function resolveMarkFill(
127
+ fill: string | GradientDef,
128
+ gradientMap: Map<string, string>,
129
+ ): string {
130
+ if (typeof fill === 'string') return fill;
131
+ const key = gradientKey(fill);
132
+ const id = gradientMap.get(key);
133
+ return id ? `url(#${id})` : '#000000';
134
+ }
package/src/mount.ts CHANGED
@@ -27,7 +27,7 @@ import type {
27
27
  ThemeConfig,
28
28
  TooltipContent,
29
29
  } from '@opendata-ai/openchart-core';
30
- import { elementRef, isLayerSpec } from '@opendata-ai/openchart-core';
30
+ import { elementRef, getRepresentativeColor, isLayerSpec } from '@opendata-ai/openchart-core';
31
31
  import { compileChart, compileLayer } from '@opendata-ai/openchart-engine';
32
32
  import { cancelAnimations, setupAnimationCleanup } from './animation';
33
33
  import {
@@ -243,7 +243,7 @@ function collectVoronoiPoints(layout: ChartLayout): VoronoiPoint[] {
243
243
  const points: VoronoiPoint[] = [];
244
244
  for (const mark of layout.marks) {
245
245
  if ((mark.type === 'line' || mark.type === 'area') && mark.dataPoints) {
246
- const color = mark.type === 'line' ? mark.stroke : mark.fill;
246
+ const color = mark.type === 'line' ? mark.stroke : getRepresentativeColor(mark.fill);
247
247
  for (const dp of mark.dataPoints) {
248
248
  points.push({ ...dp, color });
249
249
  }
@@ -30,6 +30,7 @@ import type {
30
30
  } from '@opendata-ai/openchart-core';
31
31
  import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
32
32
  import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
33
+ import { buildGradientDefs, resolveMarkFill } from './gradient-utils';
33
34
 
34
35
  const SVG_NS = 'http://www.w3.org/2000/svg';
35
36
 
@@ -39,6 +40,12 @@ const SVG_NS = 'http://www.w3.org/2000/svg';
39
40
  */
40
41
  let currentAnimation: ResolvedAnimation | undefined;
41
42
 
43
+ /**
44
+ * Module-level gradient map. Set by renderChartSVG after building gradient defs
45
+ * so mark renderers can resolve gradient fills without signature changes.
46
+ */
47
+ let currentGradientMap: Map<string, string> = new Map();
48
+
42
49
  /**
43
50
  * Stamp animation index attributes on a mark element when animation is enabled.
44
51
  * Sets `data-animation-index` (for querySelector) and `--oc-mark-index`
@@ -508,7 +515,7 @@ function renderAreaMark(mark: AreaMark, index: number): SVGElement {
508
515
  const fill = createSVGElement('path');
509
516
  setAttrs(fill, {
510
517
  d: mark.path,
511
- fill: mark.fill,
518
+ fill: resolveMarkFill(mark.fill, currentGradientMap),
512
519
  'fill-opacity': mark.fillOpacity,
513
520
  stroke: 'none',
514
521
  });
@@ -549,7 +556,7 @@ function renderRectMark(mark: RectMark, index: number): SVGElement {
549
556
  y: mark.y,
550
557
  width: mark.width,
551
558
  height: mark.height,
552
- fill: mark.fill,
559
+ fill: resolveMarkFill(mark.fill, currentGradientMap),
553
560
  });
554
561
  if (mark.stroke) {
555
562
  rect.setAttribute('stroke', mark.stroke);
@@ -585,7 +592,7 @@ function renderArcMark(mark: ArcMark, index: number): SVGElement {
585
592
  const path = createSVGElement('path');
586
593
  setAttrs(path, {
587
594
  d: mark.path,
588
- fill: mark.fill,
595
+ fill: resolveMarkFill(mark.fill, currentGradientMap),
589
596
  stroke: mark.stroke,
590
597
  'stroke-width': mark.strokeWidth,
591
598
  });
@@ -619,7 +626,7 @@ function renderPointMark(mark: PointMark, index: number): SVGElement {
619
626
  cx: mark.cx,
620
627
  cy: mark.cy,
621
628
  r: mark.r,
622
- fill: mark.fill,
629
+ fill: resolveMarkFill(mark.fill, currentGradientMap),
623
630
  stroke: mark.stroke,
624
631
  'stroke-width': mark.strokeWidth,
625
632
  });
@@ -1279,6 +1286,10 @@ export function renderChartSVG(
1279
1286
  });
1280
1287
  clipPath.appendChild(clipRect);
1281
1288
  defs.appendChild(clipPath);
1289
+
1290
+ // Build gradient defs for marks with gradient fills
1291
+ currentGradientMap = buildGradientDefs(layout.marks as Array<{ fill?: unknown }>, defs);
1292
+
1282
1293
  svg.appendChild(defs);
1283
1294
 
1284
1295
  // Render layers in order (back to front)
@@ -1322,8 +1333,9 @@ export function renderChartSVG(
1322
1333
  // Brand renders as a footer item, right-aligned on the source/footer row
1323
1334
  renderBrand(svg, layout);
1324
1335
 
1325
- // Reset module-level animation state after rendering
1336
+ // Reset module-level state after rendering
1326
1337
  currentAnimation = undefined;
1338
+ currentGradientMap = new Map();
1327
1339
 
1328
1340
  container.appendChild(svg);
1329
1341
  return svg;