@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.
Files changed (46) hide show
  1. package/README.md +1 -0
  2. package/dist/index.d.ts +8 -11
  3. package/dist/index.js +12307 -11338
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/src/__test-fixtures__/specs.ts +3 -0
  7. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +452 -377
  8. package/src/__tests__/axes.test.ts +75 -0
  9. package/src/__tests__/compile-chart.test.ts +304 -0
  10. package/src/__tests__/dimensions.test.ts +224 -0
  11. package/src/__tests__/legend.test.ts +44 -3
  12. package/src/annotations/__tests__/compute.test.ts +111 -0
  13. package/src/annotations/__tests__/resolve-text.test.ts +288 -0
  14. package/src/annotations/constants.ts +20 -0
  15. package/src/annotations/resolve-text.ts +161 -7
  16. package/src/charts/bar/compute.ts +24 -0
  17. package/src/charts/bar/labels.ts +1 -0
  18. package/src/charts/column/compute.ts +33 -1
  19. package/src/charts/column/labels.ts +1 -0
  20. package/src/charts/dot/labels.ts +1 -0
  21. package/src/charts/line/__tests__/compute.test.ts +153 -3
  22. package/src/charts/line/area.ts +111 -23
  23. package/src/charts/line/compute.ts +40 -10
  24. package/src/charts/line/index.ts +34 -7
  25. package/src/charts/line/labels.ts +29 -0
  26. package/src/charts/pie/labels.ts +1 -0
  27. package/src/compile/layer.ts +498 -0
  28. package/src/compile.ts +221 -586
  29. package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
  30. package/src/compiler/normalize.ts +12 -1
  31. package/src/compiler/sparkline-defaults.ts +138 -0
  32. package/src/compiler/types.ts +8 -0
  33. package/src/endpoint-labels/__tests__/compute.test.ts +438 -0
  34. package/src/endpoint-labels/compute.ts +417 -0
  35. package/src/endpoint-labels/constants.ts +54 -0
  36. package/src/endpoint-labels/format.ts +30 -0
  37. package/src/endpoint-labels/predict.ts +108 -0
  38. package/src/graphs/compile-graph.ts +1 -0
  39. package/src/layout/axes.ts +27 -2
  40. package/src/layout/dimensions.ts +282 -34
  41. package/src/layout/metrics.ts +118 -0
  42. package/src/layout/scales.ts +41 -4
  43. package/src/legend/__tests__/suppression.test.ts +294 -0
  44. package/src/legend/compute.ts +50 -40
  45. package/src/legend/suppression.ts +204 -0
  46. 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
+ }
@@ -109,6 +109,7 @@ function buildGraphLegend(
109
109
  swatchSize: SWATCH_SIZE,
110
110
  swatchGap: SWATCH_GAP,
111
111
  entryGap: ENTRY_GAP,
112
+ swatchChipFill: theme.colors.annotationFill,
112
113
  };
113
114
  }
114
115
 
@@ -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: axisConfig?.domain,
456
- tickMarks: axisConfig?.ticks,
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