@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
|
@@ -11,19 +11,57 @@ import type {
|
|
|
11
11
|
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
12
12
|
import {
|
|
13
13
|
ANCHOR_OFFSET,
|
|
14
|
+
ARROWHEAD_HALF_WIDTH,
|
|
15
|
+
ARROWHEAD_LENGTH,
|
|
16
|
+
CONNECTOR_STANDOFF,
|
|
17
|
+
connectorIsDrawable,
|
|
14
18
|
DEFAULT_ANNOTATION_FONT_SIZE,
|
|
15
19
|
DEFAULT_ANNOTATION_FONT_WEIGHT,
|
|
16
20
|
DEFAULT_LINE_HEIGHT,
|
|
21
|
+
DROP_LINE_TOP_GAP,
|
|
17
22
|
} from './constants';
|
|
23
|
+
import { BOLD_SPAN_FONT_WEIGHT, parseAnnotationSpans } from './rich-text';
|
|
18
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Measure one run of text at a given size and weight.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately takes no `fontFamily`: neither implementation could use one.
|
|
29
|
+
* `heuristicMeasure` below is family-blind, and the real canvas measurer gets its
|
|
30
|
+
* family from the `createMeasureText(fontFamily)` closure, not per call -- so a
|
|
31
|
+
* family passed here would be silently dropped while implying the opposite.
|
|
32
|
+
* `style.fontFamily` on a *resolved label* is a different thing: that one is real
|
|
33
|
+
* and gets rendered.
|
|
34
|
+
*/
|
|
19
35
|
export type AnnotationMeasureTextFn = (
|
|
20
36
|
text: string,
|
|
21
|
-
font: { fontSize: number; fontWeight: number
|
|
37
|
+
font: { fontSize: number; fontWeight: number },
|
|
22
38
|
) => number;
|
|
23
39
|
|
|
24
40
|
export const heuristicMeasure: AnnotationMeasureTextFn = (text, { fontSize, fontWeight }) =>
|
|
25
41
|
estimateTextWidth(text, fontSize, fontWeight);
|
|
26
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Width of one line of annotation text, `**bold**` spans included: each span is
|
|
45
|
+
* measured at its own weight and the widths are summed. Measuring the raw string
|
|
46
|
+
* would count the `**` markers and use one weight for the whole line, so bounds,
|
|
47
|
+
* collisions, placement, and connector exits would all be wrong for rich text.
|
|
48
|
+
*/
|
|
49
|
+
export function measureRichLine(
|
|
50
|
+
line: string,
|
|
51
|
+
style: { fontSize: number; fontWeight: number },
|
|
52
|
+
measure: AnnotationMeasureTextFn,
|
|
53
|
+
): number {
|
|
54
|
+
const spans = parseAnnotationSpans(line);
|
|
55
|
+
let width = 0;
|
|
56
|
+
for (const span of spans) {
|
|
57
|
+
width += measure(span.text, {
|
|
58
|
+
fontSize: style.fontSize,
|
|
59
|
+
fontWeight: span.bold ? BOLD_SPAN_FONT_WEIGHT : style.fontWeight,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return width;
|
|
63
|
+
}
|
|
64
|
+
|
|
27
65
|
/**
|
|
28
66
|
* Compute the bounding box of a text block, aware of textAnchor and multi-line layout.
|
|
29
67
|
* labelY is the first-line baseline.
|
|
@@ -37,15 +75,12 @@ export function computeTextBlockBounds(
|
|
|
37
75
|
fontWeight: number;
|
|
38
76
|
lineHeight: number;
|
|
39
77
|
textAnchor?: 'start' | 'middle' | 'end';
|
|
78
|
+
fontFamily?: string;
|
|
40
79
|
},
|
|
41
80
|
measure: AnnotationMeasureTextFn = heuristicMeasure,
|
|
42
81
|
): Rect {
|
|
43
82
|
const lines = text.split('\n');
|
|
44
|
-
const maxWidth = Math.max(
|
|
45
|
-
...lines.map((line) =>
|
|
46
|
-
measure(line, { fontSize: style.fontSize, fontWeight: style.fontWeight }),
|
|
47
|
-
),
|
|
48
|
-
);
|
|
83
|
+
const maxWidth = Math.max(...lines.map((line) => measureRichLine(line, style, measure)));
|
|
49
84
|
const height =
|
|
50
85
|
style.fontSize + (lines.length - 1) * style.fontSize * style.lineHeight + style.fontSize * 0.3;
|
|
51
86
|
|
|
@@ -119,59 +154,73 @@ export function applyOffset(
|
|
|
119
154
|
};
|
|
120
155
|
}
|
|
121
156
|
|
|
157
|
+
/** Inflation applied to the label box before intersecting the connector ray. */
|
|
158
|
+
const BOX_INFLATE = 2;
|
|
159
|
+
|
|
122
160
|
/**
|
|
123
|
-
* Compute
|
|
124
|
-
*
|
|
125
|
-
*
|
|
161
|
+
* Compute where a connector leaves the annotation's text block on its way to the
|
|
162
|
+
* target. Casts a ray from the box center toward the target, intersects the
|
|
163
|
+
* (slightly inflated) box, then advances `standoff` px along the ray so the line
|
|
164
|
+
* never touches the glyphs.
|
|
165
|
+
*
|
|
166
|
+
* Returns `null` when the target sits inside the inflated box (a connector would
|
|
167
|
+
* cross its own text) or when the standoff would overshoot the target.
|
|
126
168
|
*/
|
|
127
|
-
export function
|
|
128
|
-
|
|
129
|
-
labelY: number,
|
|
130
|
-
text: string,
|
|
131
|
-
fontSize: number,
|
|
132
|
-
fontWeight: number,
|
|
169
|
+
export function connectorExit(
|
|
170
|
+
bounds: Rect,
|
|
133
171
|
targetX: number,
|
|
134
172
|
targetY: number,
|
|
135
|
-
|
|
136
|
-
): { x: number; y: number } {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
const boxCenterY = box.y + box.height / 2;
|
|
148
|
-
|
|
149
|
-
// Curve connectors always start from the right edge
|
|
150
|
-
if (connectorStyle === 'curve') {
|
|
151
|
-
return {
|
|
152
|
-
x: box.x + box.width,
|
|
153
|
-
y: boxCenterY,
|
|
154
|
-
};
|
|
155
|
-
}
|
|
173
|
+
standoff = CONNECTOR_STANDOFF,
|
|
174
|
+
): { x: number; y: number; exit: 'horizontal' | 'vertical' } | null {
|
|
175
|
+
const box = {
|
|
176
|
+
x: bounds.x - BOX_INFLATE,
|
|
177
|
+
y: bounds.y - BOX_INFLATE,
|
|
178
|
+
width: bounds.width + BOX_INFLATE * 2,
|
|
179
|
+
height: bounds.height + BOX_INFLATE * 2,
|
|
180
|
+
};
|
|
181
|
+
const cx = box.x + box.width / 2;
|
|
182
|
+
const cy = box.y + box.height / 2;
|
|
183
|
+
const halfW = box.width / 2;
|
|
184
|
+
const halfH = box.height / 2;
|
|
156
185
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
186
|
+
const dx = targetX - cx;
|
|
187
|
+
const dy = targetY - cy;
|
|
188
|
+
|
|
189
|
+
// Target inside the inflated box: any connector would cross the text.
|
|
190
|
+
if (Math.abs(dx) <= halfW && Math.abs(dy) <= halfH) return null;
|
|
191
|
+
if (dx === 0 && dy === 0) return null;
|
|
192
|
+
|
|
193
|
+
// Parametric ray: center + t * (dx, dy). Find the smallest t that lands on an
|
|
194
|
+
// edge of the box, and which axis that edge belongs to.
|
|
195
|
+
const tx = halfW > 0 && dx !== 0 ? halfW / Math.abs(dx) : Number.POSITIVE_INFINITY;
|
|
196
|
+
const ty = halfH > 0 && dy !== 0 ? halfH / Math.abs(dy) : Number.POSITIVE_INFINITY;
|
|
197
|
+
const t = Math.min(tx, ty);
|
|
198
|
+
if (!Number.isFinite(t)) return null;
|
|
199
|
+
|
|
200
|
+
const exit: 'horizontal' | 'vertical' = tx <= ty ? 'horizontal' : 'vertical';
|
|
201
|
+
|
|
202
|
+
const edgeX = cx + dx * t;
|
|
203
|
+
const edgeY = cy + dy * t;
|
|
204
|
+
|
|
205
|
+
// Advance along the ray by the standoff distance.
|
|
206
|
+
const rayLen = Math.sqrt(dx * dx + dy * dy);
|
|
207
|
+
const ux = dx / rayLen;
|
|
208
|
+
const uy = dy / rayLen;
|
|
209
|
+
|
|
210
|
+
// Overshoot guard: the standoff must not push the origin past the target.
|
|
211
|
+
// Measure edge-to-target in pixels rather than scaling the ray by (1 - t):
|
|
212
|
+
// t is a ratio of box extents, so that form understates the clearance when the
|
|
213
|
+
// ray leaves through the long side of a wide label, and killed connectors that
|
|
214
|
+
// had ample room. Whether the line is long enough to be worth drawing is
|
|
215
|
+
// decided downstream against MIN_CONNECTOR_LENGTH, once the marker pullback is
|
|
216
|
+
// known — this guard only rejects a standoff that would overshoot the target.
|
|
217
|
+
const edgeToTarget = Math.hypot(targetX - edgeX, targetY - edgeY);
|
|
218
|
+
if (standoff >= edgeToTarget) return null;
|
|
219
|
+
|
|
220
|
+
const x = edgeX + ux * standoff;
|
|
221
|
+
const y = edgeY + uy * standoff;
|
|
222
|
+
|
|
223
|
+
return { x, y, exit };
|
|
175
224
|
}
|
|
176
225
|
|
|
177
226
|
/** Estimate the bounding box of an annotation label using its resolved style. */
|
|
@@ -190,36 +239,132 @@ export function estimateLabelBounds(
|
|
|
190
239
|
fontWeight,
|
|
191
240
|
lineHeight: label.style.lineHeight ?? DEFAULT_LINE_HEIGHT,
|
|
192
241
|
textAnchor: label.style.textAnchor ?? 'start',
|
|
242
|
+
fontFamily: label.style.fontFamily,
|
|
193
243
|
},
|
|
194
244
|
measure,
|
|
195
245
|
);
|
|
196
246
|
}
|
|
197
247
|
|
|
248
|
+
/** Arrowhead triangle points: tip + two base corners. */
|
|
249
|
+
export interface ArrowheadPoints {
|
|
250
|
+
tip: { x: number; y: number };
|
|
251
|
+
baseLeft: { x: number; y: number };
|
|
252
|
+
baseRight: { x: number; y: number };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Both live in `constants.ts` (the min-length gate has to reason about the
|
|
256
|
+
// arrowhead's stroke budget, and constants.ts can't import from here without a
|
|
257
|
+
// cycle). Re-exported so the renderer's import path doesn't move.
|
|
258
|
+
export { ARROWHEAD_HALF_WIDTH, ARROWHEAD_LENGTH };
|
|
259
|
+
|
|
198
260
|
/**
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
261
|
+
* Compute arrowhead triangle geometry at a connector endpoint.
|
|
262
|
+
* Returns the tip (at the endpoint) and two base corners perpendicular to the
|
|
263
|
+
* tangent direction.
|
|
264
|
+
*
|
|
265
|
+
* @param tipX - X coordinate of the arrowhead tip
|
|
266
|
+
* @param tipY - Y coordinate of the arrowhead tip
|
|
267
|
+
* @param tangentX - X component of the tangent direction (toward the tip)
|
|
268
|
+
* @param tangentY - Y component of the tangent direction (toward the tip)
|
|
269
|
+
* @param length - Arrow length along the tangent (default ARROWHEAD_LENGTH)
|
|
270
|
+
* @param halfWidth - Arrow half-width perpendicular to tangent (default ARROWHEAD_HALF_WIDTH)
|
|
202
271
|
*/
|
|
203
|
-
export function
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
272
|
+
export function computeArrowheadPoints(
|
|
273
|
+
tipX: number,
|
|
274
|
+
tipY: number,
|
|
275
|
+
tangentX: number,
|
|
276
|
+
tangentY: number,
|
|
277
|
+
length = ARROWHEAD_LENGTH,
|
|
278
|
+
halfWidth = ARROWHEAD_HALF_WIDTH,
|
|
279
|
+
): ArrowheadPoints {
|
|
280
|
+
const tLen = Math.sqrt(tangentX * tangentX + tangentY * tangentY) || 1;
|
|
281
|
+
const ux = tangentX / tLen;
|
|
282
|
+
const uy = tangentY / tLen;
|
|
283
|
+
|
|
284
|
+
const baseX = tipX - ux * length;
|
|
285
|
+
const baseY = tipY - uy * length;
|
|
286
|
+
|
|
287
|
+
const px = -uy;
|
|
288
|
+
const py = ux;
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
tip: { x: tipX, y: tipY },
|
|
292
|
+
baseLeft: { x: baseX + px * halfWidth, y: baseY + py * halfWidth },
|
|
293
|
+
baseRight: { x: baseX - px * halfWidth, y: baseY - py * halfWidth },
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Distance the connector's data-point end is pulled back so the line stops short
|
|
299
|
+
* of the endpoint marker instead of piercing it.
|
|
300
|
+
*/
|
|
301
|
+
export function connectorPullbackGap(markerRadius: number, arrow: boolean): number {
|
|
302
|
+
return arrow ? Math.max(markerRadius + 2, 2) : Math.max(markerRadius + 3, 4);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Rebuild a connector's geometry against a (possibly moved) annotation bounds.
|
|
307
|
+
*
|
|
308
|
+
* `from` comes from the ray-box exit; `to` is recomputed by pulling back from
|
|
309
|
+
* `connector.endpoint` — never from the already-pulled-back `to`, which would
|
|
310
|
+
* shrink the line a little more on every pass.
|
|
311
|
+
*
|
|
312
|
+
* Returns `undefined` when the connector should be suppressed: the target sits
|
|
313
|
+
* inside the text block, or the resulting line is shorter than
|
|
314
|
+
* `MIN_CONNECTOR_LENGTH`.
|
|
315
|
+
*/
|
|
316
|
+
export function refreshConnector(
|
|
317
|
+
connector: NonNullable<ResolvedLabel['connector']>,
|
|
318
|
+
bounds: Rect,
|
|
319
|
+
markerRadius: number,
|
|
207
320
|
): ResolvedLabel['connector'] {
|
|
208
|
-
|
|
209
|
-
|
|
321
|
+
// `endpoint` is the un-pulled-back data point, and the only valid basis for a
|
|
322
|
+
// rebuild: pulling back from `to` (already pulled back) shrinks the line a
|
|
323
|
+
// little more on every pass. Every resolver sets it, but the field is optional
|
|
324
|
+
// on the public `ResolvedLabel` type, so a layout from another producer could
|
|
325
|
+
// omit it — bail rather than silently compound the error.
|
|
326
|
+
if (!connector.endpoint) return undefined;
|
|
327
|
+
const endpoint = connector.endpoint;
|
|
210
328
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
329
|
+
// A drop-line is not a leader: it's a vertical rule pinned to the data point's
|
|
330
|
+
// x, running from just above the label box down to the marker. Rebuilding it
|
|
331
|
+
// from a ray-box exit tilts it into a diagonal (which the renderer draws with
|
|
332
|
+
// shape-rendering: crispEdges, assuming axis alignment), and the
|
|
333
|
+
// MIN_CONNECTOR_LENGTH rule below would delete it outright once the label sits
|
|
334
|
+
// near its point.
|
|
335
|
+
//
|
|
336
|
+
// This mirrors `resolveDropLineAnnotation`'s formula exactly — `from` above the
|
|
337
|
+
// label top, `to` pulled back from the point — so a nudged drop-line keeps the
|
|
338
|
+
// same geometry it resolved with, just at the label's new position.
|
|
339
|
+
if (connector.style === 'drop-line') {
|
|
340
|
+
const gap = connectorPullbackGap(markerRadius, false);
|
|
341
|
+
return {
|
|
342
|
+
...connector,
|
|
343
|
+
from: { x: endpoint.x, y: bounds.y - DROP_LINE_TOP_GAP },
|
|
344
|
+
to: { x: endpoint.x, y: endpoint.y - gap },
|
|
345
|
+
exit: 'vertical',
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const exit = connectorExit(bounds, endpoint.x, endpoint.y);
|
|
350
|
+
if (!exit) return undefined;
|
|
351
|
+
|
|
352
|
+
const from = { x: exit.x, y: exit.y };
|
|
353
|
+
|
|
354
|
+
const gap = connectorPullbackGap(markerRadius, connector.arrow);
|
|
355
|
+
const cdx = endpoint.x - from.x;
|
|
356
|
+
const cdy = endpoint.y - from.y;
|
|
357
|
+
const dist = Math.sqrt(cdx * cdx + cdy * cdy);
|
|
358
|
+
const to =
|
|
359
|
+
dist > 0
|
|
360
|
+
? { x: endpoint.x - (cdx / dist) * gap, y: endpoint.y - (cdy / dist) * gap }
|
|
361
|
+
: { ...endpoint };
|
|
362
|
+
|
|
363
|
+
// Min-length check runs AFTER the pullback: a near-degenerate line can flip
|
|
364
|
+
// direction once its end is pulled back, and checking first would miss it.
|
|
365
|
+
const lx = to.x - from.x;
|
|
366
|
+
const ly = to.y - from.y;
|
|
367
|
+
if (!connectorIsDrawable(Math.sqrt(lx * lx + ly * ly), connector.arrow)) return undefined;
|
|
368
|
+
|
|
369
|
+
return { ...connector, from, to, exit: exit.exit };
|
|
225
370
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single owner of "where a resolved annotation sits".
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS MODULE EXISTS
|
|
5
|
+
*
|
|
6
|
+
* An annotation's position is not one number, it's five that have to agree: the
|
|
7
|
+
* label origin, the subtitle origin (absolute, not relative), the label-only
|
|
8
|
+
* bounds, the label-union-subtitle block bounds, and the connector endpoints. The
|
|
9
|
+
* resolver establishes that agreement -- and then FOUR later passes re-position
|
|
10
|
+
* the block (obstacle nudge, mutual-collision nudge, clamp-to-bounds, and the
|
|
11
|
+
* auto-placement search). Every one of them has to re-establish all five.
|
|
12
|
+
*
|
|
13
|
+
* When two of those passes each did their own arithmetic, the two copies drifted:
|
|
14
|
+
* one spelled the subtitle gap `SUBTITLE_GAP`, the other spelled it `+ 2`, and a
|
|
15
|
+
* subtitled auto-placed callout rendered its second line 3px off from where the
|
|
16
|
+
* search had scored it. That is the bug this module exists to make unrepresentable.
|
|
17
|
+
*
|
|
18
|
+
* So: nothing outside this file writes annotation geometry. `moveAnnotationTo`
|
|
19
|
+
* (absolute) and `moveAnnotationBy` (delta) are the only doors, and they always
|
|
20
|
+
* leave all five in agreement.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Rect, ResolvedAnnotation, ResolvedLabel } from '@opendata-ai/openchart-core';
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_ANNOTATION_FONT_SIZE,
|
|
26
|
+
DEFAULT_LINE_HEIGHT,
|
|
27
|
+
SUBTITLE_FONT_WEIGHT,
|
|
28
|
+
subtitleFontSize,
|
|
29
|
+
} from './constants';
|
|
30
|
+
import {
|
|
31
|
+
type AnnotationMeasureTextFn,
|
|
32
|
+
computeTextBlockBounds,
|
|
33
|
+
estimateLabelBounds,
|
|
34
|
+
refreshConnector,
|
|
35
|
+
unionRects,
|
|
36
|
+
} from './geometry';
|
|
37
|
+
import { markerClearance } from './resolve-text';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The full block an annotation occupies: label text plus its subtitle.
|
|
41
|
+
*
|
|
42
|
+
* This is the box other annotations must avoid, and the box the connector exits.
|
|
43
|
+
* It is NOT `label.bounds` -- that one is label-only by contract, because the
|
|
44
|
+
* renderer sizes the `background` plate from it and a subtitled annotation must
|
|
45
|
+
* not get a plate sized to both lines.
|
|
46
|
+
*/
|
|
47
|
+
export function annotationBlockBounds(
|
|
48
|
+
annotation: ResolvedAnnotation,
|
|
49
|
+
label: ResolvedLabel,
|
|
50
|
+
measure: AnnotationMeasureTextFn,
|
|
51
|
+
): Rect {
|
|
52
|
+
const labelBounds = estimateLabelBounds(label, measure);
|
|
53
|
+
const sub = annotation.subtitle;
|
|
54
|
+
if (!sub) return labelBounds;
|
|
55
|
+
|
|
56
|
+
const subBounds = computeTextBlockBounds(
|
|
57
|
+
sub.x,
|
|
58
|
+
sub.y,
|
|
59
|
+
sub.text,
|
|
60
|
+
{
|
|
61
|
+
// Every resolver stamps all three, so these fallbacks are unreachable today.
|
|
62
|
+
// They still come from the constants: a hand-rolled `?? 10` here is a second
|
|
63
|
+
// definition of the subtitle's type, free to drift from the real one.
|
|
64
|
+
fontSize: sub.style.fontSize ?? subtitleFontSize(DEFAULT_ANNOTATION_FONT_SIZE),
|
|
65
|
+
fontWeight: Number(sub.style.fontWeight) || SUBTITLE_FONT_WEIGHT,
|
|
66
|
+
lineHeight: sub.style.lineHeight ?? DEFAULT_LINE_HEIGHT,
|
|
67
|
+
textAnchor: sub.style.textAnchor ?? 'start',
|
|
68
|
+
},
|
|
69
|
+
measure,
|
|
70
|
+
);
|
|
71
|
+
return unionRects(labelBounds, subBounds);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Put a resolved annotation's block at an absolute origin, keeping every piece of
|
|
76
|
+
* its geometry in agreement.
|
|
77
|
+
*
|
|
78
|
+
* The subtitle is TRANSLATED with the label rather than re-derived from a font-metric
|
|
79
|
+
* sum: the resolver already placed it correctly relative to the label, and a second
|
|
80
|
+
* derivation here is a second chance to disagree with the first.
|
|
81
|
+
*
|
|
82
|
+
* `textAnchor` is optional because only the auto-placement search chooses a side;
|
|
83
|
+
* the nudge passes slide a block that has already picked one.
|
|
84
|
+
*/
|
|
85
|
+
export function moveAnnotationTo(
|
|
86
|
+
annotation: ResolvedAnnotation,
|
|
87
|
+
label: ResolvedLabel,
|
|
88
|
+
x: number,
|
|
89
|
+
y: number,
|
|
90
|
+
measure: AnnotationMeasureTextFn,
|
|
91
|
+
textAnchor?: 'start' | 'middle' | 'end',
|
|
92
|
+
): void {
|
|
93
|
+
moveAnnotationBy(annotation, label, x - label.x, y - label.y, measure, textAnchor);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Slide a resolved annotation's block by (dx, dy), keeping every piece of its
|
|
98
|
+
* geometry in agreement. See `moveAnnotationTo`.
|
|
99
|
+
*/
|
|
100
|
+
export function moveAnnotationBy(
|
|
101
|
+
annotation: ResolvedAnnotation,
|
|
102
|
+
label: ResolvedLabel,
|
|
103
|
+
dx: number,
|
|
104
|
+
dy: number,
|
|
105
|
+
measure: AnnotationMeasureTextFn,
|
|
106
|
+
textAnchor?: 'start' | 'middle' | 'end',
|
|
107
|
+
): void {
|
|
108
|
+
const movedLabel: ResolvedLabel = { ...label, x: label.x + dx, y: label.y + dy };
|
|
109
|
+
if (textAnchor) {
|
|
110
|
+
movedLabel.style = { ...movedLabel.style, textAnchor };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (annotation.subtitle) {
|
|
114
|
+
annotation.subtitle = {
|
|
115
|
+
...annotation.subtitle,
|
|
116
|
+
x: annotation.subtitle.x + dx,
|
|
117
|
+
y: annotation.subtitle.y + dy,
|
|
118
|
+
...(textAnchor ? { style: { ...annotation.subtitle.style, textAnchor } } : {}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
movedLabel.bounds = estimateLabelBounds(movedLabel, measure);
|
|
123
|
+
const blockBounds = annotationBlockBounds(annotation, movedLabel, measure);
|
|
124
|
+
|
|
125
|
+
if (movedLabel.connector) {
|
|
126
|
+
movedLabel.connector = refreshConnector(
|
|
127
|
+
movedLabel.connector,
|
|
128
|
+
blockBounds,
|
|
129
|
+
markerClearance(annotation.dot),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
annotation.label = movedLabel;
|
|
134
|
+
annotation.bounds = blockBounds;
|
|
135
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { overlapArea, type Rect } from '@opendata-ai/openchart-core';
|
|
7
|
+
import { ANCHOR_OFFSET, subtitleBaselineY } from './constants';
|
|
7
8
|
import {
|
|
8
9
|
type AnnotationMeasureTextFn,
|
|
9
10
|
computeTextBlockBounds,
|
|
@@ -240,7 +241,7 @@ const CLEAN_THRESHOLD = EPSILON * 16;
|
|
|
240
241
|
interface DirectionDef {
|
|
241
242
|
ux: number;
|
|
242
243
|
uy: number;
|
|
243
|
-
textAnchor: 'start' | '
|
|
244
|
+
textAnchor: 'start' | 'end';
|
|
244
245
|
attach:
|
|
245
246
|
| 'bottom-center'
|
|
246
247
|
| 'bottom-left'
|
|
@@ -255,11 +256,11 @@ interface DirectionDef {
|
|
|
255
256
|
const D = Math.SQRT1_2;
|
|
256
257
|
|
|
257
258
|
const DIRECTIONS: DirectionDef[] = [
|
|
258
|
-
{ ux: 0, uy: -1, textAnchor: '
|
|
259
|
+
{ ux: 0, uy: -1, textAnchor: 'start', attach: 'bottom-center' }, // N
|
|
259
260
|
{ ux: D, uy: -D, textAnchor: 'start', attach: 'bottom-left' }, // NE
|
|
260
261
|
{ ux: 1, uy: 0, textAnchor: 'start', attach: 'left-middle' }, // E
|
|
261
262
|
{ ux: D, uy: D, textAnchor: 'start', attach: 'top-left' }, // SE
|
|
262
|
-
{ ux: 0, uy: 1, textAnchor: '
|
|
263
|
+
{ ux: 0, uy: 1, textAnchor: 'start', attach: 'top-center' }, // S
|
|
263
264
|
{ ux: -D, uy: D, textAnchor: 'end', attach: 'top-right' }, // SW
|
|
264
265
|
{ ux: -1, uy: 0, textAnchor: 'end', attach: 'right-middle' }, // W
|
|
265
266
|
{ ux: -D, uy: -D, textAnchor: 'end', attach: 'bottom-right' }, // NW
|
|
@@ -272,7 +273,7 @@ const UPPER_HALF_ORDER = [3, 4, 5, 2, 1, 0, 7, 6]; // SE, S, SW, E, NE, N, NW, W
|
|
|
272
273
|
export interface PlacementCandidate {
|
|
273
274
|
labelX: number;
|
|
274
275
|
labelY: number;
|
|
275
|
-
textAnchor: 'start' | '
|
|
276
|
+
textAnchor: 'start' | 'end';
|
|
276
277
|
box: Rect;
|
|
277
278
|
directionIndex: number;
|
|
278
279
|
ring: 1 | 2 | 3;
|
|
@@ -309,11 +310,20 @@ function labelPositionFromAttachment(
|
|
|
309
310
|
dir: DirectionDef,
|
|
310
311
|
boxHeight: number,
|
|
311
312
|
fontSize: number,
|
|
313
|
+
boxWidth: number,
|
|
312
314
|
): { labelX: number; labelY: number } {
|
|
313
|
-
const labelX = attachPt.x;
|
|
314
|
-
let labelY: number;
|
|
315
|
-
|
|
316
315
|
const attach = dir.attach;
|
|
316
|
+
|
|
317
|
+
// A `*-center` attach means the block straddles the point, so `labelX` (the
|
|
318
|
+
// text's start edge, since these directions render textAnchor 'start') has to
|
|
319
|
+
// back off by half the block width. Centering the BLOCK is not the same as
|
|
320
|
+
// center-aligning the TEXT: the lines inside stay left-aligned and ragged
|
|
321
|
+
// right, per the reference voice. Without this the point sits at the block's
|
|
322
|
+
// left edge and a "top" anchor reads as "up and to the right".
|
|
323
|
+
const centered = attach === 'top-center' || attach === 'bottom-center';
|
|
324
|
+
const labelX = centered ? attachPt.x - boxWidth / 2 : attachPt.x;
|
|
325
|
+
|
|
326
|
+
let labelY: number;
|
|
317
327
|
if (attach === 'left-middle' || attach === 'right-middle') {
|
|
318
328
|
labelY = attachPt.y - boxHeight / 2 + fontSize;
|
|
319
329
|
} else if (attach === 'top-left' || attach === 'top-center' || attach === 'top-right') {
|
|
@@ -414,7 +424,7 @@ function scoreCandidate(
|
|
|
414
424
|
export interface PlacementResult {
|
|
415
425
|
labelX: number;
|
|
416
426
|
labelY: number;
|
|
417
|
-
textAnchor: 'start' | '
|
|
427
|
+
textAnchor: 'start' | 'end';
|
|
418
428
|
bounds: Rect;
|
|
419
429
|
score: number;
|
|
420
430
|
debug?: CandidateScoreBreakdown[];
|
|
@@ -428,9 +438,11 @@ export function findBestPlacement(
|
|
|
428
438
|
anchorX: number,
|
|
429
439
|
anchorY: number,
|
|
430
440
|
text: string,
|
|
431
|
-
style: { fontSize: number; fontWeight: number; lineHeight: number },
|
|
441
|
+
style: { fontSize: number; fontWeight: number; lineHeight: number; fontFamily?: string },
|
|
432
442
|
subtitleText: string | undefined,
|
|
433
|
-
subtitleStyle:
|
|
443
|
+
subtitleStyle:
|
|
444
|
+
| { fontSize: number; fontWeight: number; lineHeight: number; fontFamily?: string }
|
|
445
|
+
| undefined,
|
|
434
446
|
obstacles: Rect[],
|
|
435
447
|
chartArea: Rect,
|
|
436
448
|
svgRect: Rect,
|
|
@@ -454,7 +466,29 @@ export function findBestPlacement(
|
|
|
454
466
|
);
|
|
455
467
|
const boxHeight = sampleBounds.height;
|
|
456
468
|
|
|
457
|
-
|
|
469
|
+
// Centering a `*-center` attach needs the width of the WHOLE block, not just
|
|
470
|
+
// the primary line: a subtitle is often the wider of the two, and centering on
|
|
471
|
+
// the primary width alone would leave a lede+subtitle stack visibly off-center
|
|
472
|
+
// over its point.
|
|
473
|
+
const subtitleSampleWidth =
|
|
474
|
+
subtitleText && subtitleStyle
|
|
475
|
+
? computeTextBlockBounds(
|
|
476
|
+
0,
|
|
477
|
+
subtitleStyle.fontSize,
|
|
478
|
+
subtitleText,
|
|
479
|
+
{ ...subtitleStyle, textAnchor: 'start' },
|
|
480
|
+
measure,
|
|
481
|
+
).width
|
|
482
|
+
: 0;
|
|
483
|
+
const boxWidth = Math.max(sampleBounds.width, subtitleSampleWidth);
|
|
484
|
+
|
|
485
|
+
// The innermost ring is the default setback for an auto-placed label, so it's
|
|
486
|
+
// the same quantity ANCHOR_OFFSET expresses for the explicit path — keep them
|
|
487
|
+
// as one constant. A 12px ring sat inside the standoff + marker-pullback
|
|
488
|
+
// overhead, so an auto-placed label's connector was always shorter than
|
|
489
|
+
// MIN_CONNECTOR_LENGTH and got suppressed: six bare callouts rendered as
|
|
490
|
+
// floating text with no leader back to the data.
|
|
491
|
+
const r1 = ANCHOR_OFFSET;
|
|
458
492
|
const r2 = r1 + boxHeight + 8;
|
|
459
493
|
const r3 = 2 * r2;
|
|
460
494
|
|
|
@@ -473,6 +507,7 @@ export function findBestPlacement(
|
|
|
473
507
|
dir,
|
|
474
508
|
boxHeight,
|
|
475
509
|
style.fontSize,
|
|
510
|
+
boxWidth,
|
|
476
511
|
);
|
|
477
512
|
|
|
478
513
|
let box = computeTextBlockBounds(
|
|
@@ -488,8 +523,15 @@ export function findBestPlacement(
|
|
|
488
523
|
|
|
489
524
|
// Union with subtitle bounds if present
|
|
490
525
|
if (subtitleText && subtitleStyle) {
|
|
491
|
-
|
|
492
|
-
|
|
526
|
+
// This pass SCORES a candidate subtitle position; the resolver STAMPS the
|
|
527
|
+
// one that renders. They must agree, or the scored block height is a lie
|
|
528
|
+
// and the bounds other annotations avoid no longer contain the subtitle.
|
|
529
|
+
// Hence the shared formula rather than a second copy of the sum.
|
|
530
|
+
const subtitleY = subtitleBaselineY(
|
|
531
|
+
labelY,
|
|
532
|
+
style.fontSize * style.lineHeight,
|
|
533
|
+
text.split('\n').length,
|
|
534
|
+
);
|
|
493
535
|
const subBounds = computeTextBlockBounds(
|
|
494
536
|
labelX,
|
|
495
537
|
subtitleY,
|
|
@@ -579,10 +621,16 @@ export function findBestPlacement(
|
|
|
579
621
|
export function isAutoPlacement(annotation: {
|
|
580
622
|
anchor?: string;
|
|
581
623
|
offset?: { dx?: number; dy?: number };
|
|
582
|
-
connector?: string | boolean;
|
|
624
|
+
connector?: string | boolean | { type?: string };
|
|
583
625
|
}): boolean {
|
|
584
626
|
if (annotation.offset) return false;
|
|
585
627
|
if (annotation.anchor && annotation.anchor !== 'auto') return false;
|
|
586
628
|
if (annotation.connector === 'drop-line') return false;
|
|
629
|
+
if (
|
|
630
|
+
typeof annotation.connector === 'object' &&
|
|
631
|
+
annotation.connector !== null &&
|
|
632
|
+
annotation.connector.type === 'drop-line'
|
|
633
|
+
)
|
|
634
|
+
return false;
|
|
587
635
|
return true;
|
|
588
636
|
}
|
|
@@ -20,6 +20,7 @@ export function resolveRangeAnnotation(
|
|
|
20
20
|
scales: ResolvedScales,
|
|
21
21
|
chartArea: Rect,
|
|
22
22
|
isDark: boolean,
|
|
23
|
+
fontFamily?: string,
|
|
23
24
|
): ResolvedAnnotation | null {
|
|
24
25
|
let x = chartArea.x;
|
|
25
26
|
let y = chartArea.y;
|
|
@@ -79,6 +80,7 @@ export function resolveRangeAnnotation(
|
|
|
79
80
|
annotation.fontWeight ?? 500,
|
|
80
81
|
undefined,
|
|
81
82
|
isDark,
|
|
83
|
+
fontFamily,
|
|
82
84
|
);
|
|
83
85
|
if (centered) {
|
|
84
86
|
style.textAnchor = 'middle';
|