@opendata-ai/openchart-engine 7.10.0 → 8.0.0-rc.1
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 +142 -7
- package/dist/index.js +16182 -10032
- 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__/dimensions.test.ts +49 -4
- package/src/__tests__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +120 -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 +315 -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__/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/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 +28 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +30 -2
- package/src/layout/dimensions.ts +178 -95
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +86 -35
- 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 +126 -14
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- package/src/legend/wrap.ts +8 -0
- 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
|
@@ -28,6 +28,16 @@ function isCategoricalLegend(legend: LegendLayout): legend is CategoricalLegendL
|
|
|
28
28
|
return 'entries' in legend && (!legend.type || legend.type === 'categorical');
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Every legend on the layout. Falls back to the singular `legend` slot so this
|
|
33
|
+
* still works against layouts built by older code paths (and by tests that
|
|
34
|
+
* hand-assemble a ChartLayout).
|
|
35
|
+
*/
|
|
36
|
+
function allLegends(layout: ChartLayout): LegendLayout[] {
|
|
37
|
+
if (layout.legends?.length) return layout.legends;
|
|
38
|
+
return layout.legend ? [layout.legend] : [];
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
// ---------------------------------------------------------------------------
|
|
32
42
|
// Box extraction
|
|
33
43
|
// ---------------------------------------------------------------------------
|
|
@@ -51,21 +61,27 @@ export function extractBoxes(layout: ChartLayout): NamedRect[] {
|
|
|
51
61
|
// Chart area
|
|
52
62
|
boxes.push({ ...layout.area, name: 'chartArea' });
|
|
53
63
|
|
|
54
|
-
// Legend bounds
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
// Legend bounds -- EVERY legend, not just the primary. A chart can carry both
|
|
65
|
+
// a color legend and a size legend; registering only `layout.legend` would
|
|
66
|
+
// leave the overlap invariants blind to exactly the element most likely to be
|
|
67
|
+
// mis-reserved, and they would pass a chart whose size legend sits on the plot.
|
|
68
|
+
for (const legend of allLegends(layout)) {
|
|
69
|
+
const suffix = legend.channel && legend.channel !== 'color' ? `-${legend.channel}` : '';
|
|
70
|
+
boxes.push({ ...legend.bounds, name: `legend${suffix}` });
|
|
71
|
+
|
|
72
|
+
// Legend entry positions (categorical only)
|
|
73
|
+
if (isCategoricalLegend(legend) && legend.entryPositions) {
|
|
74
|
+
const rowHeight = legend.rowHeight ?? 16;
|
|
75
|
+
for (let i = 0; i < legend.entryPositions.length; i++) {
|
|
76
|
+
const pos = legend.entryPositions[i];
|
|
77
|
+
boxes.push({
|
|
78
|
+
x: pos.x,
|
|
79
|
+
y: pos.y,
|
|
80
|
+
width: pos.width,
|
|
81
|
+
height: rowHeight,
|
|
82
|
+
name: `legend${suffix}-entry-${i}`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
69
85
|
}
|
|
70
86
|
}
|
|
71
87
|
|
|
@@ -179,14 +195,32 @@ export function checkLayoutInvariants(
|
|
|
179
195
|
): string[] {
|
|
180
196
|
const violations: string[] = [];
|
|
181
197
|
|
|
182
|
-
// 1.
|
|
198
|
+
// 1. No legend intrudes into chartArea (top/bottom only; right/bottom-right
|
|
183
199
|
// use area.width reduction, so overlap is a horizontal concern checked by rule 4)
|
|
184
|
-
|
|
185
|
-
|
|
200
|
+
//
|
|
201
|
+
// Checked for EVERY legend and every legend type. A size legend is not
|
|
202
|
+
// categorical, and it is the one whose space is most likely to be
|
|
203
|
+
// under-reserved -- gating this on `isCategoricalLegend` would skip it.
|
|
204
|
+
for (const legend of allLegends(layout)) {
|
|
205
|
+
if (legend.bounds.width <= 0 || legend.bounds.height <= 0) continue;
|
|
206
|
+
const pos = legend.position;
|
|
186
207
|
if (pos === 'top' || pos === 'bottom') {
|
|
187
|
-
if (rectsOverlap(
|
|
208
|
+
if (rectsOverlap(legend.bounds, layout.area, 0.5)) {
|
|
209
|
+
const name = legend.channel ?? 'color';
|
|
210
|
+
violations.push(
|
|
211
|
+
`${name} legend intrudes into chartArea: legend=${formatRect(legend.bounds)} chartArea=${formatRect(layout.area)}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 1b. Legends don't overlap each other.
|
|
218
|
+
const legends = allLegends(layout).filter((l) => l.bounds.width > 0 && l.bounds.height > 0);
|
|
219
|
+
for (let i = 0; i < legends.length; i++) {
|
|
220
|
+
for (let j = i + 1; j < legends.length; j++) {
|
|
221
|
+
if (rectsOverlap(legends[i].bounds, legends[j].bounds, 0.5)) {
|
|
188
222
|
violations.push(
|
|
189
|
-
`
|
|
223
|
+
`legends overlap: ${legends[i].channel ?? 'color'}=${formatRect(legends[i].bounds)} ${legends[j].channel ?? 'color'}=${formatRect(legends[j].bounds)}`,
|
|
190
224
|
);
|
|
191
225
|
}
|
|
192
226
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ChartLayout, LineMark } from '@opendata-ai/openchart-core';
|
|
2
2
|
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
3
3
|
import { describe, expect, test } from 'vitest';
|
|
4
|
+
import { ANCHOR_OFFSET, ARROWHEAD_LENGTH, MIN_CONNECTOR_LENGTH } from '../annotations/constants';
|
|
4
5
|
import { compileChart } from '../compile';
|
|
5
6
|
|
|
6
7
|
describe('known layout bugs', () => {
|
|
@@ -105,9 +106,461 @@ describe('known layout bugs', () => {
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
// The label bounding box should NOT vertically overlap with the line's y-range.
|
|
108
|
-
//
|
|
109
|
-
//
|
|
109
|
+
// The label sits ANCHOR_OFFSET (28px) off its anchor point on the line, which
|
|
110
|
+
// is sized to clear both the marker and the line path.
|
|
110
111
|
const verticallyOverlaps = labelTop < lineMaxY && labelBottom > lineMinY;
|
|
111
112
|
expect(verticallyOverlaps).toBe(false);
|
|
112
113
|
});
|
|
114
|
+
|
|
115
|
+
// Regression: the collision pass calls `refreshConnector` on every annotation it
|
|
116
|
+
// moves, and that rebuilt `from`/`to` from a ray-box exit toward the target. A
|
|
117
|
+
// drop-line is not a leader — it's a vertical rule pinned to the data point's x
|
|
118
|
+
// — so the rebuild tilted it into a diagonal (which the renderer then draws with
|
|
119
|
+
// shape-rendering: crispEdges, assuming axis alignment), and the
|
|
120
|
+
// MIN_CONNECTOR_LENGTH rule could delete it outright.
|
|
121
|
+
//
|
|
122
|
+
// Two drop-lines placed close enough to collide is what makes this observable:
|
|
123
|
+
// a lone annotation is never shifted, so `refreshConnector` never runs and the
|
|
124
|
+
// bug hides. Against the unfixed code this pair yields a 12.9px-diagonal first
|
|
125
|
+
// connector and a *deleted* second one.
|
|
126
|
+
describe('drop-line connectors survive the collision pass', () => {
|
|
127
|
+
const collidingDropLines = () =>
|
|
128
|
+
compileChart(
|
|
129
|
+
{
|
|
130
|
+
mark: 'line' as const,
|
|
131
|
+
data: [
|
|
132
|
+
{ m: 'Jan', v: 10 },
|
|
133
|
+
{ m: 'Feb', v: 50 },
|
|
134
|
+
{ m: 'Mar', v: 48 },
|
|
135
|
+
{ m: 'Apr', v: 30 },
|
|
136
|
+
],
|
|
137
|
+
encoding: {
|
|
138
|
+
x: { field: 'm' as const, type: 'ordinal' as const },
|
|
139
|
+
y: { field: 'v' as const, type: 'quantitative' as const },
|
|
140
|
+
},
|
|
141
|
+
annotations: [
|
|
142
|
+
{
|
|
143
|
+
type: 'text' as const,
|
|
144
|
+
x: 'Feb',
|
|
145
|
+
y: 50,
|
|
146
|
+
text: 'First drop-line here',
|
|
147
|
+
connector: 'drop-line' as const,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
type: 'text' as const,
|
|
151
|
+
x: 'Mar',
|
|
152
|
+
y: 48,
|
|
153
|
+
text: 'Second drop-line here',
|
|
154
|
+
connector: 'drop-line' as const,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
{ width: 800, height: 460 },
|
|
159
|
+
).annotations ?? [];
|
|
160
|
+
|
|
161
|
+
// One test, not three. The `x` pinning is the only assertion the generic
|
|
162
|
+
// ray-box rebuild actually breaks -- it preserves `style` through its
|
|
163
|
+
// `{ ...connector }` spread, and this geometry happens to land on
|
|
164
|
+
// `exit: 'vertical'` either way, so splitting those into their own tests just
|
|
165
|
+
// manufactured two that pass with the fix reverted.
|
|
166
|
+
test('stay vertical, with x pinned to the data point', () => {
|
|
167
|
+
const annotations = collidingDropLines();
|
|
168
|
+
expect(annotations).toHaveLength(2);
|
|
169
|
+
for (const annotation of annotations) {
|
|
170
|
+
const connector = annotation.label?.connector;
|
|
171
|
+
// Survived the min-length rule (the generic rebuild can shorten a
|
|
172
|
+
// drop-line enough to delete it outright).
|
|
173
|
+
expect(connector).toBeDefined();
|
|
174
|
+
// The whole contract of a drop-line: from.x === to.x === the point's x.
|
|
175
|
+
expect(connector?.from.x).toBeCloseTo(connector?.to.x ?? Number.NaN, 5);
|
|
176
|
+
expect(connector?.from.x).toBeCloseTo(annotation.dot?.x ?? Number.NaN, 5);
|
|
177
|
+
expect(connector?.style).toBe('drop-line');
|
|
178
|
+
expect(connector?.exit).toBe('vertical');
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Regression: the engine gated connectors on `length >= MIN_CONNECTOR_LENGTH`,
|
|
184
|
+
// but the renderer stops an ARROWED stroke `ARROWHEAD_LENGTH` (7px) short and
|
|
185
|
+
// spends that budget on the head. So an 8.1px arrowed connector cleared the 8px
|
|
186
|
+
// gate and then shipped a 1.1px stub with an arrowhead stuck on the end. Sharing
|
|
187
|
+
// the constant didn't fix it: both sides had the number, only one reasoned about
|
|
188
|
+
// it. The gate now measures what actually gets stroked.
|
|
189
|
+
describe('the connector min-length gate accounts for the arrowhead', () => {
|
|
190
|
+
const strokeFor = (arrow: boolean, dy: number) => {
|
|
191
|
+
const annotation = compileChart(
|
|
192
|
+
{
|
|
193
|
+
mark: 'line' as const,
|
|
194
|
+
data: [
|
|
195
|
+
{ m: 'Jan', v: 10 },
|
|
196
|
+
{ m: 'Feb', v: 50 },
|
|
197
|
+
{ m: 'Mar', v: 30 },
|
|
198
|
+
],
|
|
199
|
+
encoding: {
|
|
200
|
+
x: { field: 'm' as const, type: 'ordinal' as const },
|
|
201
|
+
y: { field: 'v' as const, type: 'quantitative' as const },
|
|
202
|
+
},
|
|
203
|
+
annotations: [
|
|
204
|
+
{
|
|
205
|
+
type: 'text' as const,
|
|
206
|
+
x: 'Mar',
|
|
207
|
+
y: 30,
|
|
208
|
+
text: 'Note',
|
|
209
|
+
anchor: 'top' as const,
|
|
210
|
+
offset: { dx: 0, dy },
|
|
211
|
+
connector: arrow ? { type: 'straight' as const, arrow: true } : 'straight',
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
{ width: 800, height: 460 },
|
|
216
|
+
).annotations?.[0];
|
|
217
|
+
|
|
218
|
+
const connector = annotation?.label?.connector;
|
|
219
|
+
if (!connector) return null;
|
|
220
|
+
const length = Math.hypot(
|
|
221
|
+
connector.to.x - connector.from.x,
|
|
222
|
+
connector.to.y - connector.from.y,
|
|
223
|
+
);
|
|
224
|
+
// What the renderer actually strokes.
|
|
225
|
+
return arrow ? length - ARROWHEAD_LENGTH : length;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
test('an arrowed connector never ships a stroke shorter than the minimum', () => {
|
|
229
|
+
// dy: 6 is the case that used to slip through -- an 8.10px connector that
|
|
230
|
+
// stroked 1.10px once the head took its 7px.
|
|
231
|
+
for (let dy = 0; dy <= 8; dy++) {
|
|
232
|
+
const stroke = strokeFor(true, dy);
|
|
233
|
+
if (stroke === null) continue; // suppressed outright, which is correct
|
|
234
|
+
expect(stroke).toBeGreaterThanOrEqual(MIN_CONNECTOR_LENGTH);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('the plain (non-arrowed) gate is unchanged', () => {
|
|
239
|
+
// The head costs nothing here, so the full length is the stroke and the
|
|
240
|
+
// original 8px rule still holds. Guards against "fixing" the arrow case by
|
|
241
|
+
// tightening the gate for everyone.
|
|
242
|
+
for (let dy = 0; dy <= 8; dy++) {
|
|
243
|
+
const stroke = strokeFor(false, dy);
|
|
244
|
+
if (stroke === null) continue;
|
|
245
|
+
expect(stroke).toBeGreaterThanOrEqual(MIN_CONNECTOR_LENGTH);
|
|
246
|
+
}
|
|
247
|
+
// And a short-but-legal plain connector still draws.
|
|
248
|
+
expect(strokeFor(false, 0)).not.toBeNull();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Regression: `resolveTextAnnotation` bolts the lede rule on (a `subtitle`
|
|
253
|
+
// promotes the primary line to 700) BEFORE branching to the drop-line resolver,
|
|
254
|
+
// which used to resolve no subtitle at all. Net effect: authoring a subtitle on
|
|
255
|
+
// a drop-line silently threw the subtitle away but still bolded the primary --
|
|
256
|
+
// a field whose only visible effect was a mystery weight change.
|
|
257
|
+
describe('a drop-line annotation honors its subtitle', () => {
|
|
258
|
+
// Annotate Mar, not the Feb maximum: a callout on the chart's top value clamps
|
|
259
|
+
// against the chart edge and has nowhere to go, which is a separate (and
|
|
260
|
+
// legitimate) degradation. This is the ordinary case.
|
|
261
|
+
const dropLineWithSubtitle = () =>
|
|
262
|
+
compileChart(
|
|
263
|
+
{
|
|
264
|
+
mark: 'line' as const,
|
|
265
|
+
data: [
|
|
266
|
+
{ m: 'Jan', v: 10 },
|
|
267
|
+
{ m: 'Feb', v: 50 },
|
|
268
|
+
{ m: 'Mar', v: 30 },
|
|
269
|
+
],
|
|
270
|
+
encoding: {
|
|
271
|
+
x: { field: 'm' as const, type: 'ordinal' as const },
|
|
272
|
+
y: { field: 'v' as const, type: 'quantitative' as const },
|
|
273
|
+
},
|
|
274
|
+
annotations: [
|
|
275
|
+
{
|
|
276
|
+
type: 'text' as const,
|
|
277
|
+
x: 'Mar',
|
|
278
|
+
y: 30,
|
|
279
|
+
text: 'Peak',
|
|
280
|
+
subtitle: 'the highest month',
|
|
281
|
+
connector: 'drop-line' as const,
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
{ width: 800, height: 460 },
|
|
286
|
+
).annotations?.[0];
|
|
287
|
+
|
|
288
|
+
test('resolves the subtitle instead of dropping it', () => {
|
|
289
|
+
const annotation = dropLineWithSubtitle();
|
|
290
|
+
expect(annotation?.subtitle?.text).toBe('the highest month');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('still promotes the primary line to a bold lede', () => {
|
|
294
|
+
expect(dropLineWithSubtitle()?.label?.style.fontWeight).toBe(700);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('renders the subtitle below the primary line, left edges aligned', () => {
|
|
298
|
+
const annotation = dropLineWithSubtitle();
|
|
299
|
+
expect(annotation?.subtitle?.y).toBeGreaterThan(annotation?.label?.y ?? Number.NaN);
|
|
300
|
+
expect(annotation?.subtitle?.x).toBeCloseTo(annotation?.label?.x ?? Number.NaN, 5);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('the whole block stays above the data point, so the line still drops', () => {
|
|
304
|
+
// A drop-line runs DOWN from the block to the point. The subtitle makes the
|
|
305
|
+
// block taller and wider, which made it collide with the line mark -- and the
|
|
306
|
+
// obstacle nudge then shoved it *below* the point, inverting the connector so
|
|
307
|
+
// it pointed up, away from the data, with the block sitting on the mark.
|
|
308
|
+
const annotation = dropLineWithSubtitle();
|
|
309
|
+
const bounds = annotation?.bounds;
|
|
310
|
+
const dotY = annotation?.dot?.y ?? Number.NaN;
|
|
311
|
+
expect((bounds?.y ?? Number.NaN) + (bounds?.height ?? Number.NaN)).toBeLessThanOrEqual(dotY);
|
|
312
|
+
|
|
313
|
+
const connector = annotation?.label?.connector;
|
|
314
|
+
expect(connector?.from.y).toBeLessThan(connector?.to.y ?? Number.NaN);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('the block bounds cover the subtitle, so collisions see it', () => {
|
|
318
|
+
const annotation = dropLineWithSubtitle();
|
|
319
|
+
const bounds = annotation?.bounds;
|
|
320
|
+
const labelBounds = annotation?.label?.bounds;
|
|
321
|
+
// `bounds` is the union; `label.bounds` stays label-only (the renderer sizes
|
|
322
|
+
// the background plate from it).
|
|
323
|
+
expect(bounds?.height).toBeGreaterThan(labelBounds?.height ?? Number.NaN);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Regression: `compute.ts` stamped `result.bounds` (the UNION of label and
|
|
328
|
+
// subtitle, which is what placement scores) into `label.bounds`, whose contract
|
|
329
|
+
// is the label-only text box. The renderer sizes the `background` plate from
|
|
330
|
+
// `label.bounds`, so an auto-placed annotation with a subtitle got a plate sized
|
|
331
|
+
// to both lines while the explicit path got one sized to the label.
|
|
332
|
+
test('auto-placed label bounds are the label box, not the label+subtitle union', () => {
|
|
333
|
+
const layout = compileChart(
|
|
334
|
+
{
|
|
335
|
+
mark: 'line' as const,
|
|
336
|
+
data: [
|
|
337
|
+
{ m: 'Jan', v: 10 },
|
|
338
|
+
{ m: 'Feb', v: 50 },
|
|
339
|
+
{ m: 'Mar', v: 30 },
|
|
340
|
+
],
|
|
341
|
+
encoding: {
|
|
342
|
+
x: { field: 'm' as const, type: 'ordinal' as const },
|
|
343
|
+
y: { field: 'v' as const, type: 'quantitative' as const },
|
|
344
|
+
},
|
|
345
|
+
annotations: [
|
|
346
|
+
// No anchor/offset => the auto-placement path.
|
|
347
|
+
{
|
|
348
|
+
type: 'text' as const,
|
|
349
|
+
x: 'Feb',
|
|
350
|
+
y: 50,
|
|
351
|
+
text: 'Peak',
|
|
352
|
+
subtitle: 'a considerably wider subtitle line',
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
},
|
|
356
|
+
{ width: 800, height: 460 },
|
|
357
|
+
);
|
|
358
|
+
const annotation = layout.annotations?.[0];
|
|
359
|
+
const labelBounds = annotation?.label?.bounds;
|
|
360
|
+
const unionBounds = annotation?.bounds;
|
|
361
|
+
expect(labelBounds).toBeDefined();
|
|
362
|
+
expect(unionBounds).toBeDefined();
|
|
363
|
+
|
|
364
|
+
// The subtitle is wider and sits below, so the union must be strictly bigger.
|
|
365
|
+
// If label.bounds were the union, these would be identical.
|
|
366
|
+
expect(labelBounds?.width).toBeLessThan(unionBounds?.width ?? 0);
|
|
367
|
+
expect(labelBounds?.height).toBeLessThan(unionBounds?.height ?? 0);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Regression: `anchor: 'top'`/`'bottom'` render with textAnchor 'start', so the
|
|
371
|
+
// block used to hang entirely to the RIGHT of its data point — "above" read as
|
|
372
|
+
// "up and to the right", and authors had to hand-compute a negative dx of about
|
|
373
|
+
// half the block width to re-center it. The block now straddles the point
|
|
374
|
+
// horizontally; the TEXT inside stays left-aligned (ragged right).
|
|
375
|
+
describe('top/bottom anchored blocks straddle their data point', () => {
|
|
376
|
+
const centerOffsetFor = (annotation: Record<string, unknown>) => {
|
|
377
|
+
const layout = compileChart(
|
|
378
|
+
{
|
|
379
|
+
mark: 'line' as const,
|
|
380
|
+
data: [
|
|
381
|
+
{ m: 'Jan', v: 10 },
|
|
382
|
+
{ m: 'Feb', v: 50 },
|
|
383
|
+
{ m: 'Mar', v: 30 },
|
|
384
|
+
],
|
|
385
|
+
encoding: {
|
|
386
|
+
x: { field: 'm' as const, type: 'ordinal' as const },
|
|
387
|
+
y: { field: 'v' as const, type: 'quantitative' as const },
|
|
388
|
+
},
|
|
389
|
+
annotations: [annotation],
|
|
390
|
+
},
|
|
391
|
+
{ width: 800, height: 460 },
|
|
392
|
+
);
|
|
393
|
+
const resolved = layout.annotations?.[0];
|
|
394
|
+
const bounds = resolved?.label?.bounds;
|
|
395
|
+
const pointX = resolved?.dot?.x;
|
|
396
|
+
if (!bounds || pointX === undefined) throw new Error('annotation did not resolve');
|
|
397
|
+
return bounds.x + bounds.width / 2 - pointX;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
/** The block's horizontal span expressed relative to the data point. */
|
|
401
|
+
const spanAround = (annotation: Record<string, unknown>) => {
|
|
402
|
+
const layout = compileChart(
|
|
403
|
+
{
|
|
404
|
+
mark: 'line' as const,
|
|
405
|
+
data: [
|
|
406
|
+
{ m: 'Jan', v: 10 },
|
|
407
|
+
{ m: 'Feb', v: 50 },
|
|
408
|
+
{ m: 'Mar', v: 30 },
|
|
409
|
+
],
|
|
410
|
+
encoding: {
|
|
411
|
+
x: { field: 'm' as const, type: 'ordinal' as const },
|
|
412
|
+
y: { field: 'v' as const, type: 'quantitative' as const },
|
|
413
|
+
},
|
|
414
|
+
annotations: [annotation],
|
|
415
|
+
},
|
|
416
|
+
{ width: 800, height: 460 },
|
|
417
|
+
);
|
|
418
|
+
const resolved = layout.annotations?.[0];
|
|
419
|
+
const bounds = resolved?.label?.bounds;
|
|
420
|
+
const pointX = resolved?.dot?.x;
|
|
421
|
+
if (!bounds || pointX === undefined) throw new Error('annotation did not resolve');
|
|
422
|
+
return { left: bounds.x - pointX, right: bounds.x + bounds.width - pointX };
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
test('a single-line top-anchored block centers on its point', () => {
|
|
426
|
+
expect(
|
|
427
|
+
centerOffsetFor({ type: 'text', x: 'Feb', y: 50, text: 'Peak here', anchor: 'top' }),
|
|
428
|
+
).toBeCloseTo(0, 1);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('a multi-line top-anchored block centers on its point', () => {
|
|
432
|
+
expect(
|
|
433
|
+
centerOffsetFor({
|
|
434
|
+
type: 'text',
|
|
435
|
+
x: 'Feb',
|
|
436
|
+
y: 50,
|
|
437
|
+
text: 'Hurricanes and\na strike cut',
|
|
438
|
+
anchor: 'top',
|
|
439
|
+
}),
|
|
440
|
+
).toBeCloseTo(0, 1);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('a multi-line bottom-anchored block centers on its point', () => {
|
|
444
|
+
expect(
|
|
445
|
+
centerOffsetFor({
|
|
446
|
+
type: 'text',
|
|
447
|
+
x: 'Feb',
|
|
448
|
+
y: 50,
|
|
449
|
+
text: 'Two lines\nhere now',
|
|
450
|
+
anchor: 'bottom',
|
|
451
|
+
}),
|
|
452
|
+
).toBeCloseTo(0, 1);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('the text inside stays left-aligned, not center-aligned', () => {
|
|
456
|
+
const layout = compileChart(
|
|
457
|
+
{
|
|
458
|
+
mark: 'line' as const,
|
|
459
|
+
data: [
|
|
460
|
+
{ m: 'Jan', v: 10 },
|
|
461
|
+
{ m: 'Feb', v: 50 },
|
|
462
|
+
],
|
|
463
|
+
encoding: {
|
|
464
|
+
x: { field: 'm' as const, type: 'ordinal' as const },
|
|
465
|
+
y: { field: 'v' as const, type: 'quantitative' as const },
|
|
466
|
+
},
|
|
467
|
+
annotations: [
|
|
468
|
+
{ type: 'text' as const, x: 'Feb', y: 50, text: 'Two\nlines', anchor: 'top' as const },
|
|
469
|
+
],
|
|
470
|
+
},
|
|
471
|
+
{ width: 800, height: 460 },
|
|
472
|
+
);
|
|
473
|
+
expect(layout.annotations?.[0]?.label?.style?.textAnchor).toBe('start');
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test('left/right anchors are unaffected (block sits beside the point)', () => {
|
|
477
|
+
// "Beside", not "straddling": the whole block clears the point by exactly the
|
|
478
|
+
// ANCHOR_OFFSET setback (28px), so the point lies OUTSIDE the block's x-span.
|
|
479
|
+
// A sign check alone (`centerOffset < 0`) still passes for a centered block
|
|
480
|
+
// that has merely drifted — which is the regression this guards.
|
|
481
|
+
|
|
482
|
+
const left = spanAround({ type: 'text', x: 'Feb', y: 50, text: 'Left side', anchor: 'left' });
|
|
483
|
+
// Whole block to the LEFT: its near (right) edge stops one setback short of
|
|
484
|
+
// the point, so the point never falls inside the span.
|
|
485
|
+
expect(left.right).toBeCloseTo(-ANCHOR_OFFSET, 1);
|
|
486
|
+
expect(left.left).toBeLessThan(left.right);
|
|
487
|
+
|
|
488
|
+
const right = spanAround({
|
|
489
|
+
type: 'text',
|
|
490
|
+
x: 'Feb',
|
|
491
|
+
y: 50,
|
|
492
|
+
text: 'Right side',
|
|
493
|
+
anchor: 'right',
|
|
494
|
+
});
|
|
495
|
+
// Mirror image: near (left) edge starts one setback past the point.
|
|
496
|
+
expect(right.left).toBeCloseTo(ANCHOR_OFFSET, 1);
|
|
497
|
+
expect(right.right).toBeGreaterThan(right.left);
|
|
498
|
+
|
|
499
|
+
// And the contrast that gives "beside" its meaning: a top anchor STRADDLES,
|
|
500
|
+
// i.e. the point lies strictly inside the block's x-span.
|
|
501
|
+
const top = spanAround({ type: 'text', x: 'Feb', y: 50, text: 'Left side', anchor: 'top' });
|
|
502
|
+
expect(top.left).toBeLessThan(0);
|
|
503
|
+
expect(top.right).toBeGreaterThan(0);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
describe('annotation typography survives normalization', () => {
|
|
508
|
+
const baseSpec = (annotation: Record<string, unknown>) => ({
|
|
509
|
+
mark: 'line' as const,
|
|
510
|
+
data: [
|
|
511
|
+
{ year: '1980', value: 0.26 },
|
|
512
|
+
{ year: '2025', value: 1.17 },
|
|
513
|
+
],
|
|
514
|
+
encoding: {
|
|
515
|
+
x: { field: 'year' as const, type: 'ordinal' as const },
|
|
516
|
+
y: { field: 'value' as const, type: 'quantitative' as const },
|
|
517
|
+
},
|
|
518
|
+
annotations: [annotation],
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const styleOf = (annotation: Record<string, unknown>) => {
|
|
522
|
+
const layout = compileChart(baseSpec(annotation), { width: 800, height: 460 });
|
|
523
|
+
const style = layout.annotations?.[0]?.label?.style;
|
|
524
|
+
if (!style) throw new Error('annotation did not resolve a label style');
|
|
525
|
+
return style;
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
test('a subtitle promotes the primary line to a bold lede', () => {
|
|
529
|
+
const style = styleOf({
|
|
530
|
+
type: 'text',
|
|
531
|
+
x: '2025',
|
|
532
|
+
y: 1.17,
|
|
533
|
+
text: 'Peak',
|
|
534
|
+
subtitle: 'the warmest year on record',
|
|
535
|
+
});
|
|
536
|
+
expect(style.fontWeight).toBe(700);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test('a plain annotation stays regular weight', () => {
|
|
540
|
+
const style = styleOf({ type: 'text', x: '2025', y: 1.17, text: 'Peak' });
|
|
541
|
+
expect(style.fontWeight).toBe(400);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('the default font size reaches the compiled output', () => {
|
|
545
|
+
const style = styleOf({ type: 'text', x: '2025', y: 1.17, text: 'Peak' });
|
|
546
|
+
expect(style.fontSize).toBe(13);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test('an explicit fontWeight beats the lede rule', () => {
|
|
550
|
+
const style = styleOf({
|
|
551
|
+
type: 'text',
|
|
552
|
+
x: '2025',
|
|
553
|
+
y: 1.17,
|
|
554
|
+
text: 'Peak',
|
|
555
|
+
subtitle: 'context',
|
|
556
|
+
fontWeight: 300,
|
|
557
|
+
});
|
|
558
|
+
expect(style.fontWeight).toBe(300);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test('an explicit fontSize beats the default', () => {
|
|
562
|
+
const style = styleOf({ type: 'text', x: '2025', y: 1.17, text: 'Peak', fontSize: 18 });
|
|
563
|
+
expect(style.fontSize).toBe(18);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
113
566
|
});
|