@opendata-ai/openchart-engine 6.28.6 → 7.0.2
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/README.md +1 -0
- package/dist/index.d.ts +8 -11
- package/dist/index.js +12307 -11338
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +3 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +452 -377
- package/src/__tests__/axes.test.ts +75 -0
- package/src/__tests__/compile-chart.test.ts +304 -0
- package/src/__tests__/dimensions.test.ts +224 -0
- package/src/__tests__/legend.test.ts +44 -3
- package/src/annotations/__tests__/compute.test.ts +111 -0
- package/src/annotations/__tests__/resolve-text.test.ts +288 -0
- package/src/annotations/constants.ts +20 -0
- package/src/annotations/resolve-text.ts +161 -7
- package/src/charts/bar/compute.ts +24 -0
- package/src/charts/bar/labels.ts +1 -0
- package/src/charts/column/compute.ts +33 -1
- package/src/charts/column/labels.ts +1 -0
- package/src/charts/dot/labels.ts +1 -0
- package/src/charts/line/__tests__/compute.test.ts +153 -3
- package/src/charts/line/area.ts +111 -23
- package/src/charts/line/compute.ts +40 -10
- package/src/charts/line/index.ts +34 -7
- package/src/charts/line/labels.ts +29 -0
- package/src/charts/pie/labels.ts +1 -0
- package/src/compile/layer.ts +498 -0
- package/src/compile.ts +221 -586
- package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
- package/src/compiler/normalize.ts +12 -1
- package/src/compiler/sparkline-defaults.ts +138 -0
- package/src/compiler/types.ts +8 -0
- package/src/endpoint-labels/__tests__/compute.test.ts +438 -0
- package/src/endpoint-labels/compute.ts +417 -0
- package/src/endpoint-labels/constants.ts +54 -0
- package/src/endpoint-labels/format.ts +30 -0
- package/src/endpoint-labels/predict.ts +108 -0
- package/src/graphs/compile-graph.ts +1 -0
- package/src/layout/axes.ts +27 -2
- package/src/layout/dimensions.ts +282 -34
- package/src/layout/metrics.ts +118 -0
- package/src/layout/scales.ts +41 -4
- package/src/legend/__tests__/suppression.test.ts +294 -0
- package/src/legend/compute.ts +50 -40
- package/src/legend/suppression.ts +204 -0
- package/src/sankey/compile-sankey.ts +2 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint labels: per-series right-side label column for multi-series
|
|
3
|
+
* line/area charts. Each entry pairs the series name with its last formatted
|
|
4
|
+
* value, optionally anchored to the line by an open-circle marker.
|
|
5
|
+
*
|
|
6
|
+
* Single-pass design: this is the only place that resolves entries to pixel
|
|
7
|
+
* positions. `predictEndpointLabelsWidth` (in `predict.ts`) runs before marks
|
|
8
|
+
* to reserve right margin space, but only computes width — never positions.
|
|
9
|
+
*
|
|
10
|
+
* Layout pipeline:
|
|
11
|
+
* 1. Filter marks to line/area marks with seriesKey + dataPoints.
|
|
12
|
+
* 2. For each series take the last data point, format the value, wrap the label.
|
|
13
|
+
* 3. Bidirectional collision sweep on labelY: forward pass pushes overlapping
|
|
14
|
+
* entries down, reverse pass pushes them up; final = midpoint, clamped.
|
|
15
|
+
* 4. Mark `showLeader` true when displacement exceeds threshold.
|
|
16
|
+
* 5. Optionally attach the open-circle marker on the line at the right edge.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
AreaMark,
|
|
21
|
+
EndpointLabelEntry,
|
|
22
|
+
EndpointLabelsLayout,
|
|
23
|
+
LayoutStrategy,
|
|
24
|
+
LineMark,
|
|
25
|
+
Mark,
|
|
26
|
+
Rect,
|
|
27
|
+
ResolvedTheme,
|
|
28
|
+
TextStyle,
|
|
29
|
+
} from '@opendata-ai/openchart-core';
|
|
30
|
+
import { estimateTextWidth, wrapText } from '@opendata-ai/openchart-core';
|
|
31
|
+
|
|
32
|
+
import type { NormalizedChartSpec } from '../compiler/types';
|
|
33
|
+
import { countColorSeries, resolveSuppression } from '../legend/suppression';
|
|
34
|
+
import {
|
|
35
|
+
ENDPOINT_COLUMN_GAP,
|
|
36
|
+
ENDPOINT_ENTRY_GAP,
|
|
37
|
+
ENDPOINT_GAP,
|
|
38
|
+
ENDPOINT_LABEL_FONT_SIZE,
|
|
39
|
+
ENDPOINT_LABEL_FONT_WEIGHT,
|
|
40
|
+
ENDPOINT_LEADER_THRESHOLD,
|
|
41
|
+
ENDPOINT_LINE_HEIGHT,
|
|
42
|
+
ENDPOINT_MARKER_RADIUS,
|
|
43
|
+
ENDPOINT_MARKER_STROKE_WIDTH,
|
|
44
|
+
ENDPOINT_SWATCH_SIZE,
|
|
45
|
+
ENDPOINT_VALUE_FONT_SIZE,
|
|
46
|
+
ENDPOINT_VALUE_FONT_WEIGHT,
|
|
47
|
+
ENDPOINT_VALUE_GAP,
|
|
48
|
+
ENDPOINT_WRAP_WIDTH_DEFAULT,
|
|
49
|
+
} from './constants';
|
|
50
|
+
import { formatEndpointValue } from './format';
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Helpers
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** Empty layout used as the "column suppressed" return value. */
|
|
57
|
+
function emptyLayout(theme: ResolvedTheme): EndpointLabelsLayout {
|
|
58
|
+
const labelStyle: TextStyle = {
|
|
59
|
+
fontFamily: theme.fonts.family,
|
|
60
|
+
fontSize: ENDPOINT_LABEL_FONT_SIZE,
|
|
61
|
+
fontWeight: ENDPOINT_LABEL_FONT_WEIGHT,
|
|
62
|
+
fill: theme.colors.text,
|
|
63
|
+
lineHeight: ENDPOINT_LINE_HEIGHT,
|
|
64
|
+
};
|
|
65
|
+
const valueStyle: TextStyle = {
|
|
66
|
+
fontFamily: theme.fonts.family,
|
|
67
|
+
fontSize: ENDPOINT_VALUE_FONT_SIZE,
|
|
68
|
+
fontWeight: ENDPOINT_VALUE_FONT_WEIGHT,
|
|
69
|
+
fill: theme.colors.annotationText ?? theme.colors.text,
|
|
70
|
+
lineHeight: ENDPOINT_LINE_HEIGHT,
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
entries: [],
|
|
74
|
+
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
75
|
+
labelStyle,
|
|
76
|
+
valueStyle,
|
|
77
|
+
swatchSize: ENDPOINT_SWATCH_SIZE,
|
|
78
|
+
gap: ENDPOINT_GAP,
|
|
79
|
+
valueGap: ENDPOINT_VALUE_GAP,
|
|
80
|
+
swatchChipFill: theme.colors.annotationFill ?? theme.colors.background,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isLineOrArea(mark: Mark): mark is LineMark | AreaMark {
|
|
85
|
+
return mark.type === 'line' || mark.type === 'area';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Get the last data-point pixel position for a line/area mark. */
|
|
89
|
+
function lastDataPoint(mark: LineMark | AreaMark): { x: number; y: number } | null {
|
|
90
|
+
if (mark.dataPoints && mark.dataPoints.length > 0) {
|
|
91
|
+
const last = mark.dataPoints[mark.dataPoints.length - 1];
|
|
92
|
+
return { x: last.x, y: last.y };
|
|
93
|
+
}
|
|
94
|
+
// Fallback for marks compiled without dataPoints (rare): use the last
|
|
95
|
+
// geometry point. For areas this is the top boundary.
|
|
96
|
+
if (mark.type === 'line' && mark.points.length > 0) {
|
|
97
|
+
const last = mark.points[mark.points.length - 1];
|
|
98
|
+
return { x: last.x, y: last.y };
|
|
99
|
+
}
|
|
100
|
+
if (mark.type === 'area' && mark.topPoints.length > 0) {
|
|
101
|
+
const last = mark.topPoints[mark.topPoints.length - 1];
|
|
102
|
+
return { x: last.x, y: last.y };
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Get the value to display from a data row. */
|
|
108
|
+
function readValue(
|
|
109
|
+
mark: LineMark | AreaMark,
|
|
110
|
+
valueField: string | undefined,
|
|
111
|
+
): number | string | null {
|
|
112
|
+
if (!valueField) return null;
|
|
113
|
+
const dp = mark.dataPoints;
|
|
114
|
+
if (dp && dp.length > 0) {
|
|
115
|
+
const datum = dp[dp.length - 1].datum;
|
|
116
|
+
const v = datum[valueField];
|
|
117
|
+
return typeof v === 'number' || typeof v === 'string' ? v : null;
|
|
118
|
+
}
|
|
119
|
+
if (mark.data.length > 0) {
|
|
120
|
+
const v = mark.data[mark.data.length - 1][valueField];
|
|
121
|
+
return typeof v === 'number' || typeof v === 'string' ? v : null;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Local collision sweep: keep each label anchored to its line's `dataY` and
|
|
128
|
+
* only displace neighbors that actually overlap.
|
|
129
|
+
*
|
|
130
|
+
* Each entry's "natural" top is `naturalTop` (typically `dataY - fontSize/2`
|
|
131
|
+
* so the label's first-line baseline-center aligns with the data point). We
|
|
132
|
+
* only push neighbors apart when their stacks would collide, by the minimum
|
|
133
|
+
* amount needed. This preserves the line-tracking behavior the design calls
|
|
134
|
+
* for: when lines are well-separated, labels sit at their lines; when close
|
|
135
|
+
* together, the sweep nudges them apart while staying near their data points.
|
|
136
|
+
*
|
|
137
|
+
* Algorithm:
|
|
138
|
+
* 1. Sort by naturalTop ascending.
|
|
139
|
+
* 2. Forward pass (top→bottom): push i down if it overlaps i-1, then clamp
|
|
140
|
+
* to `areaBottom - height` so an entry never lands below the chart.
|
|
141
|
+
* 3. Reverse pass (bottom→top): push i up if it overlaps i+1, then clamp
|
|
142
|
+
* to `areaTop` so an entry never lands above the chart.
|
|
143
|
+
*
|
|
144
|
+
* Both passes cascade the clamp through neighbors, so a clamp at one end can
|
|
145
|
+
* propagate all the way to the other when the chart is too short to fit every
|
|
146
|
+
* label without overlap. (When the total stack is taller than the area, the
|
|
147
|
+
* earliest entries get pinned to areaTop and overlap is unavoidable — the
|
|
148
|
+
* caller has to drop entries or shrink them.)
|
|
149
|
+
*/
|
|
150
|
+
export function bidirectionalSweep(
|
|
151
|
+
entries: { naturalTop: number; height: number; index: number }[],
|
|
152
|
+
areaTop: number,
|
|
153
|
+
areaBottom: number,
|
|
154
|
+
): number[] {
|
|
155
|
+
const n = entries.length;
|
|
156
|
+
if (n === 0) return [];
|
|
157
|
+
|
|
158
|
+
// Sort by naturalTop ascending so neighbors in the chart are neighbors here.
|
|
159
|
+
const sorted = [...entries].sort((a, b) => a.naturalTop - b.naturalTop);
|
|
160
|
+
|
|
161
|
+
// Initialize tops at the natural (anchored-to-dataY) positions.
|
|
162
|
+
const tops = sorted.map((e) => e.naturalTop);
|
|
163
|
+
|
|
164
|
+
// Forward pass: push down only when overlapping the previous entry, then
|
|
165
|
+
// cap at the chart's bottom edge so we don't run off the canvas.
|
|
166
|
+
for (let i = 0; i < n; i++) {
|
|
167
|
+
if (i > 0) {
|
|
168
|
+
const minTop = tops[i - 1] + sorted[i - 1].height;
|
|
169
|
+
if (tops[i] < minTop) tops[i] = minTop;
|
|
170
|
+
}
|
|
171
|
+
const maxTop = areaBottom - sorted[i].height;
|
|
172
|
+
if (tops[i] > maxTop) tops[i] = maxTop;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Reverse pass: when the forward pass clamped a tail entry up to fit the
|
|
176
|
+
// bottom edge, propagate that displacement back through the predecessors so
|
|
177
|
+
// they don't overlap the now-raised tail. Then clamp at areaTop.
|
|
178
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
179
|
+
if (i < n - 1) {
|
|
180
|
+
const maxTop = tops[i + 1] - sorted[i].height;
|
|
181
|
+
if (tops[i] > maxTop) tops[i] = maxTop;
|
|
182
|
+
}
|
|
183
|
+
if (tops[i] < areaTop) tops[i] = areaTop;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Write back in original index order.
|
|
187
|
+
const result = new Array<number>(n);
|
|
188
|
+
for (let i = 0; i < n; i++) {
|
|
189
|
+
result[sorted[i].index] = tops[i];
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Public API
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Compute the resolved endpoint-labels layout for a chart.
|
|
200
|
+
*
|
|
201
|
+
* Returns an empty layout (entries: []) when:
|
|
202
|
+
* - The chart isn't a multi-series line/area.
|
|
203
|
+
* - The user opted out via `endpointLabels: false`.
|
|
204
|
+
* - Responsive strategy strips inline labels (compact breakpoint).
|
|
205
|
+
* - Suppression truth table resolves to "endpoint column off".
|
|
206
|
+
*
|
|
207
|
+
* Otherwise produces fully-positioned entries with bidirectional collision
|
|
208
|
+
* sweep applied to labelY and optional open-circle markers on the line.
|
|
209
|
+
*/
|
|
210
|
+
export function computeEndpointLabels(
|
|
211
|
+
spec: NormalizedChartSpec,
|
|
212
|
+
marks: Mark[],
|
|
213
|
+
theme: ResolvedTheme,
|
|
214
|
+
chartArea: Rect,
|
|
215
|
+
strategy?: LayoutStrategy,
|
|
216
|
+
): EndpointLabelsLayout {
|
|
217
|
+
// Compact strategy: drop the column entirely. The traditional legend takes
|
|
218
|
+
// over series identification at the compact breakpoint.
|
|
219
|
+
if (strategy?.labelMode === 'none') return emptyLayout(theme);
|
|
220
|
+
|
|
221
|
+
const seriesCount = countColorSeries(spec);
|
|
222
|
+
const sup = resolveSuppression(spec, {
|
|
223
|
+
seriesCount,
|
|
224
|
+
// The 'none' branch above returned; by here labelMode is not 'none'.
|
|
225
|
+
labelsHiddenByStrategy: false,
|
|
226
|
+
labelsDensityNone: spec.labels.density === 'none',
|
|
227
|
+
});
|
|
228
|
+
if (!sup.showEndpointLabels) return emptyLayout(theme);
|
|
229
|
+
|
|
230
|
+
// Dedupe by seriesKey: for area charts, the engine emits BOTH an AreaMark
|
|
231
|
+
// and a derived LineMark per series (see `linesFromAreas` in
|
|
232
|
+
// `charts/line/index.ts`). Without dedupe each series would produce two
|
|
233
|
+
// endpoint entries. Prefer the line mark — its `stroke` is the canonical
|
|
234
|
+
// series color and matches the visible line, whereas the area mark's
|
|
235
|
+
// `stroke` may be derived from a gradient via `getRepresentativeColor`.
|
|
236
|
+
// Same-type collisions (area→area, line→line) keep the first mark; the
|
|
237
|
+
// engine never emits two of the same type per seriesKey.
|
|
238
|
+
const bySeriesKey = new Map<string, LineMark | AreaMark>();
|
|
239
|
+
for (const mark of marks) {
|
|
240
|
+
if (!isLineOrArea(mark) || !mark.seriesKey) continue;
|
|
241
|
+
const existing = bySeriesKey.get(mark.seriesKey);
|
|
242
|
+
if (!existing || (existing.type === 'area' && mark.type === 'line')) {
|
|
243
|
+
bySeriesKey.set(mark.seriesKey, mark);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const lineOrAreaMarks = Array.from(bySeriesKey.values());
|
|
247
|
+
if (lineOrAreaMarks.length < 2) return emptyLayout(theme);
|
|
248
|
+
|
|
249
|
+
const config = typeof spec.endpointLabels === 'object' ? spec.endpointLabels : undefined;
|
|
250
|
+
const wrapWidth = config?.width ?? ENDPOINT_WRAP_WIDTH_DEFAULT;
|
|
251
|
+
const valueField = config?.valueField ?? spec.encoding.y?.field;
|
|
252
|
+
const formatString =
|
|
253
|
+
config?.format ??
|
|
254
|
+
((spec.encoding.y?.axis as Record<string, unknown> | undefined)?.format as string | undefined);
|
|
255
|
+
const showMarker = config?.showMarker !== false;
|
|
256
|
+
const markerRadius = config?.markerStyle?.radius ?? ENDPOINT_MARKER_RADIUS;
|
|
257
|
+
const markerStrokeWidth = config?.markerStyle?.strokeWidth ?? ENDPOINT_MARKER_STROKE_WIDTH;
|
|
258
|
+
const markerFill = config?.markerStyle?.fill ?? theme.colors.background;
|
|
259
|
+
|
|
260
|
+
// Resolve the styles once.
|
|
261
|
+
const labelStyle: TextStyle = {
|
|
262
|
+
fontFamily: theme.fonts.family,
|
|
263
|
+
fontSize: ENDPOINT_LABEL_FONT_SIZE,
|
|
264
|
+
fontWeight: ENDPOINT_LABEL_FONT_WEIGHT,
|
|
265
|
+
fill: theme.colors.text,
|
|
266
|
+
lineHeight: ENDPOINT_LINE_HEIGHT,
|
|
267
|
+
};
|
|
268
|
+
const valueStyle: TextStyle = {
|
|
269
|
+
fontFamily: theme.fonts.family,
|
|
270
|
+
fontSize: ENDPOINT_VALUE_FONT_SIZE,
|
|
271
|
+
fontWeight: ENDPOINT_VALUE_FONT_WEIGHT,
|
|
272
|
+
fill: theme.colors.annotationText ?? theme.colors.text,
|
|
273
|
+
lineHeight: ENDPOINT_LINE_HEIGHT,
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// First pass: build entries with wrapped labels and dataY (no positions yet).
|
|
277
|
+
type Provisional = {
|
|
278
|
+
seriesKey: string;
|
|
279
|
+
labelLines: string[];
|
|
280
|
+
value: string;
|
|
281
|
+
color: string;
|
|
282
|
+
dataX: number;
|
|
283
|
+
dataY: number;
|
|
284
|
+
height: number;
|
|
285
|
+
width: number;
|
|
286
|
+
};
|
|
287
|
+
const provisional: Provisional[] = [];
|
|
288
|
+
const labelLineHeight = ENDPOINT_LABEL_FONT_SIZE * ENDPOINT_LINE_HEIGHT;
|
|
289
|
+
const valueLineHeight = ENDPOINT_VALUE_FONT_SIZE * ENDPOINT_LINE_HEIGHT;
|
|
290
|
+
|
|
291
|
+
for (const mark of lineOrAreaMarks) {
|
|
292
|
+
const seriesKey = mark.seriesKey;
|
|
293
|
+
if (!seriesKey) continue;
|
|
294
|
+
const last = lastDataPoint(mark);
|
|
295
|
+
if (!last) continue;
|
|
296
|
+
|
|
297
|
+
const labelLines = wrapText(
|
|
298
|
+
seriesKey,
|
|
299
|
+
ENDPOINT_LABEL_FONT_SIZE,
|
|
300
|
+
ENDPOINT_LABEL_FONT_WEIGHT,
|
|
301
|
+
wrapWidth,
|
|
302
|
+
);
|
|
303
|
+
const rawValue = readValue(mark, valueField);
|
|
304
|
+
const value = formatEndpointValue(rawValue, formatString);
|
|
305
|
+
|
|
306
|
+
// Width of the widest line in this entry (used to size the column).
|
|
307
|
+
let entryWidth = 0;
|
|
308
|
+
for (const line of labelLines) {
|
|
309
|
+
const w = estimateTextWidth(line, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT);
|
|
310
|
+
if (w > entryWidth) entryWidth = w;
|
|
311
|
+
}
|
|
312
|
+
const valueWidth = value
|
|
313
|
+
? estimateTextWidth(value, ENDPOINT_VALUE_FONT_SIZE, ENDPOINT_VALUE_FONT_WEIGHT)
|
|
314
|
+
: 0;
|
|
315
|
+
if (valueWidth > entryWidth) entryWidth = valueWidth;
|
|
316
|
+
|
|
317
|
+
const labelHeight = labelLines.length * labelLineHeight;
|
|
318
|
+
const valueHeight = value ? valueLineHeight : 0;
|
|
319
|
+
const entryHeight = labelHeight + (value ? ENDPOINT_VALUE_GAP : 0) + valueHeight;
|
|
320
|
+
|
|
321
|
+
// Determine the line stroke color. AreaMark may have a string fill; prefer
|
|
322
|
+
// mark.stroke for visual continuity with the line drawn on top of the area.
|
|
323
|
+
const color =
|
|
324
|
+
mark.type === 'line'
|
|
325
|
+
? mark.stroke
|
|
326
|
+
: (mark.stroke ??
|
|
327
|
+
(typeof mark.fill === 'string' ? mark.fill : theme.colors.categorical[0]));
|
|
328
|
+
|
|
329
|
+
provisional.push({
|
|
330
|
+
seriesKey,
|
|
331
|
+
labelLines,
|
|
332
|
+
value,
|
|
333
|
+
color,
|
|
334
|
+
dataX: last.x,
|
|
335
|
+
dataY: last.y,
|
|
336
|
+
height: entryHeight,
|
|
337
|
+
width: entryWidth,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (provisional.length < 2) return emptyLayout(theme);
|
|
342
|
+
|
|
343
|
+
// Bidirectional collision sweep. Each entry's natural top is `dataY - labelFontSize/2`
|
|
344
|
+
// so the label's first-line baseline-center aligns with the line's last data point.
|
|
345
|
+
// The marker sits on this same baseline-center row, putting the open ring directly
|
|
346
|
+
// where the line terminates when undisplaced.
|
|
347
|
+
// Add ENDPOINT_ENTRY_GAP to each entry's claimed height so the sweep leaves
|
|
348
|
+
// breathing room between stacked labels. The renderer doesn't draw the gap,
|
|
349
|
+
// so it's invisible when entries are well-separated.
|
|
350
|
+
const sweepInput = provisional.map((p, i) => ({
|
|
351
|
+
naturalTop: p.dataY - ENDPOINT_LABEL_FONT_SIZE / 2,
|
|
352
|
+
height: p.height + ENDPOINT_ENTRY_GAP,
|
|
353
|
+
index: i,
|
|
354
|
+
}));
|
|
355
|
+
const sweptTops = bidirectionalSweep(sweepInput, chartArea.y, chartArea.y + chartArea.height);
|
|
356
|
+
|
|
357
|
+
// Build final entries.
|
|
358
|
+
const columnWidth = Math.max(
|
|
359
|
+
ENDPOINT_SWATCH_SIZE + ENDPOINT_GAP + Math.max(...provisional.map((p) => p.width), 0) + 4,
|
|
360
|
+
32,
|
|
361
|
+
);
|
|
362
|
+
const columnX = chartArea.x + chartArea.width + ENDPOINT_COLUMN_GAP;
|
|
363
|
+
const markerX = chartArea.x + chartArea.width;
|
|
364
|
+
|
|
365
|
+
// The marker is the line's visual terminator — always at (chartRightX, dataY).
|
|
366
|
+
// The swatch + label first-line baseline-center sit at `labelY + fontSize/2`.
|
|
367
|
+
// The sweep uses naturalTop = `dataY - fontSize/2` so that, when undisplaced,
|
|
368
|
+
// the swatch row and the marker share the same y, producing the clean
|
|
369
|
+
// single-row look from the mocks.
|
|
370
|
+
//
|
|
371
|
+
// Leader lines are off by default. The marker on the line plus the swatch in
|
|
372
|
+
// the column already tie label to data; a connecting line adds noise without
|
|
373
|
+
// adding information for the small displacements the sweep produces. Users
|
|
374
|
+
// who want them can opt in via `endpointLabels.showLeader: true`.
|
|
375
|
+
const showLeader = config?.showLeader === true;
|
|
376
|
+
const entries: EndpointLabelEntry[] = provisional.map((p, i) => {
|
|
377
|
+
const labelY = sweptTops[i];
|
|
378
|
+
const swatchRowY = labelY + ENDPOINT_LABEL_FONT_SIZE / 2;
|
|
379
|
+
const displaced = Math.abs(swatchRowY - p.dataY) > ENDPOINT_LEADER_THRESHOLD;
|
|
380
|
+
const entry: EndpointLabelEntry = {
|
|
381
|
+
seriesKey: p.seriesKey,
|
|
382
|
+
labelLines: p.labelLines,
|
|
383
|
+
value: p.value,
|
|
384
|
+
color: p.color,
|
|
385
|
+
dataY: p.dataY,
|
|
386
|
+
labelY,
|
|
387
|
+
showLeader: showLeader && displaced,
|
|
388
|
+
};
|
|
389
|
+
if (showMarker) {
|
|
390
|
+
entry.marker = {
|
|
391
|
+
x: markerX,
|
|
392
|
+
y: p.dataY,
|
|
393
|
+
fill: markerFill,
|
|
394
|
+
stroke: config?.markerStyle?.stroke ?? p.color,
|
|
395
|
+
strokeWidth: markerStrokeWidth,
|
|
396
|
+
radius: markerRadius,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
return entry;
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
entries,
|
|
404
|
+
bounds: {
|
|
405
|
+
x: columnX,
|
|
406
|
+
y: chartArea.y,
|
|
407
|
+
width: columnWidth,
|
|
408
|
+
height: chartArea.height,
|
|
409
|
+
},
|
|
410
|
+
labelStyle,
|
|
411
|
+
valueStyle,
|
|
412
|
+
swatchSize: ENDPOINT_SWATCH_SIZE,
|
|
413
|
+
gap: ENDPOINT_GAP,
|
|
414
|
+
valueGap: ENDPOINT_VALUE_GAP,
|
|
415
|
+
swatchChipFill: theme.colors.annotationFill ?? theme.colors.background,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared constants for endpoint-labels prediction and computation.
|
|
3
|
+
*
|
|
4
|
+
* Both `predict.ts` (width-only, runs before marks) and `compute.ts` (full
|
|
5
|
+
* layout, runs after marks) read from these so the predicted width can never
|
|
6
|
+
* drift from the eventual rendered geometry.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Series-name label font size. Matches body text for editorial prominence. */
|
|
10
|
+
export const ENDPOINT_LABEL_FONT_SIZE = 13;
|
|
11
|
+
|
|
12
|
+
/** Series-name label font weight (semibold for visual prominence). */
|
|
13
|
+
export const ENDPOINT_LABEL_FONT_WEIGHT = 600;
|
|
14
|
+
|
|
15
|
+
/** Last-value label font size (slightly smaller than the series name). */
|
|
16
|
+
export const ENDPOINT_VALUE_FONT_SIZE = 12;
|
|
17
|
+
|
|
18
|
+
/** Last-value label font weight (regular, muted text tone). */
|
|
19
|
+
export const ENDPOINT_VALUE_FONT_WEIGHT = 400;
|
|
20
|
+
|
|
21
|
+
/** Default wrap width for long series names, in pixels. */
|
|
22
|
+
export const ENDPOINT_WRAP_WIDTH_DEFAULT = 110;
|
|
23
|
+
|
|
24
|
+
/** Width of the colored swatch chip drawn left of the label. */
|
|
25
|
+
export const ENDPOINT_SWATCH_SIZE = 18;
|
|
26
|
+
|
|
27
|
+
/** Gap between swatch, label, and value. */
|
|
28
|
+
export const ENDPOINT_GAP = 8;
|
|
29
|
+
|
|
30
|
+
/** Line height multiplier for wrapped label lines. */
|
|
31
|
+
export const ENDPOINT_LINE_HEIGHT = 1.25;
|
|
32
|
+
|
|
33
|
+
/** Pixel padding between the chart area's right edge and the column. */
|
|
34
|
+
export const ENDPOINT_COLUMN_GAP = 18;
|
|
35
|
+
|
|
36
|
+
/** Vertical pad between the last wrapped label line and the value text. */
|
|
37
|
+
export const ENDPOINT_VALUE_GAP = 4;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Vertical breathing room between adjacent stacked entries during the
|
|
41
|
+
* collision sweep. The renderer doesn't draw this gap — it's purely a
|
|
42
|
+
* sweep-time cushion so that the value text of entry N doesn't hug the
|
|
43
|
+
* label text of entry N+1 when the sweep has packed them tightly.
|
|
44
|
+
*/
|
|
45
|
+
export const ENDPOINT_ENTRY_GAP = 6;
|
|
46
|
+
|
|
47
|
+
/** Default open-circle marker radius on the line. */
|
|
48
|
+
export const ENDPOINT_MARKER_RADIUS = 4;
|
|
49
|
+
|
|
50
|
+
/** Default open-circle marker stroke width. */
|
|
51
|
+
export const ENDPOINT_MARKER_STROKE_WIDTH = 2;
|
|
52
|
+
|
|
53
|
+
/** Threshold (px) above which a leader line connects label back to data point. */
|
|
54
|
+
export const ENDPOINT_LEADER_THRESHOLD = 8;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared value-formatting helpers for the endpoint-labels predictor and
|
|
3
|
+
* compute pass. Keeping the format-string path in one place guarantees
|
|
4
|
+
* predicted column width matches the eventual rendered text.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { format as d3Format } from 'd3-format';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Format a value with a d3-format string, falling back to String() on
|
|
11
|
+
* unknown specifiers. When no format string is supplied, applies a
|
|
12
|
+
* sensible default: integers under 1000 render as-is, anything else gets
|
|
13
|
+
* two decimals.
|
|
14
|
+
*/
|
|
15
|
+
export function formatEndpointValue(
|
|
16
|
+
value: number | string | null,
|
|
17
|
+
formatString: string | undefined,
|
|
18
|
+
): string {
|
|
19
|
+
if (value == null) return '';
|
|
20
|
+
if (typeof value === 'string') return value;
|
|
21
|
+
if (formatString) {
|
|
22
|
+
try {
|
|
23
|
+
return d3Format(formatString)(value);
|
|
24
|
+
} catch {
|
|
25
|
+
return String(value);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (Number.isInteger(value) && Math.abs(value) < 1000) return String(value);
|
|
29
|
+
return value.toFixed(2);
|
|
30
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Width-only predictor for the endpoint labels column.
|
|
3
|
+
*
|
|
4
|
+
* `computeDimensions` runs BEFORE marks are computed, so it can't read
|
|
5
|
+
* `mark.dataPoints[-1].y` to lay out the column. But it still needs to reserve
|
|
6
|
+
* the right margin so marks render inside the data area instead of underneath
|
|
7
|
+
* the column.
|
|
8
|
+
*
|
|
9
|
+
* Single-pass design: this predictor returns ONLY the column's width. The full
|
|
10
|
+
* entry positions are computed exactly once, in `computeEndpointLabels`, after
|
|
11
|
+
* dimensions settle. Both call sites use the same wrapping math (font size,
|
|
12
|
+
* weight, max width) so the predicted width matches the eventual layout.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ResolvedTheme } from '@opendata-ai/openchart-core';
|
|
16
|
+
import { estimateTextWidth, wrapText } from '@opendata-ai/openchart-core';
|
|
17
|
+
|
|
18
|
+
import type { NormalizedChartSpec } from '../compiler/types';
|
|
19
|
+
import {
|
|
20
|
+
ENDPOINT_GAP,
|
|
21
|
+
ENDPOINT_LABEL_FONT_SIZE,
|
|
22
|
+
ENDPOINT_LABEL_FONT_WEIGHT,
|
|
23
|
+
ENDPOINT_SWATCH_SIZE,
|
|
24
|
+
ENDPOINT_VALUE_FONT_SIZE,
|
|
25
|
+
ENDPOINT_VALUE_FONT_WEIGHT,
|
|
26
|
+
ENDPOINT_WRAP_WIDTH_DEFAULT,
|
|
27
|
+
} from './constants';
|
|
28
|
+
import { formatEndpointValue } from './format';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Predict the pixel width the endpoint-labels column will need, including
|
|
32
|
+
* swatch + label + value + padding. Returns 0 when the column would be empty
|
|
33
|
+
* (single series, opt-out, non-line/area, etc.).
|
|
34
|
+
*
|
|
35
|
+
* Does NOT do collision sweep or full layout. Does the same `wrapText` math
|
|
36
|
+
* as `computeEndpointLabels` so the two stay aligned by construction.
|
|
37
|
+
*/
|
|
38
|
+
export function predictEndpointLabelsWidth(
|
|
39
|
+
spec: NormalizedChartSpec,
|
|
40
|
+
_theme: ResolvedTheme,
|
|
41
|
+
): number {
|
|
42
|
+
if (spec.endpointLabels === false) return 0;
|
|
43
|
+
if (spec.markType !== 'line' && spec.markType !== 'area') return 0;
|
|
44
|
+
const colorEnc = spec.encoding.color;
|
|
45
|
+
if (!colorEnc) return 0;
|
|
46
|
+
if ('condition' in colorEnc) return 0;
|
|
47
|
+
if (colorEnc.type === 'quantitative') return 0;
|
|
48
|
+
|
|
49
|
+
// Distinct series count.
|
|
50
|
+
const colorField = 'field' in colorEnc ? colorEnc.field : undefined;
|
|
51
|
+
if (!colorField) return 0;
|
|
52
|
+
const seriesNames = new Set<string>();
|
|
53
|
+
for (const row of spec.data) {
|
|
54
|
+
seriesNames.add(String(row[colorField]));
|
|
55
|
+
}
|
|
56
|
+
if (seriesNames.size < 2) return 0;
|
|
57
|
+
|
|
58
|
+
// When the user passed `endpointLabels: false`, predict 0. (Already handled above
|
|
59
|
+
// for the bare boolean; also handle the object form `{ show: false }`.)
|
|
60
|
+
if (typeof spec.endpointLabels === 'object' && spec.endpointLabels?.show === false) return 0;
|
|
61
|
+
|
|
62
|
+
const config = typeof spec.endpointLabels === 'object' ? spec.endpointLabels : undefined;
|
|
63
|
+
const wrapWidth = config?.width ?? ENDPOINT_WRAP_WIDTH_DEFAULT;
|
|
64
|
+
|
|
65
|
+
// Wrap each series name and find the widest wrapped line.
|
|
66
|
+
let maxLabelWidth = 0;
|
|
67
|
+
for (const name of seriesNames) {
|
|
68
|
+
const lines = wrapText(name, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT, wrapWidth);
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const w = estimateTextWidth(line, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT);
|
|
71
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Estimate value width from the spec's data + format. We don't know which row
|
|
76
|
+
// is "last" without computing scales/marks, so we sample the largest absolute
|
|
77
|
+
// value to bound the formatted width.
|
|
78
|
+
const yField = config?.valueField ?? spec.encoding.y?.field;
|
|
79
|
+
const yFormat =
|
|
80
|
+
config?.format ??
|
|
81
|
+
((spec.encoding.y?.axis as Record<string, unknown> | undefined)?.format as string | undefined);
|
|
82
|
+
let valueWidth = 0;
|
|
83
|
+
if (yField) {
|
|
84
|
+
let maxAbs = 0;
|
|
85
|
+
for (const row of spec.data) {
|
|
86
|
+
const v = Number(row[yField]);
|
|
87
|
+
if (Number.isFinite(v) && Math.abs(v) > maxAbs) maxAbs = Math.abs(v);
|
|
88
|
+
}
|
|
89
|
+
// When the user supplied a format string, run it through the same
|
|
90
|
+
// formatter compute.ts will use so width prediction matches reality.
|
|
91
|
+
// Without one, fall back to a magnitude-aware sample that bounds the
|
|
92
|
+
// expected unformatted value width (compute.ts uses toFixed(2) here,
|
|
93
|
+
// which is roughly the same character count as "1.5K"-style abbreviations
|
|
94
|
+
// for the upper end of each band).
|
|
95
|
+
let sample: string;
|
|
96
|
+
if (yFormat) {
|
|
97
|
+
sample = formatEndpointValue(maxAbs, yFormat);
|
|
98
|
+
} else if (maxAbs >= 1_000_000_000) sample = '1.5B';
|
|
99
|
+
else if (maxAbs >= 1_000_000) sample = '1.5M';
|
|
100
|
+
else if (maxAbs >= 1_000) sample = '1.5K';
|
|
101
|
+
else sample = String(Math.round(maxAbs * 100) / 100);
|
|
102
|
+
valueWidth = estimateTextWidth(sample, ENDPOINT_VALUE_FONT_SIZE, ENDPOINT_VALUE_FONT_WEIGHT);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Column = swatch + gap + max(label width, value width) + small trailing pad.
|
|
106
|
+
const textColumn = Math.max(maxLabelWidth, valueWidth);
|
|
107
|
+
return ENDPOINT_SWATCH_SIZE + ENDPOINT_GAP + textColumn + 4;
|
|
108
|
+
}
|
package/src/layout/axes.ts
CHANGED
|
@@ -213,6 +213,12 @@ export interface AxesDataContext {
|
|
|
213
213
|
skipX?: boolean;
|
|
214
214
|
/** Same as skipX, for the y-axis. */
|
|
215
215
|
skipY?: boolean;
|
|
216
|
+
/**
|
|
217
|
+
* The chart's primary mark type. Used to default tickPosition: line and area
|
|
218
|
+
* y-axes default to `'inline'` (labels above gridlines, no gutter); other
|
|
219
|
+
* marks default to `'gutter'`.
|
|
220
|
+
*/
|
|
221
|
+
markType?: import('@opendata-ai/openchart-core').MarkType;
|
|
216
222
|
}
|
|
217
223
|
|
|
218
224
|
/**
|
|
@@ -352,6 +358,9 @@ export function computeAxes(
|
|
|
352
358
|
|
|
353
359
|
const axisTitle = axisConfig?.title;
|
|
354
360
|
const xLabelColor = axisConfig?.labelColor;
|
|
361
|
+
// X-axis defaults to gutter (no inline mode is sensible for the x axis
|
|
362
|
+
// because tick labels need horizontal room around their x position).
|
|
363
|
+
const xTickPosition = axisConfig?.tickPosition ?? 'gutter';
|
|
355
364
|
|
|
356
365
|
result.x = {
|
|
357
366
|
ticks,
|
|
@@ -370,6 +379,7 @@ export function computeAxes(
|
|
|
370
379
|
labelPadding: axisConfig?.labelPadding,
|
|
371
380
|
labelOverlap: axisConfig?.labelOverlap,
|
|
372
381
|
labelFlush: axisConfig?.labelFlush,
|
|
382
|
+
tickPosition: xTickPosition,
|
|
373
383
|
};
|
|
374
384
|
}
|
|
375
385
|
|
|
@@ -440,6 +450,20 @@ export function computeAxes(
|
|
|
440
450
|
const axisTitle = axisConfig?.title;
|
|
441
451
|
const tickAngle = axisConfig?.labelAngle;
|
|
442
452
|
const yLabelColor = axisConfig?.labelColor;
|
|
453
|
+
// Editorial line/area y-axes default to inline tick labels above their
|
|
454
|
+
// gridlines. Other mark types keep the classic gutter placement. Right-side
|
|
455
|
+
// y-axes (dual-axis) always use gutter.
|
|
456
|
+
const isContinuousYAxis =
|
|
457
|
+
scales.y.type !== 'band' && scales.y.type !== 'point' && scales.y.type !== 'ordinal';
|
|
458
|
+
const isLineOrArea = dataContext?.markType === 'line' || dataContext?.markType === 'area';
|
|
459
|
+
const yTickPosition: 'inline' | 'gutter' =
|
|
460
|
+
axisConfig?.tickPosition ??
|
|
461
|
+
(isLineOrArea && isContinuousYAxis && axisConfig?.orient !== 'right' ? 'inline' : 'gutter');
|
|
462
|
+
|
|
463
|
+
// Inline mode hides the axis line and tick marks by default; the gridlines
|
|
464
|
+
// themselves serve as the visual axis. Explicit user overrides win.
|
|
465
|
+
const yDomainLine = axisConfig?.domain ?? (yTickPosition === 'inline' ? false : undefined);
|
|
466
|
+
const yTickMarks = axisConfig?.ticks ?? (yTickPosition === 'inline' ? false : undefined);
|
|
443
467
|
|
|
444
468
|
result.y = {
|
|
445
469
|
ticks,
|
|
@@ -452,13 +476,14 @@ export function computeAxes(
|
|
|
452
476
|
start: { x: chartArea.x, y: chartArea.y },
|
|
453
477
|
end: { x: chartArea.x, y: chartArea.y + chartArea.height },
|
|
454
478
|
orient: axisConfig?.orient,
|
|
455
|
-
domainLine:
|
|
456
|
-
tickMarks:
|
|
479
|
+
domainLine: yDomainLine,
|
|
480
|
+
tickMarks: yTickMarks,
|
|
457
481
|
offset: axisConfig?.offset,
|
|
458
482
|
titlePadding: axisConfig?.titlePadding,
|
|
459
483
|
labelPadding: axisConfig?.labelPadding,
|
|
460
484
|
labelOverlap: axisConfig?.labelOverlap,
|
|
461
485
|
labelFlush: axisConfig?.labelFlush,
|
|
486
|
+
tickPosition: yTickPosition,
|
|
462
487
|
};
|
|
463
488
|
}
|
|
464
489
|
|