@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
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavior characterization suite for the annotation layer.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FILE EXISTS
|
|
5
|
+
*
|
|
6
|
+
* The rest of the annotation tests (compute.test.ts, resolve-text.test.ts,
|
|
7
|
+
* placement*.test.ts) call the resolvers and passes DIRECTLY. That is how a
|
|
8
|
+
* `normalize.ts` bug once shipped two whole features as dead code while 3305
|
|
9
|
+
* tests passed: every one of them bypassed the pipeline that had the bug.
|
|
10
|
+
*
|
|
11
|
+
* These tests only ever call `compileChart`. They assert on the `ChartLayout`
|
|
12
|
+
* the renderer consumes -- which IS the engine's observable output -- and never
|
|
13
|
+
* on which function produced it. That makes them the safety net for refactoring
|
|
14
|
+
* the annotation internals: if a change is genuinely behavior-preserving, every
|
|
15
|
+
* test here passes untouched. If one fails, behavior moved.
|
|
16
|
+
*
|
|
17
|
+
* Rules for anything added here:
|
|
18
|
+
* - Go through `compileChart`. No importing from `../annotations/*` except
|
|
19
|
+
* constants (so a retuned default doesn't fail the test for the wrong reason).
|
|
20
|
+
* - Assert on what a reader would SEE: is the label above the point, does the
|
|
21
|
+
* leader reach it, is the text bold, does the block overlap the mark.
|
|
22
|
+
* - Never assert on a function being called, or on an intermediate field that
|
|
23
|
+
* only exists because of how the code is currently organized.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
Annotation,
|
|
28
|
+
ChartLayout,
|
|
29
|
+
ChartSpec,
|
|
30
|
+
ResolvedAnnotation,
|
|
31
|
+
} from '@opendata-ai/openchart-core';
|
|
32
|
+
import { describe, expect, test } from 'vitest';
|
|
33
|
+
import {
|
|
34
|
+
ANCHOR_OFFSET,
|
|
35
|
+
ARROWHEAD_LENGTH,
|
|
36
|
+
DEFAULT_ANNOTATION_FONT_SIZE,
|
|
37
|
+
DEFAULT_ANNOTATION_FONT_WEIGHT,
|
|
38
|
+
DEFAULT_DOT_RADIUS,
|
|
39
|
+
DEFAULT_DOT_STROKE_WIDTH,
|
|
40
|
+
LEDE_FONT_WEIGHT,
|
|
41
|
+
MIN_CONNECTOR_LENGTH,
|
|
42
|
+
SUBTITLE_FONT_WEIGHT,
|
|
43
|
+
subtitleFontSize,
|
|
44
|
+
} from '../annotations/constants';
|
|
45
|
+
import { compileChart } from '../compile';
|
|
46
|
+
|
|
47
|
+
// --- helpers ---------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const SERIES = [
|
|
50
|
+
{ m: 'Jan', v: 10 },
|
|
51
|
+
{ m: 'Feb', v: 50 },
|
|
52
|
+
{ m: 'Mar', v: 30 },
|
|
53
|
+
{ m: 'Apr', v: 42 },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
type Ann = Annotation;
|
|
57
|
+
|
|
58
|
+
/** Compile a line chart carrying `annotations`, and hand back the resolved ones. */
|
|
59
|
+
function annotationsFor(annotations: Ann[], width = 800, height = 460): ResolvedAnnotation[] {
|
|
60
|
+
const layout: ChartLayout = compileChart(
|
|
61
|
+
{
|
|
62
|
+
mark: 'line',
|
|
63
|
+
data: SERIES,
|
|
64
|
+
encoding: {
|
|
65
|
+
x: { field: 'm', type: 'ordinal' },
|
|
66
|
+
y: { field: 'v', type: 'quantitative', scale: { zero: true } },
|
|
67
|
+
},
|
|
68
|
+
annotations,
|
|
69
|
+
} as ChartSpec,
|
|
70
|
+
{ width, height },
|
|
71
|
+
);
|
|
72
|
+
return layout.annotations ?? [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const first = (annotations: Ann[]) => annotationsFor(annotations)[0];
|
|
76
|
+
|
|
77
|
+
/** Length of the leader the renderer will actually stroke. */
|
|
78
|
+
function strokedLength(a: ResolvedAnnotation): number | null {
|
|
79
|
+
const c = a.label?.connector;
|
|
80
|
+
if (!c) return null;
|
|
81
|
+
const len = Math.hypot(c.to.x - c.from.x, c.to.y - c.from.y);
|
|
82
|
+
return c.arrow ? len - ARROWHEAD_LENGTH : len;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function overlaps(a: { x: number; y: number; width: number; height: number }, b: typeof a) {
|
|
86
|
+
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- the block sits where the author asked -------------------------------
|
|
90
|
+
|
|
91
|
+
describe('a callout lands where the anchor says it should', () => {
|
|
92
|
+
test('a top anchor puts the block above the point, straddling it', () => {
|
|
93
|
+
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'top' }]);
|
|
94
|
+
const b = a.bounds!;
|
|
95
|
+
const dot = a.dot!;
|
|
96
|
+
|
|
97
|
+
// Above: the whole block clears the point.
|
|
98
|
+
expect(b.y + b.height).toBeLessThan(dot.y);
|
|
99
|
+
// Straddling: the point is inside the block's horizontal span, not off one end.
|
|
100
|
+
expect(b.x).toBeLessThan(dot.x);
|
|
101
|
+
expect(b.x + b.width).toBeGreaterThan(dot.x);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a bottom anchor puts the block below the point, straddling it', () => {
|
|
105
|
+
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'bottom' }]);
|
|
106
|
+
const b = a.bounds!;
|
|
107
|
+
const dot = a.dot!;
|
|
108
|
+
|
|
109
|
+
expect(b.y).toBeGreaterThan(dot.y);
|
|
110
|
+
expect(b.x).toBeLessThan(dot.x);
|
|
111
|
+
expect(b.x + b.width).toBeGreaterThan(dot.x);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a left anchor puts the block beside the point, not over it', () => {
|
|
115
|
+
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'left' }]);
|
|
116
|
+
const b = a.bounds!;
|
|
117
|
+
const dot = a.dot!;
|
|
118
|
+
|
|
119
|
+
// Entirely to the left, clear of the point by the anchor setback.
|
|
120
|
+
expect(b.x + b.width).toBeLessThan(dot.x);
|
|
121
|
+
expect(dot.x - (b.x + b.width)).toBeGreaterThanOrEqual(ANCHOR_OFFSET - 1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a right anchor puts the block beside the point, not over it', () => {
|
|
125
|
+
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'right' }]);
|
|
126
|
+
const b = a.bounds!;
|
|
127
|
+
const dot = a.dot!;
|
|
128
|
+
|
|
129
|
+
expect(b.x).toBeGreaterThan(dot.x);
|
|
130
|
+
expect(b.x - dot.x).toBeGreaterThanOrEqual(ANCHOR_OFFSET - 1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// An offset is measured FROM THE DATA POINT, never between two compiles. Adding an
|
|
134
|
+
// annotation changes the reserved margins, which resizes the plot, which moves every
|
|
135
|
+
// data point -- so `movedLayout.label.x - bareLayout.label.x` is not the offset, it's
|
|
136
|
+
// the offset plus a scale shift. `label - dot` is the quantity the reader actually sees.
|
|
137
|
+
const labelFromPoint = (a: ResolvedAnnotation) => ({
|
|
138
|
+
dx: a.label!.x - a.dot!.x,
|
|
139
|
+
dy: a.label!.y - a.dot!.y,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/** Where the block sits relative to its data point, for a given authored offset. */
|
|
143
|
+
const offsetBy = (anchor: string, dx: number, dy: number) =>
|
|
144
|
+
labelFromPoint(
|
|
145
|
+
first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor, offset: { dx, dy } }]),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Every reading below carries an offset, so every one takes the explicit path. A
|
|
149
|
+
// zero-offset baseline would NOT: with no offset the block is still eligible for the
|
|
150
|
+
// obstacle nudge, so it sits wherever that pass shoved it, and a delta measured
|
|
151
|
+
// against it is the offset PLUS the nudge. Compare like with like.
|
|
152
|
+
test.each([
|
|
153
|
+
'top',
|
|
154
|
+
'bottom',
|
|
155
|
+
'left',
|
|
156
|
+
'right',
|
|
157
|
+
] as const)('an offset translates a %s-anchored block by exactly that much', (anchor) => {
|
|
158
|
+
const base = offsetBy(anchor, 1, 1);
|
|
159
|
+
const moved = offsetBy(anchor, 26, -14);
|
|
160
|
+
|
|
161
|
+
expect(moved.dx - base.dx).toBeCloseTo(25, 5);
|
|
162
|
+
expect(moved.dy - base.dy).toBeCloseTo(-15, 5);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test.each([
|
|
166
|
+
'top',
|
|
167
|
+
'bottom',
|
|
168
|
+
'left',
|
|
169
|
+
'right',
|
|
170
|
+
] as const)('an offset on a %s-anchored block is monotonic -- no dead zones, no jumps', (anchor) => {
|
|
171
|
+
// The knob has to behave like a knob. This failed hard before explicit offsets
|
|
172
|
+
// were made to outrank the obstacle pass: on a line chart the polyline is one
|
|
173
|
+
// long obstacle, so a right-anchored `dy: -10` moved the block ZERO pixels while
|
|
174
|
+
// `dy: -15` teleported it 56px. Same knob, no monotonicity -- unaimable.
|
|
175
|
+
const base = offsetBy(anchor, 0, -5).dy;
|
|
176
|
+
|
|
177
|
+
for (const dy of [-10, -15, -20, -40]) {
|
|
178
|
+
expect(offsetBy(anchor, 0, dy).dy - base).toBeCloseTo(dy + 5, 5);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('an offset with NO anchor is honored too -- an offset is a placement either way', () => {
|
|
183
|
+
// There is exactly one definition of "the author placed this": `isAutoPlacement`,
|
|
184
|
+
// which disqualifies ANY annotation carrying an offset from the scored search. So
|
|
185
|
+
// an anchorless offset is hand-placed just like an anchored one, and must be just
|
|
186
|
+
// as aimable.
|
|
187
|
+
//
|
|
188
|
+
// A gate here that additionally demanded an `anchor` did not create a second tier
|
|
189
|
+
// of annotation -- it just disagreed with the function that had already routed the
|
|
190
|
+
// thing, and the whole dead-zone/teleport pathology stayed alive on exactly the
|
|
191
|
+
// annotations the extra condition excluded:
|
|
192
|
+
//
|
|
193
|
+
// dy=-5 -> moved -2.42px dy=-10 -> moved -2.42px (dead zone)
|
|
194
|
+
// dy=-15 -> moved -39.75px dy=-20 -> moved -39.75px (teleport, then dead again)
|
|
195
|
+
const rel = (dy: number) => {
|
|
196
|
+
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', offset: { dy } }]);
|
|
197
|
+
return a.label!.y - a.dot!.y;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const base = rel(-5);
|
|
201
|
+
for (const dy of [-10, -15, -20, -40]) {
|
|
202
|
+
expect(rel(dy) - base).toBeCloseTo(dy + 5, 5);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('a pinned callout still gets clamped onto the canvas', () => {
|
|
207
|
+
// Pinning exempts a callout from AVOIDANCE, not from staying on the page. An
|
|
208
|
+
// offset that would fling it into the void still gets reeled back in -- otherwise
|
|
209
|
+
// "explicit wins" becomes a way to render a label nobody can see.
|
|
210
|
+
const a = first([
|
|
211
|
+
{
|
|
212
|
+
type: 'text',
|
|
213
|
+
x: 'Mar',
|
|
214
|
+
y: 30,
|
|
215
|
+
text: 'Way out there',
|
|
216
|
+
anchor: 'right',
|
|
217
|
+
offset: { dx: 5000, dy: -4000 },
|
|
218
|
+
},
|
|
219
|
+
]);
|
|
220
|
+
const b = a.bounds!;
|
|
221
|
+
|
|
222
|
+
expect(b.x).toBeGreaterThanOrEqual(0);
|
|
223
|
+
expect(b.y).toBeGreaterThanOrEqual(0);
|
|
224
|
+
expect(b.x + b.width).toBeLessThanOrEqual(800);
|
|
225
|
+
expect(b.y + b.height).toBeLessThanOrEqual(460);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Two callouts on the SAME point, so they genuinely fight for the same space. An
|
|
229
|
+
// earlier version of this test used `dy: -30`, which parked them 13px apart -- they
|
|
230
|
+
// never collided, so "routes around it" was asserted against a pair that had nothing
|
|
231
|
+
// to route around, and the ordering bug below shipped underneath it.
|
|
232
|
+
const PINNED: Ann = {
|
|
233
|
+
type: 'text',
|
|
234
|
+
x: 'Mar',
|
|
235
|
+
y: 30,
|
|
236
|
+
text: 'Pinned block here',
|
|
237
|
+
anchor: 'top',
|
|
238
|
+
offset: { dy: -25 },
|
|
239
|
+
};
|
|
240
|
+
const MOVABLE: Ann = { type: 'text', x: 'Mar', y: 30, text: 'Other block here', anchor: 'top' };
|
|
241
|
+
|
|
242
|
+
test.each([
|
|
243
|
+
['pinned first', [PINNED, MOVABLE]],
|
|
244
|
+
['pinned second', [MOVABLE, PINNED]],
|
|
245
|
+
] as const)('a hand-offset block keeps its spot and the other routes around it (%s)', (_, specs) => {
|
|
246
|
+
const resolved = annotationsFor([...specs]);
|
|
247
|
+
const pinned = resolved[specs.indexOf(PINNED)];
|
|
248
|
+
const movable = resolved[specs.indexOf(MOVABLE)];
|
|
249
|
+
|
|
250
|
+
// Pinned: exactly where it was told, neighbour or no neighbour.
|
|
251
|
+
expect(pinned.label!.y - pinned.dot!.y).toBeCloseTo(offsetBy('top', 0, -25).dy, 5);
|
|
252
|
+
|
|
253
|
+
// Movable: actually got out of the way. Both halves matter -- asserting only
|
|
254
|
+
// "they don't overlap" passes on a pair that never overlapped to begin with.
|
|
255
|
+
const solo = first([MOVABLE]);
|
|
256
|
+
expect(movable.label!.y - movable.dot!.y).not.toBeCloseTo(solo.label!.y - solo.dot!.y, 1);
|
|
257
|
+
expect(overlaps(pinned.bounds!, movable.bounds!)).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('a callout does not sit on top of the line it annotates', () => {
|
|
261
|
+
const layout: ChartLayout = compileChart(
|
|
262
|
+
{
|
|
263
|
+
mark: 'line',
|
|
264
|
+
data: SERIES,
|
|
265
|
+
encoding: {
|
|
266
|
+
x: { field: 'm', type: 'ordinal' },
|
|
267
|
+
y: { field: 'v', type: 'quantitative' },
|
|
268
|
+
},
|
|
269
|
+
annotations: [{ type: 'text', x: 'Feb', y: 50, text: 'The peak' }],
|
|
270
|
+
} as ChartSpec,
|
|
271
|
+
{ width: 800, height: 460 },
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const a = layout.annotations![0];
|
|
275
|
+
const points = layout.marks.flatMap((m) => ('points' in m ? (m.points ?? []) : []));
|
|
276
|
+
const b = a.bounds!;
|
|
277
|
+
|
|
278
|
+
// No vertex of the series falls inside the label block.
|
|
279
|
+
for (const p of points) {
|
|
280
|
+
const inside = p.x >= b.x && p.x <= b.x + b.width && p.y >= b.y && p.y <= b.y + b.height;
|
|
281
|
+
expect(inside).toBe(false);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// --- the leader connects the block to the point --------------------------
|
|
287
|
+
|
|
288
|
+
describe('the leader actually connects the words to the data', () => {
|
|
289
|
+
test('a bare callout draws a leader and a marker with no authoring', () => {
|
|
290
|
+
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak' }]);
|
|
291
|
+
|
|
292
|
+
// The "minimal spec is publication-ready" rule.
|
|
293
|
+
expect(a.label?.connector).toBeDefined();
|
|
294
|
+
expect(a.dot).toBeDefined();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("the leader ends on the data point, not on the label's own edge", () => {
|
|
298
|
+
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'top' }]);
|
|
299
|
+
const c = a.label!.connector!;
|
|
300
|
+
const dot = a.dot!;
|
|
301
|
+
|
|
302
|
+
// `to` is pulled back off the marker, but it aims at the point: within the
|
|
303
|
+
// marker's own radius plus its stroke.
|
|
304
|
+
const gap = Math.hypot(dot.x - c.to.x, dot.y - c.to.y);
|
|
305
|
+
expect(gap).toBeLessThanOrEqual(DEFAULT_DOT_RADIUS + DEFAULT_DOT_STROKE_WIDTH + 3);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('the leader starts outside the text, never inside it', () => {
|
|
309
|
+
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'A longer callout', anchor: 'top' }]);
|
|
310
|
+
const c = a.label!.connector!;
|
|
311
|
+
const b = a.bounds!;
|
|
312
|
+
|
|
313
|
+
const insideText =
|
|
314
|
+
c.from.x > b.x && c.from.x < b.x + b.width && c.from.y > b.y && c.from.y < b.y + b.height;
|
|
315
|
+
expect(insideText).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('a leader too short to read is dropped, and the marker carries it alone', () => {
|
|
319
|
+
// Label pulled right down onto its point.
|
|
320
|
+
const a = first([
|
|
321
|
+
{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'top', offset: { dx: 0, dy: 26 } },
|
|
322
|
+
]);
|
|
323
|
+
|
|
324
|
+
expect(a.label?.connector).toBeUndefined();
|
|
325
|
+
// The marker survives: the reader still knows which point is meant.
|
|
326
|
+
expect(a.dot).toBeDefined();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test.each([
|
|
330
|
+
false,
|
|
331
|
+
true,
|
|
332
|
+
])('whatever leader IS drawn is long enough to read as a line (arrow: %s)', (arrow) => {
|
|
333
|
+
// Sweep the label away from its point and back in. Every connector that
|
|
334
|
+
// survives must have a strokable length -- never a 1px nub with a head on it.
|
|
335
|
+
//
|
|
336
|
+
// The sweep has to START far out. An earlier version ran dy 0..26, which only
|
|
337
|
+
// ever moves the block TOWARD the point: 53 of its 54 iterations hit the
|
|
338
|
+
// `suppressed` continue, the arrowed branch drew nothing at all, and the whole
|
|
339
|
+
// test asserted once. It could not have failed if the arrow-aware gate were
|
|
340
|
+
// deleted, which is the one thing it exists to protect.
|
|
341
|
+
let asserted = 0;
|
|
342
|
+
|
|
343
|
+
for (let dy = -60; dy <= 26; dy++) {
|
|
344
|
+
const a = first([
|
|
345
|
+
{
|
|
346
|
+
type: 'text',
|
|
347
|
+
x: 'Mar',
|
|
348
|
+
y: 30,
|
|
349
|
+
text: 'Peak',
|
|
350
|
+
anchor: 'top',
|
|
351
|
+
offset: { dx: 0, dy },
|
|
352
|
+
connector: arrow ? { type: 'straight', arrow: true } : 'straight',
|
|
353
|
+
},
|
|
354
|
+
]);
|
|
355
|
+
const stroke = strokedLength(a);
|
|
356
|
+
if (stroke === null) continue; // suppressed, which is a valid outcome
|
|
357
|
+
expect(stroke).toBeGreaterThanOrEqual(MIN_CONNECTOR_LENGTH);
|
|
358
|
+
asserted++;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Guard the guard: a sweep that suppresses everything asserts nothing.
|
|
362
|
+
expect(asserted).toBeGreaterThan(30);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('a drop-line hangs straight down from the words to the point', () => {
|
|
366
|
+
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', connector: 'drop-line' }]);
|
|
367
|
+
const c = a.label!.connector!;
|
|
368
|
+
const b = a.bounds!;
|
|
369
|
+
|
|
370
|
+
// Vertical, pinned to the point's x.
|
|
371
|
+
expect(c.from.x).toBeCloseTo(c.to.x, 5);
|
|
372
|
+
expect(c.from.x).toBeCloseTo(a.dot!.x, 5);
|
|
373
|
+
// Runs downward, from a block that sits above the point.
|
|
374
|
+
expect(c.from.y).toBeLessThan(c.to.y);
|
|
375
|
+
expect(b.y + b.height).toBeLessThanOrEqual(a.dot!.y);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// --- typography the reader sees ------------------------------------------
|
|
380
|
+
|
|
381
|
+
describe('the words are styled the way the design says', () => {
|
|
382
|
+
test('a plain callout is regular weight at the default size', () => {
|
|
383
|
+
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak' }]);
|
|
384
|
+
|
|
385
|
+
expect(a.label!.style.fontSize).toBe(DEFAULT_ANNOTATION_FONT_SIZE);
|
|
386
|
+
expect(a.label!.style.fontWeight).toBe(DEFAULT_ANNOTATION_FONT_WEIGHT);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('adding a subtitle promotes the first line to a bold lede', () => {
|
|
390
|
+
const a = first([
|
|
391
|
+
{ type: 'text', x: 'Mar', y: 30, text: '+1.17°C', subtitle: 'the warmest year' },
|
|
392
|
+
]);
|
|
393
|
+
|
|
394
|
+
// This is the pairing the whole lede rule exists for. It once shipped dead.
|
|
395
|
+
expect(a.label!.style.fontWeight).toBe(LEDE_FONT_WEIGHT);
|
|
396
|
+
expect(a.subtitle!.text).toBe('the warmest year');
|
|
397
|
+
expect(a.subtitle!.style.fontWeight).toBe(SUBTITLE_FONT_WEIGHT);
|
|
398
|
+
expect(a.subtitle!.style.fontSize).toBe(subtitleFontSize(DEFAULT_ANNOTATION_FONT_SIZE));
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('an explicit weight beats the lede rule', () => {
|
|
402
|
+
const a = first([
|
|
403
|
+
{ type: 'text', x: 'Mar', y: 30, text: 'Peak', subtitle: 'context', fontWeight: 500 },
|
|
404
|
+
]);
|
|
405
|
+
expect(a.label!.style.fontWeight).toBe(500);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test('the subtitle sits under the primary line and shares its left edge', () => {
|
|
409
|
+
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', subtitle: 'context' }]);
|
|
410
|
+
|
|
411
|
+
expect(a.subtitle!.y).toBeGreaterThan(a.label!.y);
|
|
412
|
+
expect(a.subtitle!.x).toBeCloseTo(a.label!.x, 5);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test('a subtitle works on a drop-line too', () => {
|
|
416
|
+
const a = first([
|
|
417
|
+
{ type: 'text', x: 'Mar', y: 30, text: 'Peak', subtitle: 'context', connector: 'drop-line' },
|
|
418
|
+
]);
|
|
419
|
+
expect(a.subtitle?.text).toBe('context');
|
|
420
|
+
expect(a.label!.style.fontWeight).toBe(LEDE_FONT_WEIGHT);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('text is never center-aligned; it faces the data', () => {
|
|
424
|
+
const left = first([{ type: 'text', x: 'Mar', y: 30, text: 'a\nb', anchor: 'left' }]);
|
|
425
|
+
const right = first([{ type: 'text', x: 'Mar', y: 30, text: 'a\nb', anchor: 'right' }]);
|
|
426
|
+
const top = first([{ type: 'text', x: 'Mar', y: 30, text: 'a\nb', anchor: 'top' }]);
|
|
427
|
+
|
|
428
|
+
// A left-anchored block's right edge faces the point, so it rags right-aligned.
|
|
429
|
+
expect(left.label!.style.textAnchor).toBe('end');
|
|
430
|
+
expect(right.label!.style.textAnchor).toBe('start');
|
|
431
|
+
expect(top.label!.style.textAnchor).toBe('start');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('the block grows to fit a subtitle, so collisions can see it', () => {
|
|
435
|
+
const plain = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak' }]);
|
|
436
|
+
const withSub = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', subtitle: 'context' }]);
|
|
437
|
+
|
|
438
|
+
expect(withSub.bounds!.height).toBeGreaterThan(plain.bounds!.height);
|
|
439
|
+
// ...but the label's own box stays the label's own box (the plate is sized
|
|
440
|
+
// from it, so a subtitle must not inflate it).
|
|
441
|
+
expect(withSub.label!.bounds!.height).toBeCloseTo(plain.label!.bounds!.height, 5);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// --- bold spans ----------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
describe('**bold** emphasis rides inside the sentence', () => {
|
|
448
|
+
/**
|
|
449
|
+
* Compile with a WEIGHT-AWARE measurer, i.e. what a browser gives us.
|
|
450
|
+
*
|
|
451
|
+
* The default `heuristicMeasure` (the SSR/static fallback) is weight-blind, so
|
|
452
|
+
* bold and regular measure identically there and this test would prove nothing.
|
|
453
|
+
* The real canvas path does distinguish them, and that's the path where a bold
|
|
454
|
+
* span's extra width has to reach bounds -- otherwise collisions and connector
|
|
455
|
+
* exits are computed against a box narrower than the drawn text.
|
|
456
|
+
*/
|
|
457
|
+
const widthWith = (text: string): number => {
|
|
458
|
+
const layout: ChartLayout = compileChart(
|
|
459
|
+
{
|
|
460
|
+
mark: 'line',
|
|
461
|
+
data: SERIES,
|
|
462
|
+
encoding: {
|
|
463
|
+
x: { field: 'm', type: 'ordinal' },
|
|
464
|
+
y: { field: 'v', type: 'quantitative' },
|
|
465
|
+
},
|
|
466
|
+
annotations: [{ type: 'text', x: 'Mar', y: 30, text }],
|
|
467
|
+
} as never,
|
|
468
|
+
{
|
|
469
|
+
width: 800,
|
|
470
|
+
height: 460,
|
|
471
|
+
measureText: (t: string, fontSize: number, fontWeight = 400) => ({
|
|
472
|
+
width: t.length * fontSize * (fontWeight >= 600 ? 0.62 : 0.5),
|
|
473
|
+
height: fontSize,
|
|
474
|
+
}),
|
|
475
|
+
},
|
|
476
|
+
);
|
|
477
|
+
return layout.annotations![0].bounds!.width;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
test('a bold span widens the block, and the ** markers take no space', () => {
|
|
481
|
+
const plain = widthWith('Jobs fell to 12,000');
|
|
482
|
+
const bold = widthWith('Jobs fell to **12,000**');
|
|
483
|
+
|
|
484
|
+
// Bold is wider than regular: the span is measured at its own weight.
|
|
485
|
+
expect(bold).toBeGreaterThan(plain);
|
|
486
|
+
|
|
487
|
+
// And the `**` markers are consumed, not measured. Four asterisks at 13px
|
|
488
|
+
// would add ~26px on their own; the real gain is just the 6 bold characters
|
|
489
|
+
// going from 0.5 to 0.62 width (6 * 13 * 0.12 ≈ 9.4px).
|
|
490
|
+
expect(bold - plain).toBeLessThan(15);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test('an unmatched ** is just text', () => {
|
|
494
|
+
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'unmatched ** here' }]);
|
|
495
|
+
// Round-trips verbatim; the renderer will draw the asterisks.
|
|
496
|
+
expect(a.label!.text).toBe('unmatched ** here');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('a bold subtitle span is measured too', () => {
|
|
500
|
+
const plain = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', subtitle: 'under 0.3C' }]);
|
|
501
|
+
const bold = first([
|
|
502
|
+
{ type: 'text', x: 'Mar', y: 30, text: 'Peak', subtitle: 'under **0.3C**' },
|
|
503
|
+
]);
|
|
504
|
+
// The markers are stripped from the rendered string in both engines.
|
|
505
|
+
expect(bold.subtitle!.text).toBe('under **0.3C**');
|
|
506
|
+
expect(plain.subtitle!.text).toBe('under 0.3C');
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// --- the marker ----------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
describe('the endpoint marker', () => {
|
|
513
|
+
test('an arrowed callout gets no marker: the arrowhead already points', () => {
|
|
514
|
+
const a = first([
|
|
515
|
+
{ type: 'text', x: 'Mar', y: 30, text: 'Peak', connector: { type: 'curve', arrow: true } },
|
|
516
|
+
]);
|
|
517
|
+
expect(a.dot).toBeUndefined();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test('a quiet leader gets an open-ring marker to terminate it', () => {
|
|
521
|
+
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', connector: 'straight' }]);
|
|
522
|
+
expect(a.dot).toBeDefined();
|
|
523
|
+
expect(a.dot!.radius).toBe(DEFAULT_DOT_RADIUS);
|
|
524
|
+
// Ring, not disc: the fill is the surface, the stroke is the connector's ink.
|
|
525
|
+
expect(a.dot!.stroke).toBe(a.label!.connector!.stroke);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('dot:false means bare, always', () => {
|
|
529
|
+
const a = first([
|
|
530
|
+
{ type: 'text', x: 'Mar', y: 30, text: 'Peak', connector: 'straight', dot: false },
|
|
531
|
+
]);
|
|
532
|
+
expect(a.dot).toBeUndefined();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test('the marker sits on the data point', () => {
|
|
536
|
+
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'top' }]);
|
|
537
|
+
const layout = compileChart(
|
|
538
|
+
{
|
|
539
|
+
mark: 'line',
|
|
540
|
+
data: SERIES,
|
|
541
|
+
encoding: {
|
|
542
|
+
x: { field: 'm', type: 'ordinal' },
|
|
543
|
+
y: { field: 'v', type: 'quantitative', scale: { zero: true } },
|
|
544
|
+
},
|
|
545
|
+
annotations: [{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'top' }],
|
|
546
|
+
} as ChartSpec,
|
|
547
|
+
{ width: 800, height: 460 },
|
|
548
|
+
);
|
|
549
|
+
const marks = layout.marks.flatMap((m) => ('points' in m ? (m.points ?? []) : []));
|
|
550
|
+
const mar = marks[2]; // third datum
|
|
551
|
+
expect(a.dot!.x).toBeCloseTo(mar.x, 1);
|
|
552
|
+
expect(a.dot!.y).toBeCloseTo(mar.y, 1);
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// --- collisions: the reason the mutation passes exist --------------------
|
|
557
|
+
|
|
558
|
+
describe('callouts get out of each other and stay readable', () => {
|
|
559
|
+
const crowded = () =>
|
|
560
|
+
annotationsFor([
|
|
561
|
+
{ type: 'text', x: 'Jan', y: 10, text: 'First callout here' },
|
|
562
|
+
{ type: 'text', x: 'Feb', y: 50, text: 'Second callout here' },
|
|
563
|
+
{ type: 'text', x: 'Mar', y: 30, text: 'Third callout here' },
|
|
564
|
+
{ type: 'text', x: 'Apr', y: 42, text: 'Fourth callout here' },
|
|
565
|
+
]);
|
|
566
|
+
|
|
567
|
+
test('an auto-placed block sits on the side its text alignment says it does', () => {
|
|
568
|
+
// The scored search picks a side per callout, and a left-side result gets
|
|
569
|
+
// textAnchor 'end' -- the text then extends LEFT of `label.x` instead of right.
|
|
570
|
+
// If `bounds` were computed before that flip were applied, it would sit on the
|
|
571
|
+
// wrong side of the origin: the box other callouts avoid, and that thinning
|
|
572
|
+
// measures, would be a mirror image of the words on screen.
|
|
573
|
+
for (const a of crowded()) {
|
|
574
|
+
if (a.footnoteIndex != null || !a.bounds || !a.label) continue;
|
|
575
|
+
const b = a.bounds;
|
|
576
|
+
const lx = a.label.x;
|
|
577
|
+
|
|
578
|
+
if (a.label.style.textAnchor === 'end') {
|
|
579
|
+
expect(b.x + b.width).toBeCloseTo(lx, 0);
|
|
580
|
+
} else {
|
|
581
|
+
expect(b.x).toBeCloseTo(lx, 0);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test('no two callout blocks overlap each other', () => {
|
|
587
|
+
const blocks = crowded()
|
|
588
|
+
.filter((a) => a.footnoteIndex == null)
|
|
589
|
+
.map((a) => a.bounds!)
|
|
590
|
+
.filter(Boolean);
|
|
591
|
+
|
|
592
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
593
|
+
for (let j = i + 1; j < blocks.length; j++) {
|
|
594
|
+
expect(overlaps(blocks[i], blocks[j])).toBe(false);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test('every callout keeps a leader that still reaches its own point', () => {
|
|
600
|
+
for (const a of crowded()) {
|
|
601
|
+
if (!a.label?.connector || !a.dot) continue;
|
|
602
|
+
const c = a.label.connector;
|
|
603
|
+
const gap = Math.hypot(a.dot.x - c.to.x, a.dot.y - c.to.y);
|
|
604
|
+
// After being shoved around, the leader still terminates at its marker.
|
|
605
|
+
expect(gap).toBeLessThanOrEqual(DEFAULT_DOT_RADIUS + DEFAULT_DOT_STROKE_WIDTH + 3);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test('a nudged callout brings its subtitle with it', () => {
|
|
610
|
+
const nudged = annotationsFor([
|
|
611
|
+
{ type: 'text', x: 'Jan', y: 10, text: 'First callout', subtitle: 'with context' },
|
|
612
|
+
{ type: 'text', x: 'Feb', y: 50, text: 'Second callout', subtitle: 'with context' },
|
|
613
|
+
{ type: 'text', x: 'Mar', y: 30, text: 'Third callout', subtitle: 'with context' },
|
|
614
|
+
]);
|
|
615
|
+
|
|
616
|
+
for (const a of nudged) {
|
|
617
|
+
if (!a.subtitle || !a.label) continue;
|
|
618
|
+
// The subtitle carries absolute coords; if a pass moves the label and
|
|
619
|
+
// forgets the subtitle, they separate. They must stay locked.
|
|
620
|
+
expect(a.subtitle.x).toBeCloseTo(a.label.x, 5);
|
|
621
|
+
expect(a.subtitle.y).toBeGreaterThan(a.label.y);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test('drop-lines stay vertical even when they collide', () => {
|
|
626
|
+
const both = annotationsFor([
|
|
627
|
+
{ type: 'text', x: 'Feb', y: 50, text: 'First drop-line here', connector: 'drop-line' },
|
|
628
|
+
{ type: 'text', x: 'Mar', y: 30, text: 'Second drop-line here', connector: 'drop-line' },
|
|
629
|
+
]);
|
|
630
|
+
|
|
631
|
+
for (const a of both) {
|
|
632
|
+
const c = a.label?.connector;
|
|
633
|
+
expect(c).toBeDefined();
|
|
634
|
+
expect(c!.from.x).toBeCloseTo(c!.to.x, 5);
|
|
635
|
+
expect(c!.from.x).toBeCloseTo(a.dot!.x, 5);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// --- thinning ------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
describe('when callouts cannot all fit, they demote to footnotes', () => {
|
|
643
|
+
const long = (s: string) => s.repeat(10);
|
|
644
|
+
|
|
645
|
+
const thinned = () =>
|
|
646
|
+
compileChart(
|
|
647
|
+
{
|
|
648
|
+
mark: 'line',
|
|
649
|
+
autoThin: true,
|
|
650
|
+
data: SERIES,
|
|
651
|
+
encoding: {
|
|
652
|
+
x: { field: 'm', type: 'ordinal' },
|
|
653
|
+
y: { field: 'v', type: 'quantitative' },
|
|
654
|
+
},
|
|
655
|
+
annotations: [
|
|
656
|
+
{ type: 'text', x: 'Jan', y: 10, text: long('AAA ') },
|
|
657
|
+
{ type: 'text', x: 'Feb', y: 50, text: long('BBB ') },
|
|
658
|
+
{ type: 'text', x: 'Mar', y: 30, text: long('CCC ') },
|
|
659
|
+
{ type: 'text', x: 'Apr', y: 42, text: long('DDD ') },
|
|
660
|
+
],
|
|
661
|
+
} as ChartSpec,
|
|
662
|
+
{ width: 420, height: 300 },
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
test('a demoted callout keeps its marker so the number has something to sit by', () => {
|
|
666
|
+
const layout = thinned();
|
|
667
|
+
const demoted = layout.annotations!.filter((a) => a.footnoteIndex != null);
|
|
668
|
+
expect(demoted.length).toBeGreaterThan(0);
|
|
669
|
+
|
|
670
|
+
for (const a of demoted) {
|
|
671
|
+
expect(a.dot).toBeDefined();
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test('every footnote marker points at its own text', () => {
|
|
676
|
+
const layout = thinned();
|
|
677
|
+
const byNumber = new Map((layout.chrome.footnotes ?? []).map((f) => [f.index, f.text]));
|
|
678
|
+
|
|
679
|
+
let checked = 0;
|
|
680
|
+
for (const a of layout.annotations!) {
|
|
681
|
+
if (a.footnoteIndex == null) continue;
|
|
682
|
+
expect(byNumber.get(a.footnoteIndex)).toBe(a.label?.text);
|
|
683
|
+
checked++;
|
|
684
|
+
}
|
|
685
|
+
expect(checked).toBeGreaterThan(0);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test('the footnote list is numbered from one, with no gaps or repeats', () => {
|
|
689
|
+
const footnotes = thinned().chrome.footnotes ?? [];
|
|
690
|
+
expect(footnotes.length).toBeGreaterThan(0);
|
|
691
|
+
expect(footnotes.map((f) => f.index)).toEqual(
|
|
692
|
+
Array.from({ length: footnotes.length }, (_, i) => i + 1),
|
|
693
|
+
);
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// --- data-coordinate stability -------------------------------------------
|
|
698
|
+
|
|
699
|
+
describe('callouts are anchored in the data, not in pixels', () => {
|
|
700
|
+
test('resizing the chart moves a callout with its data point', () => {
|
|
701
|
+
const narrow = annotationsFor([{ type: 'text', x: 'Mar', y: 30, text: 'Peak' }], 600, 400)[0];
|
|
702
|
+
const wide = annotationsFor([{ type: 'text', x: 'Mar', y: 30, text: 'Peak' }], 900, 400)[0];
|
|
703
|
+
|
|
704
|
+
// Different pixel positions...
|
|
705
|
+
expect(wide.dot!.x).not.toBeCloseTo(narrow.dot!.x, 1);
|
|
706
|
+
// ...but the label keeps the same relationship to its point in both.
|
|
707
|
+
const offsetNarrow = narrow.label!.x - narrow.dot!.x;
|
|
708
|
+
const offsetWide = wide.label!.x - wide.dot!.x;
|
|
709
|
+
expect(offsetWide).toBeCloseTo(offsetNarrow, 1);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test('an annotation on a value outside the domain is dropped, not misplaced', () => {
|
|
713
|
+
const all = annotationsFor([
|
|
714
|
+
{ type: 'text', x: 'Mar', y: 30, text: 'Real' },
|
|
715
|
+
{ type: 'text', x: 'NotAMonth', y: 30, text: 'Bogus' },
|
|
716
|
+
]);
|
|
717
|
+
|
|
718
|
+
expect(all).toHaveLength(1);
|
|
719
|
+
expect(all[0].label!.text).toBe('Real');
|
|
720
|
+
});
|
|
721
|
+
});
|