@opendata-ai/openchart-engine 8.0.0-rc.9 → 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 +804 -155
- 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 -2401
- 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 -630
- 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 -1503
- 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 -1128
- package/src/geo/compile-map.ts +0 -1376
- package/src/geo/join.ts +0 -115
- package/src/geo/projections.ts +0 -65
- 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 -748
- 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,273 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Range chart mark computation (dumbbell / arrow / range bar).
|
|
3
|
-
*
|
|
4
|
-
* A band scale on the nominal axis and two quantitative positions per row
|
|
5
|
-
* (x + x2 horizontal, or y + y2 vertical). Three styles via markDef.style:
|
|
6
|
-
* - 'dumbbell' (default): connector rule + a dot at each end. The start dot
|
|
7
|
-
* is muted, the end dot carries the accent color.
|
|
8
|
-
* - 'arrow': shaft rule + chevron arrowhead at the x2/y2 end, built from the
|
|
9
|
-
* shared computeArrowheadPoints geometry.
|
|
10
|
-
* - 'bar': plain floating RectMark spanning start to end.
|
|
11
|
-
*
|
|
12
|
-
* Coloring precedence for the accent (connector/shaft/bar + end dot):
|
|
13
|
-
* field-based encoding.color > markDef.colorByDirection (positive/negative
|
|
14
|
-
* semantic tokens) > scales.defaultColor (markDef.fill or first palette color).
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type {
|
|
18
|
-
Encoding,
|
|
19
|
-
LayoutStrategy,
|
|
20
|
-
Mark,
|
|
21
|
-
MarkAria,
|
|
22
|
-
Rect,
|
|
23
|
-
ResolvedTheme,
|
|
24
|
-
} from '@opendata-ai/openchart-core';
|
|
25
|
-
import { getRepresentativeColor } from '@opendata-ai/openchart-core';
|
|
26
|
-
import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
27
|
-
|
|
28
|
-
import { computeArrowheadPoints } from '../../annotations/geometry';
|
|
29
|
-
import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
|
|
30
|
-
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
31
|
-
import type { ResolvedScales } from '../../layout/scales';
|
|
32
|
-
import { getColor } from '../utils';
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Constants
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
const DOT_RADIUS = 6;
|
|
39
|
-
const CONNECTOR_WIDTH = 2;
|
|
40
|
-
const CONNECTOR_COLOR = '#cccccc';
|
|
41
|
-
const ARROW_STROKE_WIDTH = 2;
|
|
42
|
-
const ARROWHEAD_LENGTH = 8;
|
|
43
|
-
const ARROWHEAD_HALF_WIDTH = 4;
|
|
44
|
-
|
|
45
|
-
/** Range mark style variants. */
|
|
46
|
-
export type RangeStyle = 'dumbbell' | 'arrow' | 'bar';
|
|
47
|
-
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
// Orientation helpers
|
|
50
|
-
// ---------------------------------------------------------------------------
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Resolve the range orientation from resolved scales.
|
|
54
|
-
* Horizontal (nominal y, the common editorial form) when y is a band scale;
|
|
55
|
-
* vertical when x is a band scale.
|
|
56
|
-
*/
|
|
57
|
-
export function resolveRangeOrientation(scales: ResolvedScales): 'horizontal' | 'vertical' | null {
|
|
58
|
-
if (
|
|
59
|
-
scales.y?.type === 'band' &&
|
|
60
|
-
typeof (scales.y.scale as ScaleBand<string>).bandwidth === 'function'
|
|
61
|
-
) {
|
|
62
|
-
return 'horizontal';
|
|
63
|
-
}
|
|
64
|
-
if (
|
|
65
|
-
scales.x?.type === 'band' &&
|
|
66
|
-
typeof (scales.x.scale as ScaleBand<string>).bandwidth === 'function'
|
|
67
|
-
) {
|
|
68
|
-
return 'vertical';
|
|
69
|
-
}
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
// Public API
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Compute range marks from a normalized chart spec.
|
|
79
|
-
*
|
|
80
|
-
* Emission order per data row is stable and load-bearing for the label pass:
|
|
81
|
-
* connector/shaft/bar first (so dots layer on top), then the start dot, then
|
|
82
|
-
* the end dot (dumbbell style only).
|
|
83
|
-
*/
|
|
84
|
-
export function computeRangeMarks(
|
|
85
|
-
spec: NormalizedChartSpec,
|
|
86
|
-
scales: ResolvedScales,
|
|
87
|
-
_chartArea: Rect,
|
|
88
|
-
_strategy: LayoutStrategy,
|
|
89
|
-
theme: ResolvedTheme,
|
|
90
|
-
): Mark[] {
|
|
91
|
-
const encoding = spec.encoding as Encoding;
|
|
92
|
-
const orientation = resolveRangeOrientation(scales);
|
|
93
|
-
if (!orientation || !scales.x || !scales.y) return [];
|
|
94
|
-
|
|
95
|
-
const horizontal = orientation === 'horizontal';
|
|
96
|
-
const catChannel = horizontal ? encoding.y : encoding.x;
|
|
97
|
-
const startChannel = horizontal ? encoding.x : encoding.y;
|
|
98
|
-
const endChannel = horizontal ? encoding.x2 : encoding.y2;
|
|
99
|
-
if (!catChannel || !startChannel || !endChannel) return [];
|
|
100
|
-
|
|
101
|
-
const bandScale = (horizontal ? scales.y.scale : scales.x.scale) as ScaleBand<string>;
|
|
102
|
-
const valueScale = (horizontal ? scales.x.scale : scales.y.scale) as ScaleLinear<number, number>;
|
|
103
|
-
const bandwidth = bandScale.bandwidth();
|
|
104
|
-
|
|
105
|
-
const style: RangeStyle = spec.markDef.style ?? 'dumbbell';
|
|
106
|
-
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
107
|
-
const colorByDirection = spec.markDef.colorByDirection === true && !colorEnc;
|
|
108
|
-
|
|
109
|
-
// Stable identity keys per category row (deduped for repeated categories).
|
|
110
|
-
const catKeys = dedupeKeys(spec.data.map((row) => serializeKeyValue(row[catChannel.field])));
|
|
111
|
-
|
|
112
|
-
const marks: Mark[] = [];
|
|
113
|
-
|
|
114
|
-
for (let i = 0; i < spec.data.length; i++) {
|
|
115
|
-
const row = spec.data[i];
|
|
116
|
-
const category = String(row[catChannel.field] ?? '');
|
|
117
|
-
const bandPos = bandScale(category);
|
|
118
|
-
if (bandPos === undefined) continue;
|
|
119
|
-
|
|
120
|
-
const startRaw = row[startChannel.field];
|
|
121
|
-
const endRaw = row[endChannel.field];
|
|
122
|
-
if (startRaw == null || endRaw == null) continue;
|
|
123
|
-
const startVal = Number(startRaw);
|
|
124
|
-
const endVal = Number(endRaw);
|
|
125
|
-
if (!Number.isFinite(startVal) || !Number.isFinite(endVal)) continue;
|
|
126
|
-
|
|
127
|
-
const center = bandPos + bandwidth / 2;
|
|
128
|
-
const pStart = valueScale(startVal);
|
|
129
|
-
const pEnd = valueScale(endVal);
|
|
130
|
-
const delta = endVal - startVal;
|
|
131
|
-
const catKey = catKeys[i];
|
|
132
|
-
|
|
133
|
-
// Accent: field color > direction color > default (markDef.fill / palette).
|
|
134
|
-
const accent = colorEnc
|
|
135
|
-
? getColor(scales, String(row[colorEnc.field] ?? ''))
|
|
136
|
-
: colorByDirection
|
|
137
|
-
? delta > 0
|
|
138
|
-
? theme.colors.positive
|
|
139
|
-
: delta < 0
|
|
140
|
-
? theme.colors.negative
|
|
141
|
-
: theme.colors.axis
|
|
142
|
-
: getColor(scales, '__default__');
|
|
143
|
-
const accentStroke = getRepresentativeColor(accent);
|
|
144
|
-
|
|
145
|
-
const spanAria: MarkAria = {
|
|
146
|
-
label: `${category}: ${startVal} to ${endVal}`,
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
// Pixel endpoints in x/y space
|
|
150
|
-
const x1 = horizontal ? pStart : center;
|
|
151
|
-
const y1 = horizontal ? center : pStart;
|
|
152
|
-
const x2 = horizontal ? pEnd : center;
|
|
153
|
-
const y2 = horizontal ? center : pEnd;
|
|
154
|
-
const spanLength = Math.abs(pEnd - pStart);
|
|
155
|
-
|
|
156
|
-
if (style === 'bar') {
|
|
157
|
-
marks.push({
|
|
158
|
-
type: 'rect',
|
|
159
|
-
x: horizontal ? Math.min(pStart, pEnd) : bandPos,
|
|
160
|
-
y: horizontal ? bandPos : Math.min(pStart, pEnd),
|
|
161
|
-
width: horizontal ? spanLength : bandwidth,
|
|
162
|
-
height: horizontal ? bandwidth : spanLength,
|
|
163
|
-
fill: accent,
|
|
164
|
-
data: row as Record<string, unknown>,
|
|
165
|
-
aria: spanAria,
|
|
166
|
-
key: catKey,
|
|
167
|
-
orient: horizontal ? 'horizontal' : 'vertical',
|
|
168
|
-
});
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (style === 'arrow') {
|
|
173
|
-
if (spanLength > 0) {
|
|
174
|
-
marks.push({
|
|
175
|
-
type: 'rule',
|
|
176
|
-
x1,
|
|
177
|
-
y1,
|
|
178
|
-
x2,
|
|
179
|
-
y2,
|
|
180
|
-
stroke: accentStroke,
|
|
181
|
-
strokeWidth: ARROW_STROKE_WIDTH,
|
|
182
|
-
data: row as Record<string, unknown>,
|
|
183
|
-
aria: spanAria,
|
|
184
|
-
key: `${catKey}|range`,
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
// Chevron arrowhead at the end value, from the shared geometry helper.
|
|
188
|
-
const head = computeArrowheadPoints(
|
|
189
|
-
x2,
|
|
190
|
-
y2,
|
|
191
|
-
x2 - x1,
|
|
192
|
-
y2 - y1,
|
|
193
|
-
ARROWHEAD_LENGTH,
|
|
194
|
-
ARROWHEAD_HALF_WIDTH,
|
|
195
|
-
);
|
|
196
|
-
const headAria: MarkAria = { label: spanAria.label, decorative: true };
|
|
197
|
-
marks.push(
|
|
198
|
-
{
|
|
199
|
-
type: 'rule',
|
|
200
|
-
x1: head.baseLeft.x,
|
|
201
|
-
y1: head.baseLeft.y,
|
|
202
|
-
x2: head.tip.x,
|
|
203
|
-
y2: head.tip.y,
|
|
204
|
-
stroke: accentStroke,
|
|
205
|
-
strokeWidth: ARROW_STROKE_WIDTH,
|
|
206
|
-
data: row as Record<string, unknown>,
|
|
207
|
-
aria: headAria,
|
|
208
|
-
key: `${catKey}|head-left`,
|
|
209
|
-
},
|
|
210
|
-
{
|
|
211
|
-
type: 'rule',
|
|
212
|
-
x1: head.baseRight.x,
|
|
213
|
-
y1: head.baseRight.y,
|
|
214
|
-
x2: head.tip.x,
|
|
215
|
-
y2: head.tip.y,
|
|
216
|
-
stroke: accentStroke,
|
|
217
|
-
strokeWidth: ARROW_STROKE_WIDTH,
|
|
218
|
-
data: row as Record<string, unknown>,
|
|
219
|
-
aria: headAria,
|
|
220
|
-
key: `${catKey}|head-right`,
|
|
221
|
-
},
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Dumbbell (default): connector + muted start dot + accent end dot.
|
|
228
|
-
if (spanLength > 0) {
|
|
229
|
-
const connectorColor = colorEnc || colorByDirection ? accentStroke : CONNECTOR_COLOR;
|
|
230
|
-
marks.push({
|
|
231
|
-
type: 'rule',
|
|
232
|
-
x1,
|
|
233
|
-
y1,
|
|
234
|
-
x2,
|
|
235
|
-
y2,
|
|
236
|
-
stroke: connectorColor,
|
|
237
|
-
strokeWidth: CONNECTOR_WIDTH,
|
|
238
|
-
data: row as Record<string, unknown>,
|
|
239
|
-
aria: spanAria,
|
|
240
|
-
key: `${catKey}|range`,
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
marks.push(
|
|
245
|
-
{
|
|
246
|
-
type: 'point',
|
|
247
|
-
cx: x1,
|
|
248
|
-
cy: y1,
|
|
249
|
-
r: DOT_RADIUS,
|
|
250
|
-
fill: theme.colors.axis,
|
|
251
|
-
stroke: '#ffffff',
|
|
252
|
-
strokeWidth: 2,
|
|
253
|
-
data: row as Record<string, unknown>,
|
|
254
|
-
aria: { label: `${category}, start: ${startVal}` },
|
|
255
|
-
key: `${catKey}|start`,
|
|
256
|
-
},
|
|
257
|
-
{
|
|
258
|
-
type: 'point',
|
|
259
|
-
cx: x2,
|
|
260
|
-
cy: y2,
|
|
261
|
-
r: DOT_RADIUS,
|
|
262
|
-
fill: accent,
|
|
263
|
-
stroke: '#ffffff',
|
|
264
|
-
strokeWidth: 2,
|
|
265
|
-
data: row as Record<string, unknown>,
|
|
266
|
-
aria: { label: `${category}, end: ${endVal}` },
|
|
267
|
-
key: `${catKey}|end`,
|
|
268
|
-
},
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
return marks;
|
|
273
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Range chart module (dumbbell / arrow / range bar).
|
|
3
|
-
*
|
|
4
|
-
* Exports the range chart renderer and computation functions.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { PointMark } from '@opendata-ai/openchart-core';
|
|
8
|
-
import { resolveFieldFormatter } from '../../format/field-format';
|
|
9
|
-
import type { ChartRenderer } from '../registry';
|
|
10
|
-
import { computeRangeMarks, resolveRangeOrientation } from './compute';
|
|
11
|
-
import { computeRangeLabels, type RangeDotPair } from './labels';
|
|
12
|
-
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// Range chart renderer
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Range chart renderer.
|
|
19
|
-
*
|
|
20
|
-
* Produces connector/shaft/bar spans plus dot pairs (dumbbell style).
|
|
21
|
-
* Both-end value labels are attached to the dumbbell dot marks; arrow and
|
|
22
|
-
* bar styles have no dot anchors and render without value labels.
|
|
23
|
-
*/
|
|
24
|
-
export const rangeRenderer: ChartRenderer = (spec, scales, chartArea, strategy, theme) => {
|
|
25
|
-
const marks = computeRangeMarks(spec, scales, chartArea, strategy, theme);
|
|
26
|
-
|
|
27
|
-
const style = spec.markDef.style ?? 'dumbbell';
|
|
28
|
-
if (style !== 'dumbbell') return marks;
|
|
29
|
-
|
|
30
|
-
// Dumbbell dots are emitted in (start, end) order per row; pair them up
|
|
31
|
-
// for the both-end label pass.
|
|
32
|
-
const dots = marks.filter((m): m is PointMark => m.type === 'point');
|
|
33
|
-
const pairs: RangeDotPair[] = [];
|
|
34
|
-
for (let i = 0; i + 1 < dots.length; i += 2) {
|
|
35
|
-
pairs.push({ start: dots[i], end: dots[i + 1] });
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const orientation = resolveRangeOrientation(scales);
|
|
39
|
-
const horizontal = orientation !== 'vertical';
|
|
40
|
-
const encoding = spec.encoding;
|
|
41
|
-
const startChannel = horizontal ? encoding.x : encoding.y;
|
|
42
|
-
const endChannel = horizontal ? encoding.x2 : encoding.y2;
|
|
43
|
-
|
|
44
|
-
const startField = startChannel && 'field' in startChannel ? startChannel.field : undefined;
|
|
45
|
-
const endField = endChannel && 'field' in endChannel ? endChannel.field : undefined;
|
|
46
|
-
const startValues = startField ? spec.data.map((r) => r[startField]) : [];
|
|
47
|
-
const endValues = endField ? spec.data.map((r) => r[endField]) : [];
|
|
48
|
-
const labelFormatter = resolveFieldFormatter({
|
|
49
|
-
surfaceFormat: spec.labels.format,
|
|
50
|
-
channelFormat: startChannel && 'format' in startChannel ? startChannel.format : undefined,
|
|
51
|
-
values: [...startValues, ...endValues],
|
|
52
|
-
});
|
|
53
|
-
const labels = computeRangeLabels(
|
|
54
|
-
pairs,
|
|
55
|
-
horizontal,
|
|
56
|
-
spec.labels.density,
|
|
57
|
-
spec.labels.prefix,
|
|
58
|
-
labelFormatter,
|
|
59
|
-
startField,
|
|
60
|
-
endField,
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
// Attach labels back onto their dots via the index carried through the
|
|
64
|
-
// label pass (pairIndex * 2 = start dot, pairIndex * 2 + 1 = end dot).
|
|
65
|
-
for (const label of labels) {
|
|
66
|
-
if (label.index === undefined) continue;
|
|
67
|
-
const pair = pairs[Math.floor(label.index / 2)];
|
|
68
|
-
if (!pair) continue;
|
|
69
|
-
const dot = label.index % 2 === 0 ? pair.start : pair.end;
|
|
70
|
-
dot.label = label;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return marks;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
// Public exports
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
|
|
80
|
-
export { computeRangeMarks, resolveRangeOrientation } from './compute';
|
|
81
|
-
export type { RangeDotPair } from './labels';
|
|
82
|
-
export { computeRangeLabels } from './labels';
|
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Range chart label computation (dumbbell style).
|
|
3
|
-
*
|
|
4
|
-
* Produces value labels at both ends of each dumbbell: outside the range span
|
|
5
|
-
* so labels never sit on the connector. Horizontal ranges label left of the
|
|
6
|
-
* lower-value dot and right of the higher-value dot; vertical ranges label
|
|
7
|
-
* below the lower dot and above the upper dot.
|
|
8
|
-
*
|
|
9
|
-
* Respects the spec's label density setting the same way dot labels do:
|
|
10
|
-
* - 'all': show every label, skip collision detection
|
|
11
|
-
* - 'auto': collision detection drops overlapping labels
|
|
12
|
-
* - 'endpoints': first and last rows only
|
|
13
|
-
* - 'none': return empty array
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type {
|
|
17
|
-
LabelCandidate,
|
|
18
|
-
LabelDensity,
|
|
19
|
-
NumberFormatter,
|
|
20
|
-
PointMark,
|
|
21
|
-
ResolvedLabel,
|
|
22
|
-
} from '@opendata-ai/openchart-core';
|
|
23
|
-
import {
|
|
24
|
-
estimateTextWidth,
|
|
25
|
-
getRepresentativeColor,
|
|
26
|
-
resolveCollisions,
|
|
27
|
-
} from '@opendata-ai/openchart-core';
|
|
28
|
-
import { filterByDensity } from '../_shared/density-filter';
|
|
29
|
-
import { formatLabelValue } from '../_shared/format-label-value';
|
|
30
|
-
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
// Constants
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
|
|
35
|
-
const LABEL_FONT_SIZE = 11;
|
|
36
|
-
const LABEL_FONT_WEIGHT = 600;
|
|
37
|
-
const LABEL_OFFSET = 10;
|
|
38
|
-
|
|
39
|
-
/** A start/end dot pair for one data row. */
|
|
40
|
-
export interface RangeDotPair {
|
|
41
|
-
start: PointMark;
|
|
42
|
-
end: PointMark;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
// Helpers
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
|
|
49
|
-
/** Format one endpoint value using the spec's label format/prefix. */
|
|
50
|
-
function formatEndpoint(
|
|
51
|
-
value: unknown,
|
|
52
|
-
formatter: ((n: number) => string) | null,
|
|
53
|
-
prefix: string | undefined,
|
|
54
|
-
): string | null {
|
|
55
|
-
const num = Number(value);
|
|
56
|
-
if (!Number.isFinite(num)) return null;
|
|
57
|
-
const text = formatter ? formatter(num) : formatLabelValue(num);
|
|
58
|
-
return prefix ? prefix + text : text;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Build one label candidate placed outside the range span. */
|
|
62
|
-
function buildCandidate(
|
|
63
|
-
mark: PointMark,
|
|
64
|
-
text: string,
|
|
65
|
-
horizontal: boolean,
|
|
66
|
-
outerSide: 'min' | 'max',
|
|
67
|
-
index: number,
|
|
68
|
-
): LabelCandidate {
|
|
69
|
-
const textWidth = estimateTextWidth(text, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
|
|
70
|
-
const textHeight = LABEL_FONT_SIZE * 1.2;
|
|
71
|
-
|
|
72
|
-
let anchorX: number;
|
|
73
|
-
let anchorY: number;
|
|
74
|
-
if (horizontal) {
|
|
75
|
-
// 'min' dot: label to the left; 'max' dot: label to the right.
|
|
76
|
-
anchorX =
|
|
77
|
-
outerSide === 'min'
|
|
78
|
-
? mark.cx - mark.r - LABEL_OFFSET - textWidth
|
|
79
|
-
: mark.cx + mark.r + LABEL_OFFSET;
|
|
80
|
-
anchorY = mark.cy - textHeight / 2;
|
|
81
|
-
} else {
|
|
82
|
-
// SVG y grows downward: the 'min' value dot sits lower, label below it.
|
|
83
|
-
anchorX = mark.cx - textWidth / 2;
|
|
84
|
-
anchorY =
|
|
85
|
-
outerSide === 'min'
|
|
86
|
-
? mark.cy + mark.r + LABEL_OFFSET - textHeight / 2
|
|
87
|
-
: mark.cy - mark.r - LABEL_OFFSET - textHeight / 2;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
text,
|
|
92
|
-
anchorX,
|
|
93
|
-
anchorY,
|
|
94
|
-
width: textWidth,
|
|
95
|
-
height: textHeight,
|
|
96
|
-
priority: 'data',
|
|
97
|
-
index,
|
|
98
|
-
style: {
|
|
99
|
-
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
100
|
-
fontSize: LABEL_FONT_SIZE,
|
|
101
|
-
fontWeight: LABEL_FONT_WEIGHT,
|
|
102
|
-
fill: getRepresentativeColor(mark.fill),
|
|
103
|
-
lineHeight: 1.2,
|
|
104
|
-
textAnchor: 'start',
|
|
105
|
-
dominantBaseline: 'central',
|
|
106
|
-
fontVariant: 'tabular-nums',
|
|
107
|
-
},
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// ---------------------------------------------------------------------------
|
|
112
|
-
// Public API
|
|
113
|
-
// ---------------------------------------------------------------------------
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Compute both-end value labels for dumbbell range marks.
|
|
117
|
-
*
|
|
118
|
-
* Each returned label carries `index` = pairIndex * 2 (start dot) or
|
|
119
|
-
* pairIndex * 2 + 1 (end dot) so the caller can attach labels back onto the
|
|
120
|
-
* right dot marks after density filtering. Labels dropped by collision
|
|
121
|
-
* resolution come back with `visible: false`.
|
|
122
|
-
*/
|
|
123
|
-
export function computeRangeLabels(
|
|
124
|
-
pairs: RangeDotPair[],
|
|
125
|
-
horizontal: boolean,
|
|
126
|
-
density: LabelDensity = 'auto',
|
|
127
|
-
labelPrefix?: string,
|
|
128
|
-
labelFormatter?: NumberFormatter | null,
|
|
129
|
-
startField?: string,
|
|
130
|
-
endField?: string,
|
|
131
|
-
): ResolvedLabel[] {
|
|
132
|
-
if (density === 'none' || !startField || !endField) return [];
|
|
133
|
-
|
|
134
|
-
const indexed = pairs.map((pair, pairIndex) => ({ pair, pairIndex }));
|
|
135
|
-
const targetPairs = filterByDensity(indexed, density);
|
|
136
|
-
const formatter = labelFormatter ?? null;
|
|
137
|
-
const candidates: LabelCandidate[] = [];
|
|
138
|
-
|
|
139
|
-
for (const { pair, pairIndex } of targetPairs) {
|
|
140
|
-
const startText = formatEndpoint(pair.start.data[startField], formatter, labelPrefix);
|
|
141
|
-
const endText = formatEndpoint(pair.end.data[endField], formatter, labelPrefix);
|
|
142
|
-
if (startText == null || endText == null) continue;
|
|
143
|
-
|
|
144
|
-
// Place each label on the outer side of its dot. For horizontal ranges
|
|
145
|
-
// the lower pixel position is the left dot; vertical is inverted (SVG y
|
|
146
|
-
// grows downward), so the larger cy is the lower-value dot.
|
|
147
|
-
const startIsMin = horizontal ? pair.start.cx <= pair.end.cx : pair.start.cy >= pair.end.cy;
|
|
148
|
-
candidates.push(
|
|
149
|
-
buildCandidate(pair.start, startText, horizontal, startIsMin ? 'min' : 'max', pairIndex * 2),
|
|
150
|
-
buildCandidate(pair.end, endText, horizontal, startIsMin ? 'max' : 'min', pairIndex * 2 + 1),
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (candidates.length === 0) return [];
|
|
155
|
-
|
|
156
|
-
// 'all': skip collision detection, mark everything visible
|
|
157
|
-
if (density === 'all') {
|
|
158
|
-
return candidates.map((c) => ({
|
|
159
|
-
text: c.text,
|
|
160
|
-
x: c.anchorX,
|
|
161
|
-
y: c.anchorY,
|
|
162
|
-
style: c.style,
|
|
163
|
-
visible: true,
|
|
164
|
-
index: c.index,
|
|
165
|
-
}));
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return resolveCollisions(candidates);
|
|
169
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import type { RectMark } from '@opendata-ai/openchart-core';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import { compileChart } from '../../../compile';
|
|
4
|
-
|
|
5
|
-
/** A 3x2 day/hour grid with a quantitative value: the canonical heatmap shape. */
|
|
6
|
-
const grid = [
|
|
7
|
-
{ day: 'Mon', hour: '9am', v: 3 },
|
|
8
|
-
{ day: 'Mon', hour: '10am', v: 7 },
|
|
9
|
-
{ day: 'Tue', hour: '9am', v: 5 },
|
|
10
|
-
{ day: 'Tue', hour: '10am', v: 9 },
|
|
11
|
-
{ day: 'Wed', hour: '9am', v: 1 },
|
|
12
|
-
{ day: 'Wed', hour: '10am', v: 4 },
|
|
13
|
-
];
|
|
14
|
-
|
|
15
|
-
function heatmap(data = grid) {
|
|
16
|
-
return {
|
|
17
|
-
mark: { type: 'rect' as const },
|
|
18
|
-
data: [...data],
|
|
19
|
-
encoding: {
|
|
20
|
-
x: { field: 'day', type: 'nominal' as const },
|
|
21
|
-
y: { field: 'hour', type: 'nominal' as const },
|
|
22
|
-
color: { field: 'v', type: 'quantitative' as const },
|
|
23
|
-
},
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function cellsOf(spec: Parameters<typeof compileChart>[0]): RectMark[] {
|
|
28
|
-
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
29
|
-
return layout.marks.filter((m): m is RectMark => m.type === 'rect');
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe('rect mark (heatmap)', () => {
|
|
33
|
-
/**
|
|
34
|
-
* Regression: `rect` used to alias `columnRenderer`, which requires a *linear*
|
|
35
|
-
* y-scale (it anchors cells to `yScale(0)`). A heatmap bands both axes, so the
|
|
36
|
-
* renderer hit its bandwidth guard and returned []. `mark: 'rect'` rendered a
|
|
37
|
-
* blank chart with no error.
|
|
38
|
-
*/
|
|
39
|
-
it('emits one cell per row instead of a blank chart', () => {
|
|
40
|
-
const cells = cellsOf(heatmap());
|
|
41
|
-
expect(cells).toHaveLength(grid.length);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('gives every cell a positive width and height', () => {
|
|
45
|
-
for (const cell of cellsOf(heatmap())) {
|
|
46
|
-
expect(cell.width).toBeGreaterThan(0);
|
|
47
|
-
expect(cell.height).toBeGreaterThan(0);
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('tiles cells into a grid: one distinct x per day, one distinct y per hour', () => {
|
|
52
|
-
const cells = cellsOf(heatmap());
|
|
53
|
-
expect(new Set(cells.map((c) => c.x)).size).toBe(3); // Mon/Tue/Wed
|
|
54
|
-
expect(new Set(cells.map((c) => c.y)).size).toBe(2); // 9am/10am
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('colors cells from the quantitative color scale, not a flat default', () => {
|
|
58
|
-
const fills = new Set(cellsOf(heatmap()).map((c) => String(c.fill)));
|
|
59
|
-
// Six distinct values -> a sequential ramp must produce more than one color.
|
|
60
|
-
expect(fills.size).toBeGreaterThan(1);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('keys each cell on its x/y pair so updates match by cell', () => {
|
|
64
|
-
const keys = cellsOf(heatmap()).map((c) => c.key);
|
|
65
|
-
expect(new Set(keys).size).toBe(grid.length);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('skips rows whose color value is not finite', () => {
|
|
69
|
-
const cells = cellsOf(
|
|
70
|
-
heatmap([...grid, { day: 'Thu', hour: '9am', v: Number.NaN as unknown as number }]),
|
|
71
|
-
);
|
|
72
|
-
expect(cells).toHaveLength(grid.length);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Cells tile. The band default (0.35) is a *bar* value -- bars need air
|
|
77
|
-
* between them to read as separate quantities -- and it left the heatmap
|
|
78
|
-
* looking like a scatter of floating rectangles. A cell gap should be a
|
|
79
|
-
* hairline gutter, so each cell claims nearly its whole step.
|
|
80
|
-
*/
|
|
81
|
-
it('tiles cells with only a hairline gutter, not a bar-sized gap', () => {
|
|
82
|
-
const cells = cellsOf(heatmap());
|
|
83
|
-
const xs = [...new Set(cells.map((c) => c.x))].sort((a, b) => a - b);
|
|
84
|
-
const step = xs[1] - xs[0];
|
|
85
|
-
const cellWidth = cells[0].width;
|
|
86
|
-
// Cell fills at least 90% of its step (0.35 padding would give ~65%).
|
|
87
|
-
expect(cellWidth / step).toBeGreaterThan(0.9);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('draws no gridlines: the cells are the grid', () => {
|
|
91
|
-
const layout = compileChart(heatmap(), { width: 600, height: 400 });
|
|
92
|
-
expect(layout.axes.y?.gridlines ?? []).toHaveLength(0);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* A quantitative axis has no bandwidth, so a cell has no width. Emitting
|
|
97
|
-
* zero-area marks would render an invisible chart -- the exact failure this
|
|
98
|
-
* renderer exists to end. Bail instead of pretending.
|
|
99
|
-
*/
|
|
100
|
-
it('emits nothing when an axis is quantitative (no bandwidth to size a cell)', () => {
|
|
101
|
-
const cells = cellsOf({
|
|
102
|
-
mark: { type: 'rect' as const },
|
|
103
|
-
data: [...grid],
|
|
104
|
-
encoding: {
|
|
105
|
-
x: { field: 'v', type: 'quantitative' as const },
|
|
106
|
-
y: { field: 'hour', type: 'nominal' as const },
|
|
107
|
-
color: { field: 'v', type: 'quantitative' as const },
|
|
108
|
-
},
|
|
109
|
-
});
|
|
110
|
-
expect(cells).toHaveLength(0);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('labels each cell with its x, y, and value for screen readers', () => {
|
|
114
|
-
const cells = cellsOf(heatmap());
|
|
115
|
-
const mon9 = cells.find((c) => c.data?.day === 'Mon' && c.data?.hour === '9am');
|
|
116
|
-
expect(mon9?.aria.label).toContain('Mon');
|
|
117
|
-
expect(mon9?.aria.label).toContain('9am');
|
|
118
|
-
expect(mon9?.aria.label).toContain('3');
|
|
119
|
-
});
|
|
120
|
-
});
|