@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.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 (159) hide show
  1. package/dist/index.d.ts +123 -14
  2. package/dist/index.js +16166 -10027
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
  6. package/src/__tests__/annotation-behavior.test.ts +721 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/helpers/invariants.ts +54 -20
  12. package/src/__tests__/known-bugs.test.ts +455 -2
  13. package/src/__tests__/legend.test.ts +69 -4
  14. package/src/__tests__/ranking-recipes.test.ts +260 -0
  15. package/src/__tests__/scales.test.ts +9 -3
  16. package/src/__tests__/series-search.test.ts +92 -0
  17. package/src/__tests__/spec-sugar.test.ts +801 -0
  18. package/src/__tests__/you-draw-it.test.ts +160 -0
  19. package/src/annotations/__tests__/compute.test.ts +207 -92
  20. package/src/annotations/__tests__/geometry.test.ts +66 -0
  21. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  22. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  23. package/src/annotations/__tests__/thinning.test.ts +148 -0
  24. package/src/annotations/collisions.ts +130 -84
  25. package/src/annotations/compute.ts +83 -85
  26. package/src/annotations/constants.ts +116 -6
  27. package/src/annotations/geometry.ts +222 -77
  28. package/src/annotations/move.ts +135 -0
  29. package/src/annotations/placement.ts +62 -14
  30. package/src/annotations/resolve-range.ts +2 -0
  31. package/src/annotations/resolve-refline.ts +2 -0
  32. package/src/annotations/resolve-text.ts +312 -111
  33. package/src/annotations/rich-text.ts +65 -0
  34. package/src/annotations/thinning.ts +84 -10
  35. package/src/barlist/compile-barlist.ts +16 -6
  36. package/src/charts/_shared/format-label-value.ts +9 -8
  37. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  38. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  39. package/src/charts/bar/compute.ts +2 -6
  40. package/src/charts/bar/index.ts +8 -1
  41. package/src/charts/bar/labels.ts +3 -3
  42. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  43. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  44. package/src/charts/beeswarm/compute.ts +203 -0
  45. package/src/charts/beeswarm/dodge.ts +155 -0
  46. package/src/charts/beeswarm/index.ts +32 -0
  47. package/src/charts/builtin.ts +12 -1
  48. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  49. package/src/charts/calendar/compute.ts +319 -0
  50. package/src/charts/calendar/index.ts +32 -0
  51. package/src/charts/column/__tests__/compute.test.ts +21 -7
  52. package/src/charts/column/__tests__/labels.test.ts +76 -1
  53. package/src/charts/column/compute.ts +2 -6
  54. package/src/charts/column/index.ts +10 -2
  55. package/src/charts/column/labels.ts +95 -21
  56. package/src/charts/dot/index.ts +8 -1
  57. package/src/charts/dot/labels.ts +3 -3
  58. package/src/charts/line/__tests__/compute.test.ts +15 -19
  59. package/src/charts/line/area.ts +8 -23
  60. package/src/charts/line/compute.ts +32 -2
  61. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  62. package/src/charts/parliament/compute.ts +313 -0
  63. package/src/charts/parliament/index.ts +32 -0
  64. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  65. package/src/charts/pie/compute.ts +63 -8
  66. package/src/charts/pie/labels.ts +1 -0
  67. package/src/charts/range/__tests__/compute.test.ts +486 -0
  68. package/src/charts/range/compute.ts +273 -0
  69. package/src/charts/range/index.ts +82 -0
  70. package/src/charts/range/labels.ts +169 -0
  71. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  72. package/src/charts/rect/index.ts +127 -0
  73. package/src/charts/scatter/compute.ts +7 -21
  74. package/src/charts/text/__tests__/compute.test.ts +307 -0
  75. package/src/charts/text/index.ts +44 -7
  76. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  77. package/src/charts/tick/index.ts +7 -4
  78. package/src/charts/utils.ts +5 -3
  79. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  80. package/src/charts/waffle/compute.ts +197 -0
  81. package/src/charts/waffle/index.ts +31 -0
  82. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  83. package/src/compile/__tests__/data-clip.test.ts +68 -0
  84. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  85. package/src/compile/__tests__/size-scale.test.ts +108 -0
  86. package/src/compile/color-scale-range.ts +54 -33
  87. package/src/compile/data-clip.ts +21 -6
  88. package/src/compile/fill-patterns.ts +74 -0
  89. package/src/compile/layer.ts +117 -21
  90. package/src/compile/size-scale.ts +139 -0
  91. package/src/compile/spec-sugar.ts +758 -0
  92. package/src/compile/you-draw-it.ts +123 -0
  93. package/src/compile.ts +310 -103
  94. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  95. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  114. package/src/compiler/__tests__/keys.test.ts +3 -9
  115. package/src/compiler/__tests__/normalize.test.ts +65 -1
  116. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  117. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  118. package/src/compiler/__tests__/validate.test.ts +156 -0
  119. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  120. package/src/compiler/a11y-warnings.ts +110 -0
  121. package/src/compiler/keys.ts +5 -4
  122. package/src/compiler/normalize.ts +150 -7
  123. package/src/compiler/types.ts +11 -0
  124. package/src/compiler/validate.ts +664 -18
  125. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  126. package/src/endpoint-labels/compute.ts +40 -12
  127. package/src/endpoint-labels/format.ts +45 -15
  128. package/src/endpoint-labels/predict.ts +50 -36
  129. package/src/format/__tests__/field-format.test.ts +164 -0
  130. package/src/format/field-format.ts +27 -0
  131. package/src/geo/__tests__/compile-map.test.ts +621 -0
  132. package/src/geo/compile-map.ts +856 -0
  133. package/src/geo/join.ts +115 -0
  134. package/src/geo/projections.ts +45 -0
  135. package/src/geo/types.ts +24 -0
  136. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  137. package/src/graphs/compile-graph.ts +27 -18
  138. package/src/index.ts +20 -2
  139. package/src/layout/axes/ticks.ts +59 -10
  140. package/src/layout/axes.ts +35 -6
  141. package/src/layout/dimensions.ts +139 -83
  142. package/src/layout/facet.ts +14 -3
  143. package/src/layout/plan.ts +83 -33
  144. package/src/layout/scales.ts +132 -15
  145. package/src/legend/__tests__/continuous.test.ts +318 -0
  146. package/src/legend/__tests__/size.test.ts +348 -0
  147. package/src/legend/__tests__/suppression.test.ts +17 -1
  148. package/src/legend/compute.ts +119 -13
  149. package/src/legend/continuous.ts +327 -0
  150. package/src/legend/size.ts +407 -0
  151. package/src/legend/suppression.ts +8 -7
  152. package/src/sankey/compile-sankey.ts +31 -19
  153. package/src/tables/format-cells.ts +16 -7
  154. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  155. package/src/tilemap/compile-tilemap.ts +24 -7
  156. package/src/tooltips/__tests__/compute.test.ts +2 -2
  157. package/src/tooltips/compute.ts +422 -41
  158. package/src/transforms/__tests__/conditional.test.ts +18 -0
  159. package/src/transforms/aggregate.ts +2 -1
@@ -323,9 +323,17 @@ describe('computeEndpointLabels', () => {
323
323
  });
324
324
 
325
325
  it('handles area marks (overlap, not stacked)', () => {
326
+ // v8: area stacks by default, and stacked areas suppress the endpoint
327
+ // column in favor of the traditional legend. Stamp stack: null to
328
+ // exercise the overlap path this test targets.
326
329
  const spec = makeSpec({
327
330
  markType: 'area',
328
331
  markDef: { type: 'area' },
332
+ encoding: {
333
+ x: { field: 'date', type: 'temporal' },
334
+ y: { field: 'value', type: 'quantitative', stack: null },
335
+ color: { field: 'country', type: 'nominal' },
336
+ },
329
337
  });
330
338
  const marks: Mark[] = [makeAreaMark('US', 100, 40), makeAreaMark('UK', 200, 35, '#cc6633')];
331
339
  const layout = computeEndpointLabels(spec, marks, theme, chartArea);
@@ -338,9 +346,16 @@ describe('computeEndpointLabels', () => {
338
346
  // derived LineMark per series (see linesFromAreas in
339
347
  // packages/engine/src/charts/line/index.ts). Without dedupe, each series
340
348
  // produces two endpoint entries.
349
+ // v8: stamp stack: null so this overlap-mode fixture still shows the
350
+ // endpoint column (stacked areas suppress it in favor of the legend).
341
351
  const spec = makeSpec({
342
352
  markType: 'area',
343
353
  markDef: { type: 'area' },
354
+ encoding: {
355
+ x: { field: 'date', type: 'temporal' },
356
+ y: { field: 'value', type: 'quantitative', stack: null },
357
+ color: { field: 'country', type: 'nominal' },
358
+ },
344
359
  });
345
360
  const lineColor = '#3366cc';
346
361
  const areaColor = '#ddee99'; // fake gradient-derived color, distinct from the line stroke
@@ -420,9 +435,16 @@ describe('computeEndpointLabels', () => {
420
435
  it('dedupe prefers line mark even when area appears later in the marks array', () => {
421
436
  // Defect-1 regression: area marks listed AFTER line marks should not
422
437
  // overwrite the line's canonical stroke color in the endpoint entry.
438
+ // v8: stamp stack: null so this overlap-mode fixture still shows the
439
+ // endpoint column (stacked areas suppress it in favor of the legend).
423
440
  const spec = makeSpec({
424
441
  markType: 'area',
425
442
  markDef: { type: 'area' },
443
+ encoding: {
444
+ x: { field: 'date', type: 'temporal' },
445
+ y: { field: 'value', type: 'quantitative', stack: null },
446
+ color: { field: 'country', type: 'nominal' },
447
+ },
426
448
  });
427
449
  const lineColor = '#3366cc';
428
450
  const areaColor = '#ddee99';
@@ -496,4 +518,70 @@ describe('computeEndpointLabels', () => {
496
518
  expect(entry.marker!.x).toBe(chartArea.x - ENDPOINT_MARKER_RADIUS);
497
519
  }
498
520
  });
521
+
522
+ it("content: 'label value' joins name and value on one line and drops the value line", () => {
523
+ const spec = makeSpec({ endpointLabels: { content: 'label value' } });
524
+ const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
525
+ const layout = computeEndpointLabels(spec, marks, theme, chartArea);
526
+
527
+ const us = layout.entries.find((e) => e.seriesKey === 'US');
528
+ expect(us!.labelLines[0]).toBe('US 40');
529
+ expect(us!.value).toBe('');
530
+ });
531
+
532
+ it("content: 'value' renders the formatted value alone", () => {
533
+ const spec = makeSpec({ endpointLabels: { content: 'value' } });
534
+ const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
535
+ const layout = computeEndpointLabels(spec, marks, theme, chartArea);
536
+
537
+ const us = layout.entries.find((e) => e.seriesKey === 'US');
538
+ expect(us!.labelLines).toEqual(['40']);
539
+ expect(us!.value).toBe('');
540
+ });
541
+
542
+ it("content: 'label' renders the series name alone", () => {
543
+ const spec = makeSpec({ endpointLabels: { content: 'label' } });
544
+ const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
545
+ const layout = computeEndpointLabels(spec, marks, theme, chartArea);
546
+
547
+ const us = layout.entries.find((e) => e.seriesKey === 'US');
548
+ expect(us!.labelLines).toEqual(['US']);
549
+ expect(us!.value).toBe('');
550
+ });
551
+
552
+ it('per-side content object resolves leading and trailing independently', () => {
553
+ const spec = makeSpec({
554
+ endpointLabels: {
555
+ ends: 'both',
556
+ content: { leading: 'label value', trailing: 'value' },
557
+ },
558
+ });
559
+ const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
560
+ const layout = computeEndpointLabels(spec, marks, theme, chartArea);
561
+
562
+ // Leading: first data point value (value - 5) joined with the name.
563
+ const usLeading = layout.leading!.find((e) => e.seriesKey === 'US');
564
+ expect(usLeading!.labelLines[0]).toBe('US 35');
565
+ // Trailing: last value alone.
566
+ const usTrailing = layout.entries.find((e) => e.seriesKey === 'US');
567
+ expect(usTrailing!.labelLines).toEqual(['40']);
568
+ });
569
+
570
+ it('keeps the column at compact breakpoints when endpoint labels are explicitly on', () => {
571
+ // Slope/bump recipes carry their values on the labels: explicit user
572
+ // config must survive the compact strategy strip (labelMode: 'none').
573
+ const spec = makeSpec({ endpointLabels: { ends: 'both', content: 'label value' } });
574
+ const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
575
+ const layout = computeEndpointLabels(spec, marks, theme, chartArea, {
576
+ labelMode: 'none',
577
+ legendPosition: 'top',
578
+ annotationPosition: 'tooltip-only',
579
+ axisLabelDensity: 'minimal',
580
+ chromeMode: 'full',
581
+ legendMaxHeight: -1,
582
+ });
583
+
584
+ expect(layout.entries).toHaveLength(2);
585
+ expect(layout.leading).toHaveLength(2);
586
+ });
499
587
  });
@@ -30,7 +30,12 @@ import type {
30
30
  import { estimateTextWidth, wrapText } from '@opendata-ai/openchart-core';
31
31
 
32
32
  import type { NormalizedChartSpec } from '../compiler/types';
33
- import { countColorSeries, resolveSuppression } from '../legend/suppression';
33
+ import { resolveFieldFormatter } from '../format/field-format';
34
+ import {
35
+ countColorSeries,
36
+ endpointLabelsExplicitlyOn,
37
+ resolveSuppression,
38
+ } from '../legend/suppression';
34
39
  import {
35
40
  ENDPOINT_COLUMN_GAP,
36
41
  ENDPOINT_ENTRY_GAP,
@@ -47,7 +52,7 @@ import {
47
52
  ENDPOINT_VALUE_GAP,
48
53
  ENDPOINT_WRAP_WIDTH_DEFAULT,
49
54
  } from './constants';
50
- import { formatEndpointValue } from './format';
55
+ import { composeEndpointText, formatEndpointValue, resolveEndpointContent } from './format';
51
56
 
52
57
  // ---------------------------------------------------------------------------
53
58
  // Helpers
@@ -251,15 +256,20 @@ export function computeEndpointLabels(
251
256
  endpointLabelsDemoted?: boolean,
252
257
  ): EndpointLabelsLayout {
253
258
  // Compact strategy: drop the column entirely. The traditional legend takes
254
- // over series identification at the compact breakpoint.
255
- if (strategy?.labelMode === 'none') return emptyLayout(theme);
259
+ // over series identification at the compact breakpoint. Explicit user
260
+ // opt-in overrides the strip: slope/bump recipes are unreadable without
261
+ // their labels, so an author who wrote endpointLabels config keeps it.
262
+ if (strategy?.labelMode === 'none' && !endpointLabelsExplicitlyOn(spec)) {
263
+ return emptyLayout(theme);
264
+ }
256
265
 
257
266
  const highlight = spec.highlight ?? [];
258
267
  const highlightActive = highlight.length > 0;
259
268
  const seriesCount = countColorSeries(spec);
260
269
  const sup = resolveSuppression(spec, {
261
270
  seriesCount,
262
- // The 'none' branch above returned; by here labelMode is not 'none'.
271
+ // The 'none' branch above returned unless the user explicitly opted in,
272
+ // in which case resolveSuppression's explicit-on override applies anyway.
263
273
  labelsHiddenByStrategy: false,
264
274
  labelsDensityNone: spec.labels.density === 'none',
265
275
  endpointLabelsDemoted,
@@ -291,13 +301,20 @@ export function computeEndpointLabels(
291
301
  const config = typeof spec.endpointLabels === 'object' ? spec.endpointLabels : undefined;
292
302
  const wrapWidth = config?.width ?? ENDPOINT_WRAP_WIDTH_DEFAULT;
293
303
  const valueField = config?.valueField ?? spec.encoding.y?.field;
294
- const formatString =
304
+ const surfaceFormat =
295
305
  config?.format ??
296
306
  ((spec.encoding.y?.axis as Record<string, unknown> | undefined)?.format as string | undefined);
307
+ const endpointFormatter = resolveFieldFormatter({
308
+ surfaceFormat,
309
+ channelFormat: spec.encoding.y?.format,
310
+ values: valueField ? spec.data.map((r) => r[valueField]) : [],
311
+ });
297
312
  const showMarker = config?.showMarker !== false;
298
313
  const markerRadius = config?.markerStyle?.radius ?? ENDPOINT_MARKER_RADIUS;
299
314
  const markerStrokeWidth = config?.markerStyle?.strokeWidth ?? ENDPOINT_MARKER_STROKE_WIDTH;
300
315
  const markerFill = config?.markerStyle?.fill ?? theme.colors.background;
316
+ const trailingContent = resolveEndpointContent(config?.content, 'trailing');
317
+ const leadingContent = resolveEndpointContent(config?.content, 'leading');
301
318
 
302
319
  // Resolve the styles once.
303
320
  const labelStyle: TextStyle = {
@@ -340,14 +357,21 @@ export function computeEndpointLabels(
340
357
  const last = lastDataPoint(mark);
341
358
  if (!last) continue;
342
359
 
360
+ const rawValue = readValue(mark, valueField);
361
+ const formatted = formatEndpointValue(rawValue, endpointFormatter);
362
+
363
+ // Content composition: when `content` is set, the composed text becomes
364
+ // the label lines and the second-line value is dropped.
365
+ const labelText = trailingContent
366
+ ? composeEndpointText(trailingContent, seriesKey, formatted)
367
+ : seriesKey;
368
+ const value = trailingContent ? '' : formatted;
343
369
  const labelLines = wrapText(
344
- seriesKey,
370
+ labelText,
345
371
  ENDPOINT_LABEL_FONT_SIZE,
346
372
  ENDPOINT_LABEL_FONT_WEIGHT,
347
373
  wrapWidth,
348
374
  );
349
- const rawValue = readValue(mark, valueField);
350
- const value = formatEndpointValue(rawValue, formatString);
351
375
 
352
376
  // Width of the widest line in this entry (used to size the column).
353
377
  let entryWidth = 0;
@@ -475,14 +499,18 @@ export function computeEndpointLabels(
475
499
  const first = firstDataPoint(mark);
476
500
  if (!first) continue;
477
501
 
502
+ const rawValue = readFirstValue(mark, valueField);
503
+ const formatted = formatEndpointValue(rawValue, endpointFormatter);
504
+ const labelText = leadingContent
505
+ ? composeEndpointText(leadingContent, seriesKey, formatted)
506
+ : seriesKey;
507
+ const value = leadingContent ? '' : formatted;
478
508
  const labelLines = wrapText(
479
- seriesKey,
509
+ labelText,
480
510
  ENDPOINT_LABEL_FONT_SIZE,
481
511
  ENDPOINT_LABEL_FONT_WEIGHT,
482
512
  wrapWidth,
483
513
  );
484
- const rawValue = readFirstValue(mark, valueField);
485
- const value = formatEndpointValue(rawValue, formatString);
486
514
 
487
515
  let entryWidth = 0;
488
516
  for (const line of labelLines) {
@@ -4,27 +4,57 @@
4
4
  * predicted column width matches the eventual rendered text.
5
5
  */
6
6
 
7
- import { format as d3Format } from 'd3-format';
7
+ import type { EndpointLabelContent, NumberFormatter } from '@opendata-ai/openchart-core';
8
+ import { defaultNumberFormatter } from '@opendata-ai/openchart-core';
9
+
10
+ const defaultFmt = defaultNumberFormatter();
8
11
 
9
12
  /**
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.
13
+ * Format a value for an endpoint label. When a pre-built formatter is
14
+ * supplied (resolved from the spec's format/encoding), it is used directly.
15
+ * Otherwise falls back to the smart default (compact notation with
16
+ * magnitude-aware units).
14
17
  */
15
18
  export function formatEndpointValue(
16
19
  value: number | string | null,
17
- formatString: string | undefined,
20
+ formatter?: NumberFormatter | null,
18
21
  ): string {
19
22
  if (value == null) return '';
20
23
  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);
24
+ if (formatter) return formatter(value);
25
+ return defaultFmt(value);
26
+ }
27
+
28
+ /**
29
+ * Compose the single-line text an endpoint entry renders when the spec sets
30
+ * `endpointLabels.content`. `'label value'` joins the series name and its
31
+ * formatted value on one line, which is the slope-chart convention. Returns
32
+ * the series name alone when the value is empty so a missing value never
33
+ * leaves a trailing space.
34
+ */
35
+ export function composeEndpointText(
36
+ content: EndpointLabelContent,
37
+ label: string,
38
+ value: string,
39
+ ): string {
40
+ if (content === 'label') return label;
41
+ if (content === 'value') return value;
42
+ return value ? `${label} ${value}` : label;
43
+ }
44
+
45
+ /**
46
+ * Resolve the content mode for one column from the config's `content` field,
47
+ * which accepts either a single mode for both columns or a per-side object.
48
+ * Returns undefined when unset (the classic name-plus-value-below layout).
49
+ */
50
+ export function resolveEndpointContent(
51
+ content:
52
+ | EndpointLabelContent
53
+ | { leading?: EndpointLabelContent; trailing?: EndpointLabelContent }
54
+ | undefined,
55
+ side: 'leading' | 'trailing',
56
+ ): EndpointLabelContent | undefined {
57
+ if (content == null) return undefined;
58
+ if (typeof content === 'string') return content;
59
+ return content[side];
30
60
  }
@@ -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 { resolveFieldFormatter } from '../format/field-format';
19
20
  import { endpointLabelsExplicitlyOn } from '../legend/suppression';
20
21
  import {
21
22
  ENDPOINT_GAP,
@@ -26,7 +27,7 @@ import {
26
27
  ENDPOINT_VALUE_FONT_WEIGHT,
27
28
  ENDPOINT_WRAP_WIDTH_DEFAULT,
28
29
  } from './constants';
29
- import { formatEndpointValue } from './format';
30
+ import { composeEndpointText, formatEndpointValue, resolveEndpointContent } from './format';
30
31
 
31
32
  /**
32
33
  * Predict the pixel width the endpoint-labels column will need, including
@@ -45,10 +46,11 @@ export function predictEndpointLabelsWidth(
45
46
  const colorEnc = spec.encoding.color;
46
47
  if (!colorEnc) return 0;
47
48
  if ('condition' in colorEnc) return 0;
49
+ if (!('field' in colorEnc)) return 0;
48
50
  if (colorEnc.type === 'quantitative') return 0;
49
51
 
50
52
  // Distinct series count.
51
- const colorField = 'field' in colorEnc ? colorEnc.field : undefined;
53
+ const colorField = colorEnc.field;
52
54
  if (!colorField) return 0;
53
55
  const seriesNames = new Set<string>();
54
56
  for (const row of spec.data) {
@@ -70,48 +72,60 @@ export function predictEndpointLabelsWidth(
70
72
  const config = typeof spec.endpointLabels === 'object' ? spec.endpointLabels : undefined;
71
73
  const wrapWidth = config?.width ?? ENDPOINT_WRAP_WIDTH_DEFAULT;
72
74
 
73
- // Wrap each series name and find the widest wrapped line.
74
- let maxLabelWidth = 0;
75
- for (const name of seriesNames) {
76
- const lines = wrapText(name, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT, wrapWidth);
77
- for (const line of lines) {
78
- const w = estimateTextWidth(line, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT);
79
- if (w > maxLabelWidth) maxLabelWidth = w;
80
- }
81
- }
82
-
83
- // Estimate value width from the spec's data + format. We don't know which row
84
- // is "last" without computing scales/marks, so we sample the largest absolute
85
- // value to bound the formatted width.
75
+ // Estimate the formatted value from the spec's data + format. We don't know
76
+ // which row is "last" without computing scales/marks, so we sample the
77
+ // largest absolute value to bound the formatted width.
86
78
  const yField = config?.valueField ?? spec.encoding.y?.field;
87
- const yFormat =
79
+ const surfaceFormat =
88
80
  config?.format ??
89
81
  ((spec.encoding.y?.axis as Record<string, unknown> | undefined)?.format as string | undefined);
90
- let valueWidth = 0;
82
+ let sample = '';
91
83
  if (yField) {
84
+ const values = spec.data.map((r) => r[yField]);
92
85
  let maxAbs = 0;
93
- for (const row of spec.data) {
94
- const v = Number(row[yField]);
95
- if (Number.isFinite(v) && Math.abs(v) > maxAbs) maxAbs = Math.abs(v);
86
+ for (const v of values) {
87
+ const n = Number(v);
88
+ if (Number.isFinite(n) && Math.abs(n) > maxAbs) maxAbs = Math.abs(n);
89
+ }
90
+ const formatter = resolveFieldFormatter({
91
+ surfaceFormat,
92
+ channelFormat: spec.encoding.y?.format,
93
+ values,
94
+ });
95
+ sample = formatEndpointValue(maxAbs, formatter);
96
+ }
97
+
98
+ // Text-column width for one side, using the same composition + wrapping
99
+ // math as compute.ts. Default (no content mode): widest wrapped series name
100
+ // vs. the sampled value on its own second line. Content mode: the composed
101
+ // single-line text in the label font.
102
+ const sideTextWidth = (side: 'leading' | 'trailing'): number => {
103
+ const content = resolveEndpointContent(config?.content, side);
104
+ let maxWidth = 0;
105
+ for (const name of seriesNames) {
106
+ const text = content ? composeEndpointText(content, name, sample) : name;
107
+ const lines = wrapText(text, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT, wrapWidth);
108
+ for (const line of lines) {
109
+ const w = estimateTextWidth(line, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT);
110
+ if (w > maxWidth) maxWidth = w;
111
+ }
96
112
  }
97
- // When the user supplied a format string, run it through the same
98
- // formatter compute.ts will use so width prediction matches reality.
99
- // Without one, fall back to a magnitude-aware sample that bounds the
100
- // expected unformatted value width (compute.ts uses toFixed(2) here,
101
- // which is roughly the same character count as "1.5K"-style abbreviations
102
- // for the upper end of each band).
103
- let sample: string;
104
- if (yFormat) {
105
- sample = formatEndpointValue(maxAbs, yFormat);
106
- } else if (maxAbs >= 1_000_000_000) sample = '1.5B';
107
- else if (maxAbs >= 1_000_000) sample = '1.5M';
108
- else if (maxAbs >= 1_000) sample = '1.5K';
109
- else sample = String(Math.round(maxAbs * 100) / 100);
110
- valueWidth = estimateTextWidth(sample, ENDPOINT_VALUE_FONT_SIZE, ENDPOINT_VALUE_FONT_WEIGHT);
113
+ if (!content && sample) {
114
+ const w = estimateTextWidth(sample, ENDPOINT_VALUE_FONT_SIZE, ENDPOINT_VALUE_FONT_WEIGHT);
115
+ if (w > maxWidth) maxWidth = w;
116
+ }
117
+ return maxWidth;
118
+ };
119
+
120
+ // Both columns share one reserved width (dimensions.ts mirrors the right
121
+ // reservation onto the left in `ends: 'both'` mode), so take the max of
122
+ // the sides in play.
123
+ let textColumn = sideTextWidth('trailing');
124
+ if (config?.ends === 'both') {
125
+ textColumn = Math.max(textColumn, sideTextWidth('leading'));
111
126
  }
112
127
 
113
- // Column = swatch + gap + max(label width, value width) + small trailing pad.
114
- const textColumn = Math.max(maxLabelWidth, valueWidth);
128
+ // Column = swatch + gap + text column + small trailing pad.
115
129
  return ENDPOINT_SWATCH_SIZE + ENDPOINT_GAP + textColumn + 4;
116
130
  }
117
131
 
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveFieldFormatter } from '../field-format';
3
+
4
+ describe('resolveFieldFormatter', () => {
5
+ describe('precedence', () => {
6
+ it('surfaceFormat wins over channelFormat', () => {
7
+ const fmt = resolveFieldFormatter({
8
+ surfaceFormat: '.0%',
9
+ channelFormat: ',.0f',
10
+ values: [0.5, 0.75, 1.0],
11
+ });
12
+ expect(fmt(0.5)).toBe('50%');
13
+ });
14
+
15
+ it('channelFormat is used when surfaceFormat is absent', () => {
16
+ const fmt = resolveFieldFormatter({
17
+ channelFormat: '.0%',
18
+ values: [0.5, 0.75, 1.0],
19
+ });
20
+ expect(fmt(0.5)).toBe('50%');
21
+ });
22
+
23
+ it('falls back to default when both formats are absent', () => {
24
+ const fmt = resolveFieldFormatter({
25
+ values: [1000, 2000, 3000],
26
+ });
27
+ const result = fmt(2000);
28
+ expect(result).toBe('2k');
29
+ });
30
+
31
+ it('treats empty string as unset', () => {
32
+ const fmt = resolveFieldFormatter({
33
+ surfaceFormat: '',
34
+ channelFormat: '',
35
+ values: [10000, 20000],
36
+ });
37
+ expect(fmt(10000)).toBe('10k');
38
+ });
39
+ });
40
+
41
+ describe('semantic keywords', () => {
42
+ it('percent keyword formats as percentage', () => {
43
+ const fmt = resolveFieldFormatter({
44
+ surfaceFormat: 'percent',
45
+ values: [0.25, 0.5, 0.75],
46
+ });
47
+ expect(fmt(0.5)).toBe('50%');
48
+ });
49
+
50
+ it('currency keyword formats with $', () => {
51
+ const fmt = resolveFieldFormatter({
52
+ surfaceFormat: 'currency',
53
+ values: [100, 200, 300],
54
+ });
55
+ expect(fmt(200)).toContain('$');
56
+ });
57
+
58
+ it('ordinal keyword formats with suffix', () => {
59
+ const fmt = resolveFieldFormatter({
60
+ surfaceFormat: 'ordinal',
61
+ values: [1, 2, 3],
62
+ });
63
+ expect(fmt(1)).toBe('1st');
64
+ expect(fmt(2)).toBe('2nd');
65
+ expect(fmt(3)).toBe('3rd');
66
+ });
67
+ });
68
+
69
+ describe('years guard', () => {
70
+ it('renders year-like values as bare integers, not compact or comma-grouped', () => {
71
+ const fmt = resolveFieldFormatter({ values: [1990, 2007, 2024] });
72
+ expect(fmt(2024)).toBe('2024');
73
+ expect(fmt(1990)).toBe('1990');
74
+ });
75
+
76
+ it('activates at exact boundaries [1500, 2500]', () => {
77
+ const fmt = resolveFieldFormatter({ values: [1500, 2500] });
78
+ expect(fmt(1500)).toBe('1500');
79
+ expect(fmt(2500)).toBe('2500');
80
+ });
81
+
82
+ it('deactivates when any value falls below 1500', () => {
83
+ const fmt = resolveFieldFormatter({ values: [1499, 2400] });
84
+ expect(fmt(1499)).toBe('1.5k');
85
+ });
86
+
87
+ it('deactivates when any value exceeds 2500', () => {
88
+ const fmt = resolveFieldFormatter({ values: [1600, 2501] });
89
+ expect(fmt(2501)).toBe('2.5k');
90
+ });
91
+
92
+ it('deactivates for non-integer values', () => {
93
+ const fmt = resolveFieldFormatter({ values: [2020.5, 2024.5] });
94
+ expect(fmt(2024.5)).toBe('2k');
95
+ });
96
+
97
+ it('deactivates when mixed with large magnitudes', () => {
98
+ const fmt = resolveFieldFormatter({ values: [1990, 2024, 50000] });
99
+ expect(fmt(50000)).toBe('50k');
100
+ });
101
+
102
+ it('deactivates for negative values', () => {
103
+ const fmtNeg = resolveFieldFormatter({ values: [-2000, 2000] });
104
+ expect(fmtNeg(2000)).toBe('2k');
105
+ });
106
+
107
+ it('deactivates when range includes zero', () => {
108
+ const fmtZero = resolveFieldFormatter({ values: [0, 2024] });
109
+ expect(fmtZero(2024)).toBe('2k');
110
+ });
111
+
112
+ it('explicit format overrides guard: comma-grouped', () => {
113
+ const fmt = resolveFieldFormatter({
114
+ surfaceFormat: ',d',
115
+ values: [1990, 2024],
116
+ });
117
+ expect(fmt(2024)).toBe('2,024');
118
+ });
119
+
120
+ it('explicit format overrides guard: SI prefix', () => {
121
+ const fmt = resolveFieldFormatter({
122
+ surfaceFormat: '~s',
123
+ values: [1990, 2024],
124
+ });
125
+ expect(fmt(2024)).toContain('k');
126
+ });
127
+
128
+ it('table surface still applies guard (no comma-grouping)', () => {
129
+ const fmt = resolveFieldFormatter({
130
+ values: [1990, 2024],
131
+ surface: 'table',
132
+ });
133
+ expect(fmt(2024)).toBe('2024');
134
+ });
135
+ });
136
+
137
+ describe('step threading', () => {
138
+ it('step is applied to the context', () => {
139
+ const fmt = resolveFieldFormatter({
140
+ values: [0, 5000, 10000, 15000, 20000],
141
+ step: 5000,
142
+ });
143
+ expect(fmt(15000)).toBe('15k');
144
+ });
145
+ });
146
+
147
+ describe('chart vs table surface', () => {
148
+ it('chart surface gets compact formatting', () => {
149
+ const fmt = resolveFieldFormatter({
150
+ values: [1000, 2000, 3000],
151
+ surface: 'chart',
152
+ });
153
+ expect(fmt(2000)).toBe('2k');
154
+ });
155
+
156
+ it('table surface gets full precision', () => {
157
+ const fmt = resolveFieldFormatter({
158
+ values: [1000, 2000, 3000],
159
+ surface: 'table',
160
+ });
161
+ expect(fmt(2000)).toBe('2,000');
162
+ });
163
+ });
164
+ });
@@ -0,0 +1,27 @@
1
+ import type { FieldFormatContext, NumberFormatter } from '@opendata-ai/openchart-core';
2
+ import {
3
+ computeFieldFormatContext,
4
+ defaultNumberFormatter,
5
+ resolveNumberFormatter,
6
+ } from '@opendata-ai/openchart-core';
7
+
8
+ export interface FieldFormatOptions {
9
+ surfaceFormat?: string;
10
+ channelFormat?: string;
11
+ values?: Iterable<unknown>;
12
+ context?: FieldFormatContext;
13
+ surface?: 'chart' | 'table';
14
+ step?: number;
15
+ }
16
+
17
+ export function resolveFieldFormatter(opts: FieldFormatOptions): NumberFormatter {
18
+ const surfFmt = opts.surfaceFormat || undefined;
19
+ const chanFmt = opts.channelFormat || undefined;
20
+ const ctx: FieldFormatContext = opts.context
21
+ ? { ...opts.context }
22
+ : computeFieldFormatContext(opts.values ?? [], opts.surface);
23
+ if (opts.step != null) ctx.step = opts.step;
24
+ if (opts.surface) ctx.surface = opts.surface;
25
+ const fmt = resolveNumberFormatter(surfFmt ?? chanFmt, ctx);
26
+ return fmt ?? defaultNumberFormatter(ctx);
27
+ }