@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
package/src/compile/layer.ts
CHANGED
|
@@ -9,15 +9,15 @@ import type {
|
|
|
9
9
|
} from '@opendata-ai/openchart-core';
|
|
10
10
|
import {
|
|
11
11
|
AXIS_TITLE_TRAILING_PAD,
|
|
12
|
-
abbreviateNumber,
|
|
13
12
|
BREAKPOINT_COMPACT_MAX,
|
|
13
|
+
computeFieldFormatContext,
|
|
14
|
+
defaultNumberFormatter,
|
|
14
15
|
estimateTextWidth,
|
|
15
|
-
formatNumber,
|
|
16
16
|
getAxisTitleOffset,
|
|
17
|
+
resolveNumberFormatter,
|
|
17
18
|
resolveTheme,
|
|
18
19
|
TICK_LABEL_OFFSET,
|
|
19
20
|
} from '@opendata-ai/openchart-core';
|
|
20
|
-
import { format as d3Format } from 'd3-format';
|
|
21
21
|
import { scaleLinear } from 'd3-scale';
|
|
22
22
|
import { curveMonotoneX, area as d3area, line as d3line } from 'd3-shape';
|
|
23
23
|
import { flattenLayers } from '../compiler/index';
|
|
@@ -75,9 +75,16 @@ export function compileLayer(
|
|
|
75
75
|
// axes. Freeze the primary's area (and inherit its theme) for every leaf.
|
|
76
76
|
const leafOptions: CompileOptions = { ...options, frozenChartArea: primaryLayout.area };
|
|
77
77
|
|
|
78
|
+
// Shared scales means shared *domains*, not just a shared plot rect. Each leaf
|
|
79
|
+
// otherwise re-fits its own domain from its own rows, so a layer holding fewer
|
|
80
|
+
// or narrower rows than its siblings (a label layer naming only the notable
|
|
81
|
+
// points, say) lands on a different scale and its marks slide off the ones
|
|
82
|
+
// they annotate. Union the quantitative domains across every leaf and pin them.
|
|
83
|
+
const sharedDomains = computeSharedDomains(leaves);
|
|
84
|
+
|
|
78
85
|
for (const { leaf } of indexedLeaves) {
|
|
79
86
|
const themedLeaf = {
|
|
80
|
-
...(leaf as ChartSpec),
|
|
87
|
+
...withSharedDomains(leaf as ChartSpec, sharedDomains),
|
|
81
88
|
theme: (leaf as ChartSpec).theme ?? spec.theme,
|
|
82
89
|
darkMode: (leaf as ChartSpec).darkMode ?? spec.darkMode,
|
|
83
90
|
};
|
|
@@ -96,13 +103,20 @@ export function compileLayer(
|
|
|
96
103
|
}
|
|
97
104
|
}
|
|
98
105
|
|
|
106
|
+
const mergedLegend = {
|
|
107
|
+
...primaryLayout.legend,
|
|
108
|
+
...('entries' in pLegend ? { entries: mergedLegendEntries } : {}),
|
|
109
|
+
} as typeof primaryLayout.legend;
|
|
110
|
+
|
|
99
111
|
return {
|
|
100
112
|
...primaryLayout,
|
|
101
113
|
marks: allMarks,
|
|
102
|
-
legend:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
legend: mergedLegend,
|
|
115
|
+
// `legends` is what the renderer iterates, so the merged color legend has to
|
|
116
|
+
// land in it too. Spreading `primaryLayout` alone would leave legends[0] as
|
|
117
|
+
// the *pre-merge* legend and every leaf past the first would vanish from the
|
|
118
|
+
// rendered key while `layout.legend` claimed otherwise.
|
|
119
|
+
legends: [mergedLegend, ...primaryLayout.legends.slice(1)],
|
|
106
120
|
};
|
|
107
121
|
}
|
|
108
122
|
|
|
@@ -142,18 +156,15 @@ function estimateYAxisLabelWidth(
|
|
|
142
156
|
const v = Number(row[yField]);
|
|
143
157
|
if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
|
|
144
158
|
}
|
|
159
|
+
const ctx = computeFieldFormatContext(data.map((r) => r[yField]));
|
|
145
160
|
let sampleLabel: string;
|
|
146
161
|
if (yAxisFormat) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
sampleLabel = fmt(maxAbsVal);
|
|
150
|
-
} catch {
|
|
151
|
-
sampleLabel = String(maxAbsVal);
|
|
152
|
-
}
|
|
162
|
+
const fmt = resolveNumberFormatter(yAxisFormat, ctx);
|
|
163
|
+
sampleLabel = fmt ? fmt(maxAbsVal) : String(maxAbsVal);
|
|
153
164
|
} else {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
165
|
+
sampleLabel = defaultNumberFormatter({ ...ctx, extent: ctx.extent ?? [0, maxAbsVal] })(
|
|
166
|
+
maxAbsVal,
|
|
167
|
+
);
|
|
157
168
|
}
|
|
158
169
|
const hasNeg = data.some((r) => Number(r[yField]) < 0);
|
|
159
170
|
const labelEst = (hasNeg ? '-' : '') + sampleLabel;
|
|
@@ -382,6 +393,11 @@ function compileLayerIndependent(
|
|
|
382
393
|
const marks =
|
|
383
394
|
z0 <= z1 ? [...adjustedMarks0, ...taggedMarks1] : [...taggedMarks1, ...adjustedMarks0];
|
|
384
395
|
|
|
396
|
+
const mergedLegend = {
|
|
397
|
+
...layout0.legend,
|
|
398
|
+
...('entries' in l0Legend ? { entries: mergedLegendEntries } : {}),
|
|
399
|
+
} as typeof layout0.legend;
|
|
400
|
+
|
|
385
401
|
return {
|
|
386
402
|
...layout0,
|
|
387
403
|
axes: {
|
|
@@ -390,10 +406,10 @@ function compileLayerIndependent(
|
|
|
390
406
|
y2: y2Axis,
|
|
391
407
|
},
|
|
392
408
|
marks,
|
|
393
|
-
legend:
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
409
|
+
legend: mergedLegend,
|
|
410
|
+
// See the note in compileLayer: the renderer reads `legends`, so the merged
|
|
411
|
+
// legend must replace the primary's entry there as well.
|
|
412
|
+
legends: [mergedLegend, ...layout0.legends.slice(1)],
|
|
397
413
|
tooltipDescriptors: mergedTooltips,
|
|
398
414
|
};
|
|
399
415
|
}
|
|
@@ -508,6 +524,86 @@ function withYDomain(leaf: ChartSpec, domain: [number, number]): ChartSpec {
|
|
|
508
524
|
} as ChartSpec;
|
|
509
525
|
}
|
|
510
526
|
|
|
527
|
+
type SharedDomains = { x?: [number, number]; y?: [number, number] };
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Union the quantitative x/y extents across every leaf.
|
|
531
|
+
*
|
|
532
|
+
* Only channels that every leaf encodes *quantitatively* are unioned — a leaf
|
|
533
|
+
* mixing a nominal x with a quantitative one has no common numeric domain, and
|
|
534
|
+
* an author-pinned `scale.domain` on any leaf is left to win on its own.
|
|
535
|
+
*/
|
|
536
|
+
function computeSharedDomains(leaves: ChartSpec[]): SharedDomains {
|
|
537
|
+
const shared: SharedDomains = {};
|
|
538
|
+
|
|
539
|
+
for (const channel of ['x', 'y'] as const) {
|
|
540
|
+
let lo = Number.POSITIVE_INFINITY;
|
|
541
|
+
let hi = Number.NEGATIVE_INFINITY;
|
|
542
|
+
let sawAny = false;
|
|
543
|
+
let usable = true;
|
|
544
|
+
|
|
545
|
+
for (const leaf of leaves) {
|
|
546
|
+
const enc = (leaf.encoding as Encoding | undefined)?.[channel];
|
|
547
|
+
// Skip *this channel* if any leaf can't take part: no encoding, a
|
|
548
|
+
// non-quantitative type, or a domain the author already pinned. Bailing
|
|
549
|
+
// out of the whole function here would abandon the other channel too --
|
|
550
|
+
// a nominal x would silently strand a quantitative y, which is the most
|
|
551
|
+
// common layered shape there is (bars plus their value labels).
|
|
552
|
+
if (!enc || !('field' in enc) || enc.type !== 'quantitative' || enc.scale?.domain) {
|
|
553
|
+
usable = false;
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
for (const row of leaf.data ?? []) {
|
|
558
|
+
const v = Number((row as DataRow)[enc.field]);
|
|
559
|
+
if (!Number.isFinite(v)) continue;
|
|
560
|
+
if (v < lo) lo = v;
|
|
561
|
+
if (v > hi) hi = v;
|
|
562
|
+
sawAny = true;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (!usable || !sawAny || lo > hi) continue;
|
|
567
|
+
|
|
568
|
+
// Pin the union through zero the way an unpinned scale would.
|
|
569
|
+
//
|
|
570
|
+
// `withSharedDomains` writes this extent onto each leaf as `scale.domain`,
|
|
571
|
+
// and `resolveQuantitativeScale` takes an explicit domain *verbatim* --
|
|
572
|
+
// skipping the `zero !== false` baselining it would otherwise apply. So a
|
|
573
|
+
// raw [50, 100] union lands as a literal domain and the 50-bar collapses
|
|
574
|
+
// to a sliver against its own baseline. Fold zero in here, honouring an
|
|
575
|
+
// explicit `zero: false` on any leaf (a sparkline opting out of it).
|
|
576
|
+
const wantsZero = leaves.every((leaf) => {
|
|
577
|
+
const enc = (leaf.encoding as Encoding | undefined)?.[channel];
|
|
578
|
+
return enc && 'scale' in enc ? enc.scale?.zero !== false : true;
|
|
579
|
+
});
|
|
580
|
+
shared[channel] = wantsZero ? [Math.min(0, lo), Math.max(0, hi)] : [lo, hi];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return shared;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Pin the shared domains onto a leaf's quantitative x/y channels. */
|
|
587
|
+
function withSharedDomains(leaf: ChartSpec, shared: SharedDomains): ChartSpec {
|
|
588
|
+
if (!shared.x && !shared.y) return leaf;
|
|
589
|
+
|
|
590
|
+
const encoding = { ...(leaf.encoding as Record<string, unknown>) };
|
|
591
|
+
|
|
592
|
+
for (const channel of ['x', 'y'] as const) {
|
|
593
|
+
const domain = shared[channel];
|
|
594
|
+
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
595
|
+
if (!domain || !ch) continue;
|
|
596
|
+
encoding[channel] = {
|
|
597
|
+
...ch,
|
|
598
|
+
scale: { ...((ch.scale as object) ?? {}), domain },
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ChartSpec is a union discriminated on `mark`, and spreading widens the
|
|
603
|
+
// encoding past whichever member `leaf` is, so re-assert the original type.
|
|
604
|
+
return { ...leaf, encoding } as unknown as ChartSpec;
|
|
605
|
+
}
|
|
606
|
+
|
|
511
607
|
function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec {
|
|
512
608
|
const allData = leaves.flatMap((leaf) => leaf.data);
|
|
513
609
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `size` channel scale.
|
|
3
|
+
*
|
|
4
|
+
* Three marks map a quantitative field onto a visual magnitude, and each used to
|
|
5
|
+
* build this scale inline with the same six lines and a different pair of
|
|
6
|
+
* constants. A size legend has to resolve the *same* scale the marks resolve or
|
|
7
|
+
* the key silently lies about what a big circle means, so the builder lives in
|
|
8
|
+
* one place and everyone -- marks and legend -- calls it.
|
|
9
|
+
*
|
|
10
|
+
* The curve is the one thing that genuinely differs, and it is a perceptual
|
|
11
|
+
* call, not a preference:
|
|
12
|
+
*
|
|
13
|
+
* - `sqrt` for circles (scatter, beeswarm). Perceived magnitude of a disc tracks
|
|
14
|
+
* its *area*, and area goes as r^2, so radius must go as sqrt(value).
|
|
15
|
+
* - `linear` for glyphs (text). A word reads by its *height*, which is linear in
|
|
16
|
+
* font size. Running font size through sqrt would be cargo-culting the bubble
|
|
17
|
+
* math onto a channel that doesn't share its geometry.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { DataRow, EncodingChannel } from '@opendata-ai/openchart-core';
|
|
21
|
+
import { max, min } from 'd3-array';
|
|
22
|
+
import { scaleLinear, scaleSqrt } from 'd3-scale';
|
|
23
|
+
|
|
24
|
+
/** How a size value maps onto its visual magnitude. */
|
|
25
|
+
export type SizeCurve = 'sqrt' | 'linear';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Per-mark size-scale defaults.
|
|
29
|
+
*
|
|
30
|
+
* These live here, next to the builder, rather than in each mark's compute
|
|
31
|
+
* module -- because the size *legend* needs the exact same numbers, and a key
|
|
32
|
+
* whose circles are a different size from the bubbles they explain is worse
|
|
33
|
+
* than no key. One definition, imported by both, so they cannot drift.
|
|
34
|
+
*/
|
|
35
|
+
export const SIZE_SCALE_DEFAULTS = {
|
|
36
|
+
/** Scatter bubbles. */
|
|
37
|
+
point: { curve: 'sqrt', range: [3, 30] },
|
|
38
|
+
/**
|
|
39
|
+
* Beeswarm dots. Tighter than scatter's 3-30: large radii make the dodge
|
|
40
|
+
* layout stack dots far past the lane, so the default cap stays low.
|
|
41
|
+
*/
|
|
42
|
+
beeswarm: { curve: 'sqrt', range: [2, 10] },
|
|
43
|
+
/**
|
|
44
|
+
* Text glyphs. Linear, not sqrt -- a word reads by its height, which is
|
|
45
|
+
* linear in font size, where a disc reads by its area.
|
|
46
|
+
*/
|
|
47
|
+
text: { curve: 'linear', range: [11, 32] },
|
|
48
|
+
} as const satisfies Record<string, { curve: SizeCurve; range: readonly [number, number] }>;
|
|
49
|
+
|
|
50
|
+
/** Size-scale options for a mark, or null when the mark has no size channel. */
|
|
51
|
+
export function sizeScaleDefaultsFor(
|
|
52
|
+
markType: string,
|
|
53
|
+
): { curve: SizeCurve; range: [number, number] } | null {
|
|
54
|
+
const found = (
|
|
55
|
+
SIZE_SCALE_DEFAULTS as Record<
|
|
56
|
+
string,
|
|
57
|
+
{ curve: SizeCurve; range: readonly [number, number] } | undefined
|
|
58
|
+
>
|
|
59
|
+
)[markType];
|
|
60
|
+
return found ? { curve: found.curve, range: [found.range[0], found.range[1]] } : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A resolved size scale plus the domain and range it was built from. */
|
|
64
|
+
export interface ResolvedSizeScale {
|
|
65
|
+
/** Maps a data value to a visual magnitude (radius, or font size). */
|
|
66
|
+
scale: (value: number) => number;
|
|
67
|
+
/** The domain actually used: an explicit `scale.domain` wins over the data extent. */
|
|
68
|
+
domain: [number, number];
|
|
69
|
+
/** The output range actually used: an explicit `scale.range` wins over the default. */
|
|
70
|
+
range: [number, number];
|
|
71
|
+
/** The field the scale reads. */
|
|
72
|
+
field: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the size scale for a quantitative `size` encoding.
|
|
77
|
+
*
|
|
78
|
+
* Author overrides come from the encoding, mirroring the color path: an explicit
|
|
79
|
+
* `scale.domain` (e.g. `[0, 900]`) keeps the largest datum below the max radius
|
|
80
|
+
* so dense clusters don't blob together, and an explicit `scale.range` caps the
|
|
81
|
+
* output magnitudes.
|
|
82
|
+
*
|
|
83
|
+
* The scale is **clamped**: a datum outside an explicit domain renders at the
|
|
84
|
+
* range endpoint rather than running off to an absurd radius. A legend built
|
|
85
|
+
* from this must therefore key the *domain*, not the data extent -- above an
|
|
86
|
+
* explicit `domain[1]` every datum looks identical.
|
|
87
|
+
*
|
|
88
|
+
* Holes (`null`, `undefined`, `''`) are not zeros -- they are absent data --
|
|
89
|
+
* and are dropped before the extent is fitted rather than coerced into it.
|
|
90
|
+
*
|
|
91
|
+
* @returns null when there is no size field, or when the domain is degenerate
|
|
92
|
+
* (no finite values, or every value identical). A zero-width domain would map
|
|
93
|
+
* every datum to the range's first value, so there is nothing to encode and
|
|
94
|
+
* nothing to legend.
|
|
95
|
+
*/
|
|
96
|
+
export function buildSizeScale(
|
|
97
|
+
sizeEncoding: EncodingChannel | undefined,
|
|
98
|
+
data: readonly DataRow[],
|
|
99
|
+
options: { curve: SizeCurve; range: readonly [number, number] },
|
|
100
|
+
): ResolvedSizeScale | null {
|
|
101
|
+
if (!sizeEncoding || !('field' in sizeEncoding) || !sizeEncoding.field) return null;
|
|
102
|
+
const field = sizeEncoding.field;
|
|
103
|
+
|
|
104
|
+
const values: number[] = [];
|
|
105
|
+
for (const row of data) {
|
|
106
|
+
const raw = row[field];
|
|
107
|
+
// Reject holes before coercing: `Number(null)` and `Number('')` are both a
|
|
108
|
+
// finite 0, so a null would enter the extent as a real zero and drag the
|
|
109
|
+
// domain floor down -- rescaling every genuine mark, and drawing a
|
|
110
|
+
// minimum-size mark for a datum that has no size at all.
|
|
111
|
+
if (raw == null || raw === '') continue;
|
|
112
|
+
const v = Number(raw);
|
|
113
|
+
if (Number.isFinite(v)) values.push(v);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const explicitDomain = sizeEncoding.scale?.domain as [number, number] | undefined;
|
|
117
|
+
|
|
118
|
+
// Nothing to fit a domain to. An explicit domain still stands on its own --
|
|
119
|
+
// it is the author's, not the data's -- but without one there is no extent,
|
|
120
|
+
// and `min([]) ?? 0` / `max([]) ?? 1` would fabricate a bogus [0, 1].
|
|
121
|
+
if (!explicitDomain && values.length === 0) return null;
|
|
122
|
+
|
|
123
|
+
const domain: [number, number] = explicitDomain ?? [min(values) as number, max(values) as number];
|
|
124
|
+
|
|
125
|
+
// A zero-width domain maps every datum to range[0]: no magnitude is encoded,
|
|
126
|
+
// so callers fall back to their static default rather than render a field of
|
|
127
|
+
// identical minimum-size marks.
|
|
128
|
+
if (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1]) || domain[0] >= domain[1]) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const explicitRange = sizeEncoding.scale?.range as [number, number] | undefined;
|
|
133
|
+
const range: [number, number] = explicitRange ?? [options.range[0], options.range[1]];
|
|
134
|
+
|
|
135
|
+
const build = options.curve === 'linear' ? scaleLinear : scaleSqrt;
|
|
136
|
+
const scale = build().domain(domain).range(range).clamp(true);
|
|
137
|
+
|
|
138
|
+
return { scale: (value: number) => scale(value), domain, range, field };
|
|
139
|
+
}
|