@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.
- package/dist/index.d.ts +123 -14
- package/dist/index.js +16166 -10027
- 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__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +69 -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 +310 -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__/keys.test.ts +3 -9
- 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/keys.ts +5 -4
- 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 +20 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +35 -6
- package/src/layout/dimensions.ts +139 -83
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +83 -33
- 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 +119 -13
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- 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
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* overlap to numbered dot markers with footnote text below the chart.
|
|
4
4
|
*
|
|
5
5
|
* Single deterministic pass: run collision resolution once at the full chart
|
|
6
|
-
* area, then greedily place by priority.
|
|
7
|
-
* already-placed
|
|
6
|
+
* area, then greedily place by priority. A candidate is demoted when it either
|
|
7
|
+
* overlaps an already-placed label or escapes the plot area. No iterative
|
|
8
|
+
* layout convergence.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import type {
|
|
@@ -22,6 +23,17 @@ export interface ThinningResult {
|
|
|
22
23
|
footnotes: AnnotationFootnote[];
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Max share of the plot area inline annotation labels may collectively claim
|
|
28
|
+
* before further candidates demote to footnotes. Measured coverage for a
|
|
29
|
+
* 6-callout line chart runs ~5% at 860px and ~16% at 300px, so 10% leaves wide
|
|
30
|
+
* charts fully inline and demotes the crowded tail on narrow ones.
|
|
31
|
+
*/
|
|
32
|
+
const COVERAGE_BUDGET = 0.1;
|
|
33
|
+
|
|
34
|
+
/** Gap enforced between inline labels; labels closer than this count as colliding. */
|
|
35
|
+
const COLLISION_PADDING = 4;
|
|
36
|
+
|
|
25
37
|
/**
|
|
26
38
|
* Apply auto-thinning to resolved annotations. Text annotations that still
|
|
27
39
|
* overlap after collision resolution are demoted to footnote markers.
|
|
@@ -37,11 +49,22 @@ export interface ThinningResult {
|
|
|
37
49
|
* minus any that were skipped by `computeAnnotations`).
|
|
38
50
|
* @param specs - The full spec annotations array, index-aligned with `annotations`.
|
|
39
51
|
* Non-text specs are ignored; text specs provide `priority` and `responsive`.
|
|
52
|
+
* @param plotArea - The plot rect. Sizes the coverage budget: labels are charged
|
|
53
|
+
* against the plot they crowd, so the same label costs a larger share of a
|
|
54
|
+
* smaller plot. Omit to skip the budget test.
|
|
55
|
+
* @param containerBounds - The rect a label must fit inside to stay inline. This
|
|
56
|
+
* is the whole chart, NOT the plot: annotations render outside the clip path and
|
|
57
|
+
* legitimately sit in the margins (above a peak, below a trough), which is what
|
|
58
|
+
* collision avoidance already assumes. Fencing them to the plot would demote
|
|
59
|
+
* ordinary callouts. Only labels escaping the chart entirely are demoted.
|
|
60
|
+
* Omit to skip the containment test.
|
|
40
61
|
*/
|
|
41
62
|
export function thinAnnotations(
|
|
42
63
|
annotations: ResolvedAnnotation[],
|
|
43
64
|
specs: Annotation[],
|
|
44
65
|
measure: AnnotationMeasureTextFn,
|
|
66
|
+
plotArea?: Rect,
|
|
67
|
+
containerBounds?: Rect,
|
|
45
68
|
): ThinningResult {
|
|
46
69
|
if (annotations.length <= 1) {
|
|
47
70
|
return { annotations, footnotes: [] };
|
|
@@ -49,11 +72,20 @@ export function thinAnnotations(
|
|
|
49
72
|
|
|
50
73
|
// Build index pairs: (resolved, spec, original index).
|
|
51
74
|
// Only text specs carry priority/responsive; non-text specs are ignored.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
75
|
+
//
|
|
76
|
+
// `annotations` is a filtered view of `specs` -- an annotation that resolves to
|
|
77
|
+
// nothing (position outside the domain) is dropped -- so index into `specs` by
|
|
78
|
+
// the stamped `specIndex`, never by position in `annotations`. Falling back to
|
|
79
|
+
// `i` keeps a hand-built layout (no specIndex) working as it did before.
|
|
80
|
+
const entries = annotations.map((a, i) => {
|
|
81
|
+
const specIndex = a.specIndex ?? i;
|
|
82
|
+
const spec = specs[specIndex];
|
|
83
|
+
return {
|
|
84
|
+
resolved: a,
|
|
85
|
+
spec: spec?.type === 'text' ? (spec as TextAnnotation) : undefined,
|
|
86
|
+
index: i,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
57
89
|
|
|
58
90
|
// Separate pinned (responsive: false) from candidates
|
|
59
91
|
const pinned: typeof entries = [];
|
|
@@ -85,8 +117,26 @@ export function thinAnnotations(
|
|
|
85
117
|
}
|
|
86
118
|
}
|
|
87
119
|
|
|
88
|
-
// Greedily place candidates; demote overlapping ones to footnotes
|
|
120
|
+
// Greedily place candidates; demote overlapping ones to footnotes.
|
|
121
|
+
//
|
|
122
|
+
// Overlap and containment alone under-thin at narrow widths: collision
|
|
123
|
+
// resolution spreads labels apart and clamping pulls them back inside, so
|
|
124
|
+
// they neither collide nor escape the plot — they just crowd it. Layer a
|
|
125
|
+
// coverage budget on top: inline labels may claim at most COVERAGE_BUDGET of
|
|
126
|
+
// the plot area, and candidates that push past it demote. Coverage rises as
|
|
127
|
+
// the plot shrinks (a fixed label costs a larger share of a smaller plot),
|
|
128
|
+
// so this only bites at small sizes and leaves roomy charts untouched.
|
|
129
|
+
//
|
|
130
|
+
// Pinned labels are exempt: they are guaranteed placement, so charging them
|
|
131
|
+
// against the budget could exhaust it before any candidate is seen and evict
|
|
132
|
+
// labels that would otherwise fit. They still block via `placedBounds`.
|
|
89
133
|
const footnotes: AnnotationFootnote[] = [];
|
|
134
|
+
const plotAreaSize = plotArea ? plotArea.width * plotArea.height : 0;
|
|
135
|
+
const coverageBudget = plotAreaSize * COVERAGE_BUDGET;
|
|
136
|
+
// Containment is checked against the chart, not the plot — see the param docs.
|
|
137
|
+
// Callers that pass no container fall back to the plot rect.
|
|
138
|
+
const containment = containerBounds ?? plotArea;
|
|
139
|
+
let usedArea = 0;
|
|
90
140
|
|
|
91
141
|
for (const entry of candidates) {
|
|
92
142
|
const bounds = entry.resolved.bounds ?? estimateLabelBounds(entry.resolved.label!, measure);
|
|
@@ -96,11 +146,20 @@ export function thinAnnotations(
|
|
|
96
146
|
continue;
|
|
97
147
|
}
|
|
98
148
|
|
|
149
|
+
const padded = {
|
|
150
|
+
x: bounds.x - COLLISION_PADDING,
|
|
151
|
+
y: bounds.y - COLLISION_PADDING,
|
|
152
|
+
width: bounds.width + COLLISION_PADDING * 2,
|
|
153
|
+
height: bounds.height + COLLISION_PADDING * 2,
|
|
154
|
+
};
|
|
99
155
|
const overlaps = placedBounds.some(
|
|
100
|
-
(pb) => pb.width > 0 && pb.height > 0 && detectCollision(
|
|
156
|
+
(pb) => pb.width > 0 && pb.height > 0 && detectCollision(padded, pb),
|
|
101
157
|
);
|
|
102
158
|
|
|
103
|
-
|
|
159
|
+
const candidateArea = bounds.width * bounds.height;
|
|
160
|
+
const overBudget = plotAreaSize > 0 && usedArea + candidateArea > coverageBudget;
|
|
161
|
+
|
|
162
|
+
if (overlaps || !fitsWithin(bounds, containment) || overBudget) {
|
|
104
163
|
const footnoteIndex = footnotes.length + 1;
|
|
105
164
|
footnotes.push({
|
|
106
165
|
index: footnoteIndex,
|
|
@@ -109,8 +168,23 @@ export function thinAnnotations(
|
|
|
109
168
|
entry.resolved.footnoteIndex = footnoteIndex;
|
|
110
169
|
} else {
|
|
111
170
|
placedBounds.push(bounds);
|
|
171
|
+
usedArea += candidateArea;
|
|
112
172
|
}
|
|
113
173
|
}
|
|
114
174
|
|
|
115
175
|
return { annotations, footnotes };
|
|
116
176
|
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Whether a label's bounds sit entirely inside the plot. No plot area means no
|
|
180
|
+
* containment constraint, so everything fits.
|
|
181
|
+
*/
|
|
182
|
+
function fitsWithin(bounds: Rect, plotArea: Rect | undefined): boolean {
|
|
183
|
+
if (!plotArea || plotArea.width <= 0 || plotArea.height <= 0) return true;
|
|
184
|
+
return (
|
|
185
|
+
bounds.x >= plotArea.x &&
|
|
186
|
+
bounds.y >= plotArea.y &&
|
|
187
|
+
bounds.x + bounds.width <= plotArea.x + plotArea.width &&
|
|
188
|
+
bounds.y + bounds.height <= plotArea.y + plotArea.height
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -24,15 +24,14 @@ import type {
|
|
|
24
24
|
} from '@opendata-ai/openchart-core';
|
|
25
25
|
import {
|
|
26
26
|
adaptTheme,
|
|
27
|
-
buildD3Formatter,
|
|
28
27
|
computeChrome,
|
|
29
28
|
estimateTextWidth,
|
|
30
|
-
formatNumber,
|
|
31
29
|
resolveTheme,
|
|
32
30
|
} from '@opendata-ai/openchart-core';
|
|
33
|
-
|
|
31
|
+
import { emitSpecWarnings, expandSpecSugar } from '../compile/spec-sugar';
|
|
34
32
|
import { resolveAnimation } from '../compiler/animation';
|
|
35
33
|
import { compile as compileSpec } from '../compiler/index';
|
|
34
|
+
import { resolveFieldFormatter } from '../format/field-format';
|
|
36
35
|
import type { NormalizedBarListSpec } from './types';
|
|
37
36
|
|
|
38
37
|
// ---------------------------------------------------------------------------
|
|
@@ -57,7 +56,15 @@ const BARLIST_COLORS = ['#06b6d4', '#34d399', '#fbbf24', '#f472b6', '#a78bfa'];
|
|
|
57
56
|
// ---------------------------------------------------------------------------
|
|
58
57
|
|
|
59
58
|
export function compileBarList(spec: unknown, options: CompileOptions): BarListLayout {
|
|
60
|
-
|
|
59
|
+
// Expand deprecated top-level sugar (valueFormat -> encoding.value.format)
|
|
60
|
+
// before validation, then validate + normalize via the shared pipeline.
|
|
61
|
+
const sugarWarnings: string[] = [];
|
|
62
|
+
const expandedSpec =
|
|
63
|
+
spec && typeof spec === 'object' && !Array.isArray(spec)
|
|
64
|
+
? expandSpecSugar(spec as Record<string, unknown>, sugarWarnings)
|
|
65
|
+
: spec;
|
|
66
|
+
const { spec: normalized, warnings } = compileSpec(expandedSpec);
|
|
67
|
+
emitSpecWarnings([...sugarWarnings, ...warnings], options.onWarn);
|
|
61
68
|
|
|
62
69
|
if (!('type' in normalized) || normalized.type !== 'barlist') {
|
|
63
70
|
throw new Error(
|
|
@@ -158,8 +165,11 @@ export function compileBarList(spec: unknown, options: CompileOptions): BarListL
|
|
|
158
165
|
return palette[idx % palette.length];
|
|
159
166
|
}
|
|
160
167
|
|
|
161
|
-
// Value formatter
|
|
162
|
-
const formatter =
|
|
168
|
+
// Value formatter: prefer encoding-level format (v8 canonical), fall back to deprecated valueFormat
|
|
169
|
+
const formatter = resolveFieldFormatter({
|
|
170
|
+
surfaceFormat: barlistSpec.encoding.value.format ?? barlistSpec.valueFormat,
|
|
171
|
+
values: validRows.map((r) => r[valueField]),
|
|
172
|
+
});
|
|
163
173
|
|
|
164
174
|
// Compute label width: measure all labels and use a consistent width
|
|
165
175
|
const measureText =
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared numeric value formatter for data labels.
|
|
3
3
|
*
|
|
4
|
-
* Used by bar, column, and dot label computation to display a value
|
|
5
|
-
*
|
|
6
|
-
* numeric format.
|
|
4
|
+
* Used by bar, column, and dot label computation to display a value
|
|
5
|
+
* with smart compact defaults (k/M/B/T).
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
|
-
import {
|
|
8
|
+
import type { FieldFormatContext } from '@opendata-ai/openchart-core';
|
|
9
|
+
import { defaultNumberFormatter } from '@opendata-ai/openchart-core';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const defaultFmt = defaultNumberFormatter();
|
|
12
|
+
|
|
13
|
+
/** Format a label value for display (compact by default). */
|
|
14
|
+
export function formatLabelValue(value: number, ctx?: FieldFormatContext): string {
|
|
15
|
+
return (ctx ? defaultNumberFormatter(ctx) : defaultFmt)(value);
|
|
15
16
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LayoutStrategy, Rect } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { buildD3Formatter } from '@opendata-ai/openchart-core';
|
|
2
3
|
import { describe, expect, it } from 'vitest';
|
|
3
4
|
import type { NormalizedChartSpec } from '../../../compiler/types';
|
|
4
5
|
import { computeScales } from '../../../layout/scales';
|
|
@@ -58,7 +59,9 @@ function makeGroupedBarSpec(): NormalizedChartSpec {
|
|
|
58
59
|
{ category: 'Q3', value: 70, region: 'West' },
|
|
59
60
|
],
|
|
60
61
|
encoding: {
|
|
61
|
-
|
|
62
|
+
// Grouped is opt-out since v8 (stacked is now the default): stamp
|
|
63
|
+
// stack: null explicitly so this fixture stays grouped.
|
|
64
|
+
x: { field: 'value', type: 'quantitative', stack: null },
|
|
62
65
|
y: { field: 'category', type: 'nominal' },
|
|
63
66
|
color: { field: 'region', type: 'nominal' },
|
|
64
67
|
},
|
|
@@ -214,7 +217,46 @@ describe('computeBarMarks', () => {
|
|
|
214
217
|
});
|
|
215
218
|
});
|
|
216
219
|
|
|
217
|
-
describe('
|
|
220
|
+
describe('stack: undefined (omitted) stacks by default (v8 VL-aligned default)', () => {
|
|
221
|
+
function makeDefaultStackBarSpec(): NormalizedChartSpec {
|
|
222
|
+
return {
|
|
223
|
+
markType: 'bar',
|
|
224
|
+
markDef: { type: 'bar' },
|
|
225
|
+
data: [
|
|
226
|
+
{ category: 'Q1', value: 50, region: 'East' },
|
|
227
|
+
{ category: 'Q1', value: 40, region: 'West' },
|
|
228
|
+
{ category: 'Q2', value: 60, region: 'East' },
|
|
229
|
+
{ category: 'Q2', value: 55, region: 'West' },
|
|
230
|
+
],
|
|
231
|
+
encoding: {
|
|
232
|
+
x: { field: 'value', type: 'quantitative' },
|
|
233
|
+
y: { field: 'category', type: 'nominal' },
|
|
234
|
+
color: { field: 'region', type: 'nominal' },
|
|
235
|
+
},
|
|
236
|
+
chrome: {},
|
|
237
|
+
annotations: [],
|
|
238
|
+
responsive: true,
|
|
239
|
+
theme: {},
|
|
240
|
+
darkMode: 'off',
|
|
241
|
+
labels: { density: 'auto', format: '' },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
it('stacks segments end-to-end without an explicit stack property', () => {
|
|
246
|
+
const spec = makeDefaultStackBarSpec();
|
|
247
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
248
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
249
|
+
|
|
250
|
+
const q1Marks = marks.filter((m) => m.aria.label.includes('Q1'));
|
|
251
|
+
expect(q1Marks).toHaveLength(2);
|
|
252
|
+
expect(q1Marks[0].y).toBe(q1Marks[1].y);
|
|
253
|
+
const [first, second] = q1Marks;
|
|
254
|
+
expect(second.x).toBeCloseTo(first.x + first.width, 0);
|
|
255
|
+
expect(marks.every((m) => m.stackGroup !== undefined)).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('grouped bars (stack: null)', () => {
|
|
218
260
|
it('produces marks for all data rows', () => {
|
|
219
261
|
const spec = makeGroupedBarSpec();
|
|
220
262
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
@@ -329,7 +371,10 @@ describe('computeBarMarks', () => {
|
|
|
329
371
|
markDef: { type: 'bar' },
|
|
330
372
|
data,
|
|
331
373
|
encoding: {
|
|
332
|
-
|
|
374
|
+
// Grouped is opt-out since v8 (stacked is now the default): stamp
|
|
375
|
+
// stack: null explicitly since this test exercises the
|
|
376
|
+
// grouped-bar-only readable-floor reclaim.
|
|
377
|
+
x: { field: 'dollars', type: 'quantitative', stack: null },
|
|
333
378
|
y: { field: 'district', type: 'nominal' },
|
|
334
379
|
color: { field: 'source', type: 'nominal' },
|
|
335
380
|
},
|
|
@@ -470,10 +515,12 @@ describe('computeBarMarks', () => {
|
|
|
470
515
|
markDef: { type: 'bar' },
|
|
471
516
|
data: wageData,
|
|
472
517
|
encoding: {
|
|
518
|
+
// Grouped is opt-out since v8 (stacked is now the default): stamp
|
|
519
|
+
// stack: null explicitly for the grouped case.
|
|
473
520
|
x: {
|
|
474
521
|
field: 'pay',
|
|
475
522
|
type: 'quantitative',
|
|
476
|
-
|
|
523
|
+
stack: stackZero ? 'zero' : null,
|
|
477
524
|
},
|
|
478
525
|
y: { field: 'size', type: 'nominal' },
|
|
479
526
|
color: { field: 'year', type: 'nominal' },
|
|
@@ -487,7 +534,7 @@ describe('computeBarMarks', () => {
|
|
|
487
534
|
};
|
|
488
535
|
}
|
|
489
536
|
|
|
490
|
-
it('
|
|
537
|
+
it('grouped (stack: null): bars sit at different y positions within each category', () => {
|
|
491
538
|
const spec = makeWageSpec(false);
|
|
492
539
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
493
540
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
@@ -500,12 +547,12 @@ describe('computeBarMarks', () => {
|
|
|
500
547
|
expect(smallFirmMarks[0].y).not.toBe(smallFirmMarks[1].y);
|
|
501
548
|
});
|
|
502
549
|
|
|
503
|
-
it('
|
|
550
|
+
it('grouped (stack: null): scale domain covers max individual value, not stacked sum', () => {
|
|
504
551
|
const spec = makeWageSpec(false);
|
|
505
552
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
506
553
|
|
|
507
554
|
// Max individual pay is 74800. Stacked sum for 5000+ employees = 62300 + 74800 = 137100.
|
|
508
|
-
//
|
|
555
|
+
// Grouped bars should NOT extend domain to 137100.
|
|
509
556
|
const xScale = scales.x!.scale;
|
|
510
557
|
const domain = xScale.domain() as number[];
|
|
511
558
|
expect(domain[1]).toBeLessThan(137100);
|
|
@@ -711,7 +758,7 @@ describe('computeBarLabels', () => {
|
|
|
711
758
|
const spec = makeSimpleBarSpec();
|
|
712
759
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
713
760
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
714
|
-
const labels = computeBarLabels(marks, chartArea, 'auto', '$,.0f');
|
|
761
|
+
const labels = computeBarLabels(marks, chartArea, 'auto', buildD3Formatter('$,.0f'));
|
|
715
762
|
|
|
716
763
|
const texts = labels.map((l) => l.text);
|
|
717
764
|
expect(texts).toContain('$50');
|
|
@@ -740,7 +787,7 @@ describe('computeBarLabels', () => {
|
|
|
740
787
|
};
|
|
741
788
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
742
789
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
743
|
-
const labels = computeBarLabels(marks, chartArea, 'all', '$,.2~fT');
|
|
790
|
+
const labels = computeBarLabels(marks, chartArea, 'all', buildD3Formatter('$,.2~fT'));
|
|
744
791
|
|
|
745
792
|
const texts = labels.map((l) => l.text);
|
|
746
793
|
expect(texts).toContain('$3.75T');
|
|
@@ -751,7 +798,7 @@ describe('computeBarLabels', () => {
|
|
|
751
798
|
const spec = makeSimpleBarSpec();
|
|
752
799
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
753
800
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
754
|
-
const labels = computeBarLabels(marks, chartArea, 'auto', '.0f%');
|
|
801
|
+
const labels = computeBarLabels(marks, chartArea, 'auto', buildD3Formatter('.0f%'));
|
|
755
802
|
|
|
756
803
|
const texts = labels.map((l) => l.text);
|
|
757
804
|
expect(texts).toContain('50%');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RectMark } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { buildD3Formatter } from '@opendata-ai/openchart-core';
|
|
2
3
|
import { describe, expect, it } from 'vitest';
|
|
3
4
|
import { computeBarLabels } from '../labels';
|
|
4
5
|
|
|
@@ -163,7 +164,7 @@ describe('computeBarLabels with $~s format and abbreviated aria values', () => {
|
|
|
163
164
|
makeAbbreviatedMark(2, 14000, '14K'),
|
|
164
165
|
];
|
|
165
166
|
|
|
166
|
-
const labels = computeBarLabels(thousandMarks, chartArea, 'all', '$~s');
|
|
167
|
+
const labels = computeBarLabels(thousandMarks, chartArea, 'all', buildD3Formatter('$~s'));
|
|
167
168
|
expect(labels).toHaveLength(3);
|
|
168
169
|
expect(labels[0].text).toBe('$6k');
|
|
169
170
|
expect(labels[1].text).toBe('$7k');
|
|
@@ -173,7 +174,7 @@ describe('computeBarLabels with $~s format and abbreviated aria values', () => {
|
|
|
173
174
|
it('$~s format works for millions', () => {
|
|
174
175
|
const millionMarks: RectMark[] = [makeAbbreviatedMark(0, 1500000, '1.5M')];
|
|
175
176
|
|
|
176
|
-
const labels = computeBarLabels(millionMarks, chartArea, 'all', '$~s');
|
|
177
|
+
const labels = computeBarLabels(millionMarks, chartArea, 'all', buildD3Formatter('$~s'));
|
|
177
178
|
expect(labels).toHaveLength(1);
|
|
178
179
|
expect(labels[0].text).toBe('$1.5M');
|
|
179
180
|
});
|
|
@@ -181,7 +182,7 @@ describe('computeBarLabels with $~s format and abbreviated aria values', () => {
|
|
|
181
182
|
it('handles comma-formatted aria values', () => {
|
|
182
183
|
const commaMarks: RectMark[] = [makeAbbreviatedMark(0, 500, '500')];
|
|
183
184
|
|
|
184
|
-
const labels = computeBarLabels(commaMarks, chartArea, 'all', '$,.0f');
|
|
185
|
+
const labels = computeBarLabels(commaMarks, chartArea, 'all', buildD3Formatter('$,.0f'));
|
|
185
186
|
expect(labels).toHaveLength(1);
|
|
186
187
|
expect(labels[0].text).toBe('$500');
|
|
187
188
|
});
|
|
@@ -211,7 +212,7 @@ describe('computeBarLabels with Unicode minus (U+2212) in aria values', () => {
|
|
|
211
212
|
makeUnicodeMinusMark(1, '\u22125'), // −5
|
|
212
213
|
];
|
|
213
214
|
|
|
214
|
-
const labels = computeBarLabels(unicodeMarks, chartArea, 'all', '+.0f%');
|
|
215
|
+
const labels = computeBarLabels(unicodeMarks, chartArea, 'all', buildD3Formatter('+.0f%'));
|
|
215
216
|
expect(labels).toHaveLength(2);
|
|
216
217
|
expect(labels[0].text).toBe('\u221234%'); // −34%
|
|
217
218
|
expect(labels[1].text).toBe('\u22125%'); // −5%
|
|
@@ -138,12 +138,8 @@ export function computeBarMarks(
|
|
|
138
138
|
const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
|
|
139
139
|
|
|
140
140
|
if (needsStacking) {
|
|
141
|
-
// stack: true/'zero'/'normalize'/'center' -> stacked
|
|
142
|
-
const stackEnabled =
|
|
143
|
-
xChannel.stack === true ||
|
|
144
|
-
xChannel.stack === 'zero' ||
|
|
145
|
-
xChannel.stack === 'normalize' ||
|
|
146
|
-
xChannel.stack === 'center';
|
|
141
|
+
// stack: undefined/true/'zero'/'normalize'/'center' -> stacked (default); null/false -> grouped
|
|
142
|
+
const stackEnabled = xChannel.stack !== null && xChannel.stack !== false;
|
|
147
143
|
|
|
148
144
|
if (!stackEnabled) {
|
|
149
145
|
marks = computeGroupedBars(
|
package/src/charts/bar/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Mark } from '@opendata-ai/openchart-core';
|
|
8
|
+
import { resolveFieldFormatter } from '../../format/field-format';
|
|
8
9
|
import type { ChartRenderer } from '../registry';
|
|
9
10
|
import { computeBarMarks } from './compute';
|
|
10
11
|
import { computeBarLabels } from './labels';
|
|
@@ -26,11 +27,17 @@ export const barRenderer: ChartRenderer = (
|
|
|
26
27
|
// Compute and attach value labels (respects spec.labels.density)
|
|
27
28
|
const valueField =
|
|
28
29
|
spec.encoding?.x && 'field' in spec.encoding.x ? spec.encoding.x.field : undefined;
|
|
30
|
+
const labelFormatter = resolveFieldFormatter({
|
|
31
|
+
surfaceFormat: spec.labels.format,
|
|
32
|
+
channelFormat:
|
|
33
|
+
spec.encoding?.x && 'format' in spec.encoding.x ? spec.encoding.x.format : undefined,
|
|
34
|
+
values: valueField ? spec.data.map((r) => r[valueField]) : [],
|
|
35
|
+
});
|
|
29
36
|
const labels = computeBarLabels(
|
|
30
37
|
marks,
|
|
31
38
|
chartArea,
|
|
32
39
|
spec.labels.density,
|
|
33
|
-
|
|
40
|
+
labelFormatter,
|
|
34
41
|
spec.labels.prefix,
|
|
35
42
|
valueField,
|
|
36
43
|
spec.labels.color,
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
import type {
|
|
17
17
|
LabelCandidate,
|
|
18
18
|
LabelDensity,
|
|
19
|
+
NumberFormatter,
|
|
19
20
|
RectMark,
|
|
20
21
|
ResolvedLabel,
|
|
21
22
|
} from '@opendata-ai/openchart-core';
|
|
22
23
|
import {
|
|
23
|
-
buildD3Formatter,
|
|
24
24
|
estimateTextWidth,
|
|
25
25
|
getRepresentativeColor,
|
|
26
26
|
pickLabelColor,
|
|
@@ -93,7 +93,7 @@ export function computeBarLabels(
|
|
|
93
93
|
marks: RectMark[],
|
|
94
94
|
_chartArea: { x: number; y: number; width: number; height: number },
|
|
95
95
|
density: LabelDensity = 'auto',
|
|
96
|
-
|
|
96
|
+
labelFormatter?: NumberFormatter | null,
|
|
97
97
|
labelPrefix?: string,
|
|
98
98
|
valueField?: string,
|
|
99
99
|
labelColor?: string,
|
|
@@ -113,7 +113,7 @@ export function computeBarLabels(
|
|
|
113
113
|
// horizontal nudge back onto this display anchor so textAnchor stays honored.
|
|
114
114
|
const displayAnchorX: number[] = [];
|
|
115
115
|
|
|
116
|
-
const formatter =
|
|
116
|
+
const formatter = labelFormatter ?? null;
|
|
117
117
|
|
|
118
118
|
for (const mark of targetMarks) {
|
|
119
119
|
// Get the original numeric value from the data row when possible,
|