@opendata-ai/openchart-vanilla 6.23.1 → 6.24.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.23.1",
3
+ "version": "6.24.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.23.1",
54
- "@opendata-ai/openchart-engine": "6.23.1",
53
+ "@opendata-ai/openchart-core": "6.24.0",
54
+ "@opendata-ai/openchart-engine": "6.24.0",
55
55
  "d3-force": "^3.0.0",
56
56
  "d3-quadtree": "^3.0.1"
57
57
  },
@@ -346,12 +346,12 @@ describe('chart chrome rendering', () => {
346
346
 
347
347
  it('title has font styling applied', () => {
348
348
  const { svg } = renderSpec(lineSpec);
349
- const title = svg.querySelector('.oc-title');
349
+ const title = svg.querySelector('.oc-title') as SVGElement & ElementCSSInlineStyle;
350
350
  expect(title).not.toBeNull();
351
- const fontFamily = title!.getAttribute('font-family');
352
- const fontSize = Number(title!.getAttribute('font-size'));
353
- expect(fontFamily).not.toBeNull();
354
- expect(fontSize).toBeGreaterThan(0);
351
+ const fontFamily = title.style.getPropertyValue('font-family');
352
+ const fontSize = title.style.getPropertyValue('font-size');
353
+ expect(fontFamily).not.toBe('');
354
+ expect(parseFloat(fontSize)).toBeGreaterThan(0);
355
355
  });
356
356
 
357
357
  it('wraps long title text into tspan elements at narrow widths', () => {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Canvas-backed text measurement factory.
3
+ *
4
+ * Shared by mount.ts (charts) and sankey-mount.ts (sankey diagrams) so both
5
+ * pipelines get accurate browser-measured text widths instead of the heuristic
6
+ * fallback. Falls back to the heuristic when canvas isn't available (e.g. SSR).
7
+ */
8
+
9
+ import type { MeasureTextFn } from '@opendata-ai/openchart-core';
10
+
11
+ export function createMeasureText(): MeasureTextFn {
12
+ let canvas: HTMLCanvasElement | null = null;
13
+ let ctx: CanvasRenderingContext2D | null = null;
14
+
15
+ return (
16
+ text: string,
17
+ fontSize: number,
18
+ fontWeight?: number,
19
+ ): { width: number; height: number } => {
20
+ if (!canvas) {
21
+ canvas = document.createElement('canvas');
22
+ ctx = canvas.getContext('2d');
23
+ }
24
+ if (!ctx) {
25
+ // Fallback: heuristic estimation
26
+ return { width: text.length * fontSize * 0.6, height: fontSize * 1.2 };
27
+ }
28
+
29
+ const weight = fontWeight ?? 400;
30
+ ctx.font = `${weight} ${fontSize}px Inter, sans-serif`;
31
+ const metrics = ctx.measureText(text);
32
+ return {
33
+ width: metrics.width,
34
+ height: fontSize * 1.2,
35
+ };
36
+ };
37
+ }
package/src/mount.ts CHANGED
@@ -20,7 +20,6 @@ import type {
20
20
  ElementRef,
21
21
  GraphSpec,
22
22
  LayerSpec,
23
- MeasureTextFn,
24
23
  RangeAnnotation,
25
24
  RefLineAnnotation,
26
25
  TextAnnotation,
@@ -39,6 +38,7 @@ import {
39
38
  type JPGExportOptions,
40
39
  type SVGExportOptions,
41
40
  } from './export';
41
+ import { createMeasureText } from './measure-text';
42
42
  import { observeResize } from './resize-observer';
43
43
  import { renderChartSVG } from './svg-renderer';
44
44
  import { createTextEditOverlay } from './text-edit-overlay';
@@ -115,38 +115,6 @@ function resolveDarkMode(mode?: DarkMode): boolean {
115
115
  return false;
116
116
  }
117
117
 
118
- // ---------------------------------------------------------------------------
119
- // measureText via hidden canvas
120
- // ---------------------------------------------------------------------------
121
-
122
- function createMeasureText(): MeasureTextFn {
123
- let canvas: HTMLCanvasElement | null = null;
124
- let ctx: CanvasRenderingContext2D | null = null;
125
-
126
- return (
127
- text: string,
128
- fontSize: number,
129
- fontWeight?: number,
130
- ): { width: number; height: number } => {
131
- if (!canvas) {
132
- canvas = document.createElement('canvas');
133
- ctx = canvas.getContext('2d');
134
- }
135
- if (!ctx) {
136
- // Fallback: heuristic estimation
137
- return { width: text.length * fontSize * 0.6, height: fontSize * 1.2 };
138
- }
139
-
140
- const weight = fontWeight ?? 400;
141
- ctx.font = `${weight} ${fontSize}px Inter, sans-serif`;
142
- const metrics = ctx.measureText(text);
143
- return {
144
- width: metrics.width,
145
- height: fontSize * 1.2,
146
- };
147
- };
148
- }
149
-
150
118
  // ---------------------------------------------------------------------------
151
119
  // Tooltip event wiring
152
120
  // ---------------------------------------------------------------------------
@@ -76,7 +76,39 @@ function renderAxis(
76
76
  'dominant-baseline': 'central',
77
77
  });
78
78
  applyTextStyle(label, axis.tickLabelStyle);
79
- label.textContent = tick.label;
79
+ // Truncate categorical y-axis labels that exceed available space so
80
+ // they don't overflow into the chart area. The engine may clamp
81
+ // margin.left on narrow containers; render what fits with an ellipsis.
82
+ const availableWidth = area.x - 6;
83
+ const fontSize = axis.tickLabelStyle.fontSize;
84
+ const fontWeight = axis.tickLabelStyle.fontWeight;
85
+ const fullWidth = estimateTextWidth(tick.label, fontSize, fontWeight);
86
+ if (fullWidth > availableWidth && availableWidth > 20) {
87
+ // Binary-search the longest prefix that fits with a trailing ellipsis
88
+ const ellipsis = '\u2026';
89
+ const ellipsisWidth = estimateTextWidth(ellipsis, fontSize, fontWeight);
90
+ let lo = 0;
91
+ let hi = tick.label.length;
92
+ while (lo < hi) {
93
+ const mid = (lo + hi + 1) >>> 1;
94
+ const candidate = tick.label.slice(0, mid);
95
+ if (
96
+ estimateTextWidth(candidate, fontSize, fontWeight) + ellipsisWidth <=
97
+ availableWidth
98
+ ) {
99
+ lo = mid;
100
+ } else {
101
+ hi = mid - 1;
102
+ }
103
+ }
104
+ label.textContent = lo > 0 ? tick.label.slice(0, lo).trimEnd() + ellipsis : ellipsis;
105
+ // Preserve the full label for accessibility / tooltips
106
+ const titleEl = createSVGElement('title');
107
+ titleEl.textContent = tick.label;
108
+ label.appendChild(titleEl);
109
+ } else {
110
+ label.textContent = tick.label;
111
+ }
80
112
  g.appendChild(label);
81
113
  }
82
114
  }
@@ -21,14 +21,14 @@ export function setAttrs(el: SVGElement, attrs: Record<string, string | number>)
21
21
  }
22
22
 
23
23
  export function applyTextStyle(el: SVGElement, style: TextStyle): void {
24
- setAttrs(el, {
25
- 'font-family': style.fontFamily,
26
- 'font-size': style.fontSize,
27
- 'font-weight': style.fontWeight,
28
- });
29
- // Use inline style for fill so it takes priority over CSS class defaults
30
- // (e.g. .oc-title { fill: var(--oc-text) } which would override attributes)
31
- (el as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', style.fill);
24
+ // Use inline styles so engine-computed values take priority over CSS class
25
+ // defaults (e.g. .oc-title { font-size: var(--oc-title-size) } would otherwise
26
+ // override the responsive scaling applied by the chrome layout).
27
+ const inline = (el as SVGElement & ElementCSSInlineStyle).style;
28
+ inline.setProperty('fill', style.fill);
29
+ inline.setProperty('font-size', `${style.fontSize}px`);
30
+ inline.setProperty('font-weight', String(style.fontWeight));
31
+ inline.setProperty('font-family', style.fontFamily);
32
32
  if (style.textAnchor) {
33
33
  el.setAttribute('text-anchor', style.textAnchor);
34
34
  }
@@ -23,6 +23,7 @@ import {
23
23
  type JPGExportOptions,
24
24
  type SVGExportOptions,
25
25
  } from './export';
26
+ import { createMeasureText } from './measure-text';
26
27
  import { observeResize } from './resize-observer';
27
28
  import { renderSankeySVG } from './sankey-renderer';
28
29
  import { createTooltipManager, type TooltipManager } from './tooltip';
@@ -121,6 +122,8 @@ export function createSankey(
121
122
  let animationCleanup: (() => void) | null = null;
122
123
  let pendingResize = false;
123
124
 
125
+ const measureText = createMeasureText();
126
+
124
127
  // ---------------------------------------------------------------------------
125
128
  // Helpers
126
129
  // ---------------------------------------------------------------------------
@@ -143,6 +146,7 @@ export function createSankey(
143
146
  theme: options?.theme,
144
147
  darkMode,
145
148
  watermark: options?.watermark,
149
+ measureText,
146
150
  };
147
151
 
148
152
  return compileSankey(currentSpec, compileOpts);
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type {
10
10
  LegendLayout,
11
+ MeasureTextFn,
11
12
  ResolvedAnimation,
12
13
  ResolvedChromeElement,
13
14
  SankeyLayout,
@@ -89,6 +90,7 @@ function renderChromeElement(
89
90
  element: ResolvedChromeElement,
90
91
  className: string,
91
92
  chromeKey: string,
93
+ measureText?: MeasureTextFn,
92
94
  ): void {
93
95
  const text = createSVGElement('text');
94
96
  setAttrs(text, { x: element.x, y: element.y });
@@ -101,6 +103,7 @@ function renderChromeElement(
101
103
  element.style.fontSize,
102
104
  element.style.fontWeight,
103
105
  element.maxWidth,
106
+ measureText,
104
107
  );
105
108
 
106
109
  if (lines.length === 1) {
@@ -122,13 +125,13 @@ function renderChrome(parent: SVGElement, layout: SankeyLayout): void {
122
125
  const g = createSVGElement('g');
123
126
  g.setAttribute('class', 'oc-chrome');
124
127
 
125
- const { chrome } = layout;
128
+ const { chrome, measureText } = layout;
126
129
 
127
130
  if (chrome.title) {
128
- renderChromeElement(g, chrome.title, 'oc-title', 'title');
131
+ renderChromeElement(g, chrome.title, 'oc-title', 'title', measureText);
129
132
  }
130
133
  if (chrome.subtitle) {
131
- renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle');
134
+ renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);
132
135
  }
133
136
 
134
137
  // Bottom chrome: positioned below the sankey drawing area.
@@ -140,6 +143,7 @@ function renderChrome(parent: SVGElement, layout: SankeyLayout): void {
140
143
  { ...chrome.source, y: bottomOffset + chrome.source.y },
141
144
  'oc-source',
142
145
  'source',
146
+ measureText,
143
147
  );
144
148
  }
145
149
  if (chrome.byline) {
@@ -148,6 +152,7 @@ function renderChrome(parent: SVGElement, layout: SankeyLayout): void {
148
152
  { ...chrome.byline, y: bottomOffset + chrome.byline.y },
149
153
  'oc-byline',
150
154
  'byline',
155
+ measureText,
151
156
  );
152
157
  }
153
158
  if (chrome.footer) {
@@ -156,6 +161,7 @@ function renderChrome(parent: SVGElement, layout: SankeyLayout): void {
156
161
  { ...chrome.footer, y: bottomOffset + chrome.footer.y },
157
162
  'oc-footer',
158
163
  'footer',
164
+ measureText,
159
165
  );
160
166
  }
161
167
 
@@ -476,7 +482,11 @@ function renderNodes(
476
482
  // Labels rendering
477
483
  // ---------------------------------------------------------------------------
478
484
 
479
- function renderLabels(parent: SVGElement, nodes: SankeyNodeMark[]): void {
485
+ function renderLabels(
486
+ parent: SVGElement,
487
+ nodes: SankeyNodeMark[],
488
+ measureText?: MeasureTextFn,
489
+ ): void {
480
490
  const g = createSVGElement('g');
481
491
  g.setAttribute('class', 'oc-sankey-labels');
482
492
 
@@ -492,7 +502,7 @@ function renderLabels(parent: SVGElement, nodes: SankeyNodeMark[]): void {
492
502
  if (label.maxWidth !== undefined && label.maxWidth > 0) {
493
503
  const fontSize = label.style.fontSize ?? 12;
494
504
  const fontWeight = label.style.fontWeight ?? 400;
495
- const lines = wrapText(label.text, fontSize, fontWeight, label.maxWidth);
505
+ const lines = wrapText(label.text, fontSize, fontWeight, label.maxWidth, measureText);
496
506
  if (lines.length > 1) {
497
507
  const lineHeight = fontSize * (label.style.lineHeight ?? 1.3);
498
508
  // Center the multi-line block vertically around the label y position
@@ -585,7 +595,7 @@ export function renderSankeySVG(
585
595
  renderNodes(svg, layout.nodes, animation);
586
596
 
587
597
  // Labels
588
- renderLabels(svg, layout.nodes);
598
+ renderLabels(svg, layout.nodes, layout.measureText);
589
599
 
590
600
  // Legend
591
601
  renderLegend(svg, layout.legend);