@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +123 -14
- package/dist/index.js +16166 -10027
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
- package/src/__tests__/annotation-behavior.test.ts +721 -0
- package/src/__tests__/axes.test.ts +50 -3
- package/src/__tests__/compile-chart.test.ts +30 -0
- package/src/__tests__/compile-footnotes.test.ts +277 -0
- package/src/__tests__/compile-layer.test.ts +120 -1
- package/src/__tests__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +69 -4
- package/src/__tests__/ranking-recipes.test.ts +260 -0
- package/src/__tests__/scales.test.ts +9 -3
- package/src/__tests__/series-search.test.ts +92 -0
- package/src/__tests__/spec-sugar.test.ts +801 -0
- package/src/__tests__/you-draw-it.test.ts +160 -0
- package/src/annotations/__tests__/compute.test.ts +207 -92
- package/src/annotations/__tests__/geometry.test.ts +66 -0
- package/src/annotations/__tests__/resolve-text.test.ts +527 -27
- package/src/annotations/__tests__/rich-text.test.ts +91 -0
- package/src/annotations/__tests__/thinning.test.ts +148 -0
- package/src/annotations/collisions.ts +130 -84
- package/src/annotations/compute.ts +83 -85
- package/src/annotations/constants.ts +116 -6
- package/src/annotations/geometry.ts +222 -77
- package/src/annotations/move.ts +135 -0
- package/src/annotations/placement.ts +62 -14
- package/src/annotations/resolve-range.ts +2 -0
- package/src/annotations/resolve-refline.ts +2 -0
- package/src/annotations/resolve-text.ts +312 -111
- package/src/annotations/rich-text.ts +65 -0
- package/src/annotations/thinning.ts +84 -10
- package/src/barlist/compile-barlist.ts +16 -6
- package/src/charts/_shared/format-label-value.ts +9 -8
- package/src/charts/bar/__tests__/compute.test.ts +57 -10
- package/src/charts/bar/__tests__/labels.test.ts +5 -4
- package/src/charts/bar/compute.ts +2 -6
- package/src/charts/bar/index.ts +8 -1
- package/src/charts/bar/labels.ts +3 -3
- package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
- package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
- package/src/charts/beeswarm/compute.ts +203 -0
- package/src/charts/beeswarm/dodge.ts +155 -0
- package/src/charts/beeswarm/index.ts +32 -0
- package/src/charts/builtin.ts +12 -1
- package/src/charts/calendar/__tests__/compute.test.ts +417 -0
- package/src/charts/calendar/compute.ts +319 -0
- package/src/charts/calendar/index.ts +32 -0
- package/src/charts/column/__tests__/compute.test.ts +21 -7
- package/src/charts/column/__tests__/labels.test.ts +76 -1
- package/src/charts/column/compute.ts +2 -6
- package/src/charts/column/index.ts +10 -2
- package/src/charts/column/labels.ts +95 -21
- package/src/charts/dot/index.ts +8 -1
- package/src/charts/dot/labels.ts +3 -3
- package/src/charts/line/__tests__/compute.test.ts +15 -19
- package/src/charts/line/area.ts +8 -23
- package/src/charts/line/compute.ts +32 -2
- package/src/charts/parliament/__tests__/compute.test.ts +411 -0
- package/src/charts/parliament/compute.ts +313 -0
- package/src/charts/parliament/index.ts +32 -0
- package/src/charts/pie/__tests__/compute.test.ts +103 -0
- package/src/charts/pie/compute.ts +63 -8
- package/src/charts/pie/labels.ts +1 -0
- package/src/charts/range/__tests__/compute.test.ts +486 -0
- package/src/charts/range/compute.ts +273 -0
- package/src/charts/range/index.ts +82 -0
- package/src/charts/range/labels.ts +169 -0
- package/src/charts/rect/__tests__/compute.test.ts +120 -0
- package/src/charts/rect/index.ts +127 -0
- package/src/charts/scatter/compute.ts +7 -21
- package/src/charts/text/__tests__/compute.test.ts +307 -0
- package/src/charts/text/index.ts +44 -7
- package/src/charts/tick/__tests__/compute.test.ts +75 -0
- package/src/charts/tick/index.ts +7 -4
- package/src/charts/utils.ts +5 -3
- package/src/charts/waffle/__tests__/compute.test.ts +305 -0
- package/src/charts/waffle/compute.ts +197 -0
- package/src/charts/waffle/index.ts +31 -0
- package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
- package/src/compile/__tests__/data-clip.test.ts +68 -0
- package/src/compile/__tests__/fill-patterns.test.ts +154 -0
- package/src/compile/__tests__/size-scale.test.ts +108 -0
- package/src/compile/color-scale-range.ts +54 -33
- package/src/compile/data-clip.ts +21 -6
- package/src/compile/fill-patterns.ts +74 -0
- package/src/compile/layer.ts +117 -21
- package/src/compile/size-scale.ts +139 -0
- package/src/compile/spec-sugar.ts +758 -0
- package/src/compile/you-draw-it.ts +123 -0
- package/src/compile.ts +310 -103
- package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
- package/src/compiler/__tests__/keys.test.ts +3 -9
- package/src/compiler/__tests__/normalize.test.ts +65 -1
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
- package/src/compiler/__tests__/validate.test.ts +156 -0
- package/src/compiler/__tests__/vl-parity.test.ts +68 -0
- package/src/compiler/a11y-warnings.ts +110 -0
- package/src/compiler/keys.ts +5 -4
- package/src/compiler/normalize.ts +150 -7
- package/src/compiler/types.ts +11 -0
- package/src/compiler/validate.ts +664 -18
- package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
- package/src/endpoint-labels/compute.ts +40 -12
- package/src/endpoint-labels/format.ts +45 -15
- package/src/endpoint-labels/predict.ts +50 -36
- package/src/format/__tests__/field-format.test.ts +164 -0
- package/src/format/field-format.ts +27 -0
- package/src/geo/__tests__/compile-map.test.ts +621 -0
- package/src/geo/compile-map.ts +856 -0
- package/src/geo/join.ts +115 -0
- package/src/geo/projections.ts +45 -0
- package/src/geo/types.ts +24 -0
- package/src/graphs/__tests__/compile-graph.test.ts +25 -0
- package/src/graphs/compile-graph.ts +27 -18
- package/src/index.ts +20 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +35 -6
- package/src/layout/dimensions.ts +139 -83
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +83 -33
- package/src/layout/scales.ts +132 -15
- package/src/legend/__tests__/continuous.test.ts +318 -0
- package/src/legend/__tests__/size.test.ts +348 -0
- package/src/legend/__tests__/suppression.test.ts +17 -1
- package/src/legend/compute.ts +119 -13
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- package/src/sankey/compile-sankey.ts +31 -19
- package/src/tables/format-cells.ts +16 -7
- package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
- package/src/tilemap/compile-tilemap.ts +24 -7
- package/src/tooltips/__tests__/compute.test.ts +2 -2
- package/src/tooltips/compute.ts +422 -41
- package/src/transforms/__tests__/conditional.test.ts +18 -0
- package/src/transforms/aggregate.ts +2 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rect mark renderer (heatmap cells).
|
|
3
|
+
*
|
|
4
|
+
* The Vega-Lite `rect` mark: one cell per row, positioned by x and y, colored
|
|
5
|
+
* by the `color` channel. The canonical use is a two-way heatmap (nominal x
|
|
6
|
+
* nominal, quantitative color), which is what this renderer is built for.
|
|
7
|
+
*
|
|
8
|
+
* `rect` used to alias `columnRenderer`, which hard-requires a band x-scale and
|
|
9
|
+
* a *linear* y-scale (it anchors every cell to `yScale(0)`). A heatmap has a
|
|
10
|
+
* band scale on both axes, so `columnRenderer` bailed at its bandwidth guard and
|
|
11
|
+
* emitted nothing: `mark: 'rect'` rendered a blank chart, with no error.
|
|
12
|
+
*
|
|
13
|
+
* Not to be confused with `RectMark`, the layout primitive that columns, waffle
|
|
14
|
+
* cells, calendar cells, and dot stems all emit. That was never broken; only the
|
|
15
|
+
* spec-level `mark: 'rect'` was.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Encoding, Mark, MarkAria, Rect, RectMark } from '@opendata-ai/openchart-core';
|
|
19
|
+
import { formatNumber } from '@opendata-ai/openchart-core';
|
|
20
|
+
import type { ScaleBand } from 'd3-scale';
|
|
21
|
+
|
|
22
|
+
import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
|
|
23
|
+
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
24
|
+
import type { ResolvedScales } from '../../layout/scales';
|
|
25
|
+
import type { ChartRenderer } from '../registry';
|
|
26
|
+
import { getColor, getSequentialColor, scaleValue } from '../utils';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Cell extent along one axis. `resolveScale` gives `rect` a band scale on both
|
|
30
|
+
* axes (see the `chartType === 'rect'` branch in layout/scales.ts), so the
|
|
31
|
+
* bandwidth *is* the cell size. A non-band scale means a quantitative axis,
|
|
32
|
+
* which would need binning to have a cell width at all: return 0 so the caller
|
|
33
|
+
* can bail rather than emit degenerate zero-area cells.
|
|
34
|
+
*/
|
|
35
|
+
function bandwidthOf(scale: ResolvedScales['x']): number {
|
|
36
|
+
const band = scale?.scale as Partial<ScaleBand<string>> | undefined;
|
|
37
|
+
return typeof band?.bandwidth === 'function' ? band.bandwidth() : 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compute rect (heatmap cell) marks from a normalized spec and resolved scales.
|
|
42
|
+
*
|
|
43
|
+
* Both axes are band scales, so a cell's x/y is the scaled value directly (a
|
|
44
|
+
* band places the cell's leading corner there) and its size is the bandwidth.
|
|
45
|
+
*/
|
|
46
|
+
export function computeRectMarks(
|
|
47
|
+
spec: NormalizedChartSpec,
|
|
48
|
+
scales: ResolvedScales,
|
|
49
|
+
_chartArea: Rect,
|
|
50
|
+
): RectMark[] {
|
|
51
|
+
const encoding = spec.encoding as Encoding;
|
|
52
|
+
const xChannel = encoding.x;
|
|
53
|
+
const yChannel = encoding.y;
|
|
54
|
+
|
|
55
|
+
if (!xChannel || !yChannel || !scales.x || !scales.y) return [];
|
|
56
|
+
|
|
57
|
+
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
58
|
+
const isSequential = colorEnc?.type === 'quantitative';
|
|
59
|
+
|
|
60
|
+
const cellWidth = bandwidthOf(scales.x);
|
|
61
|
+
const cellHeight = bandwidthOf(scales.y);
|
|
62
|
+
// A quantitative axis yields no bandwidth. Cells would be zero-area and
|
|
63
|
+
// invisible, so emit nothing rather than pretend: `rect` wants two
|
|
64
|
+
// categorical axes (bin the field first to heatmap a continuous one).
|
|
65
|
+
if (cellWidth <= 0 || cellHeight <= 0) return [];
|
|
66
|
+
|
|
67
|
+
const marks: RectMark[] = [];
|
|
68
|
+
|
|
69
|
+
for (const row of spec.data) {
|
|
70
|
+
const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
|
|
71
|
+
const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
|
|
72
|
+
if (xVal == null || yVal == null) continue;
|
|
73
|
+
|
|
74
|
+
// A quantitative color channel drives a sequential/binned ramp (the heatmap
|
|
75
|
+
// case); a nominal one picks a categorical swatch.
|
|
76
|
+
let fill: RectMark['fill'];
|
|
77
|
+
if (colorEnc && isSequential) {
|
|
78
|
+
const value = Number(row[colorEnc.field]);
|
|
79
|
+
if (!Number.isFinite(value)) continue;
|
|
80
|
+
fill = getSequentialColor(scales, value);
|
|
81
|
+
} else if (colorEnc) {
|
|
82
|
+
fill = getColor(scales, String(row[colorEnc.field] ?? '__default__'));
|
|
83
|
+
} else {
|
|
84
|
+
fill = getColor(scales, '__default__');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const colorValue = colorEnc ? row[colorEnc.field] : undefined;
|
|
88
|
+
const colorLabel =
|
|
89
|
+
colorEnc && colorValue != null
|
|
90
|
+
? `, ${colorEnc.field}: ${
|
|
91
|
+
typeof colorValue === 'number' ? formatNumber(colorValue) : String(colorValue)
|
|
92
|
+
}`
|
|
93
|
+
: '';
|
|
94
|
+
const aria: MarkAria = {
|
|
95
|
+
label: `${row[xChannel.field]}, ${row[yChannel.field]}${colorLabel}`,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
marks.push({
|
|
99
|
+
type: 'rect',
|
|
100
|
+
x: xVal,
|
|
101
|
+
y: yVal,
|
|
102
|
+
width: cellWidth,
|
|
103
|
+
height: cellHeight,
|
|
104
|
+
fill,
|
|
105
|
+
data: row as Record<string, unknown>,
|
|
106
|
+
aria,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Key on the cell's coordinates: the x/y pair is what identifies a cell across
|
|
111
|
+
// a data update, which is what the transition driver matches on.
|
|
112
|
+
const rawKeys = marks.map(
|
|
113
|
+
(m) =>
|
|
114
|
+
`${serializeKeyValue(m.data[xChannel.field])}|${serializeKeyValue(m.data[yChannel.field])}`,
|
|
115
|
+
);
|
|
116
|
+
const keys = dedupeKeys(rawKeys);
|
|
117
|
+
for (let i = 0; i < marks.length; i++) {
|
|
118
|
+
marks[i].key = keys[i];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return marks;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Rect (heatmap) chart renderer. */
|
|
125
|
+
export const rectRenderer: ChartRenderer = (spec, scales, chartArea, _strategy, _theme) => {
|
|
126
|
+
return computeRectMarks(spec, scales, chartArea) as Mark[];
|
|
127
|
+
};
|
|
@@ -16,10 +16,8 @@ import type {
|
|
|
16
16
|
PointMark,
|
|
17
17
|
Rect,
|
|
18
18
|
} from '@opendata-ai/openchart-core';
|
|
19
|
-
import { max, min } from 'd3-array';
|
|
20
19
|
import type { ScaleBand, ScaleLinear, ScalePoint, ScaleTime } from 'd3-scale';
|
|
21
|
-
import {
|
|
22
|
-
|
|
20
|
+
import { buildSizeScale, SIZE_SCALE_DEFAULTS } from '../../compile/size-scale';
|
|
23
21
|
import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
|
|
24
22
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
25
23
|
import type { ResolvedScales } from '../../layout/scales';
|
|
@@ -30,8 +28,6 @@ import { getColor, getSequentialColor } from '../utils';
|
|
|
30
28
|
// ---------------------------------------------------------------------------
|
|
31
29
|
|
|
32
30
|
const DEFAULT_POINT_RADIUS = 5;
|
|
33
|
-
const MIN_BUBBLE_RADIUS = 3;
|
|
34
|
-
const MAX_BUBBLE_RADIUS = 30;
|
|
35
31
|
|
|
36
32
|
// ---------------------------------------------------------------------------
|
|
37
33
|
// Helpers
|
|
@@ -117,22 +113,12 @@ export function computeScatterMarks(
|
|
|
117
113
|
const sizeEnc = encoding.size && 'field' in encoding.size ? encoding.size : undefined;
|
|
118
114
|
const sizeField = sizeEnc?.field;
|
|
119
115
|
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const sizeValues = spec.data.map((d) => Number(d[sizeField])).filter((v) => Number.isFinite(v));
|
|
127
|
-
|
|
128
|
-
const explicitDomain = sizeEnc?.scale?.domain as [number, number] | undefined;
|
|
129
|
-
const [sizeMin, sizeMax] = explicitDomain ?? [min(sizeValues) ?? 0, max(sizeValues) ?? 1];
|
|
130
|
-
|
|
131
|
-
const explicitRange = sizeEnc?.scale?.range as [number, number] | undefined;
|
|
132
|
-
const [radiusMin, radiusMax] = explicitRange ?? [MIN_BUBBLE_RADIUS, MAX_BUBBLE_RADIUS];
|
|
133
|
-
|
|
134
|
-
sizeScale = scaleSqrt().domain([sizeMin, sizeMax]).range([radiusMin, radiusMax]).clamp(true);
|
|
135
|
-
}
|
|
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;
|
|
136
122
|
|
|
137
123
|
const keyEnc = encoding.key && 'field' in encoding.key ? encoding.key : undefined;
|
|
138
124
|
const keyField = keyEnc?.field;
|
|
@@ -0,0 +1,307 @@
|
|
|
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
|
+
});
|
package/src/charts/text/index.ts
CHANGED
|
@@ -8,13 +8,28 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Encoding, Mark, MarkAria, TextMarkLayout } from '@opendata-ai/openchart-core';
|
|
10
10
|
import { getRepresentativeColor } from '@opendata-ai/openchart-core';
|
|
11
|
-
|
|
11
|
+
import { buildSizeScale, SIZE_SCALE_DEFAULTS } from '../../compile/size-scale';
|
|
12
12
|
import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
|
|
13
13
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
14
14
|
import type { ResolvedScales } from '../../layout/scales';
|
|
15
15
|
import type { ChartRenderer } from '../registry';
|
|
16
16
|
import { getColor, scaleValue } from '../utils';
|
|
17
17
|
|
|
18
|
+
/** Font size for text marks with no `size` encoding and no `mark.fontSize`. */
|
|
19
|
+
const DEFAULT_FONT_SIZE = 12;
|
|
20
|
+
|
|
21
|
+
const ALIGN_TO_ANCHOR = {
|
|
22
|
+
left: 'start',
|
|
23
|
+
center: 'middle',
|
|
24
|
+
right: 'end',
|
|
25
|
+
} as const satisfies Record<string, TextMarkLayout['textAnchor']>;
|
|
26
|
+
|
|
27
|
+
const BASELINE_TO_DOMINANT = {
|
|
28
|
+
top: 'hanging',
|
|
29
|
+
middle: 'central',
|
|
30
|
+
bottom: 'text-after-edge',
|
|
31
|
+
} as const satisfies Record<string, NonNullable<TextMarkLayout['dominantBaseline']>>;
|
|
32
|
+
|
|
18
33
|
/**
|
|
19
34
|
* Compute text marks from spec data and resolved scales.
|
|
20
35
|
*/
|
|
@@ -34,6 +49,22 @@ export function computeTextMarks(
|
|
|
34
49
|
const colorField = colorEncoding?.field;
|
|
35
50
|
const sizeEncoding = encoding.size && 'field' in encoding.size ? encoding.size : undefined;
|
|
36
51
|
|
|
52
|
+
const markDef = spec.markDef ?? {};
|
|
53
|
+
const dx = markDef.dx ?? 0;
|
|
54
|
+
const dy = markDef.dy ?? 0;
|
|
55
|
+
const isOffset = dx !== 0 || dy !== 0;
|
|
56
|
+
const textAnchor = ALIGN_TO_ANCHOR[markDef.align ?? 'center'];
|
|
57
|
+
const dominantBaseline = BASELINE_TO_DOMINANT[markDef.baseline ?? 'middle'];
|
|
58
|
+
|
|
59
|
+
// Map the `size` field onto a font-size range. Linear, not sqrt: sqrt is right
|
|
60
|
+
// for bubbles because perceived magnitude tracks *area* (∝ r²), but a glyph
|
|
61
|
+
// reads by its height, which is linear in font size.
|
|
62
|
+
//
|
|
63
|
+
// Unlike scatter/beeswarm, this channel is *not* keyed: the library ships no
|
|
64
|
+
// font-size legend, and no publication uses one. Treat it as emphasis, not as
|
|
65
|
+
// a channel a reader is expected to decode back into a value.
|
|
66
|
+
const fontSizeScale = buildSizeScale(sizeEncoding, spec.data, SIZE_SCALE_DEFAULTS.text)?.scale;
|
|
67
|
+
|
|
37
68
|
for (const row of spec.data) {
|
|
38
69
|
// Resolve x position (center of chart if no x encoding)
|
|
39
70
|
let x = 0;
|
|
@@ -60,22 +91,28 @@ export function computeTextMarks(
|
|
|
60
91
|
: getColor(scales, '__default__'),
|
|
61
92
|
);
|
|
62
93
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
94
|
+
const fallbackFontSize = markDef.fontSize ?? DEFAULT_FONT_SIZE;
|
|
95
|
+
const sizeValue = sizeEncoding ? Number(row[sizeEncoding.field]) : Number.NaN;
|
|
96
|
+
const fontSize =
|
|
97
|
+
fontSizeScale && Number.isFinite(sizeValue) ? fontSizeScale(sizeValue) : fallbackFontSize;
|
|
66
98
|
|
|
67
99
|
const aria: MarkAria = {
|
|
68
100
|
label: text,
|
|
69
101
|
};
|
|
70
102
|
|
|
103
|
+
// Bake the offset into x/y rather than emitting SVG dx/dy: the transition
|
|
104
|
+
// driver tweens the x/y attributes and rotation pivots on them, so a
|
|
105
|
+
// separate offset attribute would leave both reading the un-offset anchor.
|
|
71
106
|
marks.push({
|
|
72
107
|
type: 'textMark',
|
|
73
|
-
x,
|
|
74
|
-
y,
|
|
108
|
+
x: x + dx,
|
|
109
|
+
y: y + dy,
|
|
110
|
+
...(isOffset ? { anchorX: x, anchorY: y } : {}),
|
|
75
111
|
text,
|
|
76
112
|
fill: color,
|
|
77
113
|
fontSize,
|
|
78
|
-
textAnchor
|
|
114
|
+
textAnchor,
|
|
115
|
+
dominantBaseline,
|
|
79
116
|
angle:
|
|
80
117
|
encoding.angle && 'field' in encoding.angle
|
|
81
118
|
? Number(row[encoding.angle.field]) || 0
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { TickMarkLayout } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { compileChart } from '../../../compile';
|
|
4
|
+
|
|
5
|
+
const OPTIONS = { width: 800, height: 500 };
|
|
6
|
+
|
|
7
|
+
const ROWS = [
|
|
8
|
+
{ group: '20-29', hours: 3.1 },
|
|
9
|
+
{ group: '20-29', hours: 4.7 },
|
|
10
|
+
{ group: '60+', hours: 4.1 },
|
|
11
|
+
{ group: '60+', hours: 7.0 },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function tickMarks(layout: ReturnType<typeof compileChart>): TickMarkLayout[] {
|
|
15
|
+
return layout.marks.filter((m): m is TickMarkLayout => m.type === 'tick');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('tick marks', () => {
|
|
19
|
+
it('draws vertical strokes when the value axis is x', () => {
|
|
20
|
+
const layout = compileChart(
|
|
21
|
+
{
|
|
22
|
+
mark: 'tick',
|
|
23
|
+
data: ROWS,
|
|
24
|
+
encoding: {
|
|
25
|
+
x: { field: 'hours', type: 'quantitative' },
|
|
26
|
+
y: { field: 'group', type: 'nominal' },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
OPTIONS,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const marks = tickMarks(layout);
|
|
33
|
+
expect(marks).toHaveLength(ROWS.length);
|
|
34
|
+
// Perpendicular to the quantitative axis: values run left-to-right, so each
|
|
35
|
+
// observation is a vertical stroke. Horizontal strokes would lie along the
|
|
36
|
+
// value axis and butt end-to-end into a solid bar.
|
|
37
|
+
expect(marks.every((m) => m.orient === 'vertical')).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('draws horizontal strokes when the value axis is y', () => {
|
|
41
|
+
const layout = compileChart(
|
|
42
|
+
{
|
|
43
|
+
mark: 'tick',
|
|
44
|
+
data: ROWS,
|
|
45
|
+
encoding: {
|
|
46
|
+
x: { field: 'group', type: 'nominal' },
|
|
47
|
+
y: { field: 'hours', type: 'quantitative' },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
OPTIONS,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(tickMarks(layout).every((m) => m.orient === 'horizontal')).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('separates observations within a category along the value axis', () => {
|
|
57
|
+
const layout = compileChart(
|
|
58
|
+
{
|
|
59
|
+
mark: 'tick',
|
|
60
|
+
data: ROWS,
|
|
61
|
+
encoding: {
|
|
62
|
+
x: { field: 'hours', type: 'quantitative' },
|
|
63
|
+
y: { field: 'group', type: 'nominal' },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
OPTIONS,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const young = tickMarks(layout).filter((m) => m.data.group === '20-29');
|
|
70
|
+
expect(young).toHaveLength(2);
|
|
71
|
+
// Same row, different x: the strip plot's entire job.
|
|
72
|
+
expect(young[0].y).toBe(young[1].y);
|
|
73
|
+
expect(young[0].x).not.toBe(young[1].x);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/charts/tick/index.ts
CHANGED
|
@@ -41,9 +41,12 @@ export function computeTickMarks(
|
|
|
41
41
|
const colorField = colorEncoding?.field;
|
|
42
42
|
const marks: TickMarkLayout[] = [];
|
|
43
43
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
// Ticks run perpendicular to the quantitative axis: a quantitative x (values
|
|
45
|
+
// spread left-to-right along a categorical row) gets vertical strokes, and a
|
|
46
|
+
// quantitative y gets horizontal ones. Drawing them parallel instead makes
|
|
47
|
+
// neighbouring observations butt end-to-end and read as one continuous bar.
|
|
48
|
+
const isQuantX = xChannel.type === 'quantitative' && yChannel.type !== 'quantitative';
|
|
49
|
+
const orient: 'horizontal' | 'vertical' = isQuantX ? 'vertical' : 'horizontal';
|
|
47
50
|
|
|
48
51
|
for (const row of spec.data) {
|
|
49
52
|
const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
|
|
@@ -78,7 +81,7 @@ export function computeTickMarks(
|
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
// Stamp keys: position-defining value, fall back to index
|
|
81
|
-
const posField =
|
|
84
|
+
const posField = isQuantX ? xChannel.field : yChannel.field;
|
|
82
85
|
const rawKeys = marks.map((m) => serializeKeyValue(m.data[posField]));
|
|
83
86
|
const keys = dedupeKeys(rawKeys);
|
|
84
87
|
for (let i = 0; i < marks.length; i++) {
|