@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-vanilla",
3
- "version": "6.7.1",
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.7.1",
54
- "@opendata-ai/openchart-engine": "6.7.1",
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(
@@ -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.35));
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
 
@@ -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
- const gradId = `oc-sg-${link.sourceId}-${link.targetId}-${i}`;
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);
@@ -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> = {