@opendata-ai/openchart-vanilla 6.7.1 → 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 +38 -8
- 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 +9 -3
- package/src/svg-renderer.ts +8 -0
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
|
@@ -369,6 +369,11 @@ function buildNodePositionMap(nodes: SankeyNodeMark[]): Map<string, { x: number;
|
|
|
369
369
|
return map;
|
|
370
370
|
}
|
|
371
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
|
+
|
|
372
377
|
function renderGradientDefs(
|
|
373
378
|
defs: SVGElement,
|
|
374
379
|
links: SankeyLinkMark[],
|
|
@@ -379,7 +384,8 @@ function renderGradientDefs(
|
|
|
379
384
|
// Only create gradients when source and target colors differ
|
|
380
385
|
if (link.sourceColor === link.targetColor) continue;
|
|
381
386
|
|
|
382
|
-
|
|
387
|
+
// Use sanitized node names for debuggability, with index suffix for uniqueness
|
|
388
|
+
const gradId = `oc-sg-${sanitizeId(link.sourceId)}-${sanitizeId(link.targetId)}-${i}`;
|
|
383
389
|
const gradient = createSVGElement('linearGradient');
|
|
384
390
|
gradient.setAttribute('id', gradId);
|
|
385
391
|
gradient.setAttribute('gradientUnits', 'userSpaceOnUse');
|
|
@@ -423,7 +429,7 @@ function renderLinks(
|
|
|
423
429
|
|
|
424
430
|
const linkG = createSVGElement('g');
|
|
425
431
|
linkG.setAttribute('class', 'oc-sankey-link');
|
|
426
|
-
linkG.setAttribute('data-mark-id', `link-${link.sourceId}-${link.targetId}`);
|
|
432
|
+
linkG.setAttribute('data-mark-id', `link-${link.sourceId}-${link.targetId}-${i}`);
|
|
427
433
|
linkG.setAttribute('data-source', link.sourceId);
|
|
428
434
|
linkG.setAttribute('data-target', link.targetId);
|
|
429
435
|
|
|
@@ -440,7 +446,7 @@ function renderLinks(
|
|
|
440
446
|
|
|
441
447
|
// Use gradient fill when colors differ, otherwise solid fill
|
|
442
448
|
if (link.sourceColor !== link.targetColor) {
|
|
443
|
-
const gradId = `oc-sg-${link.sourceId}-${link.targetId}-${i}`;
|
|
449
|
+
const gradId = `oc-sg-${sanitizeId(link.sourceId)}-${sanitizeId(link.targetId)}-${i}`;
|
|
444
450
|
path.setAttribute('fill', `url(#${gradId})`);
|
|
445
451
|
} else {
|
|
446
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> = {
|