@opendata-ai/openchart-vanilla 7.10.0 → 7.11.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.10.0",
3
+ "version": "7.11.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",
@@ -54,8 +54,8 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@floating-ui/dom": "^1.7.6",
57
- "@opendata-ai/openchart-core": "7.10.0",
58
- "@opendata-ai/openchart-engine": "7.10.0",
57
+ "@opendata-ai/openchart-core": "7.11.0",
58
+ "@opendata-ai/openchart-engine": "7.11.0",
59
59
  "d3-force": "^3.0.0",
60
60
  "d3-quadtree": "^3.0.1"
61
61
  },
@@ -29,6 +29,34 @@ export function createContainer(width = 600, height = 400): HTMLDivElement {
29
29
  return container;
30
30
  }
31
31
 
32
+ /**
33
+ * Create an auto-height container: its measured height is 0 until a chart
34
+ * SVG is mounted, then tracks the SVG's own style.height — mimicking a real
35
+ * DOM container with no explicit height that wraps its content.
36
+ */
37
+ export function createAutoHeightContainer(width = 390): HTMLDivElement {
38
+ const container = document.createElement('div');
39
+ Object.defineProperty(container, 'getBoundingClientRect', {
40
+ value: () => {
41
+ const svg = container.querySelector('svg');
42
+ const height = svg ? Number.parseFloat((svg as SVGElement).style.height) || 0 : 0;
43
+ return {
44
+ width,
45
+ height,
46
+ top: 0,
47
+ left: 0,
48
+ right: width,
49
+ bottom: height,
50
+ x: 0,
51
+ y: 0,
52
+ toJSON: () => ({}),
53
+ };
54
+ },
55
+ });
56
+ document.body.appendChild(container);
57
+ return container;
58
+ }
59
+
32
60
  /**
33
61
  * Create a MouseEvent with clientX/clientY coordinates.
34
62
  * happy-dom supports basic MouseEvent construction.
@@ -31,6 +31,33 @@ export const lineSpec: ChartSpec = {
31
31
  },
32
32
  };
33
33
 
34
+ /**
35
+ * Multi-series line with wrapping chrome and an explicit top legend — the
36
+ * mobile auto-height growth scenario (long title + subtitle + legend +
37
+ * source all stack above/below the plot).
38
+ */
39
+ export const longChromeLineSpec: ChartSpec = {
40
+ mark: 'line',
41
+ data: [
42
+ { date: '2020-01-01', value: 10, country: 'US' },
43
+ { date: '2021-01-01', value: 40, country: 'US' },
44
+ { date: '2020-01-01', value: 15, country: 'UK' },
45
+ { date: '2021-01-01', value: 35, country: 'UK' },
46
+ ],
47
+ encoding: {
48
+ x: { field: 'date', type: 'temporal' },
49
+ y: { field: 'value', type: 'quantitative' },
50
+ color: { field: 'country', type: 'nominal' },
51
+ },
52
+ legend: { show: true, position: 'top' },
53
+ chrome: {
54
+ title:
55
+ 'An intentionally long editorial chart title that wraps to several lines at phone widths',
56
+ subtitle: 'A methodological subtitle that provides additional context for readers',
57
+ source: 'World Bank',
58
+ },
59
+ };
60
+
34
61
  export const singleSeriesLineSpec: ChartSpec = {
35
62
  mark: 'line',
36
63
  data: [
@@ -1,6 +1,13 @@
1
+ import type { ChartLayout } from '@opendata-ai/openchart-core';
2
+ import { legendGap } from '@opendata-ai/openchart-engine';
1
3
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
- import { createContainer } from '../__test-fixtures__/dom';
3
- import { barSpec, lineSpec } from '../__test-fixtures__/specs';
4
+ import { createAutoHeightContainer, createContainer } from '../__test-fixtures__/dom';
5
+ import {
6
+ barSpec,
7
+ lineSpec,
8
+ longChromeLineSpec,
9
+ singleSeriesLineSpec,
10
+ } from '../__test-fixtures__/specs';
4
11
  import { createChart } from '../mount';
5
12
 
6
13
  // ---------------------------------------------------------------------------
@@ -236,3 +243,89 @@ describe('resize observer integration', () => {
236
243
  chart.destroy();
237
244
  });
238
245
  });
246
+
247
+ describe('auto-height container growth', () => {
248
+ afterEach(() => {
249
+ document.body.innerHTML = '';
250
+ });
251
+
252
+ /** The chrome/legend/metrics blocks the mount grows the figure by. */
253
+ function overheadsOf(layout: ChartLayout, width: number): number {
254
+ const topLegendBlock =
255
+ layout.legend.position === 'top' &&
256
+ 'entries' in layout.legend &&
257
+ layout.legend.entries.length > 0
258
+ ? layout.legend.bounds.height + legendGap(width)
259
+ : 0;
260
+ return (
261
+ layout.chrome.topHeight +
262
+ layout.chrome.bottomHeight +
263
+ topLegendBlock +
264
+ (layout.metrics?.height ?? 0)
265
+ );
266
+ }
267
+
268
+ it('grows the figure beyond the 400px budget by the measured overheads', () => {
269
+ const container = createAutoHeightContainer(390);
270
+ const chart = createChart(container, longChromeLineSpec);
271
+
272
+ const height = chart.layout.dimensions.height;
273
+ expect(height).toBeGreaterThan(400);
274
+ expect(height).toBeCloseTo(400 + overheadsOf(chart.layout, 390), 5);
275
+
276
+ // The rendered SVG is sized to the grown figure, not the 400 fallback.
277
+ const svg = container.querySelector('svg');
278
+ expect(svg?.getAttribute('viewBox')).toBe(`0 0 390 ${height}`);
279
+ expect((svg as SVGElement).style.height).toBe(`${height}px`);
280
+
281
+ chart.destroy();
282
+ });
283
+
284
+ it('keeps explicit-height containers at the host height', () => {
285
+ const container = createContainer(390, 500);
286
+ const chart = createChart(container, longChromeLineSpec);
287
+
288
+ // Host constrains the box; chrome must fit inside it, not grow it.
289
+ expect(chart.layout.dimensions.height).toBe(500);
290
+
291
+ chart.destroy();
292
+ });
293
+
294
+ it('re-renders against the 400 budget, not the grown height (no re-pin)', () => {
295
+ const container = createAutoHeightContainer(390);
296
+ const chart = createChart(container, longChromeLineSpec);
297
+
298
+ const grownHeight = chart.layout.dimensions.height;
299
+ expect(grownHeight).toBeGreaterThan(400);
300
+
301
+ // Same spec again: the container now measures at the grown SVG height,
302
+ // but the latched-auto mount keeps compiling against the 400 budget, so
303
+ // the figure height is stable.
304
+ chart.update(longChromeLineSpec);
305
+ expect(chart.layout.dimensions.height).toBe(grownHeight);
306
+
307
+ // Shorter chrome: the figure must shrink toward the budget instead of
308
+ // staying pinned at the previously grown container height.
309
+ chart.update(singleSeriesLineSpec);
310
+ const shrunkHeight = chart.layout.dimensions.height;
311
+ expect(shrunkHeight).toBeLessThan(grownHeight);
312
+ expect(shrunkHeight).toBeCloseTo(400 + overheadsOf(chart.layout, 390), 5);
313
+
314
+ chart.destroy();
315
+ });
316
+
317
+ it('does not grow sparkline mounts', () => {
318
+ const container = createAutoHeightContainer(200);
319
+ const chart = createChart(container, {
320
+ mark: 'line',
321
+ data: lineSpec.data,
322
+ encoding: lineSpec.encoding,
323
+ display: 'sparkline',
324
+ });
325
+
326
+ // Sparkline auto-height fallback stays at the tiny 40px default.
327
+ expect(chart.layout.dimensions.height).toBe(40);
328
+
329
+ chart.destroy();
330
+ });
331
+ });
@@ -14,8 +14,8 @@
14
14
  */
15
15
 
16
16
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
17
- import { createContainer } from '../__test-fixtures__/dom';
18
- import { lineSpec } from '../__test-fixtures__/specs';
17
+ import { createAutoHeightContainer, createContainer } from '../__test-fixtures__/dom';
18
+ import { lineSpec, longChromeLineSpec } from '../__test-fixtures__/specs';
19
19
  import { createChart } from '../mount';
20
20
 
21
21
  // ---------------------------------------------------------------------------
@@ -181,4 +181,38 @@ describe('chart resize timing', () => {
181
181
  // try to render a new one. No SVG should be present.
182
182
  expect(container.querySelector('svg')).toBeNull();
183
183
  });
184
+
185
+ describe('auto-height observer feedback guard', () => {
186
+ it('ignores the observer echo from the figure growing itself', async () => {
187
+ const autoContainer = createAutoHeightContainer(390);
188
+ const chart = createChart(autoContainer, longChromeLineSpec);
189
+
190
+ const grownHeight = chart.layout.dimensions.height;
191
+ expect(grownHeight).toBeGreaterThan(400);
192
+ const genBefore = autoContainer.dataset.ocRenderGen;
193
+
194
+ // Growing the SVG grows the container, which fires the observer with
195
+ // our own dimensions. That echo must not trigger a re-render (the
196
+ // re-render would grow again and loop).
197
+ fireResize(autoContainer, 390, grownHeight);
198
+ await vi.runAllTimersAsync();
199
+ expect(autoContainer.dataset.ocRenderGen).toBe(genBefore);
200
+
201
+ chart.destroy();
202
+ });
203
+
204
+ it('still re-renders when the width changes', async () => {
205
+ const autoContainer = createAutoHeightContainer(390);
206
+ const chart = createChart(autoContainer, longChromeLineSpec);
207
+
208
+ const grownHeight = chart.layout.dimensions.height;
209
+ const genBefore = autoContainer.dataset.ocRenderGen;
210
+
211
+ fireResize(autoContainer, 340, grownHeight);
212
+ await vi.runAllTimersAsync();
213
+ expect(autoContainer.dataset.ocRenderGen).not.toBe(genBefore);
214
+
215
+ chart.destroy();
216
+ });
217
+ });
184
218
  });
package/src/mount.ts CHANGED
@@ -21,7 +21,7 @@ import type {
21
21
  ThemeConfig,
22
22
  } from '@opendata-ai/openchart-core';
23
23
  import { isGraphSpec, isLayerSpec } from '@opendata-ai/openchart-core';
24
- import { compileChart, compileLayer } from '@opendata-ai/openchart-engine';
24
+ import { compileChart, compileLayer, legendGap } from '@opendata-ai/openchart-engine';
25
25
  import { cancelAnimations, setupAnimationCleanup } from './animation';
26
26
  import {
27
27
  exportCSV,
@@ -120,6 +120,18 @@ export interface ChartInstance {
120
120
  setHighlight(values: string[] | null): void;
121
121
  }
122
122
 
123
+ // ---------------------------------------------------------------------------
124
+ // Container sizing
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Fallback height when the container doesn't constrain height. For
129
+ * auto-height ChartSpec mounts this is the fixed budget for the
130
+ * visualization itself (padding + axes + plot); chrome, the top legend
131
+ * block, and the metrics bar grow the figure on top of it.
132
+ */
133
+ const FALLBACK_HEIGHT = 400;
134
+
123
135
  // ---------------------------------------------------------------------------
124
136
  // Dark mode resolution
125
137
  // ---------------------------------------------------------------------------
@@ -194,6 +206,19 @@ export function createChart<TData extends DataRow = DataRow>(
194
206
  let cleanupAnimations: (() => void) | null = null;
195
207
  let pendingResize = false;
196
208
 
209
+ // Auto-height sizing state. `isAutoHeight` latches whether the host
210
+ // constrains the container's height:
211
+ // null = unknown — every measurement so far was an all-zero rect
212
+ // (display:none / detached); keep re-checking each measure.
213
+ // true = auto-height — the figure grows with chrome (ChartSpec only).
214
+ // false = explicit height — fit inside the host's box (today's behavior).
215
+ // The latch matters because the SVG's own style.height gives the container
216
+ // a nonzero rect after the first render, so auto-height cannot be
217
+ // re-derived per measure.
218
+ let isAutoHeight: boolean | null = null;
219
+ let lastRenderedWidth: number | null = null;
220
+ let lastRenderedSvgHeight: number | null = null;
221
+
197
222
  // Data-update transition state
198
223
  let transitionHandle: import('./transition').TransitionHandle | null = null;
199
224
  let transitionSnapshot: GeometrySnapshot | null = null;
@@ -239,8 +264,7 @@ export function createChart<TData extends DataRow = DataRow>(
239
264
  // Compilation
240
265
  // ---------------------------------------------------------------------------
241
266
 
242
- function compile(): ChartLayout {
243
- const { width, height } = getContainerDimensions();
267
+ function compileAt(width: number, height: number): ChartLayout {
244
268
  const darkMode = resolveDarkMode(options?.darkMode);
245
269
 
246
270
  const compileOpts: CompileOptions = {
@@ -258,6 +282,61 @@ export function createChart<TData extends DataRow = DataRow>(
258
282
  return compileChart(withRuntimeHidden(currentSpec) as ChartSpec | GraphSpec, compileOpts);
259
283
  }
260
284
 
285
+ /**
286
+ * Vertical overheads that grow an auto-height figure beyond the
287
+ * FALLBACK_HEIGHT budget: top+bottom chrome (bottom chrome already includes
288
+ * a bottom legend's reservation), the top legend block (height + gap), and
289
+ * the KPI metrics bar. topPad and the axis gap are plot clearance, not
290
+ * chrome — they stay inside the budget.
291
+ */
292
+ function verticalOverheads(layout: ChartLayout, width: number): number {
293
+ const topLegendBlock =
294
+ layout.legend.position === 'top' &&
295
+ 'entries' in layout.legend &&
296
+ layout.legend.entries.length > 0
297
+ ? layout.legend.bounds.height + legendGap(width)
298
+ : 0;
299
+ return (
300
+ layout.chrome.topHeight +
301
+ layout.chrome.bottomHeight +
302
+ topLegendBlock +
303
+ (layout.metrics?.height ?? 0)
304
+ );
305
+ }
306
+
307
+ function compile(): ChartLayout {
308
+ const { width, height } = getContainerDimensions();
309
+
310
+ // Auto-height growth applies to ChartSpec mounts only. GraphSpec,
311
+ // LayerSpec, and sparkline mounts keep the fixed-fallback behavior.
312
+ const growsWithChrome =
313
+ isAutoHeight === true &&
314
+ !isLayerSpec(currentSpec) &&
315
+ !isGraphSpec(currentSpec as unknown as Record<string, unknown>) &&
316
+ (currentSpec as ChartSpec).display !== 'sparkline';
317
+ if (!growsWithChrome) {
318
+ return compileAt(width, height);
319
+ }
320
+
321
+ // Bounded convergence loop, max 3 compiles. Chrome height depends on
322
+ // width (text wrap), not height — but the engine's chrome-strip guardrail
323
+ // strips chrome when the residual plot is too small, which makes chrome
324
+ // height-dependent at the budget boundary: growing the figure can
325
+ // un-strip chrome, changing the overheads once more. (Height classes are
326
+ // not the issue — they only strip below 200/350px, and growing from 400
327
+ // moves away from those thresholds.) Pass 3 is accepted unconditionally.
328
+ let layout = compileAt(width, height);
329
+ const overheads = verticalOverheads(layout, width);
330
+ if (overheads > 0) {
331
+ layout = compileAt(width, height + overheads);
332
+ const overheads2 = verticalOverheads(layout, width);
333
+ if (overheads2 !== overheads) {
334
+ layout = compileAt(width, height + overheads2);
335
+ }
336
+ }
337
+ return layout;
338
+ }
339
+
261
340
  function withRuntimeHidden<T extends { hiddenSeries?: string[]; annotations?: Annotation[] }>(
262
341
  spec: T,
263
342
  ): T {
@@ -366,9 +445,21 @@ export function createChart<TData extends DataRow = DataRow>(
366
445
  height: Math.max(height || 40, 20),
367
446
  };
368
447
  }
448
+ // Latch auto-height on the first NONZERO measurement: height 0 with a
449
+ // real width means the host doesn't constrain height. An all-zero rect
450
+ // (display:none / detached) stays "unknown" — keep re-checking until the
451
+ // container produces a real measurement.
452
+ if (isAutoHeight === null && (rect.width > 0 || rect.height > 0)) {
453
+ isAutoHeight = rect.height === 0 && rect.width > 0;
454
+ }
455
+
369
456
  return {
370
457
  width: Math.max(rect.width || 600, 100),
371
- height: Math.max(rect.height || 400, 100),
458
+ // Latched-auto mounts always compile against the fixed budget: after
459
+ // the first render the container's measured height is just our own
460
+ // SVG, so reading it back would re-pin whatever we rendered last.
461
+ height:
462
+ isAutoHeight === true ? FALLBACK_HEIGHT : Math.max(rect.height || FALLBACK_HEIGHT, 100),
372
463
  };
373
464
  }
374
465
 
@@ -676,6 +767,9 @@ export function createChart<TData extends DataRow = DataRow>(
676
767
  }
677
768
 
678
769
  currentLayout = compile();
770
+ // Recorded for the ResizeObserver's self-induced-resize guard.
771
+ lastRenderedWidth = currentLayout.dimensions.width;
772
+ lastRenderedSvgHeight = currentLayout.dimensions.height;
679
773
  const shouldAnimate = isFirstRender && !!currentLayout.animation?.enter;
680
774
  const crosshair = !!currentLayout.crosshair;
681
775
  svgElement = renderChartSVG(currentLayout, container, {
@@ -1050,7 +1144,27 @@ export function createChart<TData extends DataRow = DataRow>(
1050
1144
 
1051
1145
  // Set up responsive resize
1052
1146
  if (options?.responsive !== false) {
1053
- disconnectResize = observeResize(container, () => {
1147
+ disconnectResize = observeResize(container, (width, height) => {
1148
+ // Self-induced-resize guard, scoped to latched-auto mounts: growing
1149
+ // the SVG grows the container, which re-fires this observer. A fire
1150
+ // whose height matches the SVG we just rendered (and whose width is
1151
+ // unchanged) is our own echo — re-rendering on it would loop; the
1152
+ // 16ms debounce alone doesn't break the cycle.
1153
+ if (isAutoHeight === true) {
1154
+ const widthChanged = width !== lastRenderedWidth;
1155
+ const selfInducedHeight = height === lastRenderedSvgHeight;
1156
+ if (!widthChanged && selfInducedHeight) return;
1157
+ if (!selfInducedHeight) {
1158
+ // Any other height delta is host-driven: un-latch so the next
1159
+ // measure re-derives (and re-latches explicit if the container
1160
+ // now reports a height that isn't ours).
1161
+ isAutoHeight = null;
1162
+ }
1163
+ resize();
1164
+ return;
1165
+ }
1166
+ // Explicit-height (and not-yet-latched) mounts keep today's
1167
+ // re-render-on-every-fire behavior.
1054
1168
  resize();
1055
1169
  });
1056
1170
  }