@opendata-ai/openchart-engine 8.0.0-rc.2 → 8.0.0-rc.20
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 +140 -2
- package/dist/index.js +1403 -210
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/__test-fixtures__/specs.ts +0 -182
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2339
- package/src/__tests__/annotation-behavior.test.ts +0 -721
- package/src/__tests__/axes.test.ts +0 -1579
- package/src/__tests__/compile-animation.test.ts +0 -161
- package/src/__tests__/compile-chart.test.ts +0 -1303
- package/src/__tests__/compile-footnotes.test.ts +0 -277
- package/src/__tests__/compile-layer.test.ts +0 -969
- package/src/__tests__/compile-snapshot.test.ts +0 -156
- package/src/__tests__/compound-labels.test.ts +0 -147
- package/src/__tests__/dimensions.test.ts +0 -918
- package/src/__tests__/encoding-sugar.test.ts +0 -390
- package/src/__tests__/facet.test.ts +0 -215
- package/src/__tests__/helpers/invariants.ts +0 -299
- package/src/__tests__/known-bugs.test.ts +0 -566
- package/src/__tests__/layout-fuzz.test.ts +0 -358
- package/src/__tests__/layout-invariants.test.ts +0 -535
- package/src/__tests__/legend.test.ts +0 -721
- package/src/__tests__/mark-keys.test.ts +0 -294
- package/src/__tests__/ranking-recipes.test.ts +0 -260
- package/src/__tests__/rotation.test.ts +0 -83
- package/src/__tests__/scales.test.ts +0 -323
- package/src/__tests__/series-search.test.ts +0 -92
- package/src/__tests__/spec-sugar.test.ts +0 -801
- package/src/__tests__/you-draw-it.test.ts +0 -160
- package/src/annotations/__tests__/compute.test.ts +0 -1672
- package/src/annotations/__tests__/geometry.test.ts +0 -66
- package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
- package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
- package/src/annotations/__tests__/placement.test.ts +0 -316
- package/src/annotations/__tests__/resolve-text.test.ts +0 -788
- package/src/annotations/__tests__/rich-text.test.ts +0 -91
- package/src/annotations/__tests__/thinning.test.ts +0 -285
- package/src/annotations/collisions.ts +0 -326
- package/src/annotations/compute.ts +0 -333
- package/src/annotations/constants.ts +0 -162
- package/src/annotations/geometry.ts +0 -370
- package/src/annotations/move.ts +0 -135
- package/src/annotations/placement.ts +0 -636
- package/src/annotations/position.ts +0 -131
- package/src/annotations/resolve-range.ts +0 -117
- package/src/annotations/resolve-refline.ts +0 -157
- package/src/annotations/resolve-text.ts +0 -539
- package/src/annotations/rich-text.ts +0 -65
- package/src/annotations/thinning.ts +0 -190
- package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
- package/src/barlist/compile-barlist.ts +0 -391
- package/src/barlist/types.ts +0 -28
- package/src/charts/__tests__/post-process.test.ts +0 -237
- package/src/charts/__tests__/registry.test.ts +0 -116
- package/src/charts/__tests__/utils.test.ts +0 -195
- package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
- package/src/charts/_shared/density-filter.ts +0 -26
- package/src/charts/_shared/format-label-value.ts +0 -16
- package/src/charts/bar/__tests__/compute.test.ts +0 -934
- package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
- package/src/charts/bar/__tests__/labels.test.ts +0 -292
- package/src/charts/bar/compute.ts +0 -539
- package/src/charts/bar/index.ts +0 -60
- package/src/charts/bar/labels.ts +0 -311
- package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
- package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
- package/src/charts/beeswarm/compute.ts +0 -203
- package/src/charts/beeswarm/dodge.ts +0 -155
- package/src/charts/beeswarm/index.ts +0 -32
- package/src/charts/builtin.ts +0 -75
- package/src/charts/calendar/__tests__/compute.test.ts +0 -417
- package/src/charts/calendar/compute.ts +0 -319
- package/src/charts/calendar/index.ts +0 -32
- package/src/charts/column/__tests__/compute.test.ts +0 -540
- package/src/charts/column/__tests__/labels.test.ts +0 -179
- package/src/charts/column/compute.ts +0 -493
- package/src/charts/column/index.ts +0 -53
- package/src/charts/column/labels.ts +0 -229
- package/src/charts/dot/__tests__/compute.test.ts +0 -380
- package/src/charts/dot/__tests__/labels.test.ts +0 -98
- package/src/charts/dot/compute.ts +0 -285
- package/src/charts/dot/index.ts +0 -62
- package/src/charts/dot/labels.ts +0 -121
- package/src/charts/line/__tests__/compute.test.ts +0 -1194
- package/src/charts/line/__tests__/curves.test.ts +0 -51
- package/src/charts/line/__tests__/labels.test.ts +0 -94
- package/src/charts/line/__tests__/mark-options.test.ts +0 -175
- package/src/charts/line/area.ts +0 -484
- package/src/charts/line/compute.ts +0 -356
- package/src/charts/line/curves.ts +0 -85
- package/src/charts/line/index.ts +0 -176
- package/src/charts/line/labels.ts +0 -177
- package/src/charts/parliament/__tests__/compute.test.ts +0 -411
- package/src/charts/parliament/compute.ts +0 -313
- package/src/charts/parliament/index.ts +0 -32
- package/src/charts/pie/__tests__/compute.test.ts +0 -384
- package/src/charts/pie/__tests__/labels.test.ts +0 -132
- package/src/charts/pie/compute.ts +0 -324
- package/src/charts/pie/index.ts +0 -49
- package/src/charts/pie/labels.ts +0 -143
- package/src/charts/post-process.ts +0 -229
- package/src/charts/range/__tests__/compute.test.ts +0 -486
- package/src/charts/range/compute.ts +0 -273
- package/src/charts/range/index.ts +0 -82
- package/src/charts/range/labels.ts +0 -169
- package/src/charts/rect/__tests__/compute.test.ts +0 -120
- package/src/charts/rect/index.ts +0 -127
- package/src/charts/registry.ts +0 -72
- package/src/charts/rule/index.ts +0 -139
- package/src/charts/scatter/__tests__/compute.test.ts +0 -488
- package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
- package/src/charts/scatter/compute.ts +0 -192
- package/src/charts/scatter/index.ts +0 -46
- package/src/charts/scatter/trendline.ts +0 -100
- package/src/charts/text/__tests__/compute.test.ts +0 -307
- package/src/charts/text/index.ts +0 -141
- package/src/charts/tick/__tests__/compute.test.ts +0 -75
- package/src/charts/tick/index.ts +0 -99
- package/src/charts/utils.ts +0 -170
- package/src/charts/waffle/__tests__/compute.test.ts +0 -305
- package/src/charts/waffle/compute.ts +0 -197
- package/src/charts/waffle/index.ts +0 -31
- package/src/compile/__tests__/color-scale-range.test.ts +0 -158
- package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
- package/src/compile/__tests__/data-clip.test.ts +0 -127
- package/src/compile/__tests__/fill-patterns.test.ts +0 -154
- package/src/compile/__tests__/size-scale.test.ts +0 -108
- package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
- package/src/compile/color-scale-range.ts +0 -101
- package/src/compile/data-clip.ts +0 -48
- package/src/compile/fill-patterns.ts +0 -74
- package/src/compile/layer.ts +0 -626
- package/src/compile/size-scale.ts +0 -139
- package/src/compile/spec-sugar.ts +0 -758
- package/src/compile/watermark-obstacle.ts +0 -54
- package/src/compile/you-draw-it.ts +0 -123
- package/src/compile.ts +0 -1450
- package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
- package/src/compiler/__tests__/animation.test.ts +0 -177
- package/src/compiler/__tests__/compile.test.ts +0 -87
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
- package/src/compiler/__tests__/keys.test.ts +0 -83
- package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
- package/src/compiler/__tests__/normalize.test.ts +0 -399
- package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -86
- package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
- package/src/compiler/__tests__/validate-near-miss.test.ts +0 -89
- package/src/compiler/__tests__/validate.test.ts +0 -629
- package/src/compiler/__tests__/vl-parity.test.ts +0 -68
- package/src/compiler/a11y-warnings.ts +0 -110
- package/src/compiler/animation.ts +0 -142
- package/src/compiler/index.ts +0 -47
- package/src/compiler/keys.ts +0 -55
- package/src/compiler/normalize.ts +0 -733
- package/src/compiler/sparkline-defaults.ts +0 -138
- package/src/compiler/types.ts +0 -241
- package/src/compiler/validate.ts +0 -1810
- package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
- package/src/endpoint-labels/compute.ts +0 -621
- package/src/endpoint-labels/constants.ts +0 -54
- package/src/endpoint-labels/format.ts +0 -60
- package/src/endpoint-labels/predict.ts +0 -139
- package/src/format/__tests__/field-format.test.ts +0 -164
- package/src/format/field-format.ts +0 -27
- package/src/geo/__tests__/compile-map.test.ts +0 -621
- package/src/geo/compile-map.ts +0 -856
- package/src/geo/join.ts +0 -115
- package/src/geo/projections.ts +0 -45
- package/src/geo/types.ts +0 -24
- package/src/graphs/__tests__/community.test.ts +0 -228
- package/src/graphs/__tests__/compile-graph.test.ts +0 -396
- package/src/graphs/__tests__/encoding.test.ts +0 -415
- package/src/graphs/community.ts +0 -92
- package/src/graphs/compile-graph.ts +0 -329
- package/src/graphs/encoding.ts +0 -356
- package/src/graphs/types.ts +0 -106
- package/src/index.ts +0 -177
- package/src/layout/__tests__/metrics.test.ts +0 -97
- package/src/layout/__tests__/scales-highlight.test.ts +0 -192
- package/src/layout/axes/rotation.ts +0 -152
- package/src/layout/axes/thinning.ts +0 -98
- package/src/layout/axes/ticks.ts +0 -409
- package/src/layout/axes.ts +0 -757
- package/src/layout/dimensions.ts +0 -912
- package/src/layout/facet.ts +0 -91
- package/src/layout/gridlines.ts +0 -84
- package/src/layout/metrics.ts +0 -132
- package/src/layout/plan.ts +0 -631
- package/src/layout/scales.ts +0 -1053
- package/src/layout/shared.ts +0 -44
- package/src/legend/__tests__/continuous.test.ts +0 -318
- package/src/legend/__tests__/redundancy.test.ts +0 -108
- package/src/legend/__tests__/size.test.ts +0 -348
- package/src/legend/__tests__/suppression.test.ts +0 -382
- package/src/legend/compute.ts +0 -747
- package/src/legend/continuous.ts +0 -327
- package/src/legend/size.ts +0 -407
- package/src/legend/suppression.ts +0 -226
- package/src/legend/wrap.ts +0 -117
- package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
- package/src/sankey/__tests__/layout.test.ts +0 -165
- package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
- package/src/sankey/__tests__/node-sort.test.ts +0 -45
- package/src/sankey/compile-sankey.ts +0 -706
- package/src/sankey/layout.ts +0 -184
- package/src/sankey/types.ts +0 -41
- package/src/tables/__tests__/bar-column.test.ts +0 -147
- package/src/tables/__tests__/category-colors.test.ts +0 -230
- package/src/tables/__tests__/compile-table.test.ts +0 -236
- package/src/tables/__tests__/format-cells.test.ts +0 -143
- package/src/tables/__tests__/heatmap.test.ts +0 -148
- package/src/tables/__tests__/pagination.test.ts +0 -78
- package/src/tables/__tests__/search.test.ts +0 -94
- package/src/tables/__tests__/sort.test.ts +0 -107
- package/src/tables/__tests__/sparkline.test.ts +0 -122
- package/src/tables/bar-column.ts +0 -94
- package/src/tables/category-colors.ts +0 -78
- package/src/tables/compile-table.ts +0 -427
- package/src/tables/format-cells.ts +0 -115
- package/src/tables/heatmap.ts +0 -141
- package/src/tables/pagination.ts +0 -46
- package/src/tables/search.ts +0 -66
- package/src/tables/sort.ts +0 -69
- package/src/tables/sparkline.ts +0 -113
- package/src/tables/utils.ts +0 -16
- package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
- package/src/tilemap/compile-tilemap.ts +0 -755
- package/src/tilemap/layout.ts +0 -172
- package/src/tilemap/types.ts +0 -33
- package/src/tooltips/__tests__/compute.test.ts +0 -582
- package/src/tooltips/compute.ts +0 -700
- package/src/transforms/__tests__/aggregate.test.ts +0 -159
- package/src/transforms/__tests__/bin.test.ts +0 -88
- package/src/transforms/__tests__/calculate.test.ts +0 -146
- package/src/transforms/__tests__/conditional.test.ts +0 -127
- package/src/transforms/__tests__/filter-relative.test.ts +0 -202
- package/src/transforms/__tests__/filter.test.ts +0 -59
- package/src/transforms/__tests__/fold.test.ts +0 -79
- package/src/transforms/__tests__/index.test.ts +0 -93
- package/src/transforms/__tests__/predicates.test.ts +0 -193
- package/src/transforms/__tests__/timeunit.test.ts +0 -129
- package/src/transforms/__tests__/window.test.ts +0 -286
- package/src/transforms/aggregate.ts +0 -131
- package/src/transforms/bin.ts +0 -87
- package/src/transforms/calculate.ts +0 -60
- package/src/transforms/conditional.ts +0 -46
- package/src/transforms/filter.ts +0 -122
- package/src/transforms/fold.ts +0 -49
- package/src/transforms/index.ts +0 -60
- package/src/transforms/predicates.ts +0 -123
- package/src/transforms/timeunit.ts +0 -88
- package/src/transforms/window.ts +0 -185
package/src/legend/size.ts
DELETED
|
@@ -1,407 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Single source of truth for legend / endpoint-column / end-of-line label
|
|
3
|
-
* suppression on multi-series line/area charts.
|
|
4
|
-
*
|
|
5
|
-
* Three independent toggles drive the chart's series identification surface:
|
|
6
|
-
*
|
|
7
|
-
* - `legend.show` — the traditional swatch legend (top/bottom/right).
|
|
8
|
-
* - `endpointLabels` — the right-side per-series column with last value.
|
|
9
|
-
* - end-of-line labels — the legacy series-name label glued to the last point.
|
|
10
|
-
*
|
|
11
|
-
* For ≥2-series line/area charts the resolution table is:
|
|
12
|
-
*
|
|
13
|
-
* | `legend.show` | `endpointLabels` | Traditional legend | Endpoint column | End-of-line labels |
|
|
14
|
-
* |--|--|--|--|--|
|
|
15
|
-
* | unset | unset | hidden (auto-suppressed) | shown | hidden |
|
|
16
|
-
* | true | unset | shown | shown | hidden |
|
|
17
|
-
* | unset | false | shown (auto-revoked) | hidden | hidden |
|
|
18
|
-
* | false | false | hidden | hidden | shown |
|
|
19
|
-
* | true | false | shown | hidden | hidden |
|
|
20
|
-
* | false | true | hidden | shown | hidden |
|
|
21
|
-
* | true | true | shown | shown | hidden |
|
|
22
|
-
*
|
|
23
|
-
* Single-series and non-line/area charts: column always hidden, legend hidden
|
|
24
|
-
* (single-series rule), end-of-line labels never render.
|
|
25
|
-
*
|
|
26
|
-
* Rule of thumb: end-of-line labels are the fallback identifier of last resort.
|
|
27
|
-
* They render only when neither the traditional legend nor the endpoint column
|
|
28
|
-
* is showing. The endpoint column "owns" the right zone and absorbs the
|
|
29
|
-
* suppression role the traditional legend used to have for end-of-line labels.
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
import type { Encoding } from '@opendata-ai/openchart-core';
|
|
33
|
-
|
|
34
|
-
import type { NormalizedChartSpec } from '../compiler/types';
|
|
35
|
-
|
|
36
|
-
/** Result of the suppression resolution. */
|
|
37
|
-
export interface SuppressionResult {
|
|
38
|
-
/** Whether the traditional swatch legend should render. */
|
|
39
|
-
showTraditionalLegend: boolean;
|
|
40
|
-
/** Whether the right-side endpoint column should render. */
|
|
41
|
-
showEndpointLabels: boolean;
|
|
42
|
-
/** Whether the legacy end-of-line labels should render. */
|
|
43
|
-
showEndOfLineLabels: boolean;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Inputs needed by the resolution helper. */
|
|
47
|
-
export interface SuppressionContext {
|
|
48
|
-
/** Number of distinct series (color-encoding domain values). 0 if no color encoding. */
|
|
49
|
-
seriesCount: number;
|
|
50
|
-
/** True when responsive strategy strips inline labels (compact breakpoint). */
|
|
51
|
-
labelsHiddenByStrategy: boolean;
|
|
52
|
-
/** True when spec.labels.density === 'none'. */
|
|
53
|
-
labelsDensityNone: boolean;
|
|
54
|
-
/** When true, endpoint labels are demoted because they don't fit at the current width. */
|
|
55
|
-
endpointLabelsDemoted?: boolean;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Did the user write `legend.show: true` or any non-`show` legend config?
|
|
60
|
-
* (Used by the truth table to decide between the "unset" and "true" rows.)
|
|
61
|
-
*/
|
|
62
|
-
function legendShownExplicitly(spec: NormalizedChartSpec): boolean {
|
|
63
|
-
if (spec.legend == null) return false;
|
|
64
|
-
if (spec.legend.show === true) return true;
|
|
65
|
-
// Any non-`show` legend property counts as opt-in to render the legend.
|
|
66
|
-
return Object.keys(spec.legend).some((k) => k !== 'show');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Did the user write `legend.show: false` explicitly? */
|
|
70
|
-
function legendHiddenExplicitly(spec: NormalizedChartSpec): boolean {
|
|
71
|
-
return spec.legend?.show === false;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Keys on EndpointLabelsConfig that configure behavior but don't signal show intent. */
|
|
75
|
-
const EP_CONFIG_ONLY_KEYS = new Set(['show', 'maxSeries']);
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Did the user explicitly set endpointLabels: true?
|
|
79
|
-
* Either the bare boolean or `{ show: true }` or any display-affecting config keys count.
|
|
80
|
-
* Config-only keys like `maxSeries` don't count as show intent.
|
|
81
|
-
*/
|
|
82
|
-
export function endpointLabelsExplicitlyOn(spec: NormalizedChartSpec): boolean {
|
|
83
|
-
const ep = spec.endpointLabels;
|
|
84
|
-
if (ep === true) return true;
|
|
85
|
-
if (ep === false || ep == null) return false;
|
|
86
|
-
if (ep.show === true) return true;
|
|
87
|
-
if (ep.show === false) return false;
|
|
88
|
-
return Object.keys(ep).some((k) => !EP_CONFIG_ONLY_KEYS.has(k));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Did the user explicitly set endpointLabels: false (or { show: false })? */
|
|
92
|
-
function endpointLabelsExplicitlyOff(spec: NormalizedChartSpec): boolean {
|
|
93
|
-
const ep = spec.endpointLabels;
|
|
94
|
-
if (ep === false) return true;
|
|
95
|
-
if (typeof ep === 'object' && ep != null && ep.show === false) return true;
|
|
96
|
-
return false;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const DEFAULT_MAX_SERIES = 8;
|
|
100
|
-
|
|
101
|
-
/** Read the maxSeries cutoff from the spec's endpointLabels config. */
|
|
102
|
-
function getMaxSeries(spec: NormalizedChartSpec): number {
|
|
103
|
-
const ep = spec.endpointLabels;
|
|
104
|
-
if (typeof ep === 'object' && ep != null && ep.maxSeries != null) return ep.maxSeries;
|
|
105
|
-
return DEFAULT_MAX_SERIES;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Resolve the three booleans from spec + context.
|
|
110
|
-
*
|
|
111
|
-
* Pure: no theme, no chart area, no scales. Both `computeLegend` and
|
|
112
|
-
* `computeLineLabels` and `computeEndpointLabels` share this exact logic.
|
|
113
|
-
*/
|
|
114
|
-
export function resolveSuppression(
|
|
115
|
-
spec: NormalizedChartSpec,
|
|
116
|
-
ctx: SuppressionContext,
|
|
117
|
-
): SuppressionResult {
|
|
118
|
-
const isLineOrArea = spec.markType === 'line' || spec.markType === 'area';
|
|
119
|
-
const hasColorEncoding = spec.encoding.color != null;
|
|
120
|
-
const isMultiSeries = isLineOrArea && hasColorEncoding && ctx.seriesCount >= 2;
|
|
121
|
-
|
|
122
|
-
// Single-series, non-line/area, or compact strategy: never show endpoint
|
|
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.
|
|
127
|
-
//
|
|
128
|
-
// Note: `labelsDensityNone` is intentionally NOT in this short-circuit.
|
|
129
|
-
// `labels.density: 'none'` is the legacy switch for end-of-line labels and
|
|
130
|
-
// must not affect the endpoint column or the traditional legend (those
|
|
131
|
-
// are independent concerns governed by the truth table below).
|
|
132
|
-
const strategyHidesEndpoint = ctx.labelsHiddenByStrategy && !endpointLabelsExplicitlyOn(spec);
|
|
133
|
-
if (!isMultiSeries || strategyHidesEndpoint) {
|
|
134
|
-
return {
|
|
135
|
-
// Defer the legend's own show/hide rules to computeLegend; this helper
|
|
136
|
-
// doesn't override the existing legend behavior for non-multi-series.
|
|
137
|
-
showTraditionalLegend: !legendHiddenExplicitly(spec),
|
|
138
|
-
showEndpointLabels: false,
|
|
139
|
-
showEndOfLineLabels: false,
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Stacked-area branch: gradient fills overlap, so endpoint labels at the
|
|
144
|
-
// last data point would land on top of each other. Don't auto-enable the
|
|
145
|
-
// column for stacked areas. Users can still force it with `endpointLabels: true`.
|
|
146
|
-
const isArea = spec.markType === 'area';
|
|
147
|
-
const quantChannel = (
|
|
148
|
-
spec.encoding.y?.type === 'quantitative' ? spec.encoding.y : spec.encoding.x
|
|
149
|
-
) as Encoding[keyof Encoding] | undefined;
|
|
150
|
-
const stackValue = quantChannel && 'stack' in quantChannel ? quantChannel.stack : undefined;
|
|
151
|
-
// v8 VL-aligned default: area stacks unless stack is explicitly null/false.
|
|
152
|
-
const isStacked = isArea ? stackValue !== null && stackValue !== false : false;
|
|
153
|
-
|
|
154
|
-
// Stacked area: revert to the pre-v6 behavior — show legend, no endpoint column.
|
|
155
|
-
if (isStacked) {
|
|
156
|
-
// Allow explicit user opt-in via endpointLabels: true even on stacked areas.
|
|
157
|
-
const explicitOn = endpointLabelsExplicitlyOn(spec);
|
|
158
|
-
return {
|
|
159
|
-
showTraditionalLegend: !legendHiddenExplicitly(spec),
|
|
160
|
-
showEndpointLabels: explicitOn,
|
|
161
|
-
showEndOfLineLabels: false,
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Series-count cutoff: above maxSeries (default 8), treat as if endpoint
|
|
166
|
-
// labels are off so a legend shows instead. Explicit user opt-in overrides.
|
|
167
|
-
const exceedsCutoff = !endpointLabelsExplicitlyOn(spec) && ctx.seriesCount > getMaxSeries(spec);
|
|
168
|
-
|
|
169
|
-
// Auto-thinning demotion: endpoint labels don't fit at the current width.
|
|
170
|
-
const demoted = !endpointLabelsExplicitlyOn(spec) && !!ctx.endpointLabelsDemoted;
|
|
171
|
-
|
|
172
|
-
// The eight-cell truth table for ≥2-series overlap line/area charts.
|
|
173
|
-
const epExplicitOff = endpointLabelsExplicitlyOff(spec) || exceedsCutoff || demoted;
|
|
174
|
-
const legShown = legendShownExplicitly(spec);
|
|
175
|
-
const legHidden = legendHiddenExplicitly(spec);
|
|
176
|
-
|
|
177
|
-
// Endpoint column: on by default; only `endpointLabels: false` (or
|
|
178
|
-
// `{ show: false }`) or series-count cutoff turns it off.
|
|
179
|
-
const showEndpointLabels = !epExplicitOff;
|
|
180
|
-
|
|
181
|
-
// Traditional legend:
|
|
182
|
-
// - explicit hide -> hidden
|
|
183
|
-
// - explicit show -> shown
|
|
184
|
-
// - unset -> hidden when endpoint column is on, shown otherwise
|
|
185
|
-
let showTraditionalLegend: boolean;
|
|
186
|
-
if (legHidden) {
|
|
187
|
-
showTraditionalLegend = false;
|
|
188
|
-
} else if (legShown) {
|
|
189
|
-
showTraditionalLegend = true;
|
|
190
|
-
} else {
|
|
191
|
-
showTraditionalLegend = !showEndpointLabels;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// End-of-line labels are the last-resort series identifier: they render
|
|
195
|
-
// only when both the traditional legend and the endpoint column are off.
|
|
196
|
-
// `labels.density: 'none'` is the legacy switch that explicitly disables
|
|
197
|
-
// end-of-line labels regardless of the other toggles.
|
|
198
|
-
const showEndOfLineLabels =
|
|
199
|
-
!showTraditionalLegend && !showEndpointLabels && !ctx.labelsDensityNone;
|
|
200
|
-
|
|
201
|
-
return {
|
|
202
|
-
showTraditionalLegend,
|
|
203
|
-
showEndpointLabels,
|
|
204
|
-
showEndOfLineLabels,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Count distinct series for the color encoding (for use in
|
|
210
|
-
* SuppressionContext.seriesCount). Returns 0 when no color encoding is set
|
|
211
|
-
* or when the encoding is conditional/quantitative.
|
|
212
|
-
*/
|
|
213
|
-
export function countColorSeries(spec: NormalizedChartSpec): number {
|
|
214
|
-
const colorEnc = spec.encoding.color;
|
|
215
|
-
if (!colorEnc) return 0;
|
|
216
|
-
if ('condition' in colorEnc) return 0;
|
|
217
|
-
if (!('field' in colorEnc)) return 0;
|
|
218
|
-
if (colorEnc.type === 'quantitative') return 0;
|
|
219
|
-
const field = colorEnc.field;
|
|
220
|
-
if (!field) return 0;
|
|
221
|
-
const seen = new Set<string>();
|
|
222
|
-
for (const row of spec.data) {
|
|
223
|
-
seen.add(String(row[field]));
|
|
224
|
-
}
|
|
225
|
-
return seen.size;
|
|
226
|
-
}
|