@opendata-ai/openchart-engine 6.23.0 → 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/dist/index.js +3425 -420
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
- package/src/__tests__/axes.test.ts +25 -4
- package/src/__tests__/dimensions.test.ts +48 -0
- package/src/__tests__/legend.test.ts +52 -25
- package/src/charts/dot/__tests__/compute.test.ts +31 -0
- package/src/charts/dot/compute.ts +6 -1
- package/src/compile.ts +5 -3
- package/src/layout/axes.ts +10 -5
- package/src/layout/dimensions.ts +15 -4
- package/src/legend/compute.ts +35 -9
- package/src/legend/wrap.ts +13 -2
- package/src/sankey/compile-sankey.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
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",
|
|
@@ -30,7 +30,10 @@
|
|
|
30
30
|
"dist",
|
|
31
31
|
"src"
|
|
32
32
|
],
|
|
33
|
-
"sideEffects":
|
|
33
|
+
"sideEffects": [
|
|
34
|
+
"./src/charts/builtin.ts",
|
|
35
|
+
"./dist/index.js"
|
|
36
|
+
],
|
|
34
37
|
"keywords": [
|
|
35
38
|
"chart",
|
|
36
39
|
"visualization",
|
|
@@ -45,7 +48,7 @@
|
|
|
45
48
|
"typecheck": "tsc --noEmit"
|
|
46
49
|
},
|
|
47
50
|
"dependencies": {
|
|
48
|
-
"@opendata-ai/openchart-core": "6.
|
|
51
|
+
"@opendata-ai/openchart-core": "6.24.0",
|
|
49
52
|
"d3-array": "^3.2.0",
|
|
50
53
|
"d3-format": "^3.1.2",
|
|
51
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('
|
|
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
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
297
|
-
legendArea.height += legendLayout.bounds.height +
|
|
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 +
|
|
302
|
+
legendArea.height += legendLayout.bounds.height + gap;
|
|
301
303
|
break;
|
|
302
304
|
case 'right':
|
|
303
305
|
case 'bottom-right':
|
package/src/layout/axes.ts
CHANGED
|
@@ -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
|
|
276
|
-
|
|
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
|
|
356
|
-
|
|
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 (!
|
|
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
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -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
|
-
|
|
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 +
|
|
321
|
+
margins.top += legendLayout.bounds.height + gap;
|
|
312
322
|
} else if (legendLayout.position === 'bottom') {
|
|
313
|
-
margins.bottom += legendLayout.bounds.height +
|
|
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 +
|
|
364
|
+
? legendLayout.bounds.height + gap
|
|
354
365
|
: 0);
|
|
355
366
|
margins.bottom = newBottom;
|
|
356
367
|
|
package/src/legend/compute.ts
CHANGED
|
@@ -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
|
|
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 -
|
|
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(
|
|
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 +
|
|
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(
|
|
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 +
|
|
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:
|
|
341
|
+
entryGap: effectiveEntryGap,
|
|
316
342
|
};
|
|
317
343
|
}
|
package/src/legend/wrap.ts
CHANGED
|
@@ -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 +
|
|
86
|
+
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + entryGap;
|
|
76
87
|
|
|
77
88
|
if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
|
|
78
89
|
rowWidths.push(rowWidth);
|