@opendata-ai/openchart-vanilla 6.10.0 → 6.12.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.12.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.12.0",
54
+ "@opendata-ai/openchart-engine": "6.12.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
+ });
@@ -771,4 +771,11 @@ describe('brand watermark', () => {
771
771
  const brandLink = svg.querySelector('.oc-chrome-ref');
772
772
  expect(brandLink).toBeNull();
773
773
  });
774
+
775
+ it('does not render brand when layout.watermark is false', () => {
776
+ const spec: ChartSpec = { ...lineSpec, watermark: false };
777
+ const { svg } = renderSpec(spec);
778
+ const brandLink = svg.querySelector('.oc-chrome-ref');
779
+ expect(brandLink).toBeNull();
780
+ });
774
781
  });
@@ -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
+ }
@@ -229,7 +229,9 @@ export class GraphCanvasRenderer {
229
229
  ctx.restore();
230
230
 
231
231
  // Brand watermark in screen coordinates (unaffected by pan/zoom)
232
- this.drawBrand(ctx, cssWidth, cssHeight, theme);
232
+ if (state.watermark) {
233
+ this.drawBrand(ctx, cssWidth, cssHeight, theme);
234
+ }
233
235
  }
234
236
 
235
237
  // -------------------------------------------------------------------------
@@ -42,4 +42,6 @@ export interface GraphRenderState {
42
42
  searchMatches: Set<string> | null;
43
43
  /** True during active pan/zoom gestures. Renderer skips labels and glow. */
44
44
  isGesturing: boolean;
45
+ /** Whether the tryOpenData.ai watermark is enabled. */
46
+ watermark: boolean;
45
47
  }
@@ -35,6 +35,8 @@ export interface GraphMountOptions {
35
35
  theme?: ThemeConfig;
36
36
  darkMode?: DarkMode;
37
37
  responsive?: boolean;
38
+ /** Show the tryOpenData.ai watermark. Defaults to true. */
39
+ watermark?: boolean;
38
40
  /** Show the built-in tooltip on node/edge hover. Defaults to true. */
39
41
  tooltip?: boolean;
40
42
  /** Show the built-in legend. Defaults to true. */
@@ -157,6 +159,7 @@ export function createGraph(
157
159
  height,
158
160
  theme: options?.theme,
159
161
  darkMode,
162
+ watermark: options?.watermark,
160
163
  };
161
164
 
162
165
  return compileGraph(currentSpec, compileOpts);
@@ -465,6 +468,7 @@ export function createGraph(
465
468
  theme: compilation.theme,
466
469
  searchMatches: searchManager.getMatches(),
467
470
  isGesturing,
471
+ watermark: compilation.watermark,
468
472
  };
469
473
 
470
474
  renderer.render(state);
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 {
@@ -57,6 +57,8 @@ export interface MountOptions extends ChartEventHandlers {
57
57
  onDataPointClick?: (data: Record<string, unknown>) => void;
58
58
  /** Enable responsive resizing. Defaults to true. */
59
59
  responsive?: boolean;
60
+ /** Show the tryOpenData.ai watermark. Defaults to true. */
61
+ watermark?: boolean;
60
62
  /** Initial selected element. */
61
63
  selectedElement?: ElementRef;
62
64
  }
@@ -243,7 +245,7 @@ function collectVoronoiPoints(layout: ChartLayout): VoronoiPoint[] {
243
245
  const points: VoronoiPoint[] = [];
244
246
  for (const mark of layout.marks) {
245
247
  if ((mark.type === 'line' || mark.type === 'area') && mark.dataPoints) {
246
- const color = mark.type === 'line' ? mark.stroke : mark.fill;
248
+ const color = mark.type === 'line' ? mark.stroke : getRepresentativeColor(mark.fill);
247
249
  for (const dp of mark.dataPoints) {
248
250
  points.push({ ...dp, color });
249
251
  }
@@ -1820,6 +1822,7 @@ export function createChart(
1820
1822
  height,
1821
1823
  theme: options?.theme,
1822
1824
  darkMode,
1825
+ watermark: options?.watermark,
1823
1826
  measureText,
1824
1827
  };
1825
1828
 
@@ -38,6 +38,8 @@ export interface SankeyMountOptions {
38
38
  darkMode?: DarkMode;
39
39
  /** Enable responsive resizing. Defaults to true. */
40
40
  responsive?: boolean;
41
+ /** Show the tryOpenData.ai watermark. Defaults to true. */
42
+ watermark?: boolean;
41
43
  /** Show tooltips on hover. Defaults to true. */
42
44
  tooltip?: boolean;
43
45
  /** Callback when a node is clicked. */
@@ -140,6 +142,7 @@ export function createSankey(
140
142
  height,
141
143
  theme: options?.theme,
142
144
  darkMode,
145
+ watermark: options?.watermark,
143
146
  };
144
147
 
145
148
  return compileSankey(currentSpec, compileOpts);
@@ -608,7 +608,9 @@ export function renderSankeySVG(
608
608
  renderChrome(svg, layout);
609
609
 
610
610
  // Brand
611
- renderBrand(svg, layout);
611
+ if (layout.watermark) {
612
+ renderBrand(svg, layout);
613
+ }
612
614
 
613
615
  return svg;
614
616
  }
@@ -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)
@@ -1320,10 +1331,13 @@ export function renderChartSVG(
1320
1331
  renderChrome(svg, layout);
1321
1332
 
1322
1333
  // Brand renders as a footer item, right-aligned on the source/footer row
1323
- renderBrand(svg, layout);
1334
+ if (layout.watermark) {
1335
+ renderBrand(svg, layout);
1336
+ }
1324
1337
 
1325
- // Reset module-level animation state after rendering
1338
+ // Reset module-level state after rendering
1326
1339
  currentAnimation = undefined;
1340
+ currentGradientMap = new Map();
1327
1341
 
1328
1342
  container.appendChild(svg);
1329
1343
  return svg;
@@ -39,6 +39,8 @@ export interface TableMountOptions {
39
39
  theme?: ThemeConfig;
40
40
  darkMode?: DarkMode;
41
41
  responsive?: boolean;
42
+ /** Show the tryOpenData.ai watermark. Defaults to true. */
43
+ watermark?: boolean;
42
44
  onRowClick?: (row: Record<string, unknown>) => void;
43
45
  onStateChange?: (state: TableState) => void;
44
46
  externalState?: { sort?: SortState | null; search?: string; page?: number };
@@ -175,6 +177,7 @@ export function createTable(
175
177
  height: 600,
176
178
  theme: options?.theme,
177
179
  darkMode,
180
+ watermark: options?.watermark,
178
181
  sort: state.sort ?? undefined,
179
182
  search: state.search || undefined,
180
183
  page: state.page,
@@ -390,18 +390,20 @@ export function renderTable(
390
390
  wrapper.appendChild(liveRegion);
391
391
 
392
392
  // Brand watermark
393
- const brandColor = theme ? theme.colors.axis : '#999999';
394
- const brand = document.createElement('div');
395
- brand.className = 'oc-table-ref';
396
- brand.style.cssText = 'text-align: right; padding: 4px 8px;';
397
- const brandLink = document.createElement('a');
398
- brandLink.href = BRAND_URL;
399
- brandLink.target = '_blank';
400
- brandLink.rel = 'noopener';
401
- brandLink.style.cssText = `font-size: ${BRAND_FONT_SIZE}px; font-weight: 600; color: ${brandColor}; opacity: 0.55; text-decoration: none; font-family: ${theme ? theme.fonts.family : 'sans-serif'};`;
402
- brandLink.textContent = 'tryOpenData.ai';
403
- brand.appendChild(brandLink);
404
- wrapper.appendChild(brand);
393
+ if (layout.watermark) {
394
+ const brandColor = theme ? theme.colors.axis : '#999999';
395
+ const brand = document.createElement('div');
396
+ brand.className = 'oc-table-ref';
397
+ brand.style.cssText = 'text-align: right; padding: 4px 8px;';
398
+ const brandLink = document.createElement('a');
399
+ brandLink.href = BRAND_URL;
400
+ brandLink.target = '_blank';
401
+ brandLink.rel = 'noopener';
402
+ brandLink.style.cssText = `font-size: ${BRAND_FONT_SIZE}px; font-weight: 600; color: ${brandColor}; opacity: 0.55; text-decoration: none; font-family: ${theme ? theme.fonts.family : 'sans-serif'};`;
403
+ brandLink.textContent = 'tryOpenData.ai';
404
+ brand.appendChild(brandLink);
405
+ wrapper.appendChild(brand);
406
+ }
405
407
 
406
408
  // Animation: stamp CSS custom properties and add oc-animate class BEFORE
407
409
  // DOM insertion to avoid a flash of final state.