@opendata-ai/openchart-engine 6.19.3 → 6.20.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 +90 -118
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/legend.test.ts +39 -0
- package/src/charts/_shared/format-label-value.ts +15 -0
- package/src/charts/bar/__tests__/gradient-orient.test.ts +127 -0
- package/src/charts/bar/compute.ts +6 -11
- package/src/charts/bar/labels.ts +2 -9
- package/src/charts/column/compute.ts +6 -11
- package/src/charts/column/labels.ts +2 -13
- package/src/charts/dot/labels.ts +2 -13
- package/src/legend/compute.ts +6 -51
- package/src/legend/wrap.ts +94 -0
- package/src/sankey/__tests__/node-label-wrap.test.ts +114 -0
- package/src/sankey/__tests__/node-sort.test.ts +45 -0
- package/src/sankey/compile-sankey.ts +5 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.20.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",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"typecheck": "tsc --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@opendata-ai/openchart-core": "6.
|
|
48
|
+
"@opendata-ai/openchart-core": "6.20.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { LayoutStrategy, Rect, ResolvedTheme } from '@opendata-ai/openchart-core';
|
|
2
2
|
import { resolveTheme } from '@opendata-ai/openchart-core';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { compileChart } from '../compile';
|
|
4
5
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
5
6
|
import { computeLegend } from '../legend/compute';
|
|
6
7
|
|
|
@@ -393,4 +394,42 @@ describe('computeLegend', () => {
|
|
|
393
394
|
expect(legend.bounds.width).toBe(0);
|
|
394
395
|
});
|
|
395
396
|
});
|
|
397
|
+
|
|
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
|
+
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
|
+
};
|
|
422
|
+
|
|
423
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
424
|
+
|
|
425
|
+
expect(layout.legend.position).toBe('top');
|
|
426
|
+
expect(layout.legend.entries.length).toBeGreaterThan(0);
|
|
427
|
+
expect(layout.legend.bounds.height).toBeGreaterThan(0);
|
|
428
|
+
|
|
429
|
+
const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
|
|
430
|
+
const gap = layout.area.y - legendBottom;
|
|
431
|
+
// Pin value matches the literal at compile.ts:331 (legendArea.y -= legendLayout.bounds.height + 4)
|
|
432
|
+
expect(gap).toBe(4);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
396
435
|
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared numeric value formatter for data labels.
|
|
3
|
+
*
|
|
4
|
+
* Used by bar, column, and dot label computation to display a value:
|
|
5
|
+
* abbreviated (K/M/B/T) for magnitudes >= 1000, otherwise the default
|
|
6
|
+
* numeric format.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
|
|
10
|
+
|
|
11
|
+
/** Format a label value for display (abbreviate large numbers). */
|
|
12
|
+
export function formatLabelValue(value: number): string {
|
|
13
|
+
if (Math.abs(value) >= 1000) return abbreviateNumber(value);
|
|
14
|
+
return formatNumber(value);
|
|
15
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Characterization tests for horizontal-bar gradient auto-orientation.
|
|
3
|
+
*
|
|
4
|
+
* Part of refactor/v7-cohesion step 1. Pins the behavior of
|
|
5
|
+
* `orientGradientForHorizontalBar` in `packages/engine/src/charts/bar/compute.ts`:
|
|
6
|
+
* when a user supplies the default vertical gradient (top-to-bottom) as a
|
|
7
|
+
* horizontal-bar fill, the engine rotates it to horizontal (left-to-right) so
|
|
8
|
+
* the gradient follows the bar's data direction. Vertical bars (columns) keep
|
|
9
|
+
* the gradient unchanged. Explicit non-default coordinates are never rewritten.
|
|
10
|
+
*
|
|
11
|
+
* These tests protect the behavior through upcoming refactors that may consolidate
|
|
12
|
+
* gradient logic across bar/column/stacked variants.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ChartSpec, LinearGradient, RectMark } from '@opendata-ai/openchart-core';
|
|
16
|
+
import { isGradientDef } from '@opendata-ai/openchart-core';
|
|
17
|
+
import { describe, expect, it } from 'vitest';
|
|
18
|
+
import { compileChart } from '../../../compile';
|
|
19
|
+
|
|
20
|
+
// A default-vertical linear gradient (no explicit coords — defaults resolve to
|
|
21
|
+
// x1:0, y1:0, x2:0, y2:1 which is the "top-to-bottom" default).
|
|
22
|
+
const defaultVerticalGradient: LinearGradient = {
|
|
23
|
+
gradient: 'linear',
|
|
24
|
+
stops: [
|
|
25
|
+
{ offset: 0, color: '#1b7fa3', opacity: 0.4 },
|
|
26
|
+
{ offset: 1, color: '#1b7fa3' },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function firstGradientRectFill(marks: RectMark[]): LinearGradient {
|
|
31
|
+
const mark = marks.find((m) => m.type === 'rect' && isGradientDef(m.fill));
|
|
32
|
+
if (!mark) throw new Error('expected at least one RectMark with a gradient fill');
|
|
33
|
+
const fill = mark.fill;
|
|
34
|
+
if (!isGradientDef(fill) || fill.gradient !== 'linear') {
|
|
35
|
+
throw new Error('expected a linear gradient fill');
|
|
36
|
+
}
|
|
37
|
+
return fill as LinearGradient;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('horizontal bar gradient auto-orientation', () => {
|
|
41
|
+
it('rotates the default vertical gradient to horizontal on horizontal bars', () => {
|
|
42
|
+
const spec: ChartSpec = {
|
|
43
|
+
mark: { type: 'bar', fill: defaultVerticalGradient },
|
|
44
|
+
data: [
|
|
45
|
+
{ category: 'A', value: 10 },
|
|
46
|
+
{ category: 'B', value: 20 },
|
|
47
|
+
{ category: 'C', value: 30 },
|
|
48
|
+
],
|
|
49
|
+
// Horizontal bar: x is quantitative, y is nominal
|
|
50
|
+
encoding: {
|
|
51
|
+
x: { field: 'value', type: 'quantitative' },
|
|
52
|
+
y: { field: 'category', type: 'nominal' },
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
57
|
+
const grad = firstGradientRectFill(layout.marks as RectMark[]);
|
|
58
|
+
|
|
59
|
+
// Horizontal gradient: x1 != x2, y1 == y2
|
|
60
|
+
expect(grad.x1).toBe(0);
|
|
61
|
+
expect(grad.y1).toBe(0);
|
|
62
|
+
expect(grad.x2).toBe(1);
|
|
63
|
+
expect(grad.y2).toBe(0);
|
|
64
|
+
expect(grad.x1).not.toBe(grad.x2);
|
|
65
|
+
expect(grad.y1).toBe(grad.y2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('leaves the default vertical gradient unchanged on vertical (column) bars', () => {
|
|
69
|
+
const spec: ChartSpec = {
|
|
70
|
+
mark: { type: 'bar', fill: defaultVerticalGradient },
|
|
71
|
+
data: [
|
|
72
|
+
{ category: 'A', value: 10 },
|
|
73
|
+
{ category: 'B', value: 20 },
|
|
74
|
+
{ category: 'C', value: 30 },
|
|
75
|
+
],
|
|
76
|
+
// Vertical bar (column): x is nominal, y is quantitative
|
|
77
|
+
encoding: {
|
|
78
|
+
x: { field: 'category', type: 'nominal' },
|
|
79
|
+
y: { field: 'value', type: 'quantitative' },
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
84
|
+
const grad = firstGradientRectFill(layout.marks as RectMark[]);
|
|
85
|
+
|
|
86
|
+
// Vertical gradient preserved: x1 == x2 (both 0), y1 != y2 (0 vs 1)
|
|
87
|
+
expect(grad.x1 ?? 0).toBe(0);
|
|
88
|
+
expect(grad.x2 ?? 0).toBe(0);
|
|
89
|
+
expect(grad.y1 ?? 0).toBe(0);
|
|
90
|
+
expect(grad.y2 ?? 1).toBe(1);
|
|
91
|
+
expect(grad.x1 ?? 0).toBe(grad.x2 ?? 0);
|
|
92
|
+
expect(grad.y1 ?? 0).not.toBe(grad.y2 ?? 1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('leaves an explicitly-oriented gradient unchanged on horizontal bars', () => {
|
|
96
|
+
// Explicit non-default direction — user knows what they want, the engine
|
|
97
|
+
// must not rewrite it. Here we pass a diagonal gradient.
|
|
98
|
+
const explicitDiagonal: LinearGradient = {
|
|
99
|
+
gradient: 'linear',
|
|
100
|
+
x1: 0,
|
|
101
|
+
y1: 0,
|
|
102
|
+
x2: 1,
|
|
103
|
+
y2: 1,
|
|
104
|
+
stops: [
|
|
105
|
+
{ offset: 0, color: '#1b7fa3' },
|
|
106
|
+
{ offset: 1, color: '#ff6600' },
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const spec: ChartSpec = {
|
|
111
|
+
mark: { type: 'bar', fill: explicitDiagonal },
|
|
112
|
+
data: [{ category: 'A', value: 10 }],
|
|
113
|
+
encoding: {
|
|
114
|
+
x: { field: 'value', type: 'quantitative' },
|
|
115
|
+
y: { field: 'category', type: 'nominal' },
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
120
|
+
const grad = firstGradientRectFill(layout.marks as RectMark[]);
|
|
121
|
+
|
|
122
|
+
expect(grad.x1).toBe(0);
|
|
123
|
+
expect(grad.y1).toBe(0);
|
|
124
|
+
expect(grad.x2).toBe(1);
|
|
125
|
+
expect(grad.y2).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -17,11 +17,12 @@ import type {
|
|
|
17
17
|
Rect,
|
|
18
18
|
RectMark,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
|
-
import {
|
|
20
|
+
import { isGradientDef } from '@opendata-ai/openchart-core';
|
|
21
21
|
import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
22
22
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
23
23
|
import type { ResolvedScales } from '../../layout/scales';
|
|
24
24
|
import { isConditionalValueDef, resolveConditionalValue } from '../../transforms/conditional';
|
|
25
|
+
import { formatLabelValue } from '../_shared/format-label-value';
|
|
25
26
|
import { getColor, getSequentialColor, groupByField } from '../utils';
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -53,12 +54,6 @@ function orientGradientForHorizontalBar(grad: GradientDef): GradientDef {
|
|
|
53
54
|
|
|
54
55
|
const MIN_BAR_WIDTH = 1;
|
|
55
56
|
|
|
56
|
-
/** Format a bar value for display (abbreviate large numbers). */
|
|
57
|
-
function formatBarValue(value: number): string {
|
|
58
|
-
if (Math.abs(value) >= 1000) return abbreviateNumber(value);
|
|
59
|
-
return formatNumber(value);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
57
|
// ---------------------------------------------------------------------------
|
|
63
58
|
// Public API
|
|
64
59
|
// ---------------------------------------------------------------------------
|
|
@@ -221,7 +216,7 @@ function computeStackedBars(
|
|
|
221
216
|
const barWidth = Math.max(Math.abs(xRight - xLeft), MIN_BAR_WIDTH);
|
|
222
217
|
|
|
223
218
|
const aria: MarkAria = {
|
|
224
|
-
label: `${category}, ${groupKey}: ${
|
|
219
|
+
label: `${category}, ${groupKey}: ${formatLabelValue(rawValue)}`,
|
|
225
220
|
};
|
|
226
221
|
|
|
227
222
|
marks.push({
|
|
@@ -291,7 +286,7 @@ function computeGroupedBars(
|
|
|
291
286
|
const subY = bandY + groupIndex * (subBandHeight + gap);
|
|
292
287
|
|
|
293
288
|
const aria: MarkAria = {
|
|
294
|
-
label: `${category}, ${groupKey}: ${
|
|
289
|
+
label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
|
|
295
290
|
};
|
|
296
291
|
|
|
297
292
|
marks.push({
|
|
@@ -341,7 +336,7 @@ function computeColoredBars(
|
|
|
341
336
|
const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
|
|
342
337
|
|
|
343
338
|
const aria: MarkAria = {
|
|
344
|
-
label: `${category}, ${groupKey}: ${
|
|
339
|
+
label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
|
|
345
340
|
};
|
|
346
341
|
|
|
347
342
|
marks.push({
|
|
@@ -401,7 +396,7 @@ function computeSimpleBars(
|
|
|
401
396
|
const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
|
|
402
397
|
|
|
403
398
|
const aria: MarkAria = {
|
|
404
|
-
label: `${category}: ${
|
|
399
|
+
label: `${category}: ${formatLabelValue(value)}`,
|
|
405
400
|
};
|
|
406
401
|
|
|
407
402
|
marks.push({
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -18,24 +18,17 @@ import type {
|
|
|
18
18
|
ResolvedLabel,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
20
|
import {
|
|
21
|
-
abbreviateNumber,
|
|
22
21
|
buildD3Formatter,
|
|
23
22
|
estimateTextWidth,
|
|
24
|
-
formatNumber,
|
|
25
23
|
getRepresentativeColor,
|
|
26
24
|
resolveCollisions,
|
|
27
25
|
} from '@opendata-ai/openchart-core';
|
|
26
|
+
import { formatLabelValue } from '../_shared/format-label-value';
|
|
28
27
|
|
|
29
28
|
// ---------------------------------------------------------------------------
|
|
30
29
|
// Helpers
|
|
31
30
|
// ---------------------------------------------------------------------------
|
|
32
31
|
|
|
33
|
-
/** Format a bar value for display (abbreviate large numbers). */
|
|
34
|
-
function formatBarValue(value: number): string {
|
|
35
|
-
if (Math.abs(value) >= 1000) return abbreviateNumber(value);
|
|
36
|
-
return formatNumber(value);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
32
|
/** Suffix multipliers mirroring core's abbreviateNumber output (K/M/B/T). */
|
|
40
33
|
const SUFFIX_MULTIPLIERS: Record<string, number> = {
|
|
41
34
|
K: 1_000,
|
|
@@ -124,7 +117,7 @@ export function computeBarLabels(
|
|
|
124
117
|
if (formatter && Number.isFinite(rawNum)) {
|
|
125
118
|
valuePart = formatter(rawNum);
|
|
126
119
|
} else if (Number.isFinite(rawNum)) {
|
|
127
|
-
valuePart =
|
|
120
|
+
valuePart = formatLabelValue(rawNum);
|
|
128
121
|
} else {
|
|
129
122
|
// Fallback: extract from aria label
|
|
130
123
|
const ariaLabel = mark.aria.label;
|
|
@@ -20,11 +20,12 @@ import type {
|
|
|
20
20
|
Rect,
|
|
21
21
|
RectMark,
|
|
22
22
|
} from '@opendata-ai/openchart-core';
|
|
23
|
-
import {
|
|
23
|
+
import { isGradientDef } from '@opendata-ai/openchart-core';
|
|
24
24
|
import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
25
25
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
26
26
|
import type { ResolvedScales } from '../../layout/scales';
|
|
27
27
|
import { isConditionalValueDef, resolveConditionalValue } from '../../transforms/conditional';
|
|
28
|
+
import { formatLabelValue } from '../_shared/format-label-value';
|
|
28
29
|
import { getColor, getSequentialColor, groupByField } from '../utils';
|
|
29
30
|
|
|
30
31
|
// ---------------------------------------------------------------------------
|
|
@@ -33,12 +34,6 @@ import { getColor, getSequentialColor, groupByField } from '../utils';
|
|
|
33
34
|
|
|
34
35
|
const MIN_COLUMN_HEIGHT = 1;
|
|
35
36
|
|
|
36
|
-
/** Format a column value for display (abbreviate large numbers). */
|
|
37
|
-
function formatColumnValue(value: number): string {
|
|
38
|
-
if (Math.abs(value) >= 1000) return abbreviateNumber(value);
|
|
39
|
-
return formatNumber(value);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
37
|
// ---------------------------------------------------------------------------
|
|
43
38
|
// Public API
|
|
44
39
|
// ---------------------------------------------------------------------------
|
|
@@ -199,7 +194,7 @@ function computeSimpleColumns(
|
|
|
199
194
|
const y = value >= 0 ? yPos : baseline;
|
|
200
195
|
|
|
201
196
|
const aria: MarkAria = {
|
|
202
|
-
label: `${category}: ${
|
|
197
|
+
label: `${category}: ${formatLabelValue(value)}`,
|
|
203
198
|
};
|
|
204
199
|
|
|
205
200
|
marks.push({
|
|
@@ -250,7 +245,7 @@ function computeColoredColumns(
|
|
|
250
245
|
const y = value >= 0 ? yPos : baseline;
|
|
251
246
|
|
|
252
247
|
const aria: MarkAria = {
|
|
253
|
-
label: `${category}, ${groupKey}: ${
|
|
248
|
+
label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
|
|
254
249
|
};
|
|
255
250
|
|
|
256
251
|
marks.push({
|
|
@@ -320,7 +315,7 @@ function computeGroupedColumns(
|
|
|
320
315
|
const subX = bandX + groupIndex * (subBandWidth + gap);
|
|
321
316
|
|
|
322
317
|
const aria: MarkAria = {
|
|
323
|
-
label: `${category}, ${groupKey}: ${
|
|
318
|
+
label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
|
|
324
319
|
};
|
|
325
320
|
|
|
326
321
|
marks.push({
|
|
@@ -389,7 +384,7 @@ function computeStackedColumns(
|
|
|
389
384
|
const columnHeight = Math.max(Math.abs(yBottom - yTop), MIN_COLUMN_HEIGHT);
|
|
390
385
|
|
|
391
386
|
const aria: MarkAria = {
|
|
392
|
-
label: `${category}, ${groupKey}: ${
|
|
387
|
+
label: `${category}, ${groupKey}: ${formatLabelValue(rawValue)}`,
|
|
393
388
|
};
|
|
394
389
|
|
|
395
390
|
marks.push({
|
|
@@ -18,23 +18,12 @@ import type {
|
|
|
18
18
|
ResolvedLabel,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
20
|
import {
|
|
21
|
-
abbreviateNumber,
|
|
22
21
|
buildD3Formatter,
|
|
23
22
|
estimateTextWidth,
|
|
24
|
-
formatNumber,
|
|
25
23
|
getRepresentativeColor,
|
|
26
24
|
resolveCollisions,
|
|
27
25
|
} from '@opendata-ai/openchart-core';
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Helpers
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
/** Format a column value for display (abbreviate large numbers). */
|
|
34
|
-
function formatColumnValue(value: number): string {
|
|
35
|
-
if (Math.abs(value) >= 1000) return abbreviateNumber(value);
|
|
36
|
-
return formatNumber(value);
|
|
37
|
-
}
|
|
26
|
+
import { formatLabelValue } from '../_shared/format-label-value';
|
|
38
27
|
|
|
39
28
|
// ---------------------------------------------------------------------------
|
|
40
29
|
// Constants
|
|
@@ -82,7 +71,7 @@ export function computeColumnLabels(
|
|
|
82
71
|
if (formatter && Number.isFinite(rawNum)) {
|
|
83
72
|
valuePart = formatter(rawNum);
|
|
84
73
|
} else if (Number.isFinite(rawNum)) {
|
|
85
|
-
valuePart =
|
|
74
|
+
valuePart = formatLabelValue(rawNum);
|
|
86
75
|
} else {
|
|
87
76
|
// Fallback: extract from aria label
|
|
88
77
|
const ariaLabel = mark.aria.label;
|
package/src/charts/dot/labels.ts
CHANGED
|
@@ -18,23 +18,12 @@ import type {
|
|
|
18
18
|
ResolvedLabel,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
20
|
import {
|
|
21
|
-
abbreviateNumber,
|
|
22
21
|
buildD3Formatter,
|
|
23
22
|
estimateTextWidth,
|
|
24
|
-
formatNumber,
|
|
25
23
|
getRepresentativeColor,
|
|
26
24
|
resolveCollisions,
|
|
27
25
|
} from '@opendata-ai/openchart-core';
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Helpers
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
/** Format a dot value for display (abbreviate large numbers). */
|
|
34
|
-
function formatDotValue(value: number): string {
|
|
35
|
-
if (Math.abs(value) >= 1000) return abbreviateNumber(value);
|
|
36
|
-
return formatNumber(value);
|
|
37
|
-
}
|
|
26
|
+
import { formatLabelValue } from '../_shared/format-label-value';
|
|
38
27
|
|
|
39
28
|
// ---------------------------------------------------------------------------
|
|
40
29
|
// Constants
|
|
@@ -81,7 +70,7 @@ export function computeDotLabels(
|
|
|
81
70
|
if (formatter && Number.isFinite(rawNum)) {
|
|
82
71
|
valuePart = formatter(rawNum);
|
|
83
72
|
} else if (Number.isFinite(rawNum)) {
|
|
84
|
-
valuePart =
|
|
73
|
+
valuePart = formatLabelValue(rawNum);
|
|
85
74
|
} else {
|
|
86
75
|
// Fallback: extract from aria label
|
|
87
76
|
const ariaLabel = mark.aria.label;
|
package/src/legend/compute.ts
CHANGED
|
@@ -23,14 +23,12 @@ import type {
|
|
|
23
23
|
import { BRAND_RESERVE_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
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
// Constants
|
|
29
30
|
// ---------------------------------------------------------------------------
|
|
30
31
|
|
|
31
|
-
const SWATCH_SIZE = 12;
|
|
32
|
-
const SWATCH_GAP = 6;
|
|
33
|
-
const ENTRY_GAP = 16;
|
|
34
32
|
const LEGEND_PADDING = 8;
|
|
35
33
|
const LEGEND_RIGHT_WIDTH = 120;
|
|
36
34
|
|
|
@@ -92,38 +90,6 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
|
|
|
92
90
|
});
|
|
93
91
|
}
|
|
94
92
|
|
|
95
|
-
/**
|
|
96
|
-
* Calculate how many entries fit within a given number of horizontal rows.
|
|
97
|
-
*/
|
|
98
|
-
function entriesThatFit(
|
|
99
|
-
entries: LegendEntry[],
|
|
100
|
-
maxWidth: number,
|
|
101
|
-
maxRows: number,
|
|
102
|
-
labelStyle: TextStyle,
|
|
103
|
-
): number {
|
|
104
|
-
let row = 1;
|
|
105
|
-
let rowWidth = 0;
|
|
106
|
-
|
|
107
|
-
for (let i = 0; i < entries.length; i++) {
|
|
108
|
-
const labelWidth = estimateTextWidth(
|
|
109
|
-
entries[i].label,
|
|
110
|
-
labelStyle.fontSize,
|
|
111
|
-
labelStyle.fontWeight,
|
|
112
|
-
);
|
|
113
|
-
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
|
|
114
|
-
|
|
115
|
-
if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
|
|
116
|
-
row++;
|
|
117
|
-
rowWidth = entryWidth;
|
|
118
|
-
if (row > maxRows) return i;
|
|
119
|
-
} else {
|
|
120
|
-
rowWidth += entryWidth;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return entries.length;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
93
|
/**
|
|
128
94
|
* Truncate entries and add a "+N more" indicator if needed.
|
|
129
95
|
*/
|
|
@@ -310,10 +276,10 @@ export function computeLegend(
|
|
|
310
276
|
: spec.legend?.columns != null
|
|
311
277
|
? Math.ceil(entries.length / spec.legend.columns)
|
|
312
278
|
: TOP_LEGEND_MAX_ROWS;
|
|
313
|
-
const
|
|
279
|
+
const { fittingCount } = measureLegendWrap(entries, availableWidth, labelStyle, maxRows);
|
|
314
280
|
|
|
315
|
-
if (
|
|
316
|
-
entries = truncateEntries(entries,
|
|
281
|
+
if (fittingCount < entries.length) {
|
|
282
|
+
entries = truncateEntries(entries, fittingCount);
|
|
317
283
|
}
|
|
318
284
|
|
|
319
285
|
const totalWidth = entries.reduce((sum, entry) => {
|
|
@@ -321,19 +287,8 @@ export function computeLegend(
|
|
|
321
287
|
return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
|
|
322
288
|
}, 0);
|
|
323
289
|
|
|
324
|
-
// Calculate actual row count for height
|
|
325
|
-
|
|
326
|
-
let rowWidth = 0;
|
|
327
|
-
for (const entry of entries) {
|
|
328
|
-
const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
329
|
-
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
|
|
330
|
-
if (rowWidth + entryWidth > availableWidth && rowWidth > 0) {
|
|
331
|
-
rowCount++;
|
|
332
|
-
rowWidth = entryWidth;
|
|
333
|
-
} else {
|
|
334
|
-
rowWidth += entryWidth;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
290
|
+
// Calculate actual row count for height (recompute after truncation).
|
|
291
|
+
const { rowCount } = measureLegendWrap(entries, availableWidth, labelStyle);
|
|
337
292
|
|
|
338
293
|
const rowHeight = SWATCH_SIZE + 4;
|
|
339
294
|
const legendHeight = rowCount * rowHeight + LEGEND_PADDING * 2;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legend row-wrap geometry.
|
|
3
|
+
*
|
|
4
|
+
* Shared helper for measuring how legend entries flow across horizontal rows
|
|
5
|
+
* when wrapped at a max width. Both the main legend compute and the sankey
|
|
6
|
+
* legend compile use this to size their legends — the main legend uses
|
|
7
|
+
* `fittingCount` for truncation decisions, while sankey uses `rowCount` to
|
|
8
|
+
* reserve vertical height.
|
|
9
|
+
*
|
|
10
|
+
* The geometry matches the existing layout exactly: each entry occupies
|
|
11
|
+
* SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP pixels, a new row is
|
|
12
|
+
* started when the accumulated row width plus the next entry would exceed
|
|
13
|
+
* maxWidth (and the current row is non-empty), and rowWidths captures the
|
|
14
|
+
* in-row accumulated width at the point of wrapping.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { LegendEntry, TextStyle } from '@opendata-ai/openchart-core';
|
|
18
|
+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Constants
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
//
|
|
24
|
+
// Single source of truth for legend row geometry. Both compute.ts and the
|
|
25
|
+
// sankey compile site import these so the wrap math here can never drift from
|
|
26
|
+
// the layout math at the call sites.
|
|
27
|
+
|
|
28
|
+
export const SWATCH_SIZE = 12;
|
|
29
|
+
export const SWATCH_GAP = 6;
|
|
30
|
+
export const ENTRY_GAP = 16;
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Public API
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export interface LegendWrapResult {
|
|
37
|
+
/** Total number of rows the entries occupy when wrapped at maxWidth. */
|
|
38
|
+
rowCount: number;
|
|
39
|
+
/** Entries that fit within maxRows (for truncation). Equals entries.length when maxRows is not set or all entries fit. */
|
|
40
|
+
fittingCount: number;
|
|
41
|
+
/** Width (in px) of each row — callers can use for alignment. */
|
|
42
|
+
rowWidths: number[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Measure how legend entries wrap across rows at a given max width.
|
|
47
|
+
*
|
|
48
|
+
* @param entries - Legend entries to measure.
|
|
49
|
+
* @param maxWidth - Maximum width (in px) available for a single row.
|
|
50
|
+
* @param labelStyle - Text style used to estimate label widths.
|
|
51
|
+
* @param maxRows - Optional cap used only for the `fittingCount` truncation decision. When provided, `fittingCount` will be the index of the first entry that would spill onto a row beyond `maxRows`. `rowCount` is always the real row count regardless of this cap.
|
|
52
|
+
*/
|
|
53
|
+
export function measureLegendWrap(
|
|
54
|
+
entries: LegendEntry[],
|
|
55
|
+
maxWidth: number,
|
|
56
|
+
labelStyle: TextStyle,
|
|
57
|
+
maxRows?: number,
|
|
58
|
+
): LegendWrapResult {
|
|
59
|
+
if (entries.length === 0) {
|
|
60
|
+
return { rowCount: 0, fittingCount: 0, rowWidths: [] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let rowCount = 1;
|
|
64
|
+
let rowWidth = 0;
|
|
65
|
+
const rowWidths: number[] = [];
|
|
66
|
+
let fittingCount = entries.length;
|
|
67
|
+
let fittingCountLocked = false;
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < entries.length; i++) {
|
|
70
|
+
const labelWidth = estimateTextWidth(
|
|
71
|
+
entries[i].label,
|
|
72
|
+
labelStyle.fontSize,
|
|
73
|
+
labelStyle.fontWeight,
|
|
74
|
+
);
|
|
75
|
+
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
|
|
76
|
+
|
|
77
|
+
if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
|
|
78
|
+
rowWidths.push(rowWidth);
|
|
79
|
+
rowCount++;
|
|
80
|
+
rowWidth = entryWidth;
|
|
81
|
+
if (!fittingCountLocked && maxRows != null && rowCount > maxRows) {
|
|
82
|
+
fittingCount = i;
|
|
83
|
+
fittingCountLocked = true;
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
rowWidth += entryWidth;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Flush the final row width so rowWidths has one entry per row.
|
|
91
|
+
rowWidths.push(rowWidth);
|
|
92
|
+
|
|
93
|
+
return { rowCount, fittingCount, rowWidths };
|
|
94
|
+
}
|