@opendata-ai/openchart-engine 6.13.0 → 6.15.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 +178 -76
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/encoding-sugar.test.ts +3 -2
- package/src/charts/bar/index.ts +3 -0
- package/src/charts/bar/labels.ts +31 -13
- package/src/charts/column/index.ts +3 -0
- package/src/charts/column/labels.ts +35 -13
- package/src/charts/dot/index.ts +10 -1
- package/src/charts/dot/labels.ts +37 -6
- package/src/charts/line/area.ts +12 -4
- package/src/charts/line/compute.ts +7 -2
- package/src/charts/line/index.ts +33 -2
- package/src/compile.ts +1 -0
- package/src/layout/axes.ts +2 -2
- package/src/layout/scales.ts +15 -12
- package/src/legend/compute.ts +2 -4
- package/src/tooltips/compute.ts +29 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.15.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.15.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -234,11 +234,12 @@ describe('categorical sort', () => {
|
|
|
234
234
|
seriesStyles: {},
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
-
it('
|
|
237
|
+
it('preserves data order by default (undefined sort)', () => {
|
|
238
238
|
const spec = makeBarSpec(undefined);
|
|
239
239
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
240
240
|
const domain = (scales.y!.scale as ScaleBand<string>).domain();
|
|
241
|
-
|
|
241
|
+
// Data order: Banana, Apple, Cherry
|
|
242
|
+
expect(domain).toEqual(['Banana', 'Apple', 'Cherry']);
|
|
242
243
|
});
|
|
243
244
|
|
|
244
245
|
it('sorts domain ascending when sort is "ascending"', () => {
|
package/src/charts/bar/index.ts
CHANGED
|
@@ -17,12 +17,15 @@ export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
|
|
|
17
17
|
const marks = computeBarMarks(spec, scales, chartArea, strategy);
|
|
18
18
|
|
|
19
19
|
// Compute and attach value labels (respects spec.labels.density)
|
|
20
|
+
const valueField =
|
|
21
|
+
spec.encoding?.x && 'field' in spec.encoding.x ? spec.encoding.x.field : undefined;
|
|
20
22
|
const labels = computeBarLabels(
|
|
21
23
|
marks,
|
|
22
24
|
chartArea,
|
|
23
25
|
spec.labels.density,
|
|
24
26
|
spec.labels.format,
|
|
25
27
|
spec.labels.prefix,
|
|
28
|
+
valueField,
|
|
26
29
|
);
|
|
27
30
|
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
28
31
|
marks[i].label = labels[i];
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -18,8 +18,10 @@ import type {
|
|
|
18
18
|
ResolvedLabel,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
20
|
import {
|
|
21
|
+
abbreviateNumber,
|
|
21
22
|
buildD3Formatter,
|
|
22
23
|
estimateTextWidth,
|
|
24
|
+
formatNumber,
|
|
23
25
|
getRepresentativeColor,
|
|
24
26
|
resolveCollisions,
|
|
25
27
|
} from '@opendata-ai/openchart-core';
|
|
@@ -28,6 +30,12 @@ import {
|
|
|
28
30
|
// Helpers
|
|
29
31
|
// ---------------------------------------------------------------------------
|
|
30
32
|
|
|
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
|
+
|
|
31
39
|
/** Suffix multipliers mirroring core's abbreviateNumber output (K/M/B/T). */
|
|
32
40
|
const SUFFIX_MULTIPLIERS: Record<string, number> = {
|
|
33
41
|
K: 1_000,
|
|
@@ -90,6 +98,7 @@ export function computeBarLabels(
|
|
|
90
98
|
density: LabelDensity = 'auto',
|
|
91
99
|
labelFormat?: string,
|
|
92
100
|
labelPrefix?: string,
|
|
101
|
+
valueField?: string,
|
|
93
102
|
): ResolvedLabel[] {
|
|
94
103
|
// 'none': no labels at all
|
|
95
104
|
if (density === 'none') return [];
|
|
@@ -106,19 +115,28 @@ export function computeBarLabels(
|
|
|
106
115
|
const formatter = buildD3Formatter(labelFormat);
|
|
107
116
|
|
|
108
117
|
for (const mark of targetMarks) {
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
// Get the original numeric value from the data row when possible,
|
|
119
|
+
// falling back to parsing the aria label (which may lose precision
|
|
120
|
+
// due to abbreviation rounding, e.g. 1955 → "2K" → 2000).
|
|
121
|
+
let valuePart: string;
|
|
122
|
+
const rawNum = valueField != null ? Number(mark.data[valueField]) : NaN;
|
|
123
|
+
|
|
124
|
+
if (formatter && Number.isFinite(rawNum)) {
|
|
125
|
+
valuePart = formatter(rawNum);
|
|
126
|
+
} else if (Number.isFinite(rawNum)) {
|
|
127
|
+
valuePart = formatBarValue(rawNum);
|
|
128
|
+
} else {
|
|
129
|
+
// Fallback: extract from aria label
|
|
130
|
+
const ariaLabel = mark.aria.label;
|
|
131
|
+
const lastColon = ariaLabel.lastIndexOf(':');
|
|
132
|
+
const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
|
|
133
|
+
if (!rawValue) continue;
|
|
134
|
+
if (formatter) {
|
|
135
|
+
const num = parseDisplayNumber(rawValue);
|
|
136
|
+
valuePart = !Number.isNaN(num) ? formatter(num) : rawValue;
|
|
137
|
+
} else {
|
|
138
|
+
valuePart = rawValue;
|
|
139
|
+
}
|
|
122
140
|
}
|
|
123
141
|
if (labelPrefix) valuePart = labelPrefix + valuePart;
|
|
124
142
|
|
|
@@ -17,12 +17,15 @@ export const columnRenderer: ChartRenderer = (spec, scales, chartArea, strategy,
|
|
|
17
17
|
const marks = computeColumnMarks(spec, scales, chartArea, strategy);
|
|
18
18
|
|
|
19
19
|
// Compute and attach value labels (respects spec.labels.density)
|
|
20
|
+
const valueField =
|
|
21
|
+
spec.encoding?.y && 'field' in spec.encoding.y ? spec.encoding.y.field : undefined;
|
|
20
22
|
const labels = computeColumnLabels(
|
|
21
23
|
marks,
|
|
22
24
|
chartArea,
|
|
23
25
|
spec.labels.density,
|
|
24
26
|
spec.labels.format,
|
|
25
27
|
spec.labels.prefix,
|
|
28
|
+
valueField,
|
|
26
29
|
);
|
|
27
30
|
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
28
31
|
marks[i].label = labels[i];
|
|
@@ -18,12 +18,24 @@ import type {
|
|
|
18
18
|
ResolvedLabel,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
20
|
import {
|
|
21
|
+
abbreviateNumber,
|
|
21
22
|
buildD3Formatter,
|
|
22
23
|
estimateTextWidth,
|
|
24
|
+
formatNumber,
|
|
23
25
|
getRepresentativeColor,
|
|
24
26
|
resolveCollisions,
|
|
25
27
|
} from '@opendata-ai/openchart-core';
|
|
26
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
|
+
}
|
|
38
|
+
|
|
27
39
|
// ---------------------------------------------------------------------------
|
|
28
40
|
// Constants
|
|
29
41
|
// ---------------------------------------------------------------------------
|
|
@@ -47,6 +59,7 @@ export function computeColumnLabels(
|
|
|
47
59
|
density: LabelDensity = 'auto',
|
|
48
60
|
labelFormat?: string,
|
|
49
61
|
labelPrefix?: string,
|
|
62
|
+
valueField?: string,
|
|
50
63
|
): ResolvedLabel[] {
|
|
51
64
|
// 'none': no labels at all
|
|
52
65
|
if (density === 'none') return [];
|
|
@@ -60,19 +73,28 @@ export function computeColumnLabels(
|
|
|
60
73
|
const candidates: LabelCandidate[] = [];
|
|
61
74
|
|
|
62
75
|
for (const mark of targetMarks) {
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
// Get the original numeric value from the data row when possible,
|
|
77
|
+
// falling back to parsing the aria label (which may lose precision
|
|
78
|
+
// due to abbreviation rounding, e.g. 1955 → "2K" → 2000).
|
|
79
|
+
let valuePart: string;
|
|
80
|
+
const rawNum = valueField != null ? Number(mark.data[valueField]) : NaN;
|
|
81
|
+
|
|
82
|
+
if (formatter && Number.isFinite(rawNum)) {
|
|
83
|
+
valuePart = formatter(rawNum);
|
|
84
|
+
} else if (Number.isFinite(rawNum)) {
|
|
85
|
+
valuePart = formatColumnValue(rawNum);
|
|
86
|
+
} else {
|
|
87
|
+
// Fallback: extract from aria label
|
|
88
|
+
const ariaLabel = mark.aria.label;
|
|
89
|
+
const lastColon = ariaLabel.lastIndexOf(':');
|
|
90
|
+
const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
|
|
91
|
+
if (!rawValue) continue;
|
|
92
|
+
if (formatter) {
|
|
93
|
+
const num = Number(rawValue.replace(/[^0-9.-]/g, ''));
|
|
94
|
+
valuePart = !Number.isNaN(num) ? formatter(num) : rawValue;
|
|
95
|
+
} else {
|
|
96
|
+
valuePart = rawValue;
|
|
97
|
+
}
|
|
76
98
|
}
|
|
77
99
|
if (labelPrefix) valuePart = labelPrefix + valuePart;
|
|
78
100
|
|
package/src/charts/dot/index.ts
CHANGED
|
@@ -26,7 +26,16 @@ export const dotRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
|
|
|
26
26
|
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
27
27
|
|
|
28
28
|
// Compute and attach labels to point marks (respects spec.labels.density)
|
|
29
|
-
const
|
|
29
|
+
const valueField =
|
|
30
|
+
spec.encoding?.x && 'field' in spec.encoding.x ? spec.encoding.x.field : undefined;
|
|
31
|
+
const labels = computeDotLabels(
|
|
32
|
+
pointMarks,
|
|
33
|
+
chartArea,
|
|
34
|
+
spec.labels.density,
|
|
35
|
+
spec.labels.prefix,
|
|
36
|
+
spec.labels.format,
|
|
37
|
+
valueField,
|
|
38
|
+
);
|
|
30
39
|
let labelIdx = 0;
|
|
31
40
|
for (const mark of marks) {
|
|
32
41
|
if (mark.type === 'point' && labelIdx < labels.length) {
|
package/src/charts/dot/labels.ts
CHANGED
|
@@ -18,11 +18,24 @@ import type {
|
|
|
18
18
|
ResolvedLabel,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
20
|
import {
|
|
21
|
+
abbreviateNumber,
|
|
22
|
+
buildD3Formatter,
|
|
21
23
|
estimateTextWidth,
|
|
24
|
+
formatNumber,
|
|
22
25
|
getRepresentativeColor,
|
|
23
26
|
resolveCollisions,
|
|
24
27
|
} from '@opendata-ai/openchart-core';
|
|
25
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
|
+
}
|
|
38
|
+
|
|
26
39
|
// ---------------------------------------------------------------------------
|
|
27
40
|
// Constants
|
|
28
41
|
// ---------------------------------------------------------------------------
|
|
@@ -45,6 +58,8 @@ export function computeDotLabels(
|
|
|
45
58
|
_chartArea: Rect,
|
|
46
59
|
density: LabelDensity = 'auto',
|
|
47
60
|
labelPrefix?: string,
|
|
61
|
+
labelFormat?: string,
|
|
62
|
+
valueField?: string,
|
|
48
63
|
): ResolvedLabel[] {
|
|
49
64
|
// 'none': no labels at all
|
|
50
65
|
if (density === 'none') return [];
|
|
@@ -53,15 +68,31 @@ export function computeDotLabels(
|
|
|
53
68
|
const targetMarks =
|
|
54
69
|
density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
|
|
55
70
|
|
|
71
|
+
const formatter = buildD3Formatter(labelFormat);
|
|
56
72
|
const candidates: LabelCandidate[] = [];
|
|
57
73
|
|
|
58
74
|
for (const mark of targetMarks) {
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
// Get the original numeric value from the data row when possible,
|
|
76
|
+
// falling back to parsing the aria label (which may lose precision
|
|
77
|
+
// due to abbreviation rounding, e.g. 1955 → "2K" → 2000).
|
|
78
|
+
let valuePart: string;
|
|
79
|
+
const rawNum = valueField != null ? Number(mark.data[valueField]) : NaN;
|
|
80
|
+
|
|
81
|
+
if (formatter && Number.isFinite(rawNum)) {
|
|
82
|
+
valuePart = formatter(rawNum);
|
|
83
|
+
} else if (Number.isFinite(rawNum)) {
|
|
84
|
+
valuePart = formatDotValue(rawNum);
|
|
85
|
+
} else {
|
|
86
|
+
// Fallback: extract from aria label
|
|
87
|
+
const ariaLabel = mark.aria.label;
|
|
88
|
+
const lastColon = ariaLabel.lastIndexOf(':');
|
|
89
|
+
valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
|
|
90
|
+
if (!valuePart) continue;
|
|
91
|
+
if (formatter) {
|
|
92
|
+
const num = Number(valuePart.replace(/[^0-9.-]/g, ''));
|
|
93
|
+
if (!Number.isNaN(num)) valuePart = formatter(num);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
65
96
|
if (labelPrefix) valuePart = labelPrefix + valuePart;
|
|
66
97
|
|
|
67
98
|
const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
|
package/src/charts/line/area.ts
CHANGED
|
@@ -74,8 +74,12 @@ function computeSingleArea(
|
|
|
74
74
|
for (const [seriesKey, rows] of groups) {
|
|
75
75
|
const color = getColor(scales, seriesKey);
|
|
76
76
|
|
|
77
|
-
// Sort rows by x-axis field so areas draw left-to-right
|
|
78
|
-
|
|
77
|
+
// Sort rows by x-axis field so areas draw left-to-right.
|
|
78
|
+
// For nominal/ordinal axes, preserve data order.
|
|
79
|
+
const sortedRows =
|
|
80
|
+
xChannel.type === 'nominal' || xChannel.type === 'ordinal'
|
|
81
|
+
? rows
|
|
82
|
+
: sortByField(rows, xChannel.field);
|
|
79
83
|
|
|
80
84
|
// Compute points, filtering out null values
|
|
81
85
|
const validPoints: { x: number; yTop: number; yBottom: number; row: DataRow }[] = [];
|
|
@@ -162,8 +166,12 @@ function computeStackedArea(
|
|
|
162
166
|
return computeSingleArea(spec, scales, chartArea);
|
|
163
167
|
}
|
|
164
168
|
|
|
165
|
-
// Sort data by x field so stacked areas render left-to-right
|
|
166
|
-
|
|
169
|
+
// Sort data by x field so stacked areas render left-to-right.
|
|
170
|
+
// For nominal/ordinal axes, preserve data order.
|
|
171
|
+
const sortedData =
|
|
172
|
+
xChannel.type === 'nominal' || xChannel.type === 'ordinal'
|
|
173
|
+
? spec.data
|
|
174
|
+
: sortByField(spec.data, xChannel.field);
|
|
167
175
|
|
|
168
176
|
// Collect unique series keys and x values, and build a lookup from
|
|
169
177
|
// (x-value, series-key) -> original data row so stacked area marks
|
|
@@ -75,8 +75,13 @@ export function computeLineMarks(
|
|
|
75
75
|
: getColor(scales, seriesKey);
|
|
76
76
|
const strokeColor = getRepresentativeColor(color);
|
|
77
77
|
|
|
78
|
-
// Sort rows by x-axis field so lines draw left-to-right
|
|
79
|
-
|
|
78
|
+
// Sort rows by x-axis field so lines draw left-to-right.
|
|
79
|
+
// For nominal/ordinal axes, preserve data order since there's no
|
|
80
|
+
// natural sort and the scale domain already reflects intended order.
|
|
81
|
+
const sortedRows =
|
|
82
|
+
xChannel.type === 'nominal' || xChannel.type === 'ordinal'
|
|
83
|
+
? rows
|
|
84
|
+
: sortByField(rows, xChannel.field);
|
|
80
85
|
|
|
81
86
|
// Compute pixel positions for each data point, preserving nulls
|
|
82
87
|
// for line break handling
|
package/src/charts/line/index.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Exports line and area chart renderers and computation functions.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { LineMark, Mark } from '@opendata-ai/openchart-core';
|
|
7
|
+
import type { AreaMark, LineMark, Mark } from '@opendata-ai/openchart-core';
|
|
8
|
+
import { getRepresentativeColor } from '@opendata-ai/openchart-core';
|
|
8
9
|
import type { ChartRenderer } from '../registry';
|
|
9
10
|
import { computeAreaMarks } from './area';
|
|
10
11
|
import { computeLineMarks } from './compute';
|
|
@@ -53,12 +54,42 @@ export const lineRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _
|
|
|
53
54
|
*/
|
|
54
55
|
export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
|
|
55
56
|
const areas = computeAreaMarks(spec, scales, chartArea);
|
|
56
|
-
|
|
57
|
+
|
|
58
|
+
const encoding = spec.encoding;
|
|
59
|
+
const hasColor = !!(encoding.color && 'field' in encoding.color);
|
|
60
|
+
|
|
61
|
+
// For stacked areas, derive line marks from the area top paths so lines
|
|
62
|
+
// align with stacked positions. For non-stacked, compute lines normally.
|
|
63
|
+
const lines = hasColor
|
|
64
|
+
? linesFromAreas(areas)
|
|
65
|
+
: computeLineMarks(spec, scales, chartArea, strategy);
|
|
57
66
|
|
|
58
67
|
// Areas go first (rendered behind lines), then lines on top
|
|
59
68
|
return [...areas, ...lines] as Mark[];
|
|
60
69
|
};
|
|
61
70
|
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Helpers
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Derive LineMark[] from stacked AreaMark[] using each area's top boundary.
|
|
77
|
+
* This ensures lines sit on top of their corresponding stacked area bands.
|
|
78
|
+
*/
|
|
79
|
+
function linesFromAreas(areas: AreaMark[]): LineMark[] {
|
|
80
|
+
return areas.map((a) => ({
|
|
81
|
+
type: 'line' as const,
|
|
82
|
+
points: a.topPoints,
|
|
83
|
+
path: a.topPath,
|
|
84
|
+
stroke: getRepresentativeColor(a.fill),
|
|
85
|
+
strokeWidth: a.strokeWidth ?? 1,
|
|
86
|
+
seriesKey: a.seriesKey,
|
|
87
|
+
data: a.data,
|
|
88
|
+
dataPoints: a.dataPoints,
|
|
89
|
+
aria: { label: `${a.seriesKey ?? 'Series'}: line with ${a.topPoints.length} data points` },
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
|
|
62
93
|
// ---------------------------------------------------------------------------
|
|
63
94
|
// Public exports
|
|
64
95
|
// ---------------------------------------------------------------------------
|
package/src/compile.ts
CHANGED
package/src/layout/axes.ts
CHANGED
package/src/layout/scales.ts
CHANGED
|
@@ -141,18 +141,16 @@ function uniqueStrings(values: unknown[]): string[] {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
|
-
* Apply sort order to categorical domain values
|
|
144
|
+
* Apply sort order to categorical domain values.
|
|
145
145
|
* - 'ascending': sort alphabetically/numerically ascending
|
|
146
146
|
* - 'descending': sort descending
|
|
147
|
-
* - null: preserve data order (no sorting)
|
|
148
|
-
* - undefined: ascending (VL default)
|
|
147
|
+
* - null | undefined: preserve data order (no sorting)
|
|
149
148
|
*/
|
|
150
149
|
function applyCategoricalSort(
|
|
151
150
|
values: string[],
|
|
152
151
|
sort: 'ascending' | 'descending' | null | undefined,
|
|
153
152
|
): string[] {
|
|
154
|
-
|
|
155
|
-
if (sort === null) return values;
|
|
153
|
+
if (!sort) return values;
|
|
156
154
|
|
|
157
155
|
const sorted = [...values].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
158
156
|
if (sort === 'descending') sorted.reverse();
|
|
@@ -194,7 +192,11 @@ function buildTimeScale(
|
|
|
194
192
|
|
|
195
193
|
const scale = scaleTime().domain(domain).range([rangeStart, rangeEnd]);
|
|
196
194
|
|
|
197
|
-
|
|
195
|
+
// Temporal scales default to nice: false because date data typically starts
|
|
196
|
+
// at clean boundaries and nice() rounds the domain outward, creating visible
|
|
197
|
+
// gaps (e.g. data starting 2018-01-01 gets rounded to 2017-01-01).
|
|
198
|
+
// Users can opt in with scale: { nice: true }.
|
|
199
|
+
if (!channel.scale?.domain && channel.scale?.nice === true) {
|
|
198
200
|
scale.nice();
|
|
199
201
|
}
|
|
200
202
|
applyContinuousConfig(scale, channel);
|
|
@@ -215,7 +217,8 @@ function buildUtcScale(
|
|
|
215
217
|
|
|
216
218
|
const scale = scaleUtc().domain(domain).range([rangeStart, rangeEnd]);
|
|
217
219
|
|
|
218
|
-
|
|
220
|
+
// Temporal scales default to nice: false (see buildTimeScale comment).
|
|
221
|
+
if (!channel.scale?.domain && channel.scale?.nice === true) {
|
|
219
222
|
scale.nice();
|
|
220
223
|
}
|
|
221
224
|
applyContinuousConfig(scale, channel);
|
|
@@ -251,7 +254,7 @@ function buildLinearScale(
|
|
|
251
254
|
|
|
252
255
|
const scale = scaleLinear().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
|
|
253
256
|
|
|
254
|
-
if (channel.scale?.nice !== false) {
|
|
257
|
+
if (!channel.scale?.domain && channel.scale?.nice !== false) {
|
|
255
258
|
scale.nice();
|
|
256
259
|
}
|
|
257
260
|
applyContinuousConfig(scale, channel);
|
|
@@ -278,7 +281,7 @@ function buildLogScale(
|
|
|
278
281
|
if (channel.scale?.base !== undefined) {
|
|
279
282
|
scale.base(channel.scale.base);
|
|
280
283
|
}
|
|
281
|
-
if (channel.scale?.nice !== false) {
|
|
284
|
+
if (!channel.scale?.domain && channel.scale?.nice !== false) {
|
|
282
285
|
scale.nice();
|
|
283
286
|
}
|
|
284
287
|
applyContinuousConfig(scale, channel);
|
|
@@ -313,7 +316,7 @@ function buildPowScale(
|
|
|
313
316
|
if (channel.scale?.exponent !== undefined) {
|
|
314
317
|
scale.exponent(channel.scale.exponent);
|
|
315
318
|
}
|
|
316
|
-
if (channel.scale?.nice !== false) {
|
|
319
|
+
if (!channel.scale?.domain && channel.scale?.nice !== false) {
|
|
317
320
|
scale.nice();
|
|
318
321
|
}
|
|
319
322
|
applyContinuousConfig(scale, channel);
|
|
@@ -345,7 +348,7 @@ function buildSqrtScale(
|
|
|
345
348
|
|
|
346
349
|
const scale = scaleSqrt().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
|
|
347
350
|
|
|
348
|
-
if (channel.scale?.nice !== false) {
|
|
351
|
+
if (!channel.scale?.domain && channel.scale?.nice !== false) {
|
|
349
352
|
scale.nice();
|
|
350
353
|
}
|
|
351
354
|
applyContinuousConfig(scale, channel);
|
|
@@ -380,7 +383,7 @@ function buildSymlogScale(
|
|
|
380
383
|
if (channel.scale?.constant !== undefined) {
|
|
381
384
|
scale.constant(channel.scale.constant);
|
|
382
385
|
}
|
|
383
|
-
if (channel.scale?.nice !== false) {
|
|
386
|
+
if (!channel.scale?.domain && channel.scale?.nice !== false) {
|
|
384
387
|
scale.nice();
|
|
385
388
|
}
|
|
386
389
|
applyContinuousConfig(scale, channel);
|
package/src/legend/compute.ts
CHANGED
|
@@ -219,11 +219,9 @@ export function computeLegend(
|
|
|
219
219
|
1,
|
|
220
220
|
Math.floor((maxLegendHeight - LEGEND_PADDING * 2) / (entryHeight + 4)),
|
|
221
221
|
);
|
|
222
|
-
// symbolLimit overrides the space-based limit when set (minimum 1)
|
|
222
|
+
// symbolLimit overrides the space-based limit when explicitly set (minimum 1)
|
|
223
223
|
const maxEntries =
|
|
224
|
-
spec.legend?.symbolLimit != null
|
|
225
|
-
? Math.min(Math.max(1, spec.legend.symbolLimit), maxFromSpace)
|
|
226
|
-
: maxFromSpace;
|
|
224
|
+
spec.legend?.symbolLimit != null ? Math.max(1, spec.legend.symbolLimit) : maxFromSpace;
|
|
227
225
|
if (entries.length > maxEntries) {
|
|
228
226
|
entries = truncateEntries(entries, maxEntries);
|
|
229
227
|
}
|
package/src/tooltips/compute.ts
CHANGED
|
@@ -84,12 +84,25 @@ function buildFields(row: DataRow, encoding: Encoding, color?: string): TooltipF
|
|
|
84
84
|
|
|
85
85
|
const fields: TooltipField[] = [];
|
|
86
86
|
|
|
87
|
+
// Color/series field (e.g. "Source: Coal") so the user knows which series
|
|
88
|
+
if (encoding.color && 'field' in encoding.color) {
|
|
89
|
+
fields.push({
|
|
90
|
+
label: resolveLabel(encoding.color),
|
|
91
|
+
value: formatValue(
|
|
92
|
+
row[encoding.color.field],
|
|
93
|
+
encoding.color.type,
|
|
94
|
+
resolveFormat(encoding.color),
|
|
95
|
+
),
|
|
96
|
+
color,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
// Y-axis value (the "main" value in most charts)
|
|
88
101
|
if (encoding.y) {
|
|
89
102
|
fields.push({
|
|
90
103
|
label: resolveLabel(encoding.y),
|
|
91
104
|
value: formatValue(row[encoding.y.field], encoding.y.type, resolveFormat(encoding.y)),
|
|
92
|
-
color,
|
|
105
|
+
color: encoding.color ? undefined : color,
|
|
93
106
|
});
|
|
94
107
|
}
|
|
95
108
|
|
|
@@ -138,6 +151,21 @@ function getTooltipTitle(row: DataRow, encoding: Encoding): string | undefined {
|
|
|
138
151
|
return String(row[encoding.y.field] ?? '');
|
|
139
152
|
}
|
|
140
153
|
|
|
154
|
+
// For scatter/bubble (both axes quantitative), find a name-like string field
|
|
155
|
+
// in the data row that isn't already used by an encoding channel
|
|
156
|
+
if (encoding.x?.type === 'quantitative' && encoding.y?.type === 'quantitative') {
|
|
157
|
+
const encodedFields = new Set(
|
|
158
|
+
[encoding.x, encoding.y, encoding.color, encoding.size, encoding.detail]
|
|
159
|
+
.filter((ch): ch is EncodingChannel => !!ch && 'field' in ch)
|
|
160
|
+
.map((ch) => ch.field),
|
|
161
|
+
);
|
|
162
|
+
for (const [key, value] of Object.entries(row)) {
|
|
163
|
+
if (!encodedFields.has(key) && typeof value === 'string') {
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
141
169
|
// For color-encoded series, use the series name (skip conditional defs)
|
|
142
170
|
if (encoding.color && 'field' in encoding.color) {
|
|
143
171
|
return String(row[encoding.color.field] ?? '');
|