@opendata-ai/openchart-engine 7.11.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 +123 -7
- package/dist/index.js +16159 -10024
- 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__/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 +20 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +30 -2
- 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
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Size legend: graduated circles keying a quantitative `size` encoding.
|
|
3
|
+
*
|
|
4
|
+
* Without this, `size` is an *unkeyed* channel: a bubble chart shows circles of
|
|
5
|
+
* varying area and gives the reader nothing to decode them with.
|
|
6
|
+
*
|
|
7
|
+
* Three rules drive the module, and they are all about not lying to the reader.
|
|
8
|
+
*
|
|
9
|
+
* **Resolve the same scale the marks resolve.** The values come from
|
|
10
|
+
* `buildSizeScale` -- the identical builder `scatter/compute.ts` and
|
|
11
|
+
* `beeswarm/compute.ts` call -- rather than a re-derivation. A key computed
|
|
12
|
+
* independently of the marks is a key that can silently drift from them.
|
|
13
|
+
* (`continuous.ts` mirrors the color path for exactly this reason.)
|
|
14
|
+
*
|
|
15
|
+
* **Key the domain, not the data extent.** The size scale clamps. With an
|
|
16
|
+
* explicit `scale.domain`, every datum past `domain[1]` renders at max radius,
|
|
17
|
+
* so a circle labeled with the largest *datum* would imply a correspondence that
|
|
18
|
+
* doesn't hold. The domain is what the scale actually promises.
|
|
19
|
+
*
|
|
20
|
+
* **A key you can't see keys nothing.** The one place the drawn circles are
|
|
21
|
+
* allowed to depart from the marks: when the mark's radius range is too small to
|
|
22
|
+
* read (a beeswarm's [2, 10] is a 20px smudge), the circles are redrawn across
|
|
23
|
+
* `MIN_LEGIBLE_RANGE`. The labels still carry the true values -- only the swatch
|
|
24
|
+
* is scaled up, and only when the literal version would be illegible.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type {
|
|
28
|
+
DataRow,
|
|
29
|
+
EncodingChannel,
|
|
30
|
+
LegendLayout,
|
|
31
|
+
Rect,
|
|
32
|
+
ResolvedTheme,
|
|
33
|
+
SizeLegendCircle,
|
|
34
|
+
SizeLegendLayout,
|
|
35
|
+
} from '@opendata-ai/openchart-core';
|
|
36
|
+
import {
|
|
37
|
+
abbreviateNumber,
|
|
38
|
+
buildD3Formatter,
|
|
39
|
+
estimateTextWidth,
|
|
40
|
+
formatNumber,
|
|
41
|
+
} from '@opendata-ai/openchart-core';
|
|
42
|
+
|
|
43
|
+
import { scaleLinear, scaleSqrt } from 'd3-scale';
|
|
44
|
+
|
|
45
|
+
import { buildSizeScale, type SizeCurve, sizeScaleDefaultsFor } from '../compile/size-scale';
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Constants
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** Gap between the circle stack and its labels. */
|
|
52
|
+
const LABEL_GAP = 6;
|
|
53
|
+
|
|
54
|
+
/** Padding below the baseline so the largest circle's stroke isn't clipped. */
|
|
55
|
+
const BASELINE_PAD = 2;
|
|
56
|
+
|
|
57
|
+
/** How many circles to key. Three reads as a scale; two reads as a comparison. */
|
|
58
|
+
const CIRCLE_COUNT = 3;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Smallest radius range the drawn circles are allowed to occupy.
|
|
62
|
+
*
|
|
63
|
+
* A beeswarm sizes its dots [2, 10]: rendering the key at those radii puts three
|
|
64
|
+
* labels next to a 20px smudge that reads as no key at all. When the mark's own
|
|
65
|
+
* range is smaller than this, the circles are redrawn across [4, 18] -- still
|
|
66
|
+
* ordered and still proportional to each other, but big enough to see. The key
|
|
67
|
+
* becomes *indicative* rather than a 1:1 swatch of the marks, which is the right
|
|
68
|
+
* trade: a key you can't see keys nothing. Marks that already exceed this (a
|
|
69
|
+
* scatter's [3, 30]) are drawn at their true radii and match the bubbles exactly.
|
|
70
|
+
*/
|
|
71
|
+
const MIN_LEGIBLE_RANGE: readonly [number, number] = [4, 18];
|
|
72
|
+
|
|
73
|
+
/** Gap between the plot's right edge and the size legend column. */
|
|
74
|
+
export const SIZE_LEGEND_GAP = 12;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Vertical gap between the color legend and the size legend stacked beneath it.
|
|
78
|
+
*
|
|
79
|
+
* Bigger than the row gap *inside* either block, so the two read as two keys in
|
|
80
|
+
* one column rather than one long key whose last few rows happen to be circles.
|
|
81
|
+
*/
|
|
82
|
+
export const SIZE_LEGEND_STACK_GAP = 16;
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Types
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/** Pre-placement size legend content (origin-relative coordinates). */
|
|
89
|
+
export interface SizeLegendContent {
|
|
90
|
+
circles: SizeLegendCircle[];
|
|
91
|
+
/** Total width of the legend block. */
|
|
92
|
+
width: number;
|
|
93
|
+
/** Total height of the legend block. */
|
|
94
|
+
height: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Helpers
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A radius scale over an explicit range, bypassing the encoding's own
|
|
103
|
+
* `scale.range` (which `buildSizeScale` honours, and which is exactly the
|
|
104
|
+
* too-small range the legible floor exists to escape). Same curve and clamping
|
|
105
|
+
* as the mark scale, so the circles stay proportional to each other.
|
|
106
|
+
*/
|
|
107
|
+
function buildRadiusScale(
|
|
108
|
+
curve: SizeCurve,
|
|
109
|
+
domain: [number, number],
|
|
110
|
+
range: readonly [number, number],
|
|
111
|
+
): (value: number) => number {
|
|
112
|
+
const build = curve === 'linear' ? scaleLinear : scaleSqrt;
|
|
113
|
+
const scale = build().domain(domain).range([range[0], range[1]]).clamp(true);
|
|
114
|
+
return (value: number) => scale(value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Format a legend value: channel format wins, then the house number style. */
|
|
118
|
+
function formatValue(value: number, formatStr?: string): string {
|
|
119
|
+
if (formatStr) {
|
|
120
|
+
const fmt = buildD3Formatter(formatStr);
|
|
121
|
+
if (fmt) return fmt(value);
|
|
122
|
+
}
|
|
123
|
+
if (Math.abs(value) >= 1000) return abbreviateNumber(value);
|
|
124
|
+
return formatNumber(value);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Round a value down to one significant-ish figure (1, 2, 5 x 10^n).
|
|
129
|
+
*
|
|
130
|
+
* Real graduated-circle keys show round numbers -- "500M", not "487.3M". A
|
|
131
|
+
* naive min/median/max would also cluster badly on the skewed (log-normal)
|
|
132
|
+
* data that bubble charts are usually made of: population min/median/max might
|
|
133
|
+
* be 300k / 9M / 1.4B, and under a sqrt scale the first two are visually
|
|
134
|
+
* identical. Nice-rounding off the domain's top spreads the circles out.
|
|
135
|
+
*/
|
|
136
|
+
function niceFloor(value: number): number {
|
|
137
|
+
if (!Number.isFinite(value) || value <= 0) return value;
|
|
138
|
+
const magnitude = 10 ** Math.floor(Math.log10(value));
|
|
139
|
+
const normalized = value / magnitude;
|
|
140
|
+
const step = normalized >= 5 ? 5 : normalized >= 2 ? 2 : 1;
|
|
141
|
+
return step * magnitude;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Pick the values to key, largest first. At most `CIRCLE_COUNT` of them.
|
|
146
|
+
*
|
|
147
|
+
* **Spaced evenly in radius, not in value.** Radius is what the reader actually
|
|
148
|
+
* sees, and the mapping to it is non-linear (sqrt, so circle *area* tracks the
|
|
149
|
+
* value). Stepping the value by halves or by a constant ratio therefore bunches
|
|
150
|
+
* the small circles together: on `[300k, 1.4B]` the geometric midpoint is 20M,
|
|
151
|
+
* whose radius sits a couple of pixels off the floor. Walking the *radius* range
|
|
152
|
+
* in equal steps and inverting back to a value spreads the circles out the way a
|
|
153
|
+
* published graduated-circle key does.
|
|
154
|
+
*
|
|
155
|
+
* The domain top is always keyed first: it is the one value the maximum radius
|
|
156
|
+
* corresponds to, round number or not.
|
|
157
|
+
*
|
|
158
|
+
* Interior values are then nice-rounded, but only when the rounded number still
|
|
159
|
+
* lands inside the domain and doesn't collapse onto the neighbour above --
|
|
160
|
+
* `niceFloor(95)` is 50, which on `[90, 100]` is a value the scale never renders.
|
|
161
|
+
* Prettiness loses to being true.
|
|
162
|
+
*/
|
|
163
|
+
function pickValues(domain: [number, number], scale: (v: number) => number): number[] {
|
|
164
|
+
const [lo, hi] = domain;
|
|
165
|
+
const rHi = scale(hi);
|
|
166
|
+
const rLo = scale(lo);
|
|
167
|
+
const out: number[] = [hi];
|
|
168
|
+
|
|
169
|
+
// Invert the scale numerically: it is monotone on [lo, hi], so bisection finds
|
|
170
|
+
// the value for a target radius without needing the scale's own `.invert`
|
|
171
|
+
// (which `buildSizeScale` doesn't surface, and which wouldn't exist for a
|
|
172
|
+
// clamped custom curve anyway).
|
|
173
|
+
const valueAtRadius = (target: number): number => {
|
|
174
|
+
let a = lo;
|
|
175
|
+
let b = hi;
|
|
176
|
+
for (let i = 0; i < 40; i++) {
|
|
177
|
+
const mid = (a + b) / 2;
|
|
178
|
+
if (scale(mid) < target) a = mid;
|
|
179
|
+
else b = mid;
|
|
180
|
+
}
|
|
181
|
+
return (a + b) / 2;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
for (let i = 1; i < CIRCLE_COUNT; i++) {
|
|
185
|
+
const targetRadius = rHi - ((rHi - rLo) * i) / (CIRCLE_COUNT - 1);
|
|
186
|
+
// Bisection lands on float dust (89.99999...), which formats as "90.00".
|
|
187
|
+
// Snap to 3 significant figures so the fallback label is readable even when
|
|
188
|
+
// it isn't a round number.
|
|
189
|
+
const raw = toPrecision(valueAtRadius(targetRadius), 3);
|
|
190
|
+
const nice = niceFloor(raw);
|
|
191
|
+
const prev = out[out.length - 1];
|
|
192
|
+
// Take the round number only when it's in-domain and still distinct.
|
|
193
|
+
const value = nice >= lo && nice <= hi && nice < prev ? nice : raw;
|
|
194
|
+
if (!Number.isFinite(value) || value <= 0) continue;
|
|
195
|
+
if (value < lo || value >= prev) continue;
|
|
196
|
+
// A zero-anchored domain bisects toward the floor and lands on a value that
|
|
197
|
+
// is positive only in the float sense (1e-9 of the domain). A bubble of ~zero
|
|
198
|
+
// magnitude has no area to key, so drop it rather than label a dot "0".
|
|
199
|
+
if (value < hi * 1e-6) continue;
|
|
200
|
+
out.push(value);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Round to `digits` significant figures. */
|
|
206
|
+
function toPrecision(value: number, digits: number): number {
|
|
207
|
+
if (!Number.isFinite(value) || value === 0) return value;
|
|
208
|
+
const magnitude = 10 ** (Math.floor(Math.log10(Math.abs(value))) - digits + 1);
|
|
209
|
+
return Math.round(value / magnitude) * magnitude;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Public API
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
/** True when the spec encodes a quantitative `size` channel worth keying. */
|
|
217
|
+
export function hasSizeChannel(encoding: { size?: unknown } | undefined): boolean {
|
|
218
|
+
const size = encoding?.size as EncodingChannel | undefined;
|
|
219
|
+
return !!size && 'field' in size && !!size.field && size.type === 'quantitative';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Marks whose `size` channel is a *keyed* encoding -- one a reader is meant to
|
|
224
|
+
* decode back into a value, and which therefore earns a legend.
|
|
225
|
+
*
|
|
226
|
+
* `point` and `beeswarm` map size to circle area: keyable, and the graduated
|
|
227
|
+
* circles below key it.
|
|
228
|
+
*
|
|
229
|
+
* `text` is deliberately absent. It maps size to *font size*, and no publication
|
|
230
|
+
* legends font size -- a stack of type at 12/24/48px reads as a font-sample
|
|
231
|
+
* sheet, not a key. Size on a text mark is emphasis, not a channel a reader
|
|
232
|
+
* decodes. (Vega-Lite emits one only because its legend generation is
|
|
233
|
+
* mechanical, not because anyone wants it.)
|
|
234
|
+
*/
|
|
235
|
+
const KEYED_SIZE_MARKS = new Set(['point', 'beeswarm']);
|
|
236
|
+
|
|
237
|
+
/** Size-scale options for a mark's legend, or null when its size channel gets none. */
|
|
238
|
+
export function sizeLegendScaleFor(
|
|
239
|
+
markType: string,
|
|
240
|
+
): { curve: SizeCurve; range: [number, number] } | null {
|
|
241
|
+
if (!KEYED_SIZE_MARKS.has(markType)) return null;
|
|
242
|
+
return sizeScaleDefaultsFor(markType);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Compute size legend content from a quantitative `size` encoding.
|
|
247
|
+
*
|
|
248
|
+
* Circles are nested: concentric, sharing a bottom edge, so the ratio between
|
|
249
|
+
* magnitudes is legible rather than just their order.
|
|
250
|
+
*
|
|
251
|
+
* @returns null when there is no size channel, or when the scale is degenerate
|
|
252
|
+
* (a zero-width domain encodes no magnitude, so there is nothing to key).
|
|
253
|
+
*/
|
|
254
|
+
export function computeSizeLegendContent(
|
|
255
|
+
sizeEncoding: EncodingChannel | undefined,
|
|
256
|
+
data: readonly DataRow[],
|
|
257
|
+
theme: ResolvedTheme,
|
|
258
|
+
options: { curve: SizeCurve; range: [number, number] },
|
|
259
|
+
): SizeLegendContent | null {
|
|
260
|
+
if (!hasSizeChannel({ size: sizeEncoding })) return null;
|
|
261
|
+
|
|
262
|
+
const resolved = buildSizeScale(sizeEncoding, data, options);
|
|
263
|
+
if (!resolved) return null;
|
|
264
|
+
|
|
265
|
+
// Circle area encodes magnitude, and a negative magnitude has no area to
|
|
266
|
+
// encode -- a key for it would be nonsense (and `sqrt` of a negative is NaN).
|
|
267
|
+
// The marks still render; the channel just isn't keyable, so we say nothing
|
|
268
|
+
// rather than key it wrongly.
|
|
269
|
+
if (!(resolved.domain[0] >= 0) || !(resolved.domain[1] > 0)) return null;
|
|
270
|
+
|
|
271
|
+
// A lone circle is not a graduated key: it tells the reader only that the
|
|
272
|
+
// biggest bubble is the biggest datum, which they already assumed. Two is the
|
|
273
|
+
// minimum that establishes a scale.
|
|
274
|
+
const values = pickValues(resolved.domain, resolved.scale);
|
|
275
|
+
if (values.length < 2) return null;
|
|
276
|
+
|
|
277
|
+
// Draw at the mark's own radii, unless they're too small to see -- then redraw
|
|
278
|
+
// across MIN_LEGIBLE_RANGE. The *values* always come from the real scale, so
|
|
279
|
+
// the labels stay true either way; only the circle sizes are rescaled.
|
|
280
|
+
//
|
|
281
|
+
// Test the RESOLVED range, not `options.range`: an explicit
|
|
282
|
+
// `encoding.size.scale.range` overrides the mark default, and the beeswarm
|
|
283
|
+
// fixture does exactly that ([2, 8]). Checking the default would miss it.
|
|
284
|
+
//
|
|
285
|
+
// The redraw is built straight from the domain rather than through
|
|
286
|
+
// `buildSizeScale`, which reads the range off the encoding and would hand back
|
|
287
|
+
// the same too-small range we're trying to escape.
|
|
288
|
+
const [markMin, markMax] = resolved.range;
|
|
289
|
+
const needsFloor = markMax - markMin < MIN_LEGIBLE_RANGE[1] - MIN_LEGIBLE_RANGE[0];
|
|
290
|
+
const drawScale = needsFloor
|
|
291
|
+
? buildRadiusScale(options.curve, resolved.domain, MIN_LEGIBLE_RANGE)
|
|
292
|
+
: resolved.scale;
|
|
293
|
+
|
|
294
|
+
const maxRadius = drawScale(resolved.domain[1]);
|
|
295
|
+
if (!Number.isFinite(maxRadius) || maxRadius <= 0) return null;
|
|
296
|
+
|
|
297
|
+
const fontSize = theme.fonts.sizes.small;
|
|
298
|
+
|
|
299
|
+
// Nested: every circle shares the baseline, so cy = baseline - r.
|
|
300
|
+
const baseline = maxRadius * 2;
|
|
301
|
+
const cx = maxRadius;
|
|
302
|
+
|
|
303
|
+
// Labels ride each circle's top edge -- but only if the edges are far enough
|
|
304
|
+
// apart to hold them. A beeswarm's size range is [2, 10], so the whole stack is
|
|
305
|
+
// 20px tall and three labels pinned to those edges land on top of each other.
|
|
306
|
+
// Walk down (largest first) and hold each label at least a line below the last;
|
|
307
|
+
// the leader line still points at the true edge, so a nudged label stays
|
|
308
|
+
// unambiguous.
|
|
309
|
+
const lineHeight = fontSize * 1.25;
|
|
310
|
+
let prevLabelY = Number.NEGATIVE_INFINITY;
|
|
311
|
+
|
|
312
|
+
const circles: SizeLegendCircle[] = values.map((value) => {
|
|
313
|
+
const radius = drawScale(value);
|
|
314
|
+
const edgeY = baseline - radius * 2 + fontSize * 0.35;
|
|
315
|
+
const labelY = Math.max(edgeY, prevLabelY + lineHeight);
|
|
316
|
+
prevLabelY = labelY;
|
|
317
|
+
return {
|
|
318
|
+
value,
|
|
319
|
+
label: formatValue(value, (sizeEncoding as EncodingChannel).format),
|
|
320
|
+
radius,
|
|
321
|
+
cx,
|
|
322
|
+
cy: baseline - radius,
|
|
323
|
+
labelY,
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Widest label decides the block width. Measured with the same estimator the
|
|
328
|
+
// renderer uses -- a char-count heuristic would disagree with what actually
|
|
329
|
+
// gets drawn, and the sign of that disagreement flips with the font and the
|
|
330
|
+
// number format (so "it over-reserves today" is not a guarantee).
|
|
331
|
+
const widestLabel = Math.max(
|
|
332
|
+
...circles.map((c) => estimateTextWidth(c.label, fontSize, theme.fonts.weights.normal)),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
// The label column can now run past the circle stack (see the nudge above), so
|
|
336
|
+
// the block is as tall as whichever finishes lower.
|
|
337
|
+
const lastLabelBottom = (circles[circles.length - 1]?.labelY ?? 0) + fontSize * 0.5;
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
circles,
|
|
341
|
+
width: maxRadius * 2 + LABEL_GAP + widestLabel,
|
|
342
|
+
height: Math.max(baseline, lastLabelBottom) + BASELINE_PAD,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Place size legend content into chart coordinates.
|
|
348
|
+
*
|
|
349
|
+
* Sits in the right column that `dimensions.ts` reserved, top-aligned with the
|
|
350
|
+
* plot. Circle/label coordinates in `content` are bounds-relative; the renderer
|
|
351
|
+
* adds `bounds.x/y` to each.
|
|
352
|
+
*
|
|
353
|
+
* `colorLegend` is the ALREADY-PLACED color legend. When it also lives in the
|
|
354
|
+
* right column, the two share ONE column and the size legend stacks BELOW it.
|
|
355
|
+
*
|
|
356
|
+
* Side-by-side is the obvious placement and it is the wrong one. Two right-hand
|
|
357
|
+
* legends cost `colorWidth + sizeWidth + 2*GAP` of gutter -- on a 934px bubble
|
|
358
|
+
* chart, 213px, nearly a quarter of the canvas -- and every pixel comes out of
|
|
359
|
+
* the plot. Yet each block is only ~70px tall, so the column below them is dead
|
|
360
|
+
* space: the chart pays for a 213px-wide gutter to render 70px of key. Stacking
|
|
361
|
+
* costs `max(colorWidth, sizeWidth)` instead, and spends the empty vertical room
|
|
362
|
+
* that was already reserved and already paid for.
|
|
363
|
+
*/
|
|
364
|
+
export function placeSizeLegend(
|
|
365
|
+
content: SizeLegendContent,
|
|
366
|
+
chartArea: Rect,
|
|
367
|
+
theme: ResolvedTheme,
|
|
368
|
+
colorLegend?: LegendLayout | null,
|
|
369
|
+
): SizeLegendLayout {
|
|
370
|
+
const gutterX = chartArea.x + chartArea.width + SIZE_LEGEND_GAP;
|
|
371
|
+
const sharesGutter =
|
|
372
|
+
colorLegend != null &&
|
|
373
|
+
(colorLegend.position === 'right' || colorLegend.position === 'bottom-right') &&
|
|
374
|
+
colorLegend.bounds.width > 0;
|
|
375
|
+
|
|
376
|
+
// Left-align with the color legend rather than the gutter: `dimensions.ts`
|
|
377
|
+
// reserves the column at `max(colorWidth, sizeWidth)`, so whichever block is
|
|
378
|
+
// narrower sits inside a column sized by the other. Sharing the left edge
|
|
379
|
+
// keys them as one column; anchoring to `gutterX` would leave the narrower
|
|
380
|
+
// one floating away from its partner.
|
|
381
|
+
const x = sharesGutter ? colorLegend.bounds.x : gutterX;
|
|
382
|
+
const y = sharesGutter
|
|
383
|
+
? colorLegend.bounds.y + colorLegend.bounds.height + SIZE_LEGEND_STACK_GAP
|
|
384
|
+
: chartArea.y;
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
type: 'size',
|
|
388
|
+
channel: 'size',
|
|
389
|
+
position: 'right',
|
|
390
|
+
bounds: {
|
|
391
|
+
x,
|
|
392
|
+
y,
|
|
393
|
+
width: content.width,
|
|
394
|
+
height: content.height,
|
|
395
|
+
},
|
|
396
|
+
circles: content.circles,
|
|
397
|
+
stroke: theme.colors.axis,
|
|
398
|
+
labelStyle: {
|
|
399
|
+
fontFamily: theme.fonts.family,
|
|
400
|
+
fontSize: theme.fonts.sizes.small,
|
|
401
|
+
fontWeight: theme.fonts.weights.normal,
|
|
402
|
+
fill: theme.colors.text,
|
|
403
|
+
lineHeight: 1.3,
|
|
404
|
+
fontVariant: 'tabular-nums',
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
@@ -121,12 +121,16 @@ export function resolveSuppression(
|
|
|
121
121
|
|
|
122
122
|
// Single-series, non-line/area, or compact strategy: never show endpoint
|
|
123
123
|
// column or end-of-line labels. Legend follows its own rules elsewhere.
|
|
124
|
+
// Explicit user opt-in (endpointLabels config) overrides the compact
|
|
125
|
+
// strip: slope/bump recipes carry their values on the labels, so an
|
|
126
|
+
// author who wrote endpointLabels config keeps the column at 320px.
|
|
124
127
|
//
|
|
125
128
|
// Note: `labelsDensityNone` is intentionally NOT in this short-circuit.
|
|
126
129
|
// `labels.density: 'none'` is the legacy switch for end-of-line labels and
|
|
127
130
|
// must not affect the endpoint column or the traditional legend (those
|
|
128
131
|
// are independent concerns governed by the truth table below).
|
|
129
|
-
|
|
132
|
+
const strategyHidesEndpoint = ctx.labelsHiddenByStrategy && !endpointLabelsExplicitlyOn(spec);
|
|
133
|
+
if (!isMultiSeries || strategyHidesEndpoint) {
|
|
130
134
|
return {
|
|
131
135
|
// Defer the legend's own show/hide rules to computeLegend; this helper
|
|
132
136
|
// doesn't override the existing legend behavior for non-multi-series.
|
|
@@ -144,12 +148,8 @@ export function resolveSuppression(
|
|
|
144
148
|
spec.encoding.y?.type === 'quantitative' ? spec.encoding.y : spec.encoding.x
|
|
145
149
|
) as Encoding[keyof Encoding] | undefined;
|
|
146
150
|
const stackValue = quantChannel && 'stack' in quantChannel ? quantChannel.stack : undefined;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
stackValue === 'zero' ||
|
|
150
|
-
stackValue === 'normalize' ||
|
|
151
|
-
stackValue === 'center'
|
|
152
|
-
: false;
|
|
151
|
+
// v8 VL-aligned default: area stacks unless stack is explicitly null/false.
|
|
152
|
+
const isStacked = isArea ? stackValue !== null && stackValue !== false : false;
|
|
153
153
|
|
|
154
154
|
// Stacked area: revert to the pre-v6 behavior — show legend, no endpoint column.
|
|
155
155
|
if (isStacked) {
|
|
@@ -214,6 +214,7 @@ export function countColorSeries(spec: NormalizedChartSpec): number {
|
|
|
214
214
|
const colorEnc = spec.encoding.color;
|
|
215
215
|
if (!colorEnc) return 0;
|
|
216
216
|
if ('condition' in colorEnc) return 0;
|
|
217
|
+
if (!('field' in colorEnc)) return 0;
|
|
217
218
|
if (colorEnc.type === 'quantitative') return 0;
|
|
218
219
|
const field = colorEnc.field;
|
|
219
220
|
if (!field) return 0;
|
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
CompileOptions,
|
|
17
17
|
LegendEntry,
|
|
18
18
|
LegendLayout,
|
|
19
|
+
NumberFormatter,
|
|
19
20
|
Rect,
|
|
20
21
|
ResolvedAnimation,
|
|
21
22
|
ResolvedTheme,
|
|
@@ -28,15 +29,15 @@ import type {
|
|
|
28
29
|
} from '@opendata-ai/openchart-core';
|
|
29
30
|
import {
|
|
30
31
|
adaptTheme,
|
|
31
|
-
buildD3Formatter,
|
|
32
32
|
computeChrome,
|
|
33
|
+
defaultNumberFormatter,
|
|
33
34
|
estimateTextWidth,
|
|
34
|
-
formatNumber,
|
|
35
35
|
resolveTheme,
|
|
36
36
|
} from '@opendata-ai/openchart-core';
|
|
37
|
-
|
|
37
|
+
import { emitSpecWarnings, expandSpecSugar } from '../compile/spec-sugar';
|
|
38
38
|
import { resolveAnimation } from '../compiler/animation';
|
|
39
39
|
import { compile as compileSpec } from '../compiler/index';
|
|
40
|
+
import { resolveFieldFormatter } from '../format/field-format';
|
|
40
41
|
import { ENTRY_GAP, measureLegendWrap, SWATCH_GAP, SWATCH_SIZE } from '../legend/wrap';
|
|
41
42
|
import { type ComputedNode, computeSankeyLayout, generateLinkPath } from './layout';
|
|
42
43
|
import type { NormalizedSankeySpec } from './types';
|
|
@@ -216,8 +217,15 @@ function computeNodeLabel(
|
|
|
216
217
|
* @throws Error if spec is invalid or not a sankey type.
|
|
217
218
|
*/
|
|
218
219
|
export function compileSankey(spec: unknown, options: CompileOptions): SankeyLayout {
|
|
219
|
-
// 1.
|
|
220
|
-
|
|
220
|
+
// 1. Expand deprecated top-level sugar (valueFormat -> encoding.value.format)
|
|
221
|
+
// before validation, then validate + normalize via the shared compiler pipeline.
|
|
222
|
+
const sugarWarnings: string[] = [];
|
|
223
|
+
const expandedSpec =
|
|
224
|
+
spec && typeof spec === 'object' && !Array.isArray(spec)
|
|
225
|
+
? expandSpecSugar(spec as Record<string, unknown>, sugarWarnings)
|
|
226
|
+
: spec;
|
|
227
|
+
const { spec: normalized, warnings } = compileSpec(expandedSpec);
|
|
228
|
+
emitSpecWarnings([...sugarWarnings, ...warnings], options.onWarn);
|
|
221
229
|
|
|
222
230
|
if (!('type' in normalized) || normalized.type !== 'sankey') {
|
|
223
231
|
throw new Error(
|
|
@@ -227,6 +235,9 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
227
235
|
|
|
228
236
|
const sankeySpec = normalized as NormalizedSankeySpec;
|
|
229
237
|
|
|
238
|
+
// Resolve format: encoding-level (v8 canonical) wins over deprecated top-level valueFormat
|
|
239
|
+
const resolvedValueFormat = sankeySpec.encoding.value.format ?? sankeySpec.valueFormat;
|
|
240
|
+
|
|
230
241
|
// Resolve watermark: explicit spec value wins, then options fallback, then default true.
|
|
231
242
|
const rawWatermark = (spec as Record<string, unknown>).watermark;
|
|
232
243
|
const watermark = rawWatermark !== undefined ? sankeySpec.watermark : (options.watermark ?? true);
|
|
@@ -285,6 +296,10 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
285
296
|
const targetField = sankeySpec.encoding.target.field;
|
|
286
297
|
const valueField = sankeySpec.encoding.value.field;
|
|
287
298
|
const colorField = sankeySpec.encoding.color?.field;
|
|
299
|
+
const flowFmt = resolveFieldFormatter({
|
|
300
|
+
surfaceFormat: resolvedValueFormat,
|
|
301
|
+
values: sankeySpec.data.map((r) => r[valueField]),
|
|
302
|
+
});
|
|
288
303
|
|
|
289
304
|
// 5b. Pre-compute legend to reserve vertical space
|
|
290
305
|
// We need the color map first, so build a temporary one from raw data
|
|
@@ -425,7 +440,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
425
440
|
data: { id: node.id, label: node.label },
|
|
426
441
|
aria: {
|
|
427
442
|
role: 'img',
|
|
428
|
-
label: `${node.label}: ${formatFlowValue(node.value ?? 0,
|
|
443
|
+
label: `${node.label}: ${formatFlowValue(node.value ?? 0, flowFmt)}`,
|
|
429
444
|
},
|
|
430
445
|
animationIndex: 0, // Reassigned below after sorting by depth
|
|
431
446
|
};
|
|
@@ -460,7 +475,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
460
475
|
data: (link as unknown as { data: Record<string, unknown> }).data ?? {},
|
|
461
476
|
aria: {
|
|
462
477
|
role: 'img',
|
|
463
|
-
label: `${sourceNode.label} to ${targetNode.label}: ${formatFlowValue(link.value,
|
|
478
|
+
label: `${sourceNode.label} to ${targetNode.label}: ${formatFlowValue(link.value, flowFmt)}`,
|
|
464
479
|
},
|
|
465
480
|
// Links animate after nodes
|
|
466
481
|
animationIndex: nodeMarks.length + i,
|
|
@@ -479,7 +494,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
479
494
|
);
|
|
480
495
|
|
|
481
496
|
// 13. Build tooltip descriptors
|
|
482
|
-
const tooltipDescriptors = buildTooltipDescriptors(nodeMarks, linkMarks,
|
|
497
|
+
const tooltipDescriptors = buildTooltipDescriptors(nodeMarks, linkMarks, flowFmt);
|
|
483
498
|
|
|
484
499
|
// 14. Build a11y metadata
|
|
485
500
|
const a11y = {
|
|
@@ -598,27 +613,25 @@ function buildSankeyLegend(
|
|
|
598
613
|
// Tooltip builder
|
|
599
614
|
// ---------------------------------------------------------------------------
|
|
600
615
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
return formatNumber(value);
|
|
616
|
+
const defaultFmt = defaultNumberFormatter();
|
|
617
|
+
|
|
618
|
+
function formatFlowValue(value: number, formatter?: NumberFormatter | null): string {
|
|
619
|
+
if (formatter) return formatter(value);
|
|
620
|
+
return defaultFmt(value);
|
|
607
621
|
}
|
|
608
622
|
|
|
609
623
|
function buildTooltipDescriptors(
|
|
610
624
|
nodes: SankeyNodeMark[],
|
|
611
625
|
links: SankeyLinkMark[],
|
|
612
|
-
|
|
626
|
+
formatter?: NumberFormatter | null,
|
|
613
627
|
): Map<string, TooltipContent> {
|
|
614
628
|
const descriptors = new Map<string, TooltipContent>();
|
|
615
629
|
|
|
616
|
-
// Node tooltips: keyed by "node-{nodeId}" to match renderer data-mark-id
|
|
617
630
|
for (const node of nodes) {
|
|
618
631
|
const fields: TooltipField[] = [
|
|
619
632
|
{
|
|
620
633
|
label: 'Total flow',
|
|
621
|
-
value: formatFlowValue(node.value,
|
|
634
|
+
value: formatFlowValue(node.value, formatter),
|
|
622
635
|
},
|
|
623
636
|
];
|
|
624
637
|
descriptors.set(`node-${node.nodeId}`, {
|
|
@@ -627,13 +640,12 @@ function buildTooltipDescriptors(
|
|
|
627
640
|
});
|
|
628
641
|
}
|
|
629
642
|
|
|
630
|
-
// Link tooltips: keyed by "link-{sourceId}-{targetId}" to match renderer data-mark-id
|
|
631
643
|
for (let i = 0; i < links.length; i++) {
|
|
632
644
|
const link = links[i];
|
|
633
645
|
const fields: TooltipField[] = [
|
|
634
646
|
{
|
|
635
647
|
label: 'Flow',
|
|
636
|
-
value: formatFlowValue(link.value,
|
|
648
|
+
value: formatFlowValue(link.value, formatter),
|
|
637
649
|
},
|
|
638
650
|
];
|
|
639
651
|
descriptors.set(`link-${link.sourceId}-${link.targetId}-${i}`, {
|
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CellStyle, ColumnConfig, TableCellBase } from '@opendata-ai/openchart-core';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
buildD3Formatter,
|
|
12
|
+
defaultNumberFormatter,
|
|
13
|
+
formatDate,
|
|
14
|
+
resolveNumberFormatter,
|
|
15
|
+
} from '@opendata-ai/openchart-core';
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
18
|
* Check if a value is numeric (finite number or parseable numeric string).
|
|
@@ -46,9 +51,11 @@ export function formatCell(value: unknown, column: ColumnConfig): TableCellBase
|
|
|
46
51
|
};
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
// If column has a
|
|
54
|
+
// If column has a format string and value is numeric
|
|
50
55
|
if (column.format && isNumericValue(value)) {
|
|
51
|
-
const formatter =
|
|
56
|
+
const formatter =
|
|
57
|
+
resolveNumberFormatter(column.format, { surface: 'table' }) ??
|
|
58
|
+
buildD3Formatter(column.format);
|
|
52
59
|
if (formatter) {
|
|
53
60
|
return {
|
|
54
61
|
value,
|
|
@@ -58,11 +65,11 @@ export function formatCell(value: unknown, column: ColumnConfig): TableCellBase
|
|
|
58
65
|
}
|
|
59
66
|
}
|
|
60
67
|
|
|
61
|
-
// Auto-format numbers
|
|
68
|
+
// Auto-format numbers (tables get full precision, no compact notation)
|
|
62
69
|
if (isNumericValue(value)) {
|
|
63
70
|
return {
|
|
64
71
|
value,
|
|
65
|
-
formattedValue:
|
|
72
|
+
formattedValue: defaultNumberFormatter({ surface: 'table' })(value),
|
|
66
73
|
style,
|
|
67
74
|
};
|
|
68
75
|
}
|
|
@@ -92,14 +99,16 @@ export function formatValueForSearch(value: unknown, column: ColumnConfig): stri
|
|
|
92
99
|
if (value == null) return '';
|
|
93
100
|
|
|
94
101
|
if (column.format && isNumericValue(value)) {
|
|
95
|
-
const formatter =
|
|
102
|
+
const formatter =
|
|
103
|
+
resolveNumberFormatter(column.format, { surface: 'table' }) ??
|
|
104
|
+
buildD3Formatter(column.format);
|
|
96
105
|
if (formatter) {
|
|
97
106
|
return formatter(value);
|
|
98
107
|
}
|
|
99
108
|
}
|
|
100
109
|
|
|
101
110
|
if (isNumericValue(value)) {
|
|
102
|
-
return
|
|
111
|
+
return defaultNumberFormatter({ surface: 'table' })(value);
|
|
103
112
|
}
|
|
104
113
|
|
|
105
114
|
return String(value);
|