@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-vanilla",
3
- "version": "6.7.0",
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.0",
54
- "@opendata-ai/openchart-engine": "6.7.0",
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
 
@@ -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://opendata.ai';
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': 'hanging',
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': 20,
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 openSpan = createSVGElement('tspan');
241
- setAttrs(openSpan, { 'font-weight': 500 });
242
- openSpan.textContent = 'Open';
241
+ const trySpan = createSVGElement('tspan');
242
+ setAttrs(trySpan, { 'font-weight': 500 });
243
+ trySpan.textContent = 'try';
243
244
 
244
- const dataSpan = createSVGElement('tspan');
245
- setAttrs(dataSpan, { 'font-weight': 600 });
246
- dataSpan.textContent = 'Data';
245
+ const openDataSpan = createSVGElement('tspan');
246
+ setAttrs(openDataSpan, { 'font-weight': 600, 'font-size': BRAND_LARGE });
247
+ openDataSpan.textContent = 'OpenData';
247
248
 
248
- text.appendChild(openSpan);
249
- text.appendChild(dataSpan);
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
- 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}`;
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);
@@ -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': 'hanging',
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