@opendata-ai/openchart-engine 7.6.1 → 7.8.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.
Files changed (42) hide show
  1. package/dist/index.d.ts +13 -0
  2. package/dist/index.js +1037 -187
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +6 -0
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +16 -0
  7. package/src/__tests__/axes.test.ts +3 -2
  8. package/src/__tests__/facet.test.ts +215 -0
  9. package/src/annotations/__tests__/compute.test.ts +18 -0
  10. package/src/annotations/__tests__/thinning.test.ts +137 -0
  11. package/src/annotations/compute.ts +3 -1
  12. package/src/annotations/thinning.ts +116 -0
  13. package/src/barlist/compile-barlist.ts +1 -0
  14. package/src/charts/bar/labels.ts +1 -0
  15. package/src/charts/column/labels.ts +1 -0
  16. package/src/charts/dot/labels.ts +1 -0
  17. package/src/charts/line/area.ts +16 -2
  18. package/src/charts/line/compute.ts +17 -10
  19. package/src/charts/pie/labels.ts +1 -0
  20. package/src/compile/__tests__/color-scale-range.test.ts +80 -1
  21. package/src/compile/color-scale-range.ts +44 -2
  22. package/src/compile.ts +314 -5
  23. package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
  24. package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
  25. package/src/compiler/normalize.ts +67 -2
  26. package/src/compiler/types.ts +10 -0
  27. package/src/compiler/validate.ts +79 -0
  28. package/src/endpoint-labels/__tests__/compute.test.ts +57 -0
  29. package/src/endpoint-labels/compute.ts +179 -5
  30. package/src/endpoint-labels/predict.ts +17 -0
  31. package/src/layout/__tests__/scales-highlight.test.ts +192 -0
  32. package/src/layout/axes.ts +8 -2
  33. package/src/layout/dimensions.ts +6 -1
  34. package/src/layout/facet.ts +80 -0
  35. package/src/layout/plan.ts +16 -0
  36. package/src/legend/__tests__/redundancy.test.ts +108 -0
  37. package/src/legend/__tests__/suppression.test.ts +72 -0
  38. package/src/legend/compute.ts +18 -0
  39. package/src/legend/suppression.ts +27 -6
  40. package/src/tilemap/__tests__/compile-tilemap.test.ts +210 -0
  41. package/src/tilemap/compile-tilemap.ts +379 -117
  42. package/src/tilemap/types.ts +1 -0
@@ -217,6 +217,43 @@ function normalizeLabels(labels?: LabelSpec): NormalizedChartSpec['labels'] {
217
217
  };
218
218
  }
219
219
 
220
+ // ---------------------------------------------------------------------------
221
+ // Highlight normalization
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /**
225
+ * Normalize `encoding.color.highlight` to a flat string array.
226
+ * Warns when highlight values don't appear in the data.
227
+ */
228
+ function normalizeHighlight(encoding: Encoding, data: DataRow[], warnings: string[]): string[] {
229
+ const color = encoding.color;
230
+ if (!color || !('field' in color)) return [];
231
+ const raw = (color as { highlight?: string | string[] }).highlight;
232
+ if (raw == null) return [];
233
+ const highlight = Array.isArray(raw) ? raw : [raw];
234
+
235
+ // Warn on unknown values
236
+ if (highlight.length > 0 && data.length > 0) {
237
+ const colorField = color.field;
238
+ const knownValues = new Set(data.map((row) => String(row[colorField])));
239
+ for (const v of highlight) {
240
+ if (!knownValues.has(v)) {
241
+ warnings.push(
242
+ `[openchart] encoding.color.highlight value "${v}" does not match any value in the "${colorField}" data column`,
243
+ );
244
+ }
245
+ }
246
+ }
247
+
248
+ if (highlight.length > 0 && color.scale?.range) {
249
+ warnings.push(
250
+ '[openchart] encoding.color.highlight has no effect on color assignment when encoding.color.scale.range is explicitly provided',
251
+ );
252
+ }
253
+
254
+ return highlight;
255
+ }
256
+
220
257
  // ---------------------------------------------------------------------------
221
258
  // Spec-level normalization
222
259
  // ---------------------------------------------------------------------------
@@ -250,13 +287,19 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
250
287
  labels: normalizeLabels(spec.labels),
251
288
  legend: spec.legend,
252
289
  endpointLabels: spec.endpointLabels,
253
- responsive: spec.responsive ?? true,
290
+ responsive: typeof spec.responsive === 'object' ? true : (spec.responsive ?? true),
291
+ autoThin:
292
+ typeof spec.responsive === 'object'
293
+ ? (spec.responsive.autoThin ?? true)
294
+ : (spec.responsive ?? true),
254
295
  theme: spec.theme ?? {},
255
296
  darkMode: spec.darkMode ?? 'off',
256
297
  hiddenSeries: spec.hiddenSeries ?? [],
257
298
  seriesStyles: spec.seriesStyles ?? {},
258
299
  watermark: spec.watermark ?? true,
300
+ highlight: normalizeHighlight(encoding, spec.data, warnings),
259
301
  display,
302
+ resolve: spec.resolve,
260
303
  // Default empty userExplicit; compileChart overwrites this with the real
261
304
  // descriptor built from the raw expanded spec before normalize runs.
262
305
  userExplicit: {
@@ -353,15 +396,36 @@ function normalizeTileMapSpec(spec: TileMapSpec, warnings: string[]): Normalized
353
396
  data = Object.entries(spec.data).map(([state, value]) => ({ state, value }));
354
397
  }
355
398
 
399
+ // Detect categorical mode: colors map provided, color encoding channel, or string values
400
+ const hasStringValues =
401
+ !Array.isArray(spec.data) && Object.values(spec.data).some((v) => typeof v === 'string');
402
+ const isCategorical =
403
+ spec.colors !== undefined || spec.encoding?.color !== undefined || hasStringValues;
404
+
405
+ // Warn on mixed number/string values in record map (likely a data quality issue)
406
+ if (hasStringValues && !Array.isArray(spec.data)) {
407
+ const hasNumericValues = Object.values(spec.data).some((v) => typeof v === 'number');
408
+ if (hasNumericValues) {
409
+ warnings.push(
410
+ 'TileMap data: record map contains mixed number and string values. Treating as categorical. Use all numbers for quantitative or all strings for categorical.',
411
+ );
412
+ }
413
+ }
414
+
356
415
  // Auto-generate encoding if not provided
357
416
  let encoding = spec.encoding;
358
417
  if (!encoding) {
359
418
  encoding = {
360
419
  state: { field: 'state', type: 'nominal' },
361
- value: { field: 'value', type: 'quantitative' },
420
+ value: { field: 'value', type: isCategorical ? 'nominal' : 'quantitative' },
362
421
  };
363
422
  }
364
423
 
424
+ // For categorical record maps without explicit color encoding, use value as color channel
425
+ if (isCategorical && !encoding.color) {
426
+ encoding = { ...encoding, color: encoding.value };
427
+ }
428
+
365
429
  // Count matched states and warn if low match ratio
366
430
  let matchedCount = 0;
367
431
  for (const row of data) {
@@ -383,6 +447,7 @@ function normalizeTileMapSpec(spec: TileMapSpec, warnings: string[]): Normalized
383
447
  data,
384
448
  encoding,
385
449
  palette: spec.palette ?? 'blue',
450
+ colors: spec.colors,
386
451
  chrome: normalizeChrome(spec.chrome),
387
452
  legend: spec.legend,
388
453
  theme: spec.theme ?? {},
@@ -116,6 +116,8 @@ export interface NormalizedChartSpec {
116
116
  /** Right-side endpoint labels column config (multi-series line/area only). */
117
117
  endpointLabels?: boolean | import('@opendata-ai/openchart-core').EndpointLabelsConfig;
118
118
  responsive: boolean;
119
+ /** Whether auto-thinning of annotations at narrow widths is enabled. Resolved from `responsive.autoThin`. */
120
+ autoThin: boolean;
119
121
  theme: ThemeConfig;
120
122
  darkMode: DarkMode;
121
123
  /** Whether the tryOpenData.ai watermark is enabled. */
@@ -126,12 +128,20 @@ export interface NormalizedChartSpec {
126
128
  seriesStyles: Record<string, import('@opendata-ai/openchart-core').SeriesStyle>;
127
129
  /** Display mode controlling chrome/axes/legend stripping. Defaults to `'full'`. */
128
130
  display: Display;
131
+ /** Resolve configuration for independent/shared scales in faceted charts. */
132
+ resolve?: import('@opendata-ai/openchart-core').ResolveConfig;
129
133
  /**
130
134
  * Which top-level fields the user explicitly set. Populated by compileChart
131
135
  * from the raw expanded spec before normalization. NormalizeChartSpec runs
132
136
  * with a default-empty descriptor; compileChart overwrites it post-normalize.
133
137
  */
134
138
  userExplicit: UserExplicit;
139
+ /**
140
+ * Series values to visually emphasize. Non-highlighted series are muted to
141
+ * a neutral gray. Normalized from `encoding.color.highlight` (always an
142
+ * array; empty when no highlight is active).
143
+ */
144
+ highlight: string[];
135
145
  }
136
146
 
137
147
  /** A TableSpec with all optional fields filled with sensible defaults. */
@@ -11,6 +11,7 @@
11
11
 
12
12
  import {
13
13
  type FieldType,
14
+ inferFieldType,
14
15
  MARK_ENCODING_RULES,
15
16
  MARK_TYPES,
16
17
  type MarkType,
@@ -269,6 +270,84 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
269
270
  }
270
271
  }
271
272
 
273
+ // Validate highlight on the color channel
274
+ if (encoding) {
275
+ const colorCh = (encoding as Record<string, Record<string, unknown> | undefined>).color;
276
+ if (colorCh && colorCh.highlight != null) {
277
+ const hlVal = colorCh.highlight;
278
+ const isValidType =
279
+ typeof hlVal === 'string' ||
280
+ (Array.isArray(hlVal) && hlVal.every((v: unknown) => typeof v === 'string'));
281
+ if (!isValidType) {
282
+ errors.push({
283
+ message: 'Spec error: encoding.color.highlight must be a string or array of strings',
284
+ path: 'encoding.color.highlight',
285
+ code: 'INVALID_TYPE',
286
+ suggestion: 'Provide a series name (string) or an array of series names.',
287
+ });
288
+ } else {
289
+ const colorType =
290
+ (colorCh.type as string | undefined) ??
291
+ (colorCh.field && Array.isArray(spec.data) && spec.data.length > 0
292
+ ? inferFieldType(spec.data as Record<string, unknown>[], colorCh.field as string)
293
+ : undefined);
294
+ if (colorType === 'quantitative') {
295
+ errors.push({
296
+ message:
297
+ 'Spec error: encoding.color.highlight is not supported on quantitative color channels',
298
+ path: 'encoding.color.highlight',
299
+ code: 'INVALID_VALUE',
300
+ suggestion:
301
+ 'Highlight works with nominal/ordinal color (categorical series). Remove highlight or change encoding.color.type to nominal.',
302
+ });
303
+ }
304
+ }
305
+ }
306
+ }
307
+
308
+ // Validate facet channel if provided
309
+ if (encoding.facet && typeof encoding.facet === 'object') {
310
+ const facet = encoding.facet as Record<string, unknown>;
311
+ if (!facet.field || typeof facet.field !== 'string') {
312
+ errors.push({
313
+ message: 'Spec error: encoding.facet.field is required and must be a non-empty string',
314
+ path: 'encoding.facet.field',
315
+ code: 'MISSING_FIELD',
316
+ suggestion:
317
+ 'Add a field property to encoding.facet (e.g. { field: "category", type: "nominal" })',
318
+ });
319
+ } else if (
320
+ !dataColumns.has(facet.field as string) &&
321
+ !transformFields.has(facet.field as string)
322
+ ) {
323
+ errors.push({
324
+ message: `Spec error: encoding.facet.field "${facet.field}" does not exist in data. Available columns: ${availableColumns}`,
325
+ path: 'encoding.facet.field',
326
+ code: 'DATA_FIELD_MISSING',
327
+ suggestion: `Use one of the available data columns: ${availableColumns}`,
328
+ });
329
+ }
330
+ if (!facet.type || (facet.type !== 'nominal' && facet.type !== 'ordinal')) {
331
+ errors.push({
332
+ message: `Spec error: encoding.facet.type must be "nominal" or "ordinal"${facet.type ? `, got "${facet.type}"` : ''}`,
333
+ path: 'encoding.facet.type',
334
+ code: facet.type ? 'INVALID_VALUE' : 'MISSING_FIELD',
335
+ suggestion: 'Set encoding.facet.type to "nominal" or "ordinal"',
336
+ });
337
+ }
338
+ if (facet.columns !== undefined) {
339
+ const cols = Number(facet.columns);
340
+ if (!Number.isInteger(cols) || cols < 1) {
341
+ errors.push({
342
+ message: 'Spec error: encoding.facet.columns must be a positive integer',
343
+ path: 'encoding.facet.columns',
344
+ code: 'INVALID_VALUE',
345
+ suggestion: 'Set columns to a positive integer (e.g. 3)',
346
+ });
347
+ }
348
+ }
349
+ }
350
+
272
351
  // Validate darkMode if provided
273
352
  if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
274
353
  errors.push({
@@ -439,4 +439,61 @@ describe('computeEndpointLabels', () => {
439
439
  expect(entry.color).toBe(lineColor);
440
440
  }
441
441
  });
442
+
443
+ it('produces leading entries when ends: "both"', () => {
444
+ const spec = makeSpec({ endpointLabels: { ends: 'both' } });
445
+ const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
446
+ const layout = computeEndpointLabels(spec, marks, theme, chartArea);
447
+
448
+ expect(layout.entries).toHaveLength(2);
449
+ expect(layout.leading).toBeDefined();
450
+ expect(layout.leading).toHaveLength(2);
451
+ const leadingKeys = layout.leading!.map((e) => e.seriesKey).sort();
452
+ expect(leadingKeys).toEqual(['UK', 'US']);
453
+ });
454
+
455
+ it('positions leading column to the left of the chart area', () => {
456
+ const spec = makeSpec({ endpointLabels: { ends: 'both' } });
457
+ const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
458
+ const layout = computeEndpointLabels(spec, marks, theme, chartArea);
459
+
460
+ expect(layout.leadingBounds).toBeDefined();
461
+ expect(layout.leadingBounds!.x + layout.leadingBounds!.width).toBeLessThanOrEqual(chartArea.x);
462
+ expect(layout.leadingBounds!.width).toBeGreaterThan(0);
463
+ });
464
+
465
+ it('leading entries use the first data point value', () => {
466
+ const spec = makeSpec({ endpointLabels: { ends: 'both' } });
467
+ const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
468
+ const layout = computeEndpointLabels(spec, marks, theme, chartArea);
469
+
470
+ const usTrailing = layout.entries.find((e) => e.seriesKey === 'US');
471
+ const usLeading = layout.leading!.find((e) => e.seriesKey === 'US');
472
+ expect(usTrailing).toBeDefined();
473
+ expect(usLeading).toBeDefined();
474
+ // The first data point has value - 5 (35 for US), the last has the full value (40).
475
+ expect(usLeading!.value).not.toBe(usTrailing!.value);
476
+ });
477
+
478
+ it('does not produce leading entries when ends is unset (default)', () => {
479
+ const spec = makeSpec();
480
+ const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
481
+ const layout = computeEndpointLabels(spec, marks, theme, chartArea);
482
+
483
+ expect(layout.leading).toBeUndefined();
484
+ expect(layout.leadingBounds).toBeUndefined();
485
+ });
486
+
487
+ it('leading entries get markers at the first data point', () => {
488
+ const spec = makeSpec({ endpointLabels: { ends: 'both' } });
489
+ const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
490
+ const layout = computeEndpointLabels(spec, marks, theme, chartArea);
491
+
492
+ for (const entry of layout.leading!) {
493
+ expect(entry.marker).toBeDefined();
494
+ expect(entry.marker!.dataX).toBe(chartArea.x);
495
+ // Leading marker offset: x = dataX - radius (left side).
496
+ expect(entry.marker!.x).toBe(chartArea.x - ENDPOINT_MARKER_RADIUS);
497
+ }
498
+ });
442
499
  });
@@ -68,6 +68,7 @@ function emptyLayout(theme: ResolvedTheme): EndpointLabelsLayout {
68
68
  fontWeight: ENDPOINT_VALUE_FONT_WEIGHT,
69
69
  fill: theme.colors.annotationText ?? theme.colors.text,
70
70
  lineHeight: ENDPOINT_LINE_HEIGHT,
71
+ fontVariant: 'tabular-nums',
71
72
  };
72
73
  return {
73
74
  entries: [],
@@ -91,8 +92,6 @@ function lastDataPoint(mark: LineMark | AreaMark): { x: number; y: number } | nu
91
92
  const last = mark.dataPoints[mark.dataPoints.length - 1];
92
93
  return { x: last.x, y: last.y };
93
94
  }
94
- // Fallback for marks compiled without dataPoints (rare): use the last
95
- // geometry point. For areas this is the top boundary.
96
95
  if (mark.type === 'line' && mark.points.length > 0) {
97
96
  const last = mark.points[mark.points.length - 1];
98
97
  return { x: last.x, y: last.y };
@@ -104,7 +103,24 @@ function lastDataPoint(mark: LineMark | AreaMark): { x: number; y: number } | nu
104
103
  return null;
105
104
  }
106
105
 
107
- /** Get the value to display from a data row. */
106
+ /** Get the first data-point pixel position for a line/area mark. */
107
+ function firstDataPoint(mark: LineMark | AreaMark): { x: number; y: number } | null {
108
+ if (mark.dataPoints && mark.dataPoints.length > 0) {
109
+ const first = mark.dataPoints[0];
110
+ return { x: first.x, y: first.y };
111
+ }
112
+ if (mark.type === 'line' && mark.points.length > 0) {
113
+ const first = mark.points[0];
114
+ return { x: first.x, y: first.y };
115
+ }
116
+ if (mark.type === 'area' && mark.topPoints.length > 0) {
117
+ const first = mark.topPoints[0];
118
+ return { x: first.x, y: first.y };
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /** Get the value to display from the last data row. */
108
124
  function readValue(
109
125
  mark: LineMark | AreaMark,
110
126
  valueField: string | undefined,
@@ -123,6 +139,25 @@ function readValue(
123
139
  return null;
124
140
  }
125
141
 
142
+ /** Get the value to display from the first data row. */
143
+ function readFirstValue(
144
+ mark: LineMark | AreaMark,
145
+ valueField: string | undefined,
146
+ ): number | string | null {
147
+ if (!valueField) return null;
148
+ const dp = mark.dataPoints;
149
+ if (dp && dp.length > 0) {
150
+ const datum = dp[0].datum;
151
+ const v = datum[valueField];
152
+ return typeof v === 'number' || typeof v === 'string' ? v : null;
153
+ }
154
+ if (mark.data.length > 0) {
155
+ const v = mark.data[0][valueField];
156
+ return typeof v === 'number' || typeof v === 'string' ? v : null;
157
+ }
158
+ return null;
159
+ }
160
+
126
161
  /**
127
162
  * Local collision sweep: keep each label anchored to its line's `dataY` and
128
163
  * only displace neighbors that actually overlap.
@@ -213,19 +248,26 @@ export function computeEndpointLabels(
213
248
  theme: ResolvedTheme,
214
249
  chartArea: Rect,
215
250
  strategy?: LayoutStrategy,
251
+ endpointLabelsDemoted?: boolean,
216
252
  ): EndpointLabelsLayout {
217
253
  // Compact strategy: drop the column entirely. The traditional legend takes
218
254
  // over series identification at the compact breakpoint.
219
255
  if (strategy?.labelMode === 'none') return emptyLayout(theme);
220
256
 
257
+ const highlight = spec.highlight ?? [];
258
+ const highlightActive = highlight.length > 0;
221
259
  const seriesCount = countColorSeries(spec);
222
260
  const sup = resolveSuppression(spec, {
223
261
  seriesCount,
224
262
  // The 'none' branch above returned; by here labelMode is not 'none'.
225
263
  labelsHiddenByStrategy: false,
226
264
  labelsDensityNone: spec.labels.density === 'none',
265
+ endpointLabelsDemoted,
227
266
  });
228
- if (!sup.showEndpointLabels) return emptyLayout(theme);
267
+ const epExplicitlyOff =
268
+ spec.endpointLabels === false ||
269
+ (typeof spec.endpointLabels === 'object' && spec.endpointLabels?.show === false);
270
+ if (!sup.showEndpointLabels && !(highlightActive && !epExplicitlyOff)) return emptyLayout(theme);
229
271
 
230
272
  // Dedupe by seriesKey: for area charts, the engine emits BOTH an AreaMark
231
273
  // and a derived LineMark per series (see `linesFromAreas` in
@@ -271,6 +313,7 @@ export function computeEndpointLabels(
271
313
  fontWeight: ENDPOINT_VALUE_FONT_WEIGHT,
272
314
  fill: theme.colors.annotationText ?? theme.colors.text,
273
315
  lineHeight: ENDPOINT_LINE_HEIGHT,
316
+ fontVariant: 'tabular-nums',
274
317
  };
275
318
 
276
319
  // First pass: build entries with wrapped labels and dataY (no positions yet).
@@ -291,6 +334,9 @@ export function computeEndpointLabels(
291
334
  for (const mark of lineOrAreaMarks) {
292
335
  const seriesKey = mark.seriesKey;
293
336
  if (!seriesKey) continue;
337
+
338
+ if (highlightActive && !highlight.includes(seriesKey)) continue;
339
+
294
340
  const last = lastDataPoint(mark);
295
341
  if (!last) continue;
296
342
 
@@ -338,7 +384,8 @@ export function computeEndpointLabels(
338
384
  });
339
385
  }
340
386
 
341
- if (provisional.length < 2) return emptyLayout(theme);
387
+ if (provisional.length === 0) return emptyLayout(theme);
388
+ if (provisional.length < 2 && !highlightActive) return emptyLayout(theme);
342
389
 
343
390
  // Bidirectional collision sweep. Each entry's natural top is `dataY - labelFontSize/2`
344
391
  // so the label's first-line baseline-center aligns with the line's last data point.
@@ -401,6 +448,131 @@ export function computeEndpointLabels(
401
448
  return entry;
402
449
  });
403
450
 
451
+ // Leading column (left side) for `ends: 'both'` mode.
452
+ const endsBoth = config?.ends === 'both';
453
+ let leading: EndpointLabelEntry[] | undefined;
454
+ let leadingBounds: { x: number; y: number; width: number; height: number } | undefined;
455
+
456
+ if (endsBoth) {
457
+ type LeadingProvisional = {
458
+ seriesKey: string;
459
+ labelLines: string[];
460
+ value: string;
461
+ color: string;
462
+ dataX: number;
463
+ dataY: number;
464
+ height: number;
465
+ width: number;
466
+ };
467
+ const leadingProvisional: LeadingProvisional[] = [];
468
+
469
+ for (const mark of lineOrAreaMarks) {
470
+ const seriesKey = mark.seriesKey;
471
+ if (!seriesKey) continue;
472
+
473
+ if (highlightActive && !highlight.includes(seriesKey)) continue;
474
+
475
+ const first = firstDataPoint(mark);
476
+ if (!first) continue;
477
+
478
+ const labelLines = wrapText(
479
+ seriesKey,
480
+ ENDPOINT_LABEL_FONT_SIZE,
481
+ ENDPOINT_LABEL_FONT_WEIGHT,
482
+ wrapWidth,
483
+ );
484
+ const rawValue = readFirstValue(mark, valueField);
485
+ const value = formatEndpointValue(rawValue, formatString);
486
+
487
+ let entryWidth = 0;
488
+ for (const line of labelLines) {
489
+ const w = estimateTextWidth(line, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT);
490
+ if (w > entryWidth) entryWidth = w;
491
+ }
492
+ const valueWidth = value
493
+ ? estimateTextWidth(value, ENDPOINT_VALUE_FONT_SIZE, ENDPOINT_VALUE_FONT_WEIGHT)
494
+ : 0;
495
+ if (valueWidth > entryWidth) entryWidth = valueWidth;
496
+
497
+ const labelHeight = labelLines.length * labelLineHeight;
498
+ const valueHeight = value ? valueLineHeight : 0;
499
+ const entryHeight = labelHeight + (value ? ENDPOINT_VALUE_GAP : 0) + valueHeight;
500
+
501
+ const color =
502
+ mark.type === 'line'
503
+ ? mark.stroke
504
+ : (mark.stroke ??
505
+ (typeof mark.fill === 'string' ? mark.fill : theme.colors.categorical[0]));
506
+
507
+ leadingProvisional.push({
508
+ seriesKey,
509
+ labelLines,
510
+ value,
511
+ color,
512
+ dataX: first.x,
513
+ dataY: first.y,
514
+ height: entryHeight,
515
+ width: entryWidth,
516
+ });
517
+ }
518
+
519
+ if (leadingProvisional.length >= 2 || (leadingProvisional.length === 1 && highlightActive)) {
520
+ const leadingSweepInput = leadingProvisional.map((p, i) => ({
521
+ naturalTop: p.dataY - ENDPOINT_LABEL_FONT_SIZE / 2,
522
+ height: p.height + ENDPOINT_ENTRY_GAP,
523
+ index: i,
524
+ }));
525
+ const leadingSweptTops = bidirectionalSweep(
526
+ leadingSweepInput,
527
+ chartArea.y,
528
+ chartArea.y + chartArea.height,
529
+ );
530
+
531
+ const leadingColumnWidth = Math.max(
532
+ ENDPOINT_SWATCH_SIZE +
533
+ ENDPOINT_GAP +
534
+ Math.max(...leadingProvisional.map((p) => p.width), 0) +
535
+ 4,
536
+ 32,
537
+ );
538
+ const leadingColumnX = chartArea.x - ENDPOINT_COLUMN_GAP - leadingColumnWidth;
539
+
540
+ leading = leadingProvisional.map((p, i) => {
541
+ const labelY = leadingSweptTops[i];
542
+ const swatchRowY = labelY + ENDPOINT_LABEL_FONT_SIZE / 2;
543
+ const displaced = Math.abs(swatchRowY - p.dataY) > ENDPOINT_LEADER_THRESHOLD;
544
+ const entry: EndpointLabelEntry = {
545
+ seriesKey: p.seriesKey,
546
+ labelLines: p.labelLines,
547
+ value: p.value,
548
+ color: p.color,
549
+ dataY: p.dataY,
550
+ labelY,
551
+ showLeader: showLeader && displaced,
552
+ };
553
+ if (showMarker) {
554
+ entry.marker = {
555
+ x: p.dataX - markerRadius,
556
+ y: p.dataY,
557
+ dataX: p.dataX,
558
+ fill: markerFill,
559
+ stroke: config?.markerStyle?.stroke ?? p.color,
560
+ strokeWidth: markerStrokeWidth,
561
+ radius: markerRadius,
562
+ };
563
+ }
564
+ return entry;
565
+ });
566
+
567
+ leadingBounds = {
568
+ x: leadingColumnX,
569
+ y: chartArea.y,
570
+ width: leadingColumnWidth,
571
+ height: chartArea.height,
572
+ };
573
+ }
574
+ }
575
+
404
576
  return {
405
577
  entries,
406
578
  bounds: {
@@ -415,5 +587,7 @@ export function computeEndpointLabels(
415
587
  gap: ENDPOINT_GAP,
416
588
  valueGap: ENDPOINT_VALUE_GAP,
417
589
  swatchChipFill: theme.colors.annotationFill ?? theme.colors.background,
590
+ leading,
591
+ leadingBounds,
418
592
  };
419
593
  }
@@ -16,6 +16,7 @@ import type { ResolvedTheme } from '@opendata-ai/openchart-core';
16
16
  import { estimateTextWidth, wrapText } from '@opendata-ai/openchart-core';
17
17
 
18
18
  import type { NormalizedChartSpec } from '../compiler/types';
19
+ import { endpointLabelsExplicitlyOn } from '../legend/suppression';
19
20
  import {
20
21
  ENDPOINT_GAP,
21
22
  ENDPOINT_LABEL_FONT_SIZE,
@@ -59,6 +60,13 @@ export function predictEndpointLabelsWidth(
59
60
  // for the bare boolean; also handle the object form `{ show: false }`.)
60
61
  if (typeof spec.endpointLabels === 'object' && spec.endpointLabels?.show === false) return 0;
61
62
 
63
+ // Series-count cutoff: above maxSeries (default 8), skip endpoint labels
64
+ // unless the user explicitly enabled them. Uses the same explicit-on logic
65
+ // as suppression.ts to keep predict and render decisions aligned.
66
+ const maxSeries =
67
+ (typeof spec.endpointLabels === 'object' && spec.endpointLabels?.maxSeries) || 8;
68
+ if (!endpointLabelsExplicitlyOn(spec) && seriesNames.size > maxSeries) return 0;
69
+
62
70
  const config = typeof spec.endpointLabels === 'object' ? spec.endpointLabels : undefined;
63
71
  const wrapWidth = config?.width ?? ENDPOINT_WRAP_WIDTH_DEFAULT;
64
72
 
@@ -106,3 +114,12 @@ export function predictEndpointLabelsWidth(
106
114
  const textColumn = Math.max(maxLabelWidth, valueWidth);
107
115
  return ENDPOINT_SWATCH_SIZE + ENDPOINT_GAP + textColumn + 4;
108
116
  }
117
+
118
+ /**
119
+ * Returns true when the spec requests both-ends endpoint labels.
120
+ * Used by `computeDimensions` to also reserve left margin space.
121
+ */
122
+ export function isEndsBoth(spec: NormalizedChartSpec): boolean {
123
+ const ep = spec.endpointLabels;
124
+ return typeof ep === 'object' && ep != null && ep.ends === 'both';
125
+ }