@opendata-ai/openchart-vanilla 6.9.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/dist/index.js +100 -13
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/gradient-utils.test.ts +125 -0
- package/src/gradient-utils.ts +134 -0
- package/src/mount.ts +2 -2
- package/src/svg-renderer.ts +17 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
54
|
-
"@opendata-ai/openchart-engine": "6.
|
|
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
|
}
|
package/src/svg-renderer.ts
CHANGED
|
@@ -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
|
|
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;
|