@opendata-ai/openchart-vanilla 7.2.3 → 7.3.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": "7.2.3",
3
+ "version": "7.3.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": "7.2.3",
54
- "@opendata-ai/openchart-engine": "7.2.3",
53
+ "@opendata-ai/openchart-core": "7.3.0",
54
+ "@opendata-ai/openchart-engine": "7.3.0",
55
55
  "d3-force": "^3.0.0",
56
56
  "d3-quadtree": "^3.0.1"
57
57
  },
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Red-locked tests for known layout bugs.
3
+ *
4
+ * These use `test.fails(...)` so they pass today (the assertion is inverted).
5
+ * When the underlying bug is fixed, the test will start failing, signaling
6
+ * that the red-lock can be converted to a normal passing test.
7
+ */
8
+
9
+ import type { CategoricalLegendLayout } from '@opendata-ai/openchart-core';
10
+ import { estimateTextWidth } from '@opendata-ai/openchart-core';
11
+ import { compileChart } from '@opendata-ai/openchart-engine';
12
+ import { describe, expect, test } from 'vitest';
13
+ import { renderLegend } from '../renderers/legend';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Collect distinct y-attribute values from <text> elements, sorted ascending. */
20
+ function distinctLabelYValues(svg: SVGElement): number[] {
21
+ const texts = svg.querySelectorAll('text');
22
+ const ySet = new Set<number>();
23
+ for (const t of texts) {
24
+ const y = t.getAttribute('y');
25
+ if (y != null) ySet.add(Number(y));
26
+ }
27
+ return [...ySet].sort((a, b) => a - b);
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Constants (mirrors packages/engine/src/legend/wrap.ts)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const SWATCH_SIZE = 12;
35
+ const SWATCH_GAP = 6;
36
+ const ENTRY_GAP = 16;
37
+ const ENGINE_ROW_HEIGHT = SWATCH_SIZE + 4; // 16 - what the engine reserves
38
+ const LEGEND_PADDING = 8;
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Fixtures
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const entries: CategoricalLegendLayout['entries'] = [
45
+ { label: 'United States of America', color: '#1f77b4', shape: 'square', active: true },
46
+ { label: 'United Kingdom', color: '#ff7f0e', shape: 'square', active: true },
47
+ { label: 'Federal Republic of Germany', color: '#2ca02c', shape: 'square', active: true },
48
+ { label: 'French Republic', color: '#d62728', shape: 'square', active: true },
49
+ { label: 'Kingdom of Spain', color: '#9467bd', shape: 'square', active: true },
50
+ { label: 'Republic of Italy', color: '#8c564b', shape: 'square', active: true },
51
+ { label: 'Kingdom of Netherlands', color: '#e377c2', shape: 'square', active: true },
52
+ { label: 'Swiss Confederation', color: '#7f7f7f', shape: 'square', active: true },
53
+ ];
54
+
55
+ const labelStyle = {
56
+ fontSize: 11,
57
+ lineHeight: 1.3,
58
+ fontWeight: 400 as const,
59
+ fontFamily: 'sans-serif',
60
+ fill: '#333',
61
+ };
62
+
63
+ /**
64
+ * Compute row count by simulating the same wrap logic the engine uses
65
+ * (measureLegendWrap in packages/engine/src/legend/wrap.ts).
66
+ */
67
+ function computeRowCount(maxWidth: number): number {
68
+ let rowCount = 1;
69
+ let rowWidth = 0;
70
+ for (const entry of entries) {
71
+ const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
72
+ const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
73
+ if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
74
+ rowCount++;
75
+ rowWidth = entryWidth;
76
+ } else {
77
+ rowWidth += entryWidth;
78
+ }
79
+ }
80
+ return rowCount;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Tests
85
+ // ---------------------------------------------------------------------------
86
+
87
+ describe('known layout bugs', () => {
88
+ // Red-locked: fixed by docs/plans/04-resolved-layout-contract.md
89
+ test.fails('5a.1: legend row advancement matches engine row height', () => {
90
+ const boundsWidth = 400;
91
+ const rowCount = computeRowCount(boundsWidth);
92
+ const boundsHeight = rowCount * ENGINE_ROW_HEIGHT + LEGEND_PADDING * 2;
93
+
94
+ const legendFixture: CategoricalLegendLayout = {
95
+ position: 'top',
96
+ entries,
97
+ swatchSize: SWATCH_SIZE,
98
+ swatchGap: SWATCH_GAP,
99
+ entryGap: ENTRY_GAP,
100
+ swatchChipFill: '#f0f0f0',
101
+ labelStyle,
102
+ bounds: { x: 0, y: 0, width: boundsWidth, height: boundsHeight },
103
+ };
104
+
105
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
106
+ renderLegend(svg, legendFixture);
107
+
108
+ const yValues = distinctLabelYValues(svg);
109
+ // With 4 rows we expect 4 distinct y-values. Consecutive rows should
110
+ // differ by ENGINE_ROW_HEIGHT (16). The renderer uses swatchSize + 6 = 18,
111
+ // so this assertion will fail until the drift is fixed.
112
+ expect(yValues.length).toBeGreaterThanOrEqual(2);
113
+ for (let i = 1; i < yValues.length; i++) {
114
+ const gap = yValues[i] - yValues[i - 1];
115
+ expect(gap).toBe(ENGINE_ROW_HEIGHT);
116
+ }
117
+ });
118
+
119
+ // Red-locked: fixed by docs/plans/04-resolved-layout-contract.md and docs/plans/03-measure-then-freeze-layout.md
120
+ test.fails('5a.2: rows drawn by renderer match rows reserved by engine', () => {
121
+ // Build a line spec with 8 series whose labels vary in length.
122
+ const countries = [
123
+ 'United States of America',
124
+ 'United Kingdom',
125
+ 'Federal Republic of Germany',
126
+ 'French Republic',
127
+ 'Kingdom of Spain',
128
+ 'Republic of Italy',
129
+ 'Kingdom of Netherlands',
130
+ 'Swiss Confederation',
131
+ ];
132
+
133
+ const data = countries.flatMap((country) => [
134
+ { date: '2020-01-01', value: 10, country },
135
+ { date: '2021-01-01', value: 20, country },
136
+ ]);
137
+
138
+ const spec = {
139
+ mark: 'line' as const,
140
+ data,
141
+ encoding: {
142
+ x: { field: 'date', type: 'temporal' as const },
143
+ y: { field: 'value', type: 'quantitative' as const },
144
+ color: { field: 'country', type: 'nominal' as const },
145
+ },
146
+ legend: { position: 'top' as const },
147
+ };
148
+
149
+ // Try widths 500-700 to find one where the legend wraps AND the
150
+ // bounds.width narrowing causes extra rows in the renderer.
151
+ let foundMismatch = false;
152
+
153
+ for (let width = 500; width <= 700; width += 10) {
154
+ const layout = compileChart(spec, { width, height: 400 });
155
+ const legend = layout.legend as CategoricalLegendLayout;
156
+ if (!legend || !legend.entries || legend.entries.length === 0) continue;
157
+
158
+ // Rows reserved by the engine
159
+ const effectivePadding = width < 420 ? 2 : LEGEND_PADDING;
160
+ const rowsReserved = Math.round(
161
+ (legend.bounds.height - effectivePadding * 2) / ENGINE_ROW_HEIGHT,
162
+ );
163
+ if (rowsReserved <= 1) continue; // only interesting when wrapping occurs
164
+
165
+ // Render and count rows drawn
166
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
167
+ renderLegend(svg, legend);
168
+ const yValues = distinctLabelYValues(svg);
169
+ const rowsDrawn = yValues.length;
170
+
171
+ if (rowsDrawn !== rowsReserved) {
172
+ foundMismatch = true;
173
+ // This is the bug: the renderer drew more rows than the engine reserved.
174
+ expect(rowsDrawn).toBe(rowsReserved);
175
+ return;
176
+ }
177
+ }
178
+
179
+ // If no width triggered a mismatch, fail so test.fails still inverts.
180
+ // If this path fires, widen the width range above.
181
+ expect(foundMismatch).toBe(true);
182
+ });
183
+ });
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { MeasureTextFn } from '@opendata-ai/openchart-core';
10
+ import { estimateTextWidth } from '@opendata-ai/openchart-core';
10
11
 
11
12
  export function createMeasureText(): MeasureTextFn {
12
13
  let canvas: HTMLCanvasElement | null = null;
@@ -23,7 +24,10 @@ export function createMeasureText(): MeasureTextFn {
23
24
  }
24
25
  if (!ctx) {
25
26
  // Fallback: heuristic estimation
26
- return { width: text.length * fontSize * 0.6, height: fontSize * 1.2 };
27
+ return {
28
+ width: estimateTextWidth(text, fontSize, fontWeight ?? 400),
29
+ height: fontSize * 1.2,
30
+ };
27
31
  }
28
32
 
29
33
  const weight = fontWeight ?? 400;
@@ -35,3 +39,9 @@ export function createMeasureText(): MeasureTextFn {
35
39
  };
36
40
  };
37
41
  }
42
+
43
+ let shared: MeasureTextFn | null = null;
44
+ export function sharedMeasureText(): MeasureTextFn {
45
+ if (!shared) shared = createMeasureText();
46
+ return shared;
47
+ }
@@ -214,14 +214,7 @@ function renderAnnotation(
214
214
  const lineHeight = fontSize * (annotation.label.style.lineHeight ?? 1.3);
215
215
  const isMultiLine = lines.length > 1;
216
216
 
217
- // Multi-line text: drop-line connectors keep the resolved side anchor so
218
- // the label hugs the vertical line. Other connectors center the text for
219
- // a cleaner look.
220
217
  if (isMultiLine) {
221
- const isDropLine = annotation.label.connector?.style === 'drop-line';
222
- if (!isDropLine) {
223
- text.setAttribute('text-anchor', 'middle');
224
- }
225
218
  for (let i = 0; i < lines.length; i++) {
226
219
  const tspan = createSVGElement('tspan');
227
220
  setAttrs(tspan, { x: annotation.label.x, dy: i === 0 ? 0 : lineHeight });
@@ -235,20 +228,35 @@ function renderAnnotation(
235
228
  // Render background rect behind text if specified, otherwise use
236
229
  // paint-order stroke halo to knock out lines behind text
237
230
  if (annotation.label.background) {
238
- const charWidth = fontSize * 0.55;
239
- const maxLineWidth = Math.max(...lines.map((l) => l.length)) * charWidth;
240
- const totalHeight = lines.length * lineHeight;
241
231
  const pad = 3;
242
- const bgX = isMultiLine
243
- ? annotation.label.x - maxLineWidth / 2 - pad
244
- : annotation.label.x - pad;
232
+ let bgX: number;
233
+ let bgY: number;
234
+ let bgW: number;
235
+ let bgH: number;
236
+
237
+ if (annotation.label.bounds) {
238
+ const b = annotation.label.bounds;
239
+ bgX = b.x - pad;
240
+ bgY = b.y - pad;
241
+ bgW = b.width + pad * 2;
242
+ bgH = b.height + pad * 2;
243
+ } else {
244
+ const charWidth = fontSize * 0.55;
245
+ const maxLineWidth = Math.max(...lines.map((l) => l.length)) * charWidth;
246
+ const totalHeight = lines.length * lineHeight;
247
+ bgX = isMultiLine ? annotation.label.x - maxLineWidth / 2 - pad : annotation.label.x - pad;
248
+ bgY = annotation.label.y - fontSize + (lineHeight - fontSize) / 2 - pad;
249
+ bgW = maxLineWidth + pad * 2;
250
+ bgH = totalHeight + pad * 2;
251
+ }
252
+
245
253
  const bgRect = createSVGElement('rect');
246
254
  bgRect.setAttribute('class', 'oc-annotation-bg');
247
255
  setAttrs(bgRect, {
248
256
  x: bgX,
249
- y: annotation.label.y - fontSize + (lineHeight - fontSize) / 2 - pad,
250
- width: maxLineWidth + pad * 2,
251
- height: totalHeight + pad * 2,
257
+ y: bgY,
258
+ width: bgW,
259
+ height: bgH,
252
260
  fill: annotation.label.background,
253
261
  rx: 2,
254
262
  });
@@ -242,9 +242,18 @@ function renderAxis(
242
242
  applyTextStyle(axisLabel, axis.labelStyle);
243
243
  axisLabel.textContent = axis.label;
244
244
 
245
- if (orientation === 'x') {
246
- // Position axis title below tick labels. For rotated labels, compute
247
- // the vertical extent of the rotated ticks and place the title below.
245
+ const tp = axis.titlePosition;
246
+ if (tp) {
247
+ const attrs: Record<string, string | number> = {
248
+ x: tp.x,
249
+ y: tp.y,
250
+ 'text-anchor': 'middle',
251
+ };
252
+ if (tp.angle) {
253
+ attrs.transform = `rotate(${tp.angle}, ${tp.x}, ${tp.y})`;
254
+ }
255
+ setAttrs(axisLabel, attrs);
256
+ } else if (orientation === 'x') {
248
257
  let titleY = area.y + area.height + 35;
249
258
  if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
250
259
  const angleRad = Math.abs(axis.tickAngle) * (Math.PI / 180);
@@ -266,7 +275,6 @@ function renderAxis(
266
275
  'text-anchor': 'middle',
267
276
  });
268
277
  } else if (isRight) {
269
- // Rotated right y-axis label (tighter offset on compact viewports)
270
278
  const titleOffset = getAxisTitleOffset(layout.dimensions.width);
271
279
  const titleX = area.x + area.width + titleOffset;
272
280
  setAttrs(axisLabel, {
@@ -276,11 +284,6 @@ function renderAxis(
276
284
  transform: `rotate(90, ${titleX}, ${area.y + area.height / 2})`,
277
285
  });
278
286
  } else {
279
- // Rotated left y-axis label.
280
- // Compute a dynamic offset so the title clears the widest tick label.
281
- // The title is rotated and centered, so axisTitleOffset() adds its own
282
- // half-glyph height on top of the gap (otherwise large title fonts overlap
283
- // the tick labels — the gap is visible clearance, not center-to-edge).
284
287
  const maxTickLabelWidth = axis.ticks.reduce((max, t) => {
285
288
  const w = estimateTextWidth(
286
289
  t.label,
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { ChartLayout } from '@opendata-ai/openchart-core';
6
6
  import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH } from '@opendata-ai/openchart-core';
7
- import { computeXAxisExtent, createSVGElement, setAttrs, XLINK_NS } from './svg-dom';
7
+ import { createSVGElement, setAttrs, XLINK_NS } from './svg-dom';
8
8
 
9
9
  const BRAND_URL = 'https://tryopendata.ai';
10
10
 
@@ -24,8 +24,7 @@ export function renderBrand(parent: SVGElement, layout: ChartLayout): void {
24
24
 
25
25
  // Vertically align with the first bottom chrome element.
26
26
  const { chrome } = layout;
27
- const xAxisExtent = computeXAxisExtent(layout);
28
- const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
27
+ const bottomOffset = chrome.bottomAnchorY ?? layout.area.y + layout.area.height;
29
28
  const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
30
29
  // When no bottom chrome items exist, derive a fallback offset that still
31
30
  // clears any bottom-positioned legend so the brand watermark doesn't
@@ -8,7 +8,7 @@ import type {
8
8
  ResolvedChromeElement,
9
9
  } from '@opendata-ai/openchart-core';
10
10
  import { estimateTextWidth, wrapText } from '@opendata-ai/openchart-core';
11
- import { applyTextStyle, computeXAxisExtent, createSVGElement, setAttrs } from './svg-dom';
11
+ import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
12
12
 
13
13
  function renderChromeElement(
14
14
  parent: SVGElement,
@@ -87,10 +87,7 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void {
87
87
  renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);
88
88
  }
89
89
 
90
- // Bottom chrome starts below x-axis labels/title, not at chart area bottom.
91
- // Accounts for rotated tick labels which need more vertical space.
92
- const xAxisExtent = computeXAxisExtent(layout);
93
- const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
90
+ const bottomOffset = layout.chrome.bottomAnchorY ?? layout.area.y + layout.area.height;
94
91
  if (chrome.source) {
95
92
  renderChromeElement(
96
93
  g,
@@ -19,14 +19,18 @@ export function renderLegend(parent: SVGElement, legend: LegendLayout): void {
19
19
  g.setAttribute('aria-label', 'Chart legend');
20
20
 
21
21
  const isHorizontal = legend.position === 'top' || legend.position === 'bottom';
22
+ const positions = 'entryPositions' in legend ? legend.entryPositions : undefined;
22
23
  let offsetX = legend.bounds.x;
23
24
  let offsetY = legend.bounds.y;
24
25
 
25
26
  for (let i = 0; i < legend.entries.length; i++) {
26
27
  const entry = legend.entries[i];
27
28
 
28
- // Pre-check: wrap to next line if this entry would overflow bounds
29
- if (isHorizontal && i > 0) {
29
+ const pos = positions?.[i];
30
+ if (pos) {
31
+ offsetX = pos.x;
32
+ offsetY = pos.y;
33
+ } else if (isHorizontal && i > 0) {
30
34
  const labelWidth = estimateTextWidth(
31
35
  entry.label,
32
36
  legend.labelStyle.fontSize,
@@ -116,17 +120,21 @@ export function renderLegend(parent: SVGElement, legend: LegendLayout): void {
116
120
 
117
121
  g.appendChild(entryG);
118
122
 
119
- // Advance position for next entry
120
- if (isHorizontal) {
121
- const labelWidth = estimateTextWidth(
122
- entry.label,
123
- legend.labelStyle.fontSize,
124
- legend.labelStyle.fontWeight,
125
- );
126
- const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
127
- offsetX += entryWidth;
128
- } else {
129
- offsetY += legend.swatchSize + legend.entryGap;
123
+ if (!pos) {
124
+ if (isHorizontal) {
125
+ const labelWidth = estimateTextWidth(
126
+ entry.label,
127
+ legend.labelStyle.fontSize,
128
+ legend.labelStyle.fontWeight,
129
+ );
130
+ const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
131
+ offsetX += entryWidth;
132
+ } else {
133
+ offsetY +=
134
+ 'rowHeight' in legend && legend.rowHeight
135
+ ? legend.rowHeight
136
+ : legend.swatchSize + legend.entryGap;
137
+ }
130
138
  }
131
139
  }
132
140
 
@@ -4,8 +4,7 @@
4
4
  * Pure, stateless utilities. No layout/theme knowledge.
5
5
  */
6
6
 
7
- import type { ChartLayout, TextStyle } from '@opendata-ai/openchart-core';
8
- import { estimateTextWidth } from '@opendata-ai/openchart-core';
7
+ import type { TextStyle } from '@opendata-ai/openchart-core';
9
8
 
10
9
  export const SVG_NS = 'http://www.w3.org/2000/svg';
11
10
  export const XLINK_NS = 'http://www.w3.org/1999/xlink';
@@ -39,28 +38,3 @@ export function applyTextStyle(el: SVGElement, style: TextStyle): void {
39
38
  el.setAttribute('font-variant', style.fontVariant);
40
39
  }
41
40
  }
42
-
43
- /**
44
- * Compute the vertical extent of x-axis labels below the chart area.
45
- * Accounts for rotated tick labels which need more vertical space.
46
- */
47
- export function computeXAxisExtent(layout: ChartLayout): number {
48
- const xAxis = layout.axes.x;
49
- if (!xAxis) return 0;
50
-
51
- if (xAxis.tickAngle && Math.abs(xAxis.tickAngle) > 10) {
52
- // Rotated labels: estimate height from the longest tick label.
53
- const fontSize = xAxis.tickLabelStyle.fontSize;
54
- const fontWeight = xAxis.tickLabelStyle.fontWeight;
55
- const angleRad = Math.abs(xAxis.tickAngle) * (Math.PI / 180);
56
- let maxLabelWidth = 40;
57
- for (const tick of xAxis.ticks) {
58
- const w = estimateTextWidth(tick.label, fontSize, fontWeight);
59
- if (w > maxLabelWidth) maxLabelWidth = w;
60
- }
61
- const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
62
- return xAxis.label ? rotatedHeight + 20 : rotatedHeight;
63
- }
64
-
65
- return xAxis.label ? 48 : 26;
66
- }