@opendata-ai/openchart-engine 7.10.0 → 8.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +142 -7
- package/dist/index.js +16182 -10032
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
- package/src/__tests__/annotation-behavior.test.ts +721 -0
- package/src/__tests__/axes.test.ts +50 -3
- package/src/__tests__/compile-chart.test.ts +30 -0
- package/src/__tests__/compile-footnotes.test.ts +277 -0
- package/src/__tests__/compile-layer.test.ts +120 -1
- package/src/__tests__/dimensions.test.ts +49 -4
- package/src/__tests__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +120 -4
- package/src/__tests__/ranking-recipes.test.ts +260 -0
- package/src/__tests__/scales.test.ts +9 -3
- package/src/__tests__/series-search.test.ts +92 -0
- package/src/__tests__/spec-sugar.test.ts +801 -0
- package/src/__tests__/you-draw-it.test.ts +160 -0
- package/src/annotations/__tests__/compute.test.ts +207 -92
- package/src/annotations/__tests__/geometry.test.ts +66 -0
- package/src/annotations/__tests__/resolve-text.test.ts +527 -27
- package/src/annotations/__tests__/rich-text.test.ts +91 -0
- package/src/annotations/__tests__/thinning.test.ts +148 -0
- package/src/annotations/collisions.ts +130 -84
- package/src/annotations/compute.ts +83 -85
- package/src/annotations/constants.ts +116 -6
- package/src/annotations/geometry.ts +222 -77
- package/src/annotations/move.ts +135 -0
- package/src/annotations/placement.ts +62 -14
- package/src/annotations/resolve-range.ts +2 -0
- package/src/annotations/resolve-refline.ts +2 -0
- package/src/annotations/resolve-text.ts +312 -111
- package/src/annotations/rich-text.ts +65 -0
- package/src/annotations/thinning.ts +84 -10
- package/src/barlist/compile-barlist.ts +16 -6
- package/src/charts/_shared/format-label-value.ts +9 -8
- package/src/charts/bar/__tests__/compute.test.ts +57 -10
- package/src/charts/bar/__tests__/labels.test.ts +5 -4
- package/src/charts/bar/compute.ts +2 -6
- package/src/charts/bar/index.ts +8 -1
- package/src/charts/bar/labels.ts +3 -3
- package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
- package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
- package/src/charts/beeswarm/compute.ts +203 -0
- package/src/charts/beeswarm/dodge.ts +155 -0
- package/src/charts/beeswarm/index.ts +32 -0
- package/src/charts/builtin.ts +12 -1
- package/src/charts/calendar/__tests__/compute.test.ts +417 -0
- package/src/charts/calendar/compute.ts +319 -0
- package/src/charts/calendar/index.ts +32 -0
- package/src/charts/column/__tests__/compute.test.ts +21 -7
- package/src/charts/column/__tests__/labels.test.ts +76 -1
- package/src/charts/column/compute.ts +2 -6
- package/src/charts/column/index.ts +10 -2
- package/src/charts/column/labels.ts +95 -21
- package/src/charts/dot/index.ts +8 -1
- package/src/charts/dot/labels.ts +3 -3
- package/src/charts/line/__tests__/compute.test.ts +15 -19
- package/src/charts/line/area.ts +8 -23
- package/src/charts/line/compute.ts +32 -2
- package/src/charts/parliament/__tests__/compute.test.ts +411 -0
- package/src/charts/parliament/compute.ts +313 -0
- package/src/charts/parliament/index.ts +32 -0
- package/src/charts/pie/__tests__/compute.test.ts +103 -0
- package/src/charts/pie/compute.ts +63 -8
- package/src/charts/pie/labels.ts +1 -0
- package/src/charts/range/__tests__/compute.test.ts +486 -0
- package/src/charts/range/compute.ts +273 -0
- package/src/charts/range/index.ts +82 -0
- package/src/charts/range/labels.ts +169 -0
- package/src/charts/rect/__tests__/compute.test.ts +120 -0
- package/src/charts/rect/index.ts +127 -0
- package/src/charts/scatter/compute.ts +7 -21
- package/src/charts/text/__tests__/compute.test.ts +307 -0
- package/src/charts/text/index.ts +44 -7
- package/src/charts/tick/__tests__/compute.test.ts +75 -0
- package/src/charts/tick/index.ts +7 -4
- package/src/charts/utils.ts +5 -3
- package/src/charts/waffle/__tests__/compute.test.ts +305 -0
- package/src/charts/waffle/compute.ts +197 -0
- package/src/charts/waffle/index.ts +31 -0
- package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
- package/src/compile/__tests__/data-clip.test.ts +68 -0
- package/src/compile/__tests__/fill-patterns.test.ts +154 -0
- package/src/compile/__tests__/size-scale.test.ts +108 -0
- package/src/compile/color-scale-range.ts +54 -33
- package/src/compile/data-clip.ts +21 -6
- package/src/compile/fill-patterns.ts +74 -0
- package/src/compile/layer.ts +117 -21
- package/src/compile/size-scale.ts +139 -0
- package/src/compile/spec-sugar.ts +758 -0
- package/src/compile/you-draw-it.ts +123 -0
- package/src/compile.ts +315 -103
- package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
- package/src/compiler/__tests__/normalize.test.ts +65 -1
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
- package/src/compiler/__tests__/validate.test.ts +156 -0
- package/src/compiler/__tests__/vl-parity.test.ts +68 -0
- package/src/compiler/a11y-warnings.ts +110 -0
- package/src/compiler/normalize.ts +150 -7
- package/src/compiler/types.ts +11 -0
- package/src/compiler/validate.ts +664 -18
- package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
- package/src/endpoint-labels/compute.ts +40 -12
- package/src/endpoint-labels/format.ts +45 -15
- package/src/endpoint-labels/predict.ts +50 -36
- package/src/format/__tests__/field-format.test.ts +164 -0
- package/src/format/field-format.ts +27 -0
- package/src/geo/__tests__/compile-map.test.ts +621 -0
- package/src/geo/compile-map.ts +856 -0
- package/src/geo/join.ts +115 -0
- package/src/geo/projections.ts +45 -0
- package/src/geo/types.ts +24 -0
- package/src/graphs/__tests__/compile-graph.test.ts +25 -0
- package/src/graphs/compile-graph.ts +27 -18
- package/src/index.ts +28 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +30 -2
- package/src/layout/dimensions.ts +178 -95
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +86 -35
- package/src/layout/scales.ts +132 -15
- package/src/legend/__tests__/continuous.test.ts +318 -0
- package/src/legend/__tests__/size.test.ts +348 -0
- package/src/legend/__tests__/suppression.test.ts +17 -1
- package/src/legend/compute.ts +126 -14
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- package/src/legend/wrap.ts +8 -0
- package/src/sankey/compile-sankey.ts +31 -19
- package/src/tables/format-cells.ts +16 -7
- package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
- package/src/tilemap/compile-tilemap.ts +24 -7
- package/src/tooltips/__tests__/compute.test.ts +2 -2
- package/src/tooltips/compute.ts +422 -41
- package/src/transforms/__tests__/conditional.test.ts +18 -0
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
20
|
+
formatter?: NumberFormatter | null,
|
|
18
21
|
): string {
|
|
19
22
|
if (value == null) return '';
|
|
20
23
|
if (typeof value === 'string') return value;
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 =
|
|
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
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
79
|
+
const surfaceFormat =
|
|
88
80
|
config?.format ??
|
|
89
81
|
((spec.encoding.y?.axis as Record<string, unknown> | undefined)?.format as string | undefined);
|
|
90
|
-
let
|
|
82
|
+
let sample = '';
|
|
91
83
|
if (yField) {
|
|
84
|
+
const values = spec.data.map((r) => r[yField]);
|
|
92
85
|
let maxAbs = 0;
|
|
93
|
-
for (const
|
|
94
|
-
const
|
|
95
|
-
if (Number.isFinite(
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 +
|
|
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
|
+
}
|