@opendata-ai/openchart-engine 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-engine",
3
- "version": "6.23.1",
3
+ "version": "6.24.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "6.23.1",
51
+ "@opendata-ai/openchart-core": "6.24.0",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -418,7 +418,7 @@ describe('text-aware tick density', () => {
418
418
  expect(axesNarrow.x!.ticks.length).toBeLessThanOrEqual(axesWide.x!.ticks.length);
419
419
  });
420
420
 
421
- it('does not thin x-axis ticks when explicit tickCount is set', () => {
421
+ it('still thins x-axis ticks when tickCount is set but D3 overshoots', () => {
422
422
  const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
423
423
  const specWithTickCount: NormalizedChartSpec = {
424
424
  ...lineSpec,
@@ -431,12 +431,33 @@ describe('text-aware tick density', () => {
431
431
  const scales = computeScales(specWithTickCount, narrowArea, specWithTickCount.data);
432
432
  const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
433
433
 
434
- // With explicit tickCount, the engine should not thin
435
- // D3 may return fewer than 8 for this small dataset, but the point is
436
- // thinTicksUntilFit should not be called
434
+ // tickCount is advisory for D3 - if it overshoots, thinning still applies
435
+ // to prevent overlap. The result should still have ticks, just not more
436
+ // than the narrow area can display without overlap.
437
437
  expect(axes.x!.ticks.length).toBeGreaterThan(0);
438
438
  });
439
439
 
440
+ it('does not thin x-axis ticks when explicit values are set', () => {
441
+ const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
442
+ const specWithValues: NormalizedChartSpec = {
443
+ ...lineSpec,
444
+ encoding: {
445
+ x: {
446
+ field: 'date',
447
+ type: 'temporal',
448
+ axis: { values: ['2020-01-01', '2021-01-01', '2022-01-01'] },
449
+ },
450
+ y: { field: 'value', type: 'quantitative' },
451
+ },
452
+ };
453
+
454
+ const scales = computeScales(specWithValues, narrowArea, specWithValues.data);
455
+ const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
456
+
457
+ // Explicit values should be preserved exactly as specified
458
+ expect(axes.x!.ticks.length).toBe(3);
459
+ });
460
+
440
461
  it('band scale shows all categories regardless of width', () => {
441
462
  const categories = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'];
442
463
  const barSpec: NormalizedChartSpec = {
@@ -265,4 +265,52 @@ describe('computeDimensions', () => {
265
265
  // Tooltip-only should NOT reserve extra margin (annotations are hidden)
266
266
  expect(dimsTooltipOnly.margins.right).toBe(dimsNoAnnotations.margins.right);
267
267
  });
268
+
269
+ it('clamps y-axis label margin on narrow containers to preserve chart area', () => {
270
+ const longLabelSpec: NormalizedChartSpec = {
271
+ ...baseSpec,
272
+ markType: 'bar',
273
+ markDef: { type: 'bar' },
274
+ data: [
275
+ {
276
+ category: 'This is a very long category label that would consume lots of space',
277
+ value: 10,
278
+ },
279
+ { category: 'Another extremely verbose category name', value: 20 },
280
+ ],
281
+ encoding: {
282
+ x: { field: 'value', type: 'quantitative' },
283
+ y: { field: 'category', type: 'nominal' },
284
+ },
285
+ };
286
+
287
+ const narrowDims = computeDimensions(
288
+ longLabelSpec,
289
+ { width: 350, height: 300 },
290
+ emptyLegend,
291
+ lightTheme,
292
+ );
293
+
294
+ // On narrow viewports, left margin should be clamped so the chart area
295
+ // retains at least ~45% of the container width
296
+ expect(narrowDims.chartArea.width).toBeGreaterThanOrEqual(350 * 0.4);
297
+ });
298
+
299
+ it('tightens legend gap on narrow viewports', () => {
300
+ const wideDims = computeDimensions(
301
+ baseSpec,
302
+ { width: 600, height: 400 },
303
+ topLegend,
304
+ lightTheme,
305
+ );
306
+ const narrowDims = computeDimensions(
307
+ baseSpec,
308
+ { width: 360, height: 400 },
309
+ topLegend,
310
+ lightTheme,
311
+ );
312
+
313
+ // Narrow viewport should have more chart height available (smaller legend gap)
314
+ expect(narrowDims.chartArea.height).toBeGreaterThanOrEqual(wideDims.chartArea.height - 10);
315
+ });
268
316
  });
@@ -276,6 +276,30 @@ describe('computeLegend', () => {
276
276
  expect(deEntry.color).toBe('#00ff00');
277
277
  });
278
278
 
279
+ it('orders legend entries by explicit domain, not data order', () => {
280
+ const specExplicit: NormalizedChartSpec = {
281
+ ...specWithColor,
282
+ data: [
283
+ { date: '2020', value: 10, country: 'Germany' },
284
+ { date: '2021', value: 20, country: 'UK' },
285
+ { date: '2022', value: 30, country: 'US' },
286
+ ],
287
+ encoding: {
288
+ x: { field: 'date', type: 'temporal' },
289
+ y: { field: 'value', type: 'quantitative' },
290
+ color: {
291
+ field: 'country',
292
+ type: 'nominal',
293
+ scale: {
294
+ domain: ['US', 'UK', 'Germany'],
295
+ },
296
+ },
297
+ },
298
+ };
299
+ const legend = computeLegend(specExplicit, compactStrategy, theme, chartArea);
300
+ expect(legend.entries.map((e) => e.label)).toEqual(['US', 'UK', 'Germany']);
301
+ });
302
+
279
303
  it('uses correct swatch shape for chart type', () => {
280
304
  const lineLegend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
281
305
  expect(lineLegend.entries[0].shape).toBe('line');
@@ -395,32 +419,25 @@ describe('computeLegend', () => {
395
419
  });
396
420
  });
397
421
 
398
- // ---------------------------------------------------------------------------
399
- // Characterization test (refactor/v7-cohesion step 1):
400
- // Pins the 4px gap between a top-positioned legend and the chart area,
401
- // as enforced at packages/engine/src/compile.ts:331. Refactor step 4 will
402
- // consolidate legend row-wrapping geometry; this test guards the spacing
403
- // invariant through that change.
404
- // ---------------------------------------------------------------------------
405
422
  describe('top legend spacing', () => {
406
- it('places the legend exactly 4px above the chart area', () => {
407
- const spec = {
408
- mark: 'bar' as const,
409
- data: [
410
- { name: 'A', value: 10, group: 'X' },
411
- { name: 'A', value: 20, group: 'Y' },
412
- { name: 'B', value: 30, group: 'X' },
413
- { name: 'B', value: 25, group: 'Y' },
414
- ],
415
- encoding: {
416
- x: { field: 'name', type: 'nominal' as const },
417
- y: { field: 'value', type: 'quantitative' as const },
418
- color: { field: 'group', type: 'nominal' as const },
419
- },
420
- legend: { position: 'top' as const },
421
- };
423
+ const topLegendSpec = {
424
+ mark: 'bar' as const,
425
+ data: [
426
+ { name: 'A', value: 10, group: 'X' },
427
+ { name: 'A', value: 20, group: 'Y' },
428
+ { name: 'B', value: 30, group: 'X' },
429
+ { name: 'B', value: 25, group: 'Y' },
430
+ ],
431
+ encoding: {
432
+ x: { field: 'name', type: 'nominal' as const },
433
+ y: { field: 'value', type: 'quantitative' as const },
434
+ color: { field: 'group', type: 'nominal' as const },
435
+ },
436
+ legend: { position: 'top' as const },
437
+ };
422
438
 
423
- const layout = compileChart(spec, { width: 600, height: 400 });
439
+ it('places the legend exactly 4px above the chart area at standard width', () => {
440
+ const layout = compileChart(topLegendSpec, { width: 600, height: 400 });
424
441
 
425
442
  expect(layout.legend.position).toBe('top');
426
443
  expect(layout.legend.entries.length).toBeGreaterThan(0);
@@ -428,8 +445,18 @@ describe('computeLegend', () => {
428
445
 
429
446
  const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
430
447
  const gap = layout.area.y - legendBottom;
431
- // Pin value matches the literal at compile.ts:331 (legendArea.y -= legendLayout.bounds.height + 4)
432
448
  expect(gap).toBe(4);
433
449
  });
450
+
451
+ it('eliminates legend gap on narrow viewports (< 420px)', () => {
452
+ const layout = compileChart(topLegendSpec, { width: 360, height: 400 });
453
+
454
+ expect(layout.legend.position).toBe('top');
455
+ expect(layout.legend.entries.length).toBeGreaterThan(0);
456
+
457
+ const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
458
+ const gap = layout.area.y - legendBottom;
459
+ expect(gap).toBe(0);
460
+ });
434
461
  });
435
462
  });
@@ -264,6 +264,37 @@ describe('computeDotMarks', () => {
264
264
  });
265
265
  });
266
266
 
267
+ describe('baseline clamping', () => {
268
+ it('clamps stems to the plot area when domain does not include zero', () => {
269
+ const spec: NormalizedChartSpec = {
270
+ markType: 'circle',
271
+ markDef: { type: 'circle' },
272
+ data: [
273
+ { country: 'USA', score: 50 },
274
+ { country: 'UK', score: 80 },
275
+ ],
276
+ encoding: {
277
+ x: { field: 'score', type: 'quantitative', scale: { domain: [40, 100] } },
278
+ y: { field: 'country', type: 'nominal' },
279
+ },
280
+ chrome: {},
281
+ annotations: [],
282
+ responsive: true,
283
+ theme: {},
284
+ darkMode: 'off',
285
+ labels: { density: 'auto', format: '' },
286
+ };
287
+ const scales = computeScales(spec, chartArea, spec.data);
288
+ const marks = computeDotMarks(spec, scales, chartArea, fullStrategy);
289
+
290
+ const stems = marks.filter((m): m is RectMark => m.type === 'rect');
291
+ for (const stem of stems) {
292
+ expect(stem.x).toBeGreaterThanOrEqual(chartArea.x);
293
+ expect(stem.x + stem.width).toBeLessThanOrEqual(chartArea.x + chartArea.width);
294
+ }
295
+ });
296
+ });
297
+
267
298
  describe('edge cases', () => {
268
299
  it('returns empty array when no x encoding', () => {
269
300
  const spec: NormalizedChartSpec = {
@@ -67,7 +67,12 @@ export function computeDotMarks(
67
67
  }
68
68
 
69
69
  const bandwidth = yScale.bandwidth();
70
- const baseline = xScale(0);
70
+ // Clamp baseline to the scale range so stems never extend past the plot area
71
+ // (e.g., when domain doesn't include zero, xScale(0) would land outside).
72
+ const [rangeStart, rangeEnd] = xScale.range();
73
+ const rangeMin = Math.min(rangeStart, rangeEnd);
74
+ const rangeMax = Math.max(rangeStart, rangeEnd);
75
+ const baseline = Math.max(rangeMin, Math.min(rangeMax, xScale(0)));
71
76
  const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
72
77
  const isSequentialColor = colorEnc?.type === 'quantitative';
73
78
  const colorField = isSequentialColor ? undefined : colorEnc?.field;
package/src/compile.ts CHANGED
@@ -64,6 +64,7 @@ import { computeDimensions } from './layout/dimensions';
64
64
  import { computeGridlines } from './layout/gridlines';
65
65
  import { computeScales } from './layout/scales';
66
66
  import { computeLegend } from './legend/compute';
67
+ import { legendGap } from './legend/wrap';
67
68
  import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
68
69
  import { compileTableLayout } from './tables/compile-table';
69
70
  import { computeTooltipDescriptors } from './tooltips/compute';
@@ -291,13 +292,14 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
291
292
  // the data area (in the margin) instead of overlapping data marks.
292
293
  const legendArea: Rect = { ...chartArea };
293
294
  if (legendLayout.entries.length > 0) {
295
+ const gap = legendGap(options.width);
294
296
  switch (legendLayout.position) {
295
297
  case 'top':
296
- legendArea.y -= legendLayout.bounds.height + 4;
297
- legendArea.height += legendLayout.bounds.height + 4;
298
+ legendArea.y -= legendLayout.bounds.height + gap;
299
+ legendArea.height += legendLayout.bounds.height + gap;
298
300
  break;
299
301
  case 'bottom':
300
- legendArea.height += legendLayout.bounds.height + 4;
302
+ legendArea.height += legendLayout.bounds.height + gap;
301
303
  break;
302
304
  case 'right':
303
305
  case 'bottom-right':
@@ -272,8 +272,11 @@ export function computeAxes(
272
272
  }));
273
273
 
274
274
  // Thin tick labels to prevent overlap (skip for band scales which use
275
- // auto-rotation, and when the user set an explicit tickCount or values).
276
- const shouldThin = scales.x.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
275
+ // auto-rotation, and when the user set explicit tick values).
276
+ // When tickCount is set, we still thin if D3 overshot the requested count
277
+ // (common with log scales where ticks(4) can return 26 values).
278
+ const hasExplicitValues = !!axisConfig?.values;
279
+ const shouldThin = scales.x.type !== 'band' && !hasExplicitValues;
277
280
  let ticks: AxisTick[];
278
281
  if (!shouldThin) {
279
282
  ticks = allTicks;
@@ -352,10 +355,12 @@ export function computeAxes(
352
355
  allTicks = continuousTicks(scales.y, yDensity, yTargetCount);
353
356
  }
354
357
 
355
- // Thin tick labels to prevent overlap (skip for band scales, explicit tickCount, and values).
356
- const shouldThin = scales.y.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
358
+ // Thin tick labels to prevent overlap (skip for band scales and explicit tick values).
359
+ // When tickCount is set, we still thin if D3 overshot the requested count
360
+ // (common with log scales where ticks(4) can return 26 values).
361
+ const shouldThinY = scales.y.type !== 'band' && !axisConfig?.values;
357
362
  let ticks: AxisTick[];
358
- if (!shouldThin) {
363
+ if (!shouldThinY) {
359
364
  ticks = allTicks;
360
365
  } else if (isContinuousY) {
361
366
  // Continuous y-axis: re-request ticks at a lower count on overlap so
@@ -25,6 +25,7 @@ import { computeChrome, estimateTextWidth } from '@opendata-ai/openchart-core';
25
25
  import { format as d3Format } from 'd3-format';
26
26
 
27
27
  import type { NormalizedChartSpec, NormalizedChrome } from '../compiler/types';
28
+ import { legendGap } from '../legend/wrap';
28
29
 
29
30
  // ---------------------------------------------------------------------------
30
31
  // Types
@@ -251,7 +252,15 @@ export function computeDimensions(
251
252
  if (w > maxLabelWidth) maxLabelWidth = w;
252
253
  }
253
254
  if (maxLabelWidth > 0) {
254
- margins.left = Math.max(margins.left, padding + maxLabelWidth + 12);
255
+ // Tighter label-to-chart gap on narrow containers
256
+ const labelGap = width < 500 ? 8 : 12;
257
+ // Clamp reservation so bars keep at least ~45% of container width on
258
+ // narrow viewports. Labels that exceed the cap will be truncated by
259
+ // the axis renderer (see axes.ts).
260
+ const maxLeftFraction = width < 400 ? 0.45 : width < 600 ? 0.55 : 1;
261
+ const maxLeftReserved = Math.floor(width * maxLeftFraction);
262
+ const reserved = Math.min(padding + maxLabelWidth + labelGap, maxLeftReserved);
263
+ margins.left = Math.max(margins.left, reserved);
255
264
  }
256
265
  } else if (encoding.y.type === 'quantitative' || encoding.y.type === 'temporal') {
257
266
  // Numeric tick labels on the left. Estimate width from the data range.
@@ -305,12 +314,13 @@ export function computeDimensions(
305
314
 
306
315
  // Reserve legend space
307
316
  if (legendLayout.entries.length > 0) {
317
+ const gap = legendGap(width);
308
318
  if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
309
319
  margins.right += legendLayout.bounds.width + 8;
310
320
  } else if (legendLayout.position === 'top') {
311
- margins.top += legendLayout.bounds.height + 4;
321
+ margins.top += legendLayout.bounds.height + gap;
312
322
  } else if (legendLayout.position === 'bottom') {
313
- margins.bottom += legendLayout.bounds.height + 4;
323
+ margins.bottom += legendLayout.bounds.height + gap;
314
324
  }
315
325
  }
316
326
 
@@ -347,10 +357,11 @@ export function computeDimensions(
347
357
  const bottomDelta = margins.bottom - newBottom;
348
358
 
349
359
  if (topDelta > 0 || bottomDelta > 0) {
360
+ const gap = legendGap(width);
350
361
  margins.top =
351
362
  newTop +
352
363
  (legendLayout.entries.length > 0 && legendLayout.position === 'top'
353
- ? legendLayout.bounds.height + 4
364
+ ? legendLayout.bounds.height + gap
354
365
  : 0);
355
366
  margins.bottom = newBottom;
356
367
 
@@ -20,10 +20,10 @@ import type {
20
20
  ResolvedTheme,
21
21
  TextStyle,
22
22
  } from '@opendata-ai/openchart-core';
23
- import { BRAND_RESERVE_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
23
+ import { BRAND_RESERVE_WIDTH, COMPACT_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
24
24
 
25
25
  import type { NormalizedChartSpec } from '../compiler/types';
26
- import { ENTRY_GAP, measureLegendWrap, SWATCH_GAP, SWATCH_SIZE } from './wrap';
26
+ import { ENTRY_GAP, ENTRY_GAP_COMPACT, measureLegendWrap, SWATCH_GAP, SWATCH_SIZE } from './wrap';
27
27
 
28
28
  // ---------------------------------------------------------------------------
29
29
  // Constants
@@ -67,12 +67,22 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
67
67
  // Sequential (quantitative) color doesn't produce discrete legend entries
68
68
  if (colorEnc.type === 'quantitative') return [];
69
69
 
70
- const uniqueValues = [...new Set(spec.data.map((d) => String(d[colorEnc.field])))];
70
+ const dataValues = [...new Set(spec.data.map((d) => String(d[colorEnc.field])))];
71
71
  const explicitDomain = colorEnc.scale?.domain as string[] | undefined;
72
72
  const explicitRange = colorEnc.scale?.range as string[] | undefined;
73
73
  const palette = explicitRange ?? theme.colors.categorical;
74
74
  const shape = swatchShapeForType(spec.markType);
75
75
 
76
+ // Order legend entries by explicit domain when provided so the author
77
+ // controls which entries render first (and which get truncated last when
78
+ // symbolLimit applies). Without explicit domain, preserve data order.
79
+ const uniqueValues = explicitDomain
80
+ ? [
81
+ ...explicitDomain.filter((v) => dataValues.includes(v)),
82
+ ...dataValues.filter((v) => !explicitDomain.includes(v)),
83
+ ]
84
+ : dataValues;
85
+
76
86
  return uniqueValues.map((value, i) => {
77
87
  // When explicit domain+range are provided, look up the color by domain index
78
88
  // so legend colors match the mark colors exactly.
@@ -258,8 +268,12 @@ export function computeLegend(
258
268
  // Reserve space on the right for bottom legends so they don't overlap the brand
259
269
  // watermark. Top legends don't need this since the brand renders at the bottom.
260
270
  const reserveBrand = watermark && resolvedPosition === 'bottom';
271
+ // Tighten gaps on narrow viewports so horizontal legends keep fitting on one row.
272
+ const isCompact = chartArea.width < COMPACT_WIDTH;
273
+ const effectivePadding = isCompact ? 2 : LEGEND_PADDING;
274
+ const effectiveEntryGap = isCompact ? ENTRY_GAP_COMPACT : ENTRY_GAP;
261
275
  const availableWidth =
262
- chartArea.width - LEGEND_PADDING * 2 - (reserveBrand ? BRAND_RESERVE_WIDTH : 0);
276
+ chartArea.width - effectivePadding * 2 - (reserveBrand ? BRAND_RESERVE_WIDTH : 0);
263
277
 
264
278
  // Apply symbolLimit first if set (minimum 1), then fit remaining entries to available rows.
265
279
  if (spec.legend?.symbolLimit != null) {
@@ -276,7 +290,13 @@ export function computeLegend(
276
290
  : spec.legend?.columns != null
277
291
  ? Math.ceil(entries.length / spec.legend.columns)
278
292
  : TOP_LEGEND_MAX_ROWS;
279
- const { fittingCount } = measureLegendWrap(entries, availableWidth, labelStyle, maxRows);
293
+ const { fittingCount } = measureLegendWrap(
294
+ entries,
295
+ availableWidth,
296
+ labelStyle,
297
+ maxRows,
298
+ effectiveEntryGap,
299
+ );
280
300
 
281
301
  if (fittingCount < entries.length) {
282
302
  entries = truncateEntries(entries, fittingCount);
@@ -284,14 +304,20 @@ export function computeLegend(
284
304
 
285
305
  const totalWidth = entries.reduce((sum, entry) => {
286
306
  const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
287
- return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
307
+ return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + effectiveEntryGap;
288
308
  }, 0);
289
309
 
290
310
  // Calculate actual row count for height (recompute after truncation).
291
- const { rowCount } = measureLegendWrap(entries, availableWidth, labelStyle);
311
+ const { rowCount } = measureLegendWrap(
312
+ entries,
313
+ availableWidth,
314
+ labelStyle,
315
+ undefined,
316
+ effectiveEntryGap,
317
+ );
292
318
 
293
319
  const rowHeight = SWATCH_SIZE + 4;
294
- const legendHeight = rowCount * rowHeight + LEGEND_PADDING * 2;
320
+ const legendHeight = rowCount * rowHeight + effectivePadding * 2;
295
321
 
296
322
  // Apply user-provided legend offset
297
323
  const offsetDx = spec.legend?.offset?.dx ?? 0;
@@ -312,6 +338,6 @@ export function computeLegend(
312
338
  labelStyle,
313
339
  swatchSize: SWATCH_SIZE,
314
340
  swatchGap: SWATCH_GAP,
315
- entryGap: ENTRY_GAP,
341
+ entryGap: effectiveEntryGap,
316
342
  };
317
343
  }
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { LegendEntry, TextStyle } from '@opendata-ai/openchart-core';
18
- import { estimateTextWidth } from '@opendata-ai/openchart-core';
18
+ import { COMPACT_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
19
19
 
20
20
  // ---------------------------------------------------------------------------
21
21
  // Constants
@@ -28,6 +28,16 @@ import { estimateTextWidth } from '@opendata-ai/openchart-core';
28
28
  export const SWATCH_SIZE = 12;
29
29
  export const SWATCH_GAP = 6;
30
30
  export const ENTRY_GAP = 16;
31
+ /** Tighter inter-entry gap for narrow viewports where every pixel matters. */
32
+ export const ENTRY_GAP_COMPACT = 10;
33
+
34
+ /** Default gap between legend bounds and chart area. Zero on narrow viewports. */
35
+ export const LEGEND_GAP = 4;
36
+
37
+ /** Gap between legend and chart area, responsive to container width. */
38
+ export function legendGap(width: number): number {
39
+ return width < COMPACT_WIDTH ? 0 : LEGEND_GAP;
40
+ }
31
41
 
32
42
  // ---------------------------------------------------------------------------
33
43
  // Public API
@@ -55,6 +65,7 @@ export function measureLegendWrap(
55
65
  maxWidth: number,
56
66
  labelStyle: TextStyle,
57
67
  maxRows?: number,
68
+ entryGap: number = ENTRY_GAP,
58
69
  ): LegendWrapResult {
59
70
  if (entries.length === 0) {
60
71
  return { rowCount: 0, fittingCount: 0, rowWidths: [] };
@@ -72,7 +83,7 @@ export function measureLegendWrap(
72
83
  labelStyle.fontSize,
73
84
  labelStyle.fontWeight,
74
85
  );
75
- const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
86
+ const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + entryGap;
76
87
 
77
88
  if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
78
89
  rowWidths.push(rowWidth);
@@ -507,6 +507,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
507
507
  },
508
508
  animation: resolvedAnimation,
509
509
  watermark,
510
+ measureText: options.measureText,
510
511
  };
511
512
  }
512
513