@opendata-ai/openchart-engine 8.0.0-rc.8 → 8.0.0
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 +149 -12
- package/dist/index.js +894 -171
- 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 -852
- 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 -376
- 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 -545
- 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 -141
- 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 -170
- package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
- package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
- 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 -734
- package/src/compiler/sparkline-defaults.ts +0 -138
- package/src/compiler/types.ts +0 -241
- package/src/compiler/validate.ts +0 -2041
- 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 -1004
- package/src/geo/compile-map.ts +0 -1341
- package/src/geo/join.ts +0 -115
- package/src/geo/projections.ts +0 -45
- package/src/geo/types.ts +0 -26
- 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
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Scatter / bubble chart mark computation.
|
|
3
|
-
*
|
|
4
|
-
* Takes a normalized chart spec with resolved scales and produces
|
|
5
|
-
* PointMark[] for rendering scatter plots. Axes can be any field type.
|
|
6
|
-
* Optional size encoding produces area-proportional bubbles via sqrt
|
|
7
|
-
* scaling, and color encoding groups points by category.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type {
|
|
11
|
-
Encoding,
|
|
12
|
-
FieldType,
|
|
13
|
-
GradientDef,
|
|
14
|
-
LayoutStrategy,
|
|
15
|
-
MarkAria,
|
|
16
|
-
PointMark,
|
|
17
|
-
Rect,
|
|
18
|
-
} from '@opendata-ai/openchart-core';
|
|
19
|
-
import type { ScaleBand, ScaleLinear, ScalePoint, ScaleTime } from 'd3-scale';
|
|
20
|
-
import { buildSizeScale, SIZE_SCALE_DEFAULTS } from '../../compile/size-scale';
|
|
21
|
-
import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
|
|
22
|
-
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
23
|
-
import type { ResolvedScales } from '../../layout/scales';
|
|
24
|
-
import { getColor, getSequentialColor } from '../utils';
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Constants
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
const DEFAULT_POINT_RADIUS = 5;
|
|
31
|
-
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// Helpers
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
/** Resolve a data value to a pixel position based on channel type and scale. */
|
|
37
|
-
function resolvePosition(
|
|
38
|
-
value: unknown,
|
|
39
|
-
channelType: FieldType,
|
|
40
|
-
scale:
|
|
41
|
-
| ScaleLinear<number, number>
|
|
42
|
-
| ScaleTime<number, number>
|
|
43
|
-
| ScaleBand<string>
|
|
44
|
-
| ScalePoint<string>,
|
|
45
|
-
): number | undefined {
|
|
46
|
-
switch (channelType) {
|
|
47
|
-
case 'nominal':
|
|
48
|
-
case 'ordinal': {
|
|
49
|
-
const s = String(value);
|
|
50
|
-
if ('bandwidth' in scale && typeof scale.bandwidth === 'function') {
|
|
51
|
-
const bw = (scale as ScaleBand<string>).bandwidth();
|
|
52
|
-
const pos = (scale as ScaleBand<string>)(s);
|
|
53
|
-
if (pos === undefined) return undefined;
|
|
54
|
-
// ScalePoint has bandwidth() === 0; ScaleBand has > 0.
|
|
55
|
-
return bw > 0 ? pos + bw / 2 : pos;
|
|
56
|
-
}
|
|
57
|
-
return (scale as ScalePoint<string>)(s);
|
|
58
|
-
}
|
|
59
|
-
case 'temporal': {
|
|
60
|
-
const px = (scale as ScaleTime<number, number>)(new Date(value as string | number));
|
|
61
|
-
return Number.isNaN(px) ? undefined : px;
|
|
62
|
-
}
|
|
63
|
-
default: {
|
|
64
|
-
const num = Number(value);
|
|
65
|
-
if (!Number.isFinite(num)) return undefined;
|
|
66
|
-
return (scale as ScaleLinear<number, number>)(num);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
72
|
-
// Public API
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Compute scatter/bubble marks from a normalized chart spec.
|
|
77
|
-
*
|
|
78
|
-
* Axes accept any field type: quantitative (linear), temporal (time),
|
|
79
|
-
* nominal/ordinal (band or point scale). Optional size encoding maps a
|
|
80
|
-
* data field to point radius using sqrt scale (area-proportional).
|
|
81
|
-
* Optional color encoding groups points by category with distinct colors.
|
|
82
|
-
*/
|
|
83
|
-
export function computeScatterMarks(
|
|
84
|
-
spec: NormalizedChartSpec,
|
|
85
|
-
scales: ResolvedScales,
|
|
86
|
-
_chartArea: Rect,
|
|
87
|
-
_strategy: LayoutStrategy,
|
|
88
|
-
): PointMark[] {
|
|
89
|
-
const encoding = spec.encoding as Encoding;
|
|
90
|
-
const xChannel = encoding.x;
|
|
91
|
-
const yChannel = encoding.y;
|
|
92
|
-
|
|
93
|
-
if (!xChannel || !yChannel || !scales.x || !scales.y) {
|
|
94
|
-
return [];
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const xScale = scales.x.scale as
|
|
98
|
-
| ScaleLinear<number, number>
|
|
99
|
-
| ScaleTime<number, number>
|
|
100
|
-
| ScaleBand<string>
|
|
101
|
-
| ScalePoint<string>;
|
|
102
|
-
const yScale = scales.y.scale as
|
|
103
|
-
| ScaleLinear<number, number>
|
|
104
|
-
| ScaleTime<number, number>
|
|
105
|
-
| ScaleBand<string>
|
|
106
|
-
| ScalePoint<string>;
|
|
107
|
-
const xType = xChannel.type;
|
|
108
|
-
const yType = yChannel.type;
|
|
109
|
-
|
|
110
|
-
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
111
|
-
const isSequentialColor = colorEnc?.type === 'quantitative';
|
|
112
|
-
const colorField = colorEnc?.field;
|
|
113
|
-
const sizeEnc = encoding.size && 'field' in encoding.size ? encoding.size : undefined;
|
|
114
|
-
const sizeField = sizeEnc?.field;
|
|
115
|
-
|
|
116
|
-
// Bubbles: sqrt so perceived magnitude tracks disc *area*. Author overrides
|
|
117
|
-
// (`encoding.size.scale.{domain,range}`) and the degenerate-domain fallback
|
|
118
|
-
// live in the shared builder, which the size legend also calls so the key and
|
|
119
|
-
// the marks can never resolve different scales.
|
|
120
|
-
const resolvedSize = buildSizeScale(sizeEnc, spec.data, SIZE_SCALE_DEFAULTS.point);
|
|
121
|
-
const sizeScale = resolvedSize?.scale;
|
|
122
|
-
|
|
123
|
-
const keyEnc = encoding.key && 'field' in encoding.key ? encoding.key : undefined;
|
|
124
|
-
const keyField = keyEnc?.field;
|
|
125
|
-
const marks: PointMark[] = [];
|
|
126
|
-
|
|
127
|
-
for (const row of spec.data) {
|
|
128
|
-
const rawX = row[xChannel.field];
|
|
129
|
-
const rawY = row[yChannel.field];
|
|
130
|
-
|
|
131
|
-
const cx = resolvePosition(rawX, xType, xScale);
|
|
132
|
-
const cy = resolvePosition(rawY, yType, yScale);
|
|
133
|
-
|
|
134
|
-
if (cx === undefined || cy === undefined) continue;
|
|
135
|
-
|
|
136
|
-
const category = colorField && !isSequentialColor ? String(row[colorField] ?? '') : undefined;
|
|
137
|
-
let color: string | GradientDef;
|
|
138
|
-
if (isSequentialColor && colorField) {
|
|
139
|
-
const val = Number(row[colorField]);
|
|
140
|
-
color = Number.isFinite(val)
|
|
141
|
-
? getSequentialColor(scales, val)
|
|
142
|
-
: getColor(scales, '__default__');
|
|
143
|
-
} else {
|
|
144
|
-
color = getColor(scales, category ?? '__default__');
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
let radius = DEFAULT_POINT_RADIUS;
|
|
148
|
-
if (sizeScale && sizeField) {
|
|
149
|
-
const sizeVal = Number(row[sizeField]);
|
|
150
|
-
if (Number.isFinite(sizeVal)) {
|
|
151
|
-
radius = sizeScale(sizeVal);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const labelParts = [`${xChannel.field}=${rawX}`, `${yChannel.field}=${rawY}`];
|
|
156
|
-
if (category) labelParts.push(`${colorField}=${category}`);
|
|
157
|
-
if (sizeField && row[sizeField] != null) {
|
|
158
|
-
labelParts.push(`${sizeField}=${row[sizeField]}`);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const aria: MarkAria = {
|
|
162
|
-
label: `Data point: ${labelParts.join(', ')}`,
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
marks.push({
|
|
166
|
-
type: 'point',
|
|
167
|
-
cx,
|
|
168
|
-
cy,
|
|
169
|
-
r: radius,
|
|
170
|
-
fill: color,
|
|
171
|
-
stroke: '#ffffff',
|
|
172
|
-
strokeWidth: 1,
|
|
173
|
-
fillOpacity: 0.7,
|
|
174
|
-
data: row as Record<string, unknown>,
|
|
175
|
-
aria,
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Stamp keys: encoding.key field when set, else seriesKey|x composite
|
|
180
|
-
const rawKeys = marks.map((m) => {
|
|
181
|
-
if (keyField) return serializeKeyValue(m.data[keyField]);
|
|
182
|
-
const cat = colorField ? String(m.data[colorField] ?? '') : '';
|
|
183
|
-
const xVal = serializeKeyValue(m.data[xChannel.field]);
|
|
184
|
-
return cat ? `${cat}|${xVal}` : xVal;
|
|
185
|
-
});
|
|
186
|
-
const keys = dedupeKeys(rawKeys);
|
|
187
|
-
for (let i = 0; i < marks.length; i++) {
|
|
188
|
-
marks[i].key = keys[i];
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return marks;
|
|
192
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Scatter / bubble chart module.
|
|
3
|
-
*
|
|
4
|
-
* Exports the scatter chart renderer and computation functions.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { Mark } from '@opendata-ai/openchart-core';
|
|
8
|
-
import type { ChartRenderer } from '../registry';
|
|
9
|
-
import { computeScatterMarks } from './compute';
|
|
10
|
-
import { computeTrendLine } from './trendline';
|
|
11
|
-
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
// Scatter chart renderer
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Scatter chart renderer.
|
|
18
|
-
*
|
|
19
|
-
* Produces point marks for each data point, optionally with size encoding
|
|
20
|
-
* for bubbles and a trend line overlay.
|
|
21
|
-
*/
|
|
22
|
-
export const scatterRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
|
|
23
|
-
const pointMarks = computeScatterMarks(spec, scales, chartArea, strategy);
|
|
24
|
-
const marks: Mark[] = [...pointMarks];
|
|
25
|
-
|
|
26
|
-
// Regression trend line renders by default but is opt-out via
|
|
27
|
-
// `mark: { type: 'point', trendline: false }`. Rendering it unconditionally
|
|
28
|
-
// competed with author-drawn reference lines (e.g. a manual x=y diagonal),
|
|
29
|
-
// so a chart could show two diagonals.
|
|
30
|
-
if (spec.markDef?.trendline !== false) {
|
|
31
|
-
const trendLine = computeTrendLine(pointMarks);
|
|
32
|
-
if (trendLine) {
|
|
33
|
-
// Trend line goes behind points
|
|
34
|
-
marks.unshift(trendLine);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return marks;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
// Public exports
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
|
|
45
|
-
export { computeScatterMarks } from './compute';
|
|
46
|
-
export { computeTrendLine } from './trendline';
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Trend line computation for scatter plots.
|
|
3
|
-
*
|
|
4
|
-
* Computes a simple linear regression (least squares) over the
|
|
5
|
-
* point marks and returns a LineMark representing the best-fit line.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { LineMark, MarkAria, PointMark } from '@opendata-ai/openchart-core';
|
|
9
|
-
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
// Constants
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
|
|
14
|
-
const TRENDLINE_COLOR = '#666666';
|
|
15
|
-
const TRENDLINE_STROKE_WIDTH = 1.5;
|
|
16
|
-
const TRENDLINE_DASH = '6 4';
|
|
17
|
-
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
// Linear regression
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Compute slope and intercept for a simple linear regression.
|
|
24
|
-
* Returns null if there aren't enough points or variance is zero.
|
|
25
|
-
*/
|
|
26
|
-
function linearRegression(
|
|
27
|
-
points: { x: number; y: number }[],
|
|
28
|
-
): { slope: number; intercept: number } | null {
|
|
29
|
-
const n = points.length;
|
|
30
|
-
if (n < 2) return null;
|
|
31
|
-
|
|
32
|
-
let sumX = 0;
|
|
33
|
-
let sumY = 0;
|
|
34
|
-
let sumXY = 0;
|
|
35
|
-
let sumXX = 0;
|
|
36
|
-
|
|
37
|
-
for (const p of points) {
|
|
38
|
-
sumX += p.x;
|
|
39
|
-
sumY += p.y;
|
|
40
|
-
sumXY += p.x * p.y;
|
|
41
|
-
sumXX += p.x * p.x;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const denominator = n * sumXX - sumX * sumX;
|
|
45
|
-
if (denominator === 0) return null;
|
|
46
|
-
|
|
47
|
-
const slope = (n * sumXY - sumX * sumY) / denominator;
|
|
48
|
-
const intercept = (sumY - slope * sumX) / n;
|
|
49
|
-
|
|
50
|
-
return { slope, intercept };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
// Public API
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Compute a trend line (linear regression) over scatter point marks.
|
|
59
|
-
*
|
|
60
|
-
* Returns a single LineMark spanning the x-range of the data points,
|
|
61
|
-
* rendered as a dashed line. Returns null if regression can't be computed.
|
|
62
|
-
*/
|
|
63
|
-
export function computeTrendLine(marks: PointMark[]): LineMark | null {
|
|
64
|
-
if (marks.length < 2) return null;
|
|
65
|
-
|
|
66
|
-
const points = marks.map((m) => ({ x: m.cx, y: m.cy }));
|
|
67
|
-
const result = linearRegression(points);
|
|
68
|
-
if (!result) return null;
|
|
69
|
-
|
|
70
|
-
const { slope, intercept } = result;
|
|
71
|
-
|
|
72
|
-
// Find x range from marks
|
|
73
|
-
let minX = Infinity;
|
|
74
|
-
let maxX = -Infinity;
|
|
75
|
-
for (const m of marks) {
|
|
76
|
-
if (m.cx < minX) minX = m.cx;
|
|
77
|
-
if (m.cx > maxX) maxX = m.cx;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Compute y values at the endpoints
|
|
81
|
-
const y1 = slope * minX + intercept;
|
|
82
|
-
const y2 = slope * maxX + intercept;
|
|
83
|
-
|
|
84
|
-
const aria: MarkAria = {
|
|
85
|
-
label: `Trend line: linear regression`,
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
return {
|
|
89
|
-
type: 'line',
|
|
90
|
-
points: [
|
|
91
|
-
{ x: minX, y: y1 },
|
|
92
|
-
{ x: maxX, y: y2 },
|
|
93
|
-
],
|
|
94
|
-
stroke: TRENDLINE_COLOR,
|
|
95
|
-
strokeWidth: TRENDLINE_STROKE_WIDTH,
|
|
96
|
-
strokeDasharray: TRENDLINE_DASH,
|
|
97
|
-
data: [],
|
|
98
|
-
aria,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
import type { ChartSpec, LayerSpec, PointMark, TextMarkLayout } from '@opendata-ai/openchart-core';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import { compileChart, compileLayer } from '../../../compile';
|
|
4
|
-
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
// Shared fixtures
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Ten US states, GDP (trillions) vs population (millions). The low end clusters
|
|
11
|
-
* tightly, which is what made the scale-domain bug so visible: the further a
|
|
12
|
-
* point sat from the shared upper bound, the further its label drifted.
|
|
13
|
-
*/
|
|
14
|
-
const states = [
|
|
15
|
-
{ label: 'CA', gdp: 3.9, pop: 39.0 },
|
|
16
|
-
{ label: 'TX', gdp: 2.6, pop: 30.5 },
|
|
17
|
-
{ label: 'NY', gdp: 2.1, pop: 19.6 },
|
|
18
|
-
{ label: 'FL', gdp: 1.6, pop: 22.6 },
|
|
19
|
-
{ label: 'IL', gdp: 1.1, pop: 12.5 },
|
|
20
|
-
{ label: 'PA', gdp: 1.0, pop: 12.9 },
|
|
21
|
-
{ label: 'OH', gdp: 0.9, pop: 11.8 },
|
|
22
|
-
{ label: 'GA', gdp: 0.8, pop: 11.0 },
|
|
23
|
-
{ label: 'NJ', gdp: 0.8, pop: 9.3 },
|
|
24
|
-
{ label: 'WA', gdp: 0.8, pop: 7.8 },
|
|
25
|
-
];
|
|
26
|
-
|
|
27
|
-
/** A points layer plus a text layer, both encoding the *same* x/y fields. */
|
|
28
|
-
function labeledScatter(textMark: ChartSpec['mark'] = { type: 'text' }): LayerSpec {
|
|
29
|
-
return {
|
|
30
|
-
layer: [
|
|
31
|
-
{
|
|
32
|
-
mark: { type: 'point' },
|
|
33
|
-
data: [...states],
|
|
34
|
-
encoding: {
|
|
35
|
-
x: { field: 'gdp', type: 'quantitative' },
|
|
36
|
-
y: { field: 'pop', type: 'quantitative' },
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
mark: textMark,
|
|
41
|
-
data: [...states],
|
|
42
|
-
encoding: {
|
|
43
|
-
x: { field: 'gdp', type: 'quantitative' },
|
|
44
|
-
y: { field: 'pop', type: 'quantitative' },
|
|
45
|
-
text: { field: 'label', type: 'nominal' },
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
],
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function textMarksOf(spec: ChartSpec): TextMarkLayout[] {
|
|
53
|
-
const layout = compileChart(spec, { width: 900, height: 500 });
|
|
54
|
-
return layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Standalone text chart over the states, with an overridable mark def. */
|
|
58
|
-
function textChart(mark: ChartSpec['mark'] = { type: 'text' }): ChartSpec {
|
|
59
|
-
return {
|
|
60
|
-
mark,
|
|
61
|
-
data: [...states],
|
|
62
|
-
encoding: {
|
|
63
|
-
x: { field: 'gdp', type: 'quantitative' },
|
|
64
|
-
y: { field: 'pop', type: 'quantitative' },
|
|
65
|
-
text: { field: 'label', type: 'nominal' },
|
|
66
|
-
},
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
// Scale alignment
|
|
72
|
-
// ---------------------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
describe('text marks in a layer', () => {
|
|
75
|
-
/**
|
|
76
|
-
* Regression: text is a position-encoding mark, so it must resolve its domain
|
|
77
|
-
* the same way `point`/`beeswarm`/`range` do (tight, not zero-anchored). When
|
|
78
|
-
* it didn't, a text layer and a point layer over identical fields resolved
|
|
79
|
-
* *different* domains across the same pixel range, and every label drifted off
|
|
80
|
-
* the dot it was labeling — by up to 160px at the low end of the domain.
|
|
81
|
-
*/
|
|
82
|
-
it('positions each label exactly on the point it labels', () => {
|
|
83
|
-
const layout = compileLayer(labeledScatter(), { width: 900, height: 500 });
|
|
84
|
-
|
|
85
|
-
const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
|
|
86
|
-
const texts = layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
|
|
87
|
-
|
|
88
|
-
expect(points).toHaveLength(states.length);
|
|
89
|
-
expect(texts).toHaveLength(states.length);
|
|
90
|
-
|
|
91
|
-
for (const text of texts) {
|
|
92
|
-
const point = points.find((p) => p.data?.label === text.text);
|
|
93
|
-
expect(point, `no point for label ${text.text}`).toBeDefined();
|
|
94
|
-
|
|
95
|
-
expect(text.x).toBeCloseTo((point as PointMark).cx, 5);
|
|
96
|
-
expect(text.y).toBeCloseTo((point as PointMark).cy, 5);
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('offsets labels off their points by exactly dy, keeping x aligned', () => {
|
|
101
|
-
const layout = compileLayer(labeledScatter({ type: 'text', dy: -10 }), {
|
|
102
|
-
width: 900,
|
|
103
|
-
height: 500,
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
|
|
107
|
-
const texts = layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
|
|
108
|
-
|
|
109
|
-
for (const text of texts) {
|
|
110
|
-
const point = points.find((p) => p.data?.label === text.text) as PointMark;
|
|
111
|
-
expect(text.x).toBeCloseTo(point.cx, 5);
|
|
112
|
-
expect(text.y).toBeCloseTo(point.cy - 10, 5);
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
// Offsets
|
|
119
|
-
// ---------------------------------------------------------------------------
|
|
120
|
-
|
|
121
|
-
describe('dx / dy', () => {
|
|
122
|
-
it('bakes the offset into x/y and retains the pre-offset anchor', () => {
|
|
123
|
-
const plain = textMarksOf(textChart());
|
|
124
|
-
const offset = textMarksOf(textChart({ type: 'text', dx: 6, dy: -10 }));
|
|
125
|
-
|
|
126
|
-
for (let i = 0; i < plain.length; i++) {
|
|
127
|
-
expect(offset[i].x).toBeCloseTo(plain[i].x + 6, 5);
|
|
128
|
-
expect(offset[i].y).toBeCloseTo(plain[i].y - 10, 5);
|
|
129
|
-
// The anchor is what a leader line would point back to.
|
|
130
|
-
expect(offset[i].anchorX).toBeCloseTo(plain[i].x, 5);
|
|
131
|
-
expect(offset[i].anchorY).toBeCloseTo(plain[i].y, 5);
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('leaves the anchor unset when the mark is not offset', () => {
|
|
136
|
-
for (const mark of textMarksOf(textChart())) {
|
|
137
|
-
expect(mark.anchorX).toBeUndefined();
|
|
138
|
-
expect(mark.anchorY).toBeUndefined();
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
// ---------------------------------------------------------------------------
|
|
144
|
-
// Alignment / baseline
|
|
145
|
-
// ---------------------------------------------------------------------------
|
|
146
|
-
|
|
147
|
-
describe('align and baseline', () => {
|
|
148
|
-
it('centers on the data point by default', () => {
|
|
149
|
-
for (const mark of textMarksOf(textChart())) {
|
|
150
|
-
expect(mark.textAnchor).toBe('middle');
|
|
151
|
-
expect(mark.dominantBaseline).toBe('central');
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('maps align/baseline onto SVG anchor and baseline', () => {
|
|
156
|
-
const marks = textMarksOf(textChart({ type: 'text', align: 'left', baseline: 'top' }));
|
|
157
|
-
for (const mark of marks) {
|
|
158
|
-
expect(mark.textAnchor).toBe('start');
|
|
159
|
-
expect(mark.dominantBaseline).toBe('hanging');
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
// ---------------------------------------------------------------------------
|
|
165
|
-
// Size channel
|
|
166
|
-
// ---------------------------------------------------------------------------
|
|
167
|
-
|
|
168
|
-
describe('size channel', () => {
|
|
169
|
-
const sized = (): ChartSpec => ({
|
|
170
|
-
mark: { type: 'text' },
|
|
171
|
-
data: [...states],
|
|
172
|
-
encoding: {
|
|
173
|
-
x: { field: 'gdp', type: 'quantitative' },
|
|
174
|
-
y: { field: 'pop', type: 'quantitative' },
|
|
175
|
-
text: { field: 'label', type: 'nominal' },
|
|
176
|
-
size: { field: 'gdp', type: 'quantitative' },
|
|
177
|
-
},
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('maps the size field linearly onto the default font range', () => {
|
|
181
|
-
const marks = textMarksOf(sized());
|
|
182
|
-
const byLabel = new Map(marks.map((m) => [m.text, m]));
|
|
183
|
-
|
|
184
|
-
// gdp spans 0.8 (WA) to 3.9 (CA) -> the default 11..32px range.
|
|
185
|
-
expect(byLabel.get('WA')?.fontSize).toBeCloseTo(11, 5);
|
|
186
|
-
expect(byLabel.get('CA')?.fontSize).toBeCloseTo(32, 5);
|
|
187
|
-
|
|
188
|
-
// Linear, not sqrt: interior values interpolate proportionally. A sqrt scale
|
|
189
|
-
// would land NY well above this.
|
|
190
|
-
const ny = byLabel.get('NY') as TextMarkLayout; // gdp 2.1
|
|
191
|
-
const expectedNy = 11 + (32 - 11) * ((2.1 - 0.8) / (3.9 - 0.8));
|
|
192
|
-
expect(ny.fontSize).toBeCloseTo(expectedNy, 5);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('honors an explicit scale range', () => {
|
|
196
|
-
const spec = sized();
|
|
197
|
-
(spec.encoding.size as { scale?: unknown }).scale = { range: [10, 20] };
|
|
198
|
-
|
|
199
|
-
const marks = textMarksOf(spec);
|
|
200
|
-
const byLabel = new Map(marks.map((m) => [m.text, m]));
|
|
201
|
-
expect(byLabel.get('WA')?.fontSize).toBeCloseTo(10, 5);
|
|
202
|
-
expect(byLabel.get('CA')?.fontSize).toBeCloseTo(20, 5);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it('falls back to mark.fontSize when there is no size encoding', () => {
|
|
206
|
-
for (const mark of textMarksOf(textChart({ type: 'text', fontSize: 14 }))) {
|
|
207
|
-
expect(mark.fontSize).toBe(14);
|
|
208
|
-
}
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it('defaults to 12px with no size encoding and no fontSize', () => {
|
|
212
|
-
for (const mark of textMarksOf(textChart())) {
|
|
213
|
-
expect(mark.fontSize).toBe(12);
|
|
214
|
-
}
|
|
215
|
-
});
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
// ---------------------------------------------------------------------------
|
|
219
|
-
// Subset label layers
|
|
220
|
-
// ---------------------------------------------------------------------------
|
|
221
|
-
|
|
222
|
-
describe('a label layer over a subset of the points', () => {
|
|
223
|
-
/**
|
|
224
|
-
* Direct labeling usually names only the notable points, so the text layer
|
|
225
|
-
* carries fewer rows than the point layer. The two must still resolve the same
|
|
226
|
-
* domain — a subset must not re-fit the scale to its own narrower extent.
|
|
227
|
-
*/
|
|
228
|
-
it('keeps subset labels aligned with the full point layer', () => {
|
|
229
|
-
const labeled = new Set(['CA', 'TX', 'NY', 'FL', 'IL', 'WA']);
|
|
230
|
-
const spec: LayerSpec = {
|
|
231
|
-
layer: [
|
|
232
|
-
{
|
|
233
|
-
mark: { type: 'point' },
|
|
234
|
-
data: [...states],
|
|
235
|
-
encoding: {
|
|
236
|
-
x: { field: 'gdp', type: 'quantitative' },
|
|
237
|
-
y: { field: 'pop', type: 'quantitative' },
|
|
238
|
-
},
|
|
239
|
-
},
|
|
240
|
-
{
|
|
241
|
-
mark: { type: 'text', dy: -14 },
|
|
242
|
-
data: states.filter((d) => labeled.has(d.label)),
|
|
243
|
-
encoding: {
|
|
244
|
-
x: { field: 'gdp', type: 'quantitative' },
|
|
245
|
-
y: { field: 'pop', type: 'quantitative' },
|
|
246
|
-
text: { field: 'label', type: 'nominal' },
|
|
247
|
-
},
|
|
248
|
-
},
|
|
249
|
-
],
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
const layout = compileLayer(spec, { width: 900, height: 500 });
|
|
253
|
-
const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
|
|
254
|
-
const texts = layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
|
|
255
|
-
|
|
256
|
-
expect(points).toHaveLength(states.length);
|
|
257
|
-
expect(texts).toHaveLength(labeled.size);
|
|
258
|
-
|
|
259
|
-
for (const text of texts) {
|
|
260
|
-
const point = points.find((p) => p.data?.label === text.text) as PointMark;
|
|
261
|
-
expect(text.x).toBeCloseTo(point.cx, 5);
|
|
262
|
-
expect(text.y).toBeCloseTo(point.cy - 14, 5);
|
|
263
|
-
}
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
describe('a subset whose extent is narrower than the point layer', () => {
|
|
268
|
-
/**
|
|
269
|
-
* Dropping the min-x row from the label layer narrows *its* data extent. If
|
|
270
|
-
* each leaf resolves its own domain, the label layer re-fits [1.1, 3.9] while
|
|
271
|
-
* the points keep [0.8, 3.9], and every label slides left off its dot.
|
|
272
|
-
*/
|
|
273
|
-
it('still aligns when the label subset omits the extreme point', () => {
|
|
274
|
-
const labeled = new Set(['CA', 'TX', 'NY', 'FL', 'IL']); // no WA (the min gdp)
|
|
275
|
-
const spec: LayerSpec = {
|
|
276
|
-
layer: [
|
|
277
|
-
{
|
|
278
|
-
mark: { type: 'point' },
|
|
279
|
-
data: [...states],
|
|
280
|
-
encoding: {
|
|
281
|
-
x: { field: 'gdp', type: 'quantitative' },
|
|
282
|
-
y: { field: 'pop', type: 'quantitative' },
|
|
283
|
-
},
|
|
284
|
-
},
|
|
285
|
-
{
|
|
286
|
-
mark: { type: 'text' },
|
|
287
|
-
data: states.filter((d) => labeled.has(d.label)),
|
|
288
|
-
encoding: {
|
|
289
|
-
x: { field: 'gdp', type: 'quantitative' },
|
|
290
|
-
y: { field: 'pop', type: 'quantitative' },
|
|
291
|
-
text: { field: 'label', type: 'nominal' },
|
|
292
|
-
},
|
|
293
|
-
},
|
|
294
|
-
],
|
|
295
|
-
};
|
|
296
|
-
|
|
297
|
-
const layout = compileLayer(spec, { width: 900, height: 500 });
|
|
298
|
-
const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
|
|
299
|
-
const texts = layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
|
|
300
|
-
|
|
301
|
-
for (const text of texts) {
|
|
302
|
-
const point = points.find((p) => p.data?.label === text.text) as PointMark;
|
|
303
|
-
expect(text.x, `label ${text.text} drifted in x`).toBeCloseTo(point.cx, 5);
|
|
304
|
-
expect(text.y, `label ${text.text} drifted in y`).toBeCloseTo(point.cy, 5);
|
|
305
|
-
}
|
|
306
|
-
});
|
|
307
|
-
});
|