@opendata-ai/openchart-engine 6.19.2 → 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 +105 -123
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +68 -0
- 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/layout/axes.ts +21 -4
- 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",
|
|
@@ -655,3 +655,71 @@ describe('axis config properties', () => {
|
|
|
655
655
|
expect(axes.x!.labelFlush).toBeUndefined();
|
|
656
656
|
});
|
|
657
657
|
});
|
|
658
|
+
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
// Y-axis produces ~5 ticks at standard sizes
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
|
|
663
|
+
describe('y-axis tick density', () => {
|
|
664
|
+
it('produces 5+ y-axis ticks at standard chart size with full density', () => {
|
|
665
|
+
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
666
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
667
|
+
|
|
668
|
+
// A 500x300 chart with domain [100, 500] should show ~5+ ticks, not just 2
|
|
669
|
+
expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(5);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('y-axis thinning uses vertical overlap, not horizontal text width', () => {
|
|
673
|
+
// Even with a measureText that reports very wide labels, y-axis should
|
|
674
|
+
// not thin aggressively because overlap is checked vertically
|
|
675
|
+
const wideMeasure = () => ({ width: 500, height: 12 });
|
|
676
|
+
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
677
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
|
|
678
|
+
|
|
679
|
+
// Wide label text shouldn't cause y-axis thinning (only height matters)
|
|
680
|
+
expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(5);
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// ---------------------------------------------------------------------------
|
|
685
|
+
// Vertical orientation overlap detection
|
|
686
|
+
// ---------------------------------------------------------------------------
|
|
687
|
+
|
|
688
|
+
describe('ticksOverlap with vertical orientation', () => {
|
|
689
|
+
const fontSize = 12;
|
|
690
|
+
const fontWeight = 400;
|
|
691
|
+
|
|
692
|
+
it('returns false when vertical ticks have sufficient spacing', () => {
|
|
693
|
+
// Labels at 30px intervals with 12px font (14.4px with lineHeight)
|
|
694
|
+
const ticks: AxisTick[] = [
|
|
695
|
+
{ value: 0, position: 0, label: '100' },
|
|
696
|
+
{ value: 1, position: 30, label: '200' },
|
|
697
|
+
{ value: 2, position: 60, label: '300' },
|
|
698
|
+
{ value: 3, position: 90, label: '400' },
|
|
699
|
+
{ value: 4, position: 120, label: '500' },
|
|
700
|
+
];
|
|
701
|
+
expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'vertical')).toBe(false);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it('returns true when vertical ticks are too close', () => {
|
|
705
|
+
// Labels at 10px intervals with 12px font - should overlap vertically
|
|
706
|
+
const ticks: AxisTick[] = [
|
|
707
|
+
{ value: 0, position: 0, label: '100' },
|
|
708
|
+
{ value: 1, position: 10, label: '200' },
|
|
709
|
+
{ value: 2, position: 20, label: '300' },
|
|
710
|
+
];
|
|
711
|
+
expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'vertical')).toBe(true);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('ignores label text width for vertical orientation', () => {
|
|
715
|
+
// Very wide labels but well-spaced vertically - should NOT overlap
|
|
716
|
+
const ticks: AxisTick[] = [
|
|
717
|
+
{ value: 0, position: 0, label: 'Very Long Label Text Here' },
|
|
718
|
+
{ value: 1, position: 40, label: 'Another Very Long Label' },
|
|
719
|
+
{ value: 2, position: 80, label: 'Yet Another Long Label' },
|
|
720
|
+
];
|
|
721
|
+
// Horizontal would detect overlap, vertical should not
|
|
722
|
+
expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'horizontal')).toBe(true);
|
|
723
|
+
expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'vertical')).toBe(false);
|
|
724
|
+
});
|
|
725
|
+
});
|
|
@@ -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/layout/axes.ts
CHANGED
|
@@ -122,15 +122,31 @@ function measureLabel(
|
|
|
122
122
|
: estimateTextWidth(text, fontSize, fontWeight);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
/** Check whether any adjacent tick labels overlap
|
|
125
|
+
/** Check whether any adjacent tick labels overlap along the axis direction. */
|
|
126
126
|
export function ticksOverlap(
|
|
127
127
|
ticks: AxisTick[],
|
|
128
128
|
fontSize: number,
|
|
129
129
|
fontWeight: number,
|
|
130
130
|
measureText?: MeasureTextFn,
|
|
131
|
+
orientation: 'horizontal' | 'vertical' = 'horizontal',
|
|
131
132
|
): boolean {
|
|
132
133
|
if (ticks.length < 2) return false;
|
|
133
134
|
const minGap = fontSize * MIN_TICK_GAP_FACTOR;
|
|
135
|
+
|
|
136
|
+
if (orientation === 'vertical') {
|
|
137
|
+
// Y-axis: labels are stacked vertically. Check if vertical extent
|
|
138
|
+
// (based on font height) overlaps between adjacent ticks.
|
|
139
|
+
// Positions decrease going up in SVG coords, so sort ascending.
|
|
140
|
+
const sorted = [...ticks].sort((a, b) => a.position - b.position);
|
|
141
|
+
const labelHeight = fontSize * 1.2; // lineHeight
|
|
142
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
143
|
+
const aBottom = sorted[i].position + labelHeight / 2;
|
|
144
|
+
const bTop = sorted[i + 1].position - labelHeight / 2;
|
|
145
|
+
if (aBottom + minGap > bTop) return true;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
134
150
|
for (let i = 0; i < ticks.length - 1; i++) {
|
|
135
151
|
const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText);
|
|
136
152
|
const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText);
|
|
@@ -151,8 +167,9 @@ export function thinTicksUntilFit(
|
|
|
151
167
|
fontSize: number,
|
|
152
168
|
fontWeight: number,
|
|
153
169
|
measureText?: MeasureTextFn,
|
|
170
|
+
orientation: 'horizontal' | 'vertical' = 'horizontal',
|
|
154
171
|
): AxisTick[] {
|
|
155
|
-
if (!ticksOverlap(ticks, fontSize, fontWeight, measureText)) return ticks;
|
|
172
|
+
if (!ticksOverlap(ticks, fontSize, fontWeight, measureText, orientation)) return ticks;
|
|
156
173
|
|
|
157
174
|
let current = ticks;
|
|
158
175
|
while (current.length > MIN_TICK_COUNT) {
|
|
@@ -164,7 +181,7 @@ export function thinTicksUntilFit(
|
|
|
164
181
|
if (current.length > 1) thinned.push(current[current.length - 1]);
|
|
165
182
|
current = thinned;
|
|
166
183
|
|
|
167
|
-
if (!ticksOverlap(current, fontSize, fontWeight, measureText)) break;
|
|
184
|
+
if (!ticksOverlap(current, fontSize, fontWeight, measureText, orientation)) break;
|
|
168
185
|
}
|
|
169
186
|
return current;
|
|
170
187
|
}
|
|
@@ -455,7 +472,7 @@ export function computeAxes(
|
|
|
455
472
|
// Thin tick labels to prevent overlap (skip for band scales, explicit tickCount, and values).
|
|
456
473
|
const shouldThin = scales.y.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
|
|
457
474
|
const ticks = shouldThin
|
|
458
|
-
? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText)
|
|
475
|
+
? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText, 'vertical')
|
|
459
476
|
: allTicks;
|
|
460
477
|
|
|
461
478
|
// Gridlines match the tick set so every gridline has a 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;
|