@opendata-ai/openchart-vanilla 6.7.0 → 6.8.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 +62 -25
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/svg-renderer.test.ts +80 -0
- package/src/export.ts +24 -4
- package/src/sankey-mount.ts +25 -2
- package/src/sankey-renderer.ts +28 -16
- package/src/svg-renderer.ts +13 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.8.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.8.0",
|
|
54
|
+
"@opendata-ai/openchart-engine": "6.8.0",
|
|
55
55
|
"d3-force": "^3.0.0",
|
|
56
56
|
"d3-quadtree": "^3.0.1"
|
|
57
57
|
},
|
|
@@ -394,6 +394,86 @@ describe('chart chrome rendering', () => {
|
|
|
394
394
|
expect(title!.textContent).toBe('GDP Growth');
|
|
395
395
|
});
|
|
396
396
|
|
|
397
|
+
it('splits subtitle on newline into multiple tspan lines', () => {
|
|
398
|
+
const spec: ChartSpec = {
|
|
399
|
+
mark: 'bar',
|
|
400
|
+
data: [
|
|
401
|
+
{ name: 'A', value: 10 },
|
|
402
|
+
{ name: 'B', value: 20 },
|
|
403
|
+
],
|
|
404
|
+
encoding: {
|
|
405
|
+
x: { field: 'value', type: 'quantitative' },
|
|
406
|
+
y: { field: 'name', type: 'nominal' },
|
|
407
|
+
},
|
|
408
|
+
chrome: {
|
|
409
|
+
title: 'Title',
|
|
410
|
+
subtitle: 'Line one\nLine two',
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
const { svg } = renderSpec(spec, { width: 600, height: 400 });
|
|
414
|
+
const subtitle = svg.querySelector('.oc-subtitle');
|
|
415
|
+
expect(subtitle).not.toBeNull();
|
|
416
|
+
const tspans = subtitle!.querySelectorAll('tspan');
|
|
417
|
+
// Should produce at least 2 tspans for the two lines
|
|
418
|
+
expect(tspans.length).toBeGreaterThanOrEqual(2);
|
|
419
|
+
const fullText = Array.from(tspans)
|
|
420
|
+
.map((t) => t.textContent)
|
|
421
|
+
.join('\n');
|
|
422
|
+
expect(fullText).toContain('Line one');
|
|
423
|
+
expect(fullText).toContain('Line two');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('handles short text with newline that would fit on one line without it', () => {
|
|
427
|
+
const spec: ChartSpec = {
|
|
428
|
+
mark: 'bar',
|
|
429
|
+
data: [
|
|
430
|
+
{ name: 'A', value: 10 },
|
|
431
|
+
{ name: 'B', value: 20 },
|
|
432
|
+
],
|
|
433
|
+
encoding: {
|
|
434
|
+
x: { field: 'value', type: 'quantitative' },
|
|
435
|
+
y: { field: 'name', type: 'nominal' },
|
|
436
|
+
},
|
|
437
|
+
chrome: {
|
|
438
|
+
title: 'Hi\nThere',
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
// Wide enough that "Hi There" would fit on one line, but \n forces two
|
|
442
|
+
const { svg } = renderSpec(spec, { width: 600, height: 400 });
|
|
443
|
+
const title = svg.querySelector('.oc-title');
|
|
444
|
+
expect(title).not.toBeNull();
|
|
445
|
+
const tspans = title!.querySelectorAll('tspan');
|
|
446
|
+
expect(tspans.length).toBe(2);
|
|
447
|
+
expect(tspans[0].textContent).toBe('Hi');
|
|
448
|
+
expect(tspans[1].textContent).toBe('There');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('handles consecutive newlines producing empty line segments', () => {
|
|
452
|
+
const spec: ChartSpec = {
|
|
453
|
+
mark: 'bar',
|
|
454
|
+
data: [
|
|
455
|
+
{ name: 'A', value: 10 },
|
|
456
|
+
{ name: 'B', value: 20 },
|
|
457
|
+
],
|
|
458
|
+
encoding: {
|
|
459
|
+
x: { field: 'value', type: 'quantitative' },
|
|
460
|
+
y: { field: 'name', type: 'nominal' },
|
|
461
|
+
},
|
|
462
|
+
chrome: {
|
|
463
|
+
title: 'Above\n\nBelow',
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
const { svg } = renderSpec(spec, { width: 600, height: 400 });
|
|
467
|
+
const title = svg.querySelector('.oc-title');
|
|
468
|
+
expect(title).not.toBeNull();
|
|
469
|
+
const tspans = title!.querySelectorAll('tspan');
|
|
470
|
+
// 3 segments: "Above", "", "Below"
|
|
471
|
+
expect(tspans.length).toBe(3);
|
|
472
|
+
expect(tspans[0].textContent).toBe('Above');
|
|
473
|
+
expect(tspans[1].textContent).toBe('');
|
|
474
|
+
expect(tspans[2].textContent).toBe('Below');
|
|
475
|
+
});
|
|
476
|
+
|
|
397
477
|
it('chart with no chrome specified renders no chrome text elements', () => {
|
|
398
478
|
const noChrome: ChartSpec = {
|
|
399
479
|
mark: 'bar',
|
package/src/export.ts
CHANGED
|
@@ -59,6 +59,21 @@ function getSVGDimensions(svg: SVGElement): { width: number; height: number } {
|
|
|
59
59
|
return { width: 600, height: 400 };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Ensure an SVG string has explicit width/height attributes.
|
|
64
|
+
*
|
|
65
|
+
* When an SVG only has a viewBox (no width/height), browsers loading it as
|
|
66
|
+
* an Image blob may use 300x150 as the intrinsic size instead of the viewBox
|
|
67
|
+
* dimensions. This causes clipping at non-1x DPI scaling. Injecting explicit
|
|
68
|
+
* width/height into the root <svg> tag fixes the intrinsic size.
|
|
69
|
+
*/
|
|
70
|
+
function ensureSVGDimensions(svgString: string, width: number, height: number): string {
|
|
71
|
+
// If the <svg> already has a width attribute, leave it alone
|
|
72
|
+
if (/^<svg[^>]*\swidth\s*=/.test(svgString)) return svgString;
|
|
73
|
+
// Inject width and height right after <svg
|
|
74
|
+
return svgString.replace(/^(<svg)/, `$1 width="${width}" height="${height}"`);
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
// ---------------------------------------------------------------------------
|
|
63
78
|
// Font embedding
|
|
64
79
|
// ---------------------------------------------------------------------------
|
|
@@ -300,9 +315,14 @@ export async function exportPNG(svgElement: SVGElement, options?: PNGExportOptio
|
|
|
300
315
|
await embedFonts(svgElement);
|
|
301
316
|
}
|
|
302
317
|
|
|
303
|
-
const svgString = exportSVG(svgElement);
|
|
304
318
|
const { width, height } = getSVGDimensions(svgElement);
|
|
305
319
|
|
|
320
|
+
// Ensure the SVG has explicit width/height attributes so that when loaded
|
|
321
|
+
// as a standalone Image blob the browser knows the intrinsic size. Without
|
|
322
|
+
// these, browsers may default to 300x150 or use heuristics that break at
|
|
323
|
+
// non-1x DPI scaling.
|
|
324
|
+
const svgString = ensureSVGDimensions(exportSVG(svgElement), width, height);
|
|
325
|
+
|
|
306
326
|
const canvas = document.createElement('canvas');
|
|
307
327
|
canvas.width = width * dpi;
|
|
308
328
|
canvas.height = height * dpi;
|
|
@@ -320,7 +340,7 @@ export async function exportPNG(svgElement: SVGElement, options?: PNGExportOptio
|
|
|
320
340
|
|
|
321
341
|
return new Promise<Blob>((resolve, reject) => {
|
|
322
342
|
img.onload = () => {
|
|
323
|
-
ctx.drawImage(img, 0, 0);
|
|
343
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
324
344
|
URL.revokeObjectURL(url);
|
|
325
345
|
|
|
326
346
|
canvas.toBlob((result) => {
|
|
@@ -361,8 +381,8 @@ export async function exportJPG(svgElement: SVGElement, options?: JPGExportOptio
|
|
|
361
381
|
await embedFonts(svgElement);
|
|
362
382
|
}
|
|
363
383
|
|
|
364
|
-
const svgString = exportSVG(svgElement);
|
|
365
384
|
const { width, height } = getSVGDimensions(svgElement);
|
|
385
|
+
const svgString = ensureSVGDimensions(exportSVG(svgElement), width, height);
|
|
366
386
|
|
|
367
387
|
const canvas = document.createElement('canvas');
|
|
368
388
|
canvas.width = width * dpi;
|
|
@@ -385,7 +405,7 @@ export async function exportJPG(svgElement: SVGElement, options?: JPGExportOptio
|
|
|
385
405
|
|
|
386
406
|
return new Promise<Blob>((resolve, reject) => {
|
|
387
407
|
img.onload = () => {
|
|
388
|
-
ctx.drawImage(img, 0, 0);
|
|
408
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
389
409
|
URL.revokeObjectURL(url);
|
|
390
410
|
|
|
391
411
|
canvas.toBlob(
|
package/src/sankey-mount.ts
CHANGED
|
@@ -87,6 +87,8 @@ function resolveDarkMode(mode?: DarkMode): boolean {
|
|
|
87
87
|
const HIGHLIGHT_OPACITY = 0.7;
|
|
88
88
|
/** Opacity for links NOT connected to hovered node. */
|
|
89
89
|
const DIM_OPACITY = 0.15;
|
|
90
|
+
/** Opacity for nodes NOT connected to hovered node. */
|
|
91
|
+
const NODE_DIM_OPACITY = 0.2;
|
|
90
92
|
|
|
91
93
|
// ---------------------------------------------------------------------------
|
|
92
94
|
// Main API
|
|
@@ -287,6 +289,8 @@ export function createSankey(
|
|
|
287
289
|
nodeId: string,
|
|
288
290
|
_layout: SankeyLayout,
|
|
289
291
|
): void {
|
|
292
|
+
// Collect connected node IDs (the hovered node + its direct neighbors)
|
|
293
|
+
const connectedNodeIds = new Set<string>([nodeId]);
|
|
290
294
|
const linkElements = svg.querySelectorAll('.oc-sankey-link');
|
|
291
295
|
for (const el of linkElements) {
|
|
292
296
|
const source = el.getAttribute('data-source');
|
|
@@ -296,11 +300,24 @@ export function createSankey(
|
|
|
296
300
|
|
|
297
301
|
const isConnected = source === nodeId || target === nodeId;
|
|
298
302
|
path.setAttribute('fill-opacity', String(isConnected ? HIGHLIGHT_OPACITY : DIM_OPACITY));
|
|
303
|
+
if (isConnected) {
|
|
304
|
+
if (source) connectedNodeIds.add(source);
|
|
305
|
+
if (target) connectedNodeIds.add(target);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Dim unconnected nodes (rect + label)
|
|
310
|
+
const nodeElements = svg.querySelectorAll('.oc-sankey-node');
|
|
311
|
+
for (const el of nodeElements) {
|
|
312
|
+
const nid = el.getAttribute('data-node-id');
|
|
313
|
+
if (!nid) continue;
|
|
314
|
+
const isConnected = connectedNodeIds.has(nid);
|
|
315
|
+
(el as SVGElement).style.opacity = isConnected ? '1' : String(NODE_DIM_OPACITY);
|
|
299
316
|
}
|
|
300
317
|
}
|
|
301
318
|
|
|
302
319
|
/**
|
|
303
|
-
* Reset all link opacities to their original values.
|
|
320
|
+
* Reset all link opacities and node opacities to their original values.
|
|
304
321
|
*/
|
|
305
322
|
function resetLinkOpacity(svg: SVGSVGElement, layout: SankeyLayout): void {
|
|
306
323
|
const linkElements = svg.querySelectorAll('.oc-sankey-link');
|
|
@@ -311,7 +328,13 @@ export function createSankey(
|
|
|
311
328
|
const source = el.getAttribute('data-source');
|
|
312
329
|
const target = el.getAttribute('data-target');
|
|
313
330
|
const link = layout.links.find((l) => l.sourceId === source && l.targetId === target);
|
|
314
|
-
path.setAttribute('fill-opacity', String(link?.fillOpacity ?? 0.
|
|
331
|
+
path.setAttribute('fill-opacity', String(link?.fillOpacity ?? 0.5));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Restore all node opacities
|
|
335
|
+
const nodeElements = svg.querySelectorAll('.oc-sankey-node');
|
|
336
|
+
for (const el of nodeElements) {
|
|
337
|
+
(el as SVGElement).style.opacity = '1';
|
|
315
338
|
}
|
|
316
339
|
}
|
|
317
340
|
|
package/src/sankey-renderer.ts
CHANGED
|
@@ -15,12 +15,12 @@ import type {
|
|
|
15
15
|
SankeyNodeMark,
|
|
16
16
|
TextStyle,
|
|
17
17
|
} from '@opendata-ai/openchart-core';
|
|
18
|
-
import { BRAND_MIN_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
18
|
+
import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
19
19
|
import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
|
|
20
20
|
|
|
21
21
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
22
22
|
const XLINK_NS = 'http://www.w3.org/1999/xlink';
|
|
23
|
-
const BRAND_URL = 'https://
|
|
23
|
+
const BRAND_URL = 'https://tryopendata.ai';
|
|
24
24
|
|
|
25
25
|
/** CSS easing preset map for inline style custom properties. */
|
|
26
26
|
const EASE_VAR_MAP: Record<string, string> = {
|
|
@@ -225,28 +225,34 @@ function renderBrand(parent: SVGElement, layout: SankeyLayout): void {
|
|
|
225
225
|
a.setAttribute('rel', 'noopener');
|
|
226
226
|
a.setAttribute('class', 'oc-chrome-ref');
|
|
227
227
|
|
|
228
|
+
const BRAND_LARGE = 16;
|
|
228
229
|
const text = createSVGElement('text');
|
|
229
230
|
setAttrs(text, {
|
|
230
231
|
x: rightEdge,
|
|
231
|
-
y: chromeY,
|
|
232
|
-
'dominant-baseline': '
|
|
232
|
+
y: chromeY + BRAND_LARGE,
|
|
233
|
+
'dominant-baseline': 'alphabetic',
|
|
233
234
|
'text-anchor': 'end',
|
|
234
235
|
'font-family': layout.theme.fonts.family,
|
|
235
|
-
'font-size':
|
|
236
|
+
'font-size': BRAND_FONT_SIZE,
|
|
236
237
|
'fill-opacity': 0.55,
|
|
237
238
|
});
|
|
238
239
|
(text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
|
|
239
240
|
|
|
240
|
-
const
|
|
241
|
-
setAttrs(
|
|
242
|
-
|
|
241
|
+
const trySpan = createSVGElement('tspan');
|
|
242
|
+
setAttrs(trySpan, { 'font-weight': 500 });
|
|
243
|
+
trySpan.textContent = 'try';
|
|
243
244
|
|
|
244
|
-
const
|
|
245
|
-
setAttrs(
|
|
246
|
-
|
|
245
|
+
const openDataSpan = createSVGElement('tspan');
|
|
246
|
+
setAttrs(openDataSpan, { 'font-weight': 600, 'font-size': BRAND_LARGE });
|
|
247
|
+
openDataSpan.textContent = 'OpenData';
|
|
247
248
|
|
|
248
|
-
|
|
249
|
-
|
|
249
|
+
const aiSpan = createSVGElement('tspan');
|
|
250
|
+
setAttrs(aiSpan, { 'font-weight': 500 });
|
|
251
|
+
aiSpan.textContent = '.ai';
|
|
252
|
+
|
|
253
|
+
text.appendChild(trySpan);
|
|
254
|
+
text.appendChild(openDataSpan);
|
|
255
|
+
text.appendChild(aiSpan);
|
|
250
256
|
a.appendChild(text);
|
|
251
257
|
parent.appendChild(a);
|
|
252
258
|
}
|
|
@@ -363,6 +369,11 @@ function buildNodePositionMap(nodes: SankeyNodeMark[]): Map<string, { x: number;
|
|
|
363
369
|
return map;
|
|
364
370
|
}
|
|
365
371
|
|
|
372
|
+
/** Sanitize a string for use as an SVG element ID (no spaces, $, or other invalid chars). */
|
|
373
|
+
function sanitizeId(s: string): string {
|
|
374
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
375
|
+
}
|
|
376
|
+
|
|
366
377
|
function renderGradientDefs(
|
|
367
378
|
defs: SVGElement,
|
|
368
379
|
links: SankeyLinkMark[],
|
|
@@ -373,7 +384,8 @@ function renderGradientDefs(
|
|
|
373
384
|
// Only create gradients when source and target colors differ
|
|
374
385
|
if (link.sourceColor === link.targetColor) continue;
|
|
375
386
|
|
|
376
|
-
|
|
387
|
+
// Use sanitized node names for debuggability, with index suffix for uniqueness
|
|
388
|
+
const gradId = `oc-sg-${sanitizeId(link.sourceId)}-${sanitizeId(link.targetId)}-${i}`;
|
|
377
389
|
const gradient = createSVGElement('linearGradient');
|
|
378
390
|
gradient.setAttribute('id', gradId);
|
|
379
391
|
gradient.setAttribute('gradientUnits', 'userSpaceOnUse');
|
|
@@ -417,7 +429,7 @@ function renderLinks(
|
|
|
417
429
|
|
|
418
430
|
const linkG = createSVGElement('g');
|
|
419
431
|
linkG.setAttribute('class', 'oc-sankey-link');
|
|
420
|
-
linkG.setAttribute('data-mark-id', `link-${link.sourceId}-${link.targetId}`);
|
|
432
|
+
linkG.setAttribute('data-mark-id', `link-${link.sourceId}-${link.targetId}-${i}`);
|
|
421
433
|
linkG.setAttribute('data-source', link.sourceId);
|
|
422
434
|
linkG.setAttribute('data-target', link.targetId);
|
|
423
435
|
|
|
@@ -434,7 +446,7 @@ function renderLinks(
|
|
|
434
446
|
|
|
435
447
|
// Use gradient fill when colors differ, otherwise solid fill
|
|
436
448
|
if (link.sourceColor !== link.targetColor) {
|
|
437
|
-
const gradId = `oc-sg-${link.sourceId}-${link.targetId}-${i}`;
|
|
449
|
+
const gradId = `oc-sg-${sanitizeId(link.sourceId)}-${sanitizeId(link.targetId)}-${i}`;
|
|
438
450
|
path.setAttribute('fill', `url(#${gradId})`);
|
|
439
451
|
} else {
|
|
440
452
|
path.setAttribute('fill', link.sourceColor);
|
package/src/svg-renderer.ts
CHANGED
|
@@ -131,6 +131,14 @@ function applyTextStyle(el: SVGElement, style: TextStyle): void {
|
|
|
131
131
|
function wrapText(text: string, fontSize: number, fontWeight: number, maxWidth: number): string[] {
|
|
132
132
|
if (maxWidth <= 0) return [text];
|
|
133
133
|
|
|
134
|
+
// Split on explicit newlines first
|
|
135
|
+
const segments = text.split('\n');
|
|
136
|
+
if (segments.length > 1) {
|
|
137
|
+
return segments.flatMap((segment) =>
|
|
138
|
+
segment.length === 0 ? [''] : wrapText(segment, fontSize, fontWeight, maxWidth),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
134
142
|
// Heuristic character width matching text-measure.ts
|
|
135
143
|
const AVG_CHAR_WIDTH = 0.55;
|
|
136
144
|
const WEIGHT_FACTORS: Record<number, number> = {
|
|
@@ -1137,11 +1145,13 @@ function renderBrand(parent: SVGElement, layout: ChartLayout): void {
|
|
|
1137
1145
|
|
|
1138
1146
|
// "try" in normal weight, "OpenData" in semibold, ".ai" in normal weight,
|
|
1139
1147
|
// rendered as a single right-aligned text element with three tspans.
|
|
1148
|
+
// Use alphabetic baseline so mixed-size tspans share a common bottom line.
|
|
1149
|
+
const BRAND_LARGE = 16;
|
|
1140
1150
|
const text = createSVGElement('text');
|
|
1141
1151
|
setAttrs(text, {
|
|
1142
1152
|
x: rightEdge,
|
|
1143
|
-
y: chromeY,
|
|
1144
|
-
'dominant-baseline': '
|
|
1153
|
+
y: chromeY + BRAND_LARGE,
|
|
1154
|
+
'dominant-baseline': 'alphabetic',
|
|
1145
1155
|
'font-family': layout.theme.fonts.family,
|
|
1146
1156
|
'font-size': BRAND_FONT_SIZE,
|
|
1147
1157
|
'text-anchor': 'end',
|
|
@@ -1156,6 +1166,7 @@ function renderBrand(parent: SVGElement, layout: ChartLayout): void {
|
|
|
1156
1166
|
|
|
1157
1167
|
const openDataSpan = createSVGElement('tspan');
|
|
1158
1168
|
openDataSpan.setAttribute('font-weight', '600');
|
|
1169
|
+
openDataSpan.setAttribute('font-size', String(BRAND_LARGE));
|
|
1159
1170
|
openDataSpan.textContent = 'OpenData';
|
|
1160
1171
|
text.appendChild(openDataSpan);
|
|
1161
1172
|
|