@opendata-ai/openchart-engine 8.0.0-rc.10 → 8.0.0-rc.12
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 +7 -0
- package/dist/index.js +123 -54
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/__test-fixtures__/specs.ts +0 -182
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
- package/src/__tests__/annotation-behavior.test.ts +0 -852
- package/src/__tests__/axes.test.ts +0 -1579
- package/src/__tests__/compile-animation.test.ts +0 -161
- package/src/__tests__/compile-chart.test.ts +0 -1303
- package/src/__tests__/compile-footnotes.test.ts +0 -277
- package/src/__tests__/compile-layer.test.ts +0 -969
- package/src/__tests__/compile-snapshot.test.ts +0 -156
- package/src/__tests__/compound-labels.test.ts +0 -147
- package/src/__tests__/dimensions.test.ts +0 -918
- package/src/__tests__/encoding-sugar.test.ts +0 -390
- package/src/__tests__/facet.test.ts +0 -359
- package/src/__tests__/helpers/invariants.ts +0 -299
- package/src/__tests__/known-bugs.test.ts +0 -566
- package/src/__tests__/layout-fuzz.test.ts +0 -358
- package/src/__tests__/layout-invariants.test.ts +0 -535
- package/src/__tests__/legend.test.ts +0 -721
- package/src/__tests__/mark-keys.test.ts +0 -294
- package/src/__tests__/ranking-recipes.test.ts +0 -260
- package/src/__tests__/rotation.test.ts +0 -83
- package/src/__tests__/scales.test.ts +0 -323
- package/src/__tests__/series-search.test.ts +0 -92
- package/src/__tests__/spec-sugar.test.ts +0 -801
- package/src/__tests__/you-draw-it.test.ts +0 -160
- package/src/annotations/__tests__/compute.test.ts +0 -1672
- package/src/annotations/__tests__/geometry.test.ts +0 -66
- package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
- package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
- package/src/annotations/__tests__/placement.test.ts +0 -316
- package/src/annotations/__tests__/resolve-text.test.ts +0 -788
- package/src/annotations/__tests__/rich-text.test.ts +0 -91
- package/src/annotations/__tests__/thinning.test.ts +0 -285
- package/src/annotations/collisions.ts +0 -326
- package/src/annotations/compute.ts +0 -333
- package/src/annotations/constants.ts +0 -162
- package/src/annotations/geometry.ts +0 -376
- package/src/annotations/move.ts +0 -135
- package/src/annotations/placement.ts +0 -636
- package/src/annotations/position.ts +0 -131
- package/src/annotations/resolve-range.ts +0 -117
- package/src/annotations/resolve-refline.ts +0 -157
- package/src/annotations/resolve-text.ts +0 -545
- package/src/annotations/rich-text.ts +0 -65
- package/src/annotations/thinning.ts +0 -190
- package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
- package/src/barlist/compile-barlist.ts +0 -391
- package/src/barlist/types.ts +0 -28
- package/src/charts/__tests__/post-process.test.ts +0 -237
- package/src/charts/__tests__/registry.test.ts +0 -116
- package/src/charts/__tests__/utils.test.ts +0 -195
- package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
- package/src/charts/_shared/density-filter.ts +0 -26
- package/src/charts/_shared/format-label-value.ts +0 -16
- package/src/charts/bar/__tests__/compute.test.ts +0 -934
- package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
- package/src/charts/bar/__tests__/labels.test.ts +0 -292
- package/src/charts/bar/compute.ts +0 -539
- package/src/charts/bar/index.ts +0 -60
- package/src/charts/bar/labels.ts +0 -311
- package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
- package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
- package/src/charts/beeswarm/compute.ts +0 -203
- package/src/charts/beeswarm/dodge.ts +0 -155
- package/src/charts/beeswarm/index.ts +0 -32
- package/src/charts/builtin.ts +0 -75
- package/src/charts/calendar/__tests__/compute.test.ts +0 -417
- package/src/charts/calendar/compute.ts +0 -319
- package/src/charts/calendar/index.ts +0 -32
- package/src/charts/column/__tests__/compute.test.ts +0 -540
- package/src/charts/column/__tests__/labels.test.ts +0 -179
- package/src/charts/column/compute.ts +0 -493
- package/src/charts/column/index.ts +0 -53
- package/src/charts/column/labels.ts +0 -229
- package/src/charts/dot/__tests__/compute.test.ts +0 -380
- package/src/charts/dot/__tests__/labels.test.ts +0 -98
- package/src/charts/dot/compute.ts +0 -285
- package/src/charts/dot/index.ts +0 -62
- package/src/charts/dot/labels.ts +0 -121
- package/src/charts/line/__tests__/compute.test.ts +0 -1194
- package/src/charts/line/__tests__/curves.test.ts +0 -51
- package/src/charts/line/__tests__/labels.test.ts +0 -94
- package/src/charts/line/__tests__/mark-options.test.ts +0 -175
- package/src/charts/line/area.ts +0 -484
- package/src/charts/line/compute.ts +0 -356
- package/src/charts/line/curves.ts +0 -85
- package/src/charts/line/index.ts +0 -176
- package/src/charts/line/labels.ts +0 -177
- package/src/charts/parliament/__tests__/compute.test.ts +0 -411
- package/src/charts/parliament/compute.ts +0 -313
- package/src/charts/parliament/index.ts +0 -32
- package/src/charts/pie/__tests__/compute.test.ts +0 -384
- package/src/charts/pie/__tests__/labels.test.ts +0 -132
- package/src/charts/pie/compute.ts +0 -324
- package/src/charts/pie/index.ts +0 -49
- package/src/charts/pie/labels.ts +0 -143
- package/src/charts/post-process.ts +0 -229
- package/src/charts/range/__tests__/compute.test.ts +0 -486
- package/src/charts/range/compute.ts +0 -273
- package/src/charts/range/index.ts +0 -82
- package/src/charts/range/labels.ts +0 -169
- package/src/charts/rect/__tests__/compute.test.ts +0 -120
- package/src/charts/rect/index.ts +0 -127
- package/src/charts/registry.ts +0 -72
- package/src/charts/rule/index.ts +0 -139
- package/src/charts/scatter/__tests__/compute.test.ts +0 -488
- package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
- package/src/charts/scatter/compute.ts +0 -192
- package/src/charts/scatter/index.ts +0 -46
- package/src/charts/scatter/trendline.ts +0 -100
- package/src/charts/text/__tests__/compute.test.ts +0 -307
- package/src/charts/text/index.ts +0 -141
- package/src/charts/tick/__tests__/compute.test.ts +0 -75
- package/src/charts/tick/index.ts +0 -99
- package/src/charts/utils.ts +0 -170
- package/src/charts/waffle/__tests__/compute.test.ts +0 -305
- package/src/charts/waffle/compute.ts +0 -197
- package/src/charts/waffle/index.ts +0 -31
- package/src/compile/__tests__/color-scale-range.test.ts +0 -158
- package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
- package/src/compile/__tests__/data-clip.test.ts +0 -127
- package/src/compile/__tests__/fill-patterns.test.ts +0 -154
- package/src/compile/__tests__/size-scale.test.ts +0 -108
- package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
- package/src/compile/color-scale-range.ts +0 -101
- package/src/compile/data-clip.ts +0 -48
- package/src/compile/fill-patterns.ts +0 -74
- package/src/compile/layer.ts +0 -630
- package/src/compile/size-scale.ts +0 -141
- package/src/compile/spec-sugar.ts +0 -767
- package/src/compile/watermark-obstacle.ts +0 -54
- package/src/compile/you-draw-it.ts +0 -123
- package/src/compile.ts +0 -1548
- package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
- package/src/compiler/__tests__/animation.test.ts +0 -177
- package/src/compiler/__tests__/compile.test.ts +0 -87
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
- package/src/compiler/__tests__/keys.test.ts +0 -83
- package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
- package/src/compiler/__tests__/normalize.test.ts +0 -399
- package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
- package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
- package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
- package/src/compiler/__tests__/validate.test.ts +0 -731
- package/src/compiler/__tests__/vl-parity.test.ts +0 -68
- package/src/compiler/a11y-warnings.ts +0 -110
- package/src/compiler/animation.ts +0 -142
- package/src/compiler/index.ts +0 -47
- package/src/compiler/keys.ts +0 -55
- package/src/compiler/normalize.ts +0 -734
- package/src/compiler/sparkline-defaults.ts +0 -138
- package/src/compiler/types.ts +0 -241
- package/src/compiler/validate.ts +0 -2093
- package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
- package/src/endpoint-labels/compute.ts +0 -621
- package/src/endpoint-labels/constants.ts +0 -54
- package/src/endpoint-labels/format.ts +0 -60
- package/src/endpoint-labels/predict.ts +0 -139
- package/src/format/__tests__/field-format.test.ts +0 -164
- package/src/format/field-format.ts +0 -27
- package/src/geo/__tests__/compile-map.test.ts +0 -1128
- package/src/geo/compile-map.ts +0 -1376
- package/src/geo/join.ts +0 -115
- package/src/geo/projections.ts +0 -65
- package/src/geo/types.ts +0 -26
- package/src/graphs/__tests__/community.test.ts +0 -228
- package/src/graphs/__tests__/compile-graph.test.ts +0 -396
- package/src/graphs/__tests__/encoding.test.ts +0 -415
- package/src/graphs/community.ts +0 -92
- package/src/graphs/compile-graph.ts +0 -329
- package/src/graphs/encoding.ts +0 -356
- package/src/graphs/types.ts +0 -106
- package/src/index.ts +0 -177
- package/src/layout/__tests__/metrics.test.ts +0 -97
- package/src/layout/__tests__/scales-highlight.test.ts +0 -192
- package/src/layout/axes/rotation.ts +0 -152
- package/src/layout/axes/thinning.ts +0 -98
- package/src/layout/axes/ticks.ts +0 -409
- package/src/layout/axes.ts +0 -757
- package/src/layout/dimensions.ts +0 -912
- package/src/layout/facet.ts +0 -91
- package/src/layout/gridlines.ts +0 -84
- package/src/layout/metrics.ts +0 -132
- package/src/layout/plan.ts +0 -631
- package/src/layout/scales.ts +0 -1053
- package/src/layout/shared.ts +0 -44
- package/src/legend/__tests__/continuous.test.ts +0 -318
- package/src/legend/__tests__/redundancy.test.ts +0 -108
- package/src/legend/__tests__/size.test.ts +0 -348
- package/src/legend/__tests__/suppression.test.ts +0 -382
- package/src/legend/compute.ts +0 -748
- package/src/legend/continuous.ts +0 -327
- package/src/legend/size.ts +0 -407
- package/src/legend/suppression.ts +0 -226
- package/src/legend/wrap.ts +0 -117
- package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
- package/src/sankey/__tests__/layout.test.ts +0 -165
- package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
- package/src/sankey/__tests__/node-sort.test.ts +0 -45
- package/src/sankey/compile-sankey.ts +0 -706
- package/src/sankey/layout.ts +0 -184
- package/src/sankey/types.ts +0 -41
- package/src/tables/__tests__/bar-column.test.ts +0 -147
- package/src/tables/__tests__/category-colors.test.ts +0 -230
- package/src/tables/__tests__/compile-table.test.ts +0 -236
- package/src/tables/__tests__/format-cells.test.ts +0 -143
- package/src/tables/__tests__/heatmap.test.ts +0 -148
- package/src/tables/__tests__/pagination.test.ts +0 -78
- package/src/tables/__tests__/search.test.ts +0 -94
- package/src/tables/__tests__/sort.test.ts +0 -107
- package/src/tables/__tests__/sparkline.test.ts +0 -122
- package/src/tables/bar-column.ts +0 -94
- package/src/tables/category-colors.ts +0 -78
- package/src/tables/compile-table.ts +0 -427
- package/src/tables/format-cells.ts +0 -115
- package/src/tables/heatmap.ts +0 -141
- package/src/tables/pagination.ts +0 -46
- package/src/tables/search.ts +0 -66
- package/src/tables/sort.ts +0 -69
- package/src/tables/sparkline.ts +0 -113
- package/src/tables/utils.ts +0 -16
- package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
- package/src/tilemap/compile-tilemap.ts +0 -755
- package/src/tilemap/layout.ts +0 -172
- package/src/tilemap/types.ts +0 -33
- package/src/tooltips/__tests__/compute.test.ts +0 -582
- package/src/tooltips/compute.ts +0 -700
- package/src/transforms/__tests__/aggregate.test.ts +0 -159
- package/src/transforms/__tests__/bin.test.ts +0 -88
- package/src/transforms/__tests__/calculate.test.ts +0 -146
- package/src/transforms/__tests__/conditional.test.ts +0 -127
- package/src/transforms/__tests__/filter-relative.test.ts +0 -202
- package/src/transforms/__tests__/filter.test.ts +0 -59
- package/src/transforms/__tests__/fold.test.ts +0 -79
- package/src/transforms/__tests__/index.test.ts +0 -93
- package/src/transforms/__tests__/predicates.test.ts +0 -193
- package/src/transforms/__tests__/timeunit.test.ts +0 -129
- package/src/transforms/__tests__/window.test.ts +0 -286
- package/src/transforms/aggregate.ts +0 -131
- package/src/transforms/bin.ts +0 -87
- package/src/transforms/calculate.ts +0 -60
- package/src/transforms/conditional.ts +0 -46
- package/src/transforms/filter.ts +0 -122
- package/src/transforms/fold.ts +0 -49
- package/src/transforms/index.ts +0 -60
- package/src/transforms/predicates.ts +0 -123
- package/src/transforms/timeunit.ts +0 -88
- package/src/transforms/window.ts +0 -185
|
@@ -1,326 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Collision avoidance for annotations: nudging away from obstacles,
|
|
3
|
-
* resolving annotation-to-annotation overlaps, and clamping to SVG bounds.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { Rect, ResolvedAnnotation, TextAnnotation } from '@opendata-ai/openchart-core';
|
|
7
|
-
import { detectCollision } from '@opendata-ai/openchart-core';
|
|
8
|
-
import type { NormalizedChartSpec } from '../compiler/types';
|
|
9
|
-
import type { ResolvedScales } from '../layout/scales';
|
|
10
|
-
import { CLAMP_MARGIN, DEFAULT_ANNOTATION_FONT_SIZE, NUDGE_PADDING } from './constants';
|
|
11
|
-
import { type AnnotationMeasureTextFn, heuristicMeasure } from './geometry';
|
|
12
|
-
import { annotationBlockBounds, moveAnnotationBy } from './move';
|
|
13
|
-
import { isAutoPlacement } from './placement';
|
|
14
|
-
import { resolvePosition } from './position';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Generate candidate displacement vectors to move `selfBounds` clear of each
|
|
18
|
-
* obstacle in 4 directions (below, above, left, right), sorted by smallest
|
|
19
|
-
* movement first.
|
|
20
|
-
*/
|
|
21
|
-
export function generateNudgeCandidates(
|
|
22
|
-
selfBounds: Rect,
|
|
23
|
-
obstacles: Rect[],
|
|
24
|
-
padding: number,
|
|
25
|
-
): { dx: number; dy: number; distance: number }[] {
|
|
26
|
-
const candidates: { dx: number; dy: number; distance: number }[] = [];
|
|
27
|
-
|
|
28
|
-
for (const obs of obstacles) {
|
|
29
|
-
// Below: shift self so its top edge clears the obstacle bottom
|
|
30
|
-
const belowDy = obs.y + obs.height + padding - selfBounds.y;
|
|
31
|
-
candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
|
|
32
|
-
|
|
33
|
-
// Above: shift self so its bottom edge clears the obstacle top
|
|
34
|
-
const aboveDy = obs.y - padding - (selfBounds.y + selfBounds.height);
|
|
35
|
-
candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
|
|
36
|
-
|
|
37
|
-
// Left: shift self so its right edge clears the obstacle left
|
|
38
|
-
const leftDx = obs.x - padding - (selfBounds.x + selfBounds.width);
|
|
39
|
-
candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
|
|
40
|
-
|
|
41
|
-
// Right: shift self so its left edge clears the obstacle right
|
|
42
|
-
const rightDx = obs.x + obs.width + padding - selfBounds.x;
|
|
43
|
-
candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
candidates.sort((a, b) => a.distance - b.distance);
|
|
47
|
-
return candidates;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Did the author hand-place this block?
|
|
52
|
-
*
|
|
53
|
-
* A hand-placed block goes exactly where it was put, and the automatic passes keep
|
|
54
|
-
* their hands off it. Without that, the obstacle pass overrides the author: on a line
|
|
55
|
-
* chart the polyline is one long obstacle, so `dy: -10` moved a block by ZERO pixels
|
|
56
|
-
* while `dy: -15` teleported it 56px. Same knob, no monotonicity -- unaimable.
|
|
57
|
-
*
|
|
58
|
-
* `isAutoPlacement` is the ONLY definition of "did the author place this". It already
|
|
59
|
-
* decides which resolve path an annotation takes, and a non-zero `offset` disqualifies
|
|
60
|
-
* the scored search there, so an offset ALWAYS means hand-placed -- with or without an
|
|
61
|
-
* `anchor`. A second, subtly different rule here (an earlier version of this function
|
|
62
|
-
* additionally demanded an `anchor`) does not create a second tier of annotation; it
|
|
63
|
-
* just disagrees with the function that actually routed the thing, and the disagreement
|
|
64
|
-
* shows up as an un-aimable offset on exactly the annotations the extra condition
|
|
65
|
-
* excluded.
|
|
66
|
-
*
|
|
67
|
-
* `offset: { dx: 0 }` is not an instruction, just the default spelled out, so it does
|
|
68
|
-
* not count -- matching `applyOffset`, which is likewise a no-op for it.
|
|
69
|
-
*/
|
|
70
|
-
function hasExplicitOffset(annotation: TextAnnotation): boolean {
|
|
71
|
-
const moved = (annotation.offset?.dx ?? 0) !== 0 || (annotation.offset?.dy ?? 0) !== 0;
|
|
72
|
-
return moved && !isAutoPlacement(annotation);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Try to reposition a text annotation to avoid overlapping with obstacle rects
|
|
77
|
-
* (legend bounds, etc.). First tries standard anchor alternatives, then
|
|
78
|
-
* calculates specific offsets needed to clear obstacles. Returns true if moved.
|
|
79
|
-
*/
|
|
80
|
-
export function nudgeAnnotationFromObstacles(
|
|
81
|
-
annotation: ResolvedAnnotation,
|
|
82
|
-
originalAnnotation: TextAnnotation,
|
|
83
|
-
scales: ResolvedScales,
|
|
84
|
-
chartArea: Rect,
|
|
85
|
-
obstacles: Rect[],
|
|
86
|
-
measure: AnnotationMeasureTextFn = heuristicMeasure,
|
|
87
|
-
): boolean {
|
|
88
|
-
if (annotation.type !== 'text' || !annotation.label) return false;
|
|
89
|
-
|
|
90
|
-
// An `offset` is the author stating where the block goes. Auto-avoidance is for
|
|
91
|
-
// annotations that didn't say. Nudging one anyway makes the offset behave like a
|
|
92
|
-
// suggestion the engine is free to ignore: on a line chart the polyline is one
|
|
93
|
-
// long obstacle, so `dy: -10` moved a right-anchored block by *zero* pixels and
|
|
94
|
-
// `dy: -15` teleported it 56px. Same knob, no monotonicity -- unauthorable.
|
|
95
|
-
if (hasExplicitOffset(originalAnnotation)) return false;
|
|
96
|
-
|
|
97
|
-
const label = annotation.label;
|
|
98
|
-
const bounds = annotation.bounds ?? annotationBlockBounds(annotation, label, measure);
|
|
99
|
-
const collidingObs = obstacles.filter(
|
|
100
|
-
(obs) => obs.width > 0 && obs.height > 0 && detectCollision(bounds, obs),
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
if (collidingObs.length === 0) return false;
|
|
104
|
-
|
|
105
|
-
// A guard, not an input: the nudge geometry below comes from `bounds` and
|
|
106
|
-
// `connector.endpoint`, not from the data point. But an annotation whose
|
|
107
|
-
// position no longer resolves has nothing to anchor a connector to, so refuse
|
|
108
|
-
// to move it rather than shift a label away from a point we can't locate.
|
|
109
|
-
if (
|
|
110
|
-
resolvePosition(originalAnnotation.x, scales.x) === null ||
|
|
111
|
-
resolvePosition(originalAnnotation.y, scales.y) === null
|
|
112
|
-
) {
|
|
113
|
-
return false;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const candidates = generateNudgeCandidates(bounds, collidingObs, NUDGE_PADDING);
|
|
117
|
-
// Read the size off the label, don't back-derive it from the box: `bounds` is
|
|
118
|
-
// the label ∪ subtitle union, so dividing its height by the *primary* line
|
|
119
|
-
// count returns roughly double the real size on a subtitled annotation, which
|
|
120
|
-
// silently doubles the margin slop below.
|
|
121
|
-
const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
122
|
-
|
|
123
|
-
// A drop-line is a vertical rule running DOWN from the block to the point. Nudge
|
|
124
|
-
// the block below its own endpoint and the line inverts: it points up, away from
|
|
125
|
-
// the data, and the block covers the mark it was labelling. The resolver already
|
|
126
|
-
// placed it (side auto-flip, clamp against the chart top), so the only nudges
|
|
127
|
-
// worth taking are ones that keep it overhead.
|
|
128
|
-
const dropLineEndpointY =
|
|
129
|
-
label.connector?.style === 'drop-line' ? label.connector.endpoint?.y : undefined;
|
|
130
|
-
|
|
131
|
-
for (const { dx, dy } of candidates) {
|
|
132
|
-
const candidateBounds: Rect = { ...bounds, x: bounds.x + dx, y: bounds.y + dy };
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
dropLineEndpointY !== undefined &&
|
|
136
|
-
candidateBounds.y + candidateBounds.height > dropLineEndpointY
|
|
137
|
-
) {
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Check no collisions with any obstacle
|
|
142
|
-
const stillCollides = obstacles.some(
|
|
143
|
-
(obs) => obs.width > 0 && obs.height > 0 && detectCollision(candidateBounds, obs),
|
|
144
|
-
);
|
|
145
|
-
if (stillCollides) continue;
|
|
146
|
-
|
|
147
|
-
// Annotations render outside the clip path, so they can extend into margins.
|
|
148
|
-
// Only check that the label center is reasonably within the chart and that
|
|
149
|
-
// the text doesn't go completely off-screen.
|
|
150
|
-
const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
|
|
151
|
-
const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
|
|
152
|
-
// Allow nudged labels to extend into the chrome region below the chart
|
|
153
|
-
// (source/footer area) since annotations near the bottom edge often
|
|
154
|
-
// need to shift into that space to avoid marks or the brand watermark.
|
|
155
|
-
const inBounds =
|
|
156
|
-
labelCenterX >= chartArea.x &&
|
|
157
|
-
labelCenterX <= chartArea.x + chartArea.width + 10 &&
|
|
158
|
-
labelCenterY >= chartArea.y - fontSize &&
|
|
159
|
-
labelCenterY <= chartArea.y + chartArea.height + fontSize * 3;
|
|
160
|
-
|
|
161
|
-
if (inBounds) {
|
|
162
|
-
moveAnnotationBy(annotation, label, dx, dy, measure);
|
|
163
|
-
return true;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return false;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Resolve collisions between text annotation labels using a greedy algorithm.
|
|
172
|
-
*
|
|
173
|
-
* Iterates through text annotations in order, building a list of "placed"
|
|
174
|
-
* bounding rects. When a later annotation overlaps an already-placed one,
|
|
175
|
-
* it tries offset positions (below, above, left, right) to find a
|
|
176
|
-
* non-colliding spot. Recomputes the connector after nudging.
|
|
177
|
-
*/
|
|
178
|
-
export function resolveAnnotationCollisions(
|
|
179
|
-
annotations: ResolvedAnnotation[],
|
|
180
|
-
originalSpecs: NormalizedChartSpec['annotations'],
|
|
181
|
-
scales: ResolvedScales,
|
|
182
|
-
chartArea: Rect,
|
|
183
|
-
measure: AnnotationMeasureTextFn = heuristicMeasure,
|
|
184
|
-
/**
|
|
185
|
-
* Predicate on the index into `annotations`: true for an auto-placed entry.
|
|
186
|
-
*
|
|
187
|
-
* The scored placement pass already positioned those to avoid everything, so
|
|
188
|
-
* they must not be nudged again — but they still occupy space, so they stay in
|
|
189
|
-
* `placedBounds` and the explicit annotations avoid them. Callers pass the full
|
|
190
|
-
* annotation list; nobody hand-builds a filtered copy, because a second
|
|
191
|
-
* representation of the list is what breaks `specIndex` lookups.
|
|
192
|
-
*/
|
|
193
|
-
isAutoPlaced?: (index: number) => boolean,
|
|
194
|
-
): void {
|
|
195
|
-
/** Immovable here: sited by the scored search, or hand-placed by the author. */
|
|
196
|
-
const isPinned = (annotation: ResolvedAnnotation, i: number): boolean => {
|
|
197
|
-
if (isAutoPlaced?.(i)) return true;
|
|
198
|
-
// `annotations` is a filtered view of `originalSpecs` -- an annotation that
|
|
199
|
-
// failed to resolve was dropped -- so index by the stamped spec index, not
|
|
200
|
-
// by position here. Falling back to `i` keeps a hand-built layout working.
|
|
201
|
-
const spec = originalSpecs[annotation.specIndex ?? i];
|
|
202
|
-
return spec?.type === 'text' && hasExplicitOffset(spec);
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
const isText = (a: ResolvedAnnotation) => a.type === 'text' && !!a.label;
|
|
206
|
-
|
|
207
|
-
// Seed with EVERY pinned block before nudging anything.
|
|
208
|
-
//
|
|
209
|
-
// This used to be one forward sweep that pushed a pinned block's bounds when it
|
|
210
|
-
// reached it. That only lets annotations authored *after* a pinned one route
|
|
211
|
-
// around it -- everything earlier was nudged against a `placedBounds` that did not
|
|
212
|
-
// contain it yet, so "the pinned block claims its space" held or failed purely on
|
|
213
|
-
// spec order, and two callouts on the same point printed on top of each other when
|
|
214
|
-
// the pinned one happened to be listed second.
|
|
215
|
-
const placedBounds: Rect[] = [];
|
|
216
|
-
for (let i = 0; i < annotations.length; i++) {
|
|
217
|
-
const annotation = annotations[i];
|
|
218
|
-
if (!isText(annotation) || !isPinned(annotation, i)) continue;
|
|
219
|
-
placedBounds.push(
|
|
220
|
-
annotation.bounds ?? annotationBlockBounds(annotation, annotation.label!, measure),
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
for (let i = 0; i < annotations.length; i++) {
|
|
225
|
-
const annotation = annotations[i];
|
|
226
|
-
if (!isText(annotation) || isPinned(annotation, i)) continue;
|
|
227
|
-
|
|
228
|
-
const label = annotation.label!;
|
|
229
|
-
const bounds = annotation.bounds ?? annotationBlockBounds(annotation, label, measure);
|
|
230
|
-
const originalSpec = originalSpecs[annotation.specIndex ?? i];
|
|
231
|
-
|
|
232
|
-
// Check against all previously placed annotation blocks
|
|
233
|
-
const collidingBounds = placedBounds.filter(
|
|
234
|
-
(pb) => pb.width > 0 && pb.height > 0 && detectCollision(bounds, pb),
|
|
235
|
-
);
|
|
236
|
-
|
|
237
|
-
if (collidingBounds.length > 0) {
|
|
238
|
-
if (originalSpec?.type === 'text') {
|
|
239
|
-
// A guard, not an input: same as in nudgeAnnotationFromObstacles, the
|
|
240
|
-
// geometry below comes from `bounds` and `connector.endpoint`. Refuse to
|
|
241
|
-
// move a label whose data point we can no longer locate.
|
|
242
|
-
const px = resolvePosition(originalSpec.x, scales.x);
|
|
243
|
-
const py = resolvePosition(originalSpec.y, scales.y);
|
|
244
|
-
|
|
245
|
-
if (px !== null && py !== null) {
|
|
246
|
-
const candidates = generateNudgeCandidates(bounds, collidingBounds, NUDGE_PADDING);
|
|
247
|
-
// Read the size off the label, don't back-derive it from the box: `bounds` is
|
|
248
|
-
// the label ∪ subtitle union, so dividing its height by the *primary* line
|
|
249
|
-
// count returns roughly double the real size on a subtitled annotation, which
|
|
250
|
-
// silently doubles the margin slop below.
|
|
251
|
-
const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
252
|
-
|
|
253
|
-
for (const { dx, dy } of candidates) {
|
|
254
|
-
const candidateBounds: Rect = { ...bounds, x: bounds.x + dx, y: bounds.y + dy };
|
|
255
|
-
|
|
256
|
-
// Check no collisions with any placed block
|
|
257
|
-
const stillCollides = placedBounds.some(
|
|
258
|
-
(pb) => pb.width > 0 && pb.height > 0 && detectCollision(candidateBounds, pb),
|
|
259
|
-
);
|
|
260
|
-
if (stillCollides) continue;
|
|
261
|
-
|
|
262
|
-
// Check the label center stays reasonably in bounds
|
|
263
|
-
const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
|
|
264
|
-
const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
|
|
265
|
-
const inBounds =
|
|
266
|
-
labelCenterX >= chartArea.x &&
|
|
267
|
-
labelCenterX <= chartArea.x + chartArea.width + 10 &&
|
|
268
|
-
labelCenterY >= chartArea.y - fontSize &&
|
|
269
|
-
labelCenterY <= chartArea.y + chartArea.height + fontSize;
|
|
270
|
-
|
|
271
|
-
if (inBounds) {
|
|
272
|
-
moveAnnotationBy(annotation, label, dx, dy, measure);
|
|
273
|
-
break;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Add this annotation's final bounds to the placed list
|
|
281
|
-
placedBounds.push(annotation.bounds ?? annotationBlockBounds(annotation, label, measure));
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Shift text annotation labels so they stay within the total SVG bounds.
|
|
287
|
-
* If a label overflows the right, left, top, or bottom edge, its position
|
|
288
|
-
* is adjusted inward by the overflow amount. Connector geometry is updated
|
|
289
|
-
* to match.
|
|
290
|
-
*/
|
|
291
|
-
export function clampAnnotationsToBounds(
|
|
292
|
-
annotations: ResolvedAnnotation[],
|
|
293
|
-
svgWidth: number,
|
|
294
|
-
svgHeight: number,
|
|
295
|
-
measure: AnnotationMeasureTextFn = heuristicMeasure,
|
|
296
|
-
): void {
|
|
297
|
-
for (const annotation of annotations) {
|
|
298
|
-
if (annotation.type !== 'text' || !annotation.label) continue;
|
|
299
|
-
|
|
300
|
-
const label = annotation.label;
|
|
301
|
-
const bounds = annotation.bounds ?? annotationBlockBounds(annotation, label, measure);
|
|
302
|
-
let dx = 0;
|
|
303
|
-
let dy = 0;
|
|
304
|
-
|
|
305
|
-
// Right overflow
|
|
306
|
-
if (bounds.x + bounds.width > svgWidth - CLAMP_MARGIN) {
|
|
307
|
-
dx = svgWidth - CLAMP_MARGIN - (bounds.x + bounds.width);
|
|
308
|
-
}
|
|
309
|
-
// Left overflow
|
|
310
|
-
if (bounds.x + dx < CLAMP_MARGIN) {
|
|
311
|
-
dx = CLAMP_MARGIN - bounds.x;
|
|
312
|
-
}
|
|
313
|
-
// Top overflow
|
|
314
|
-
if (bounds.y < CLAMP_MARGIN) {
|
|
315
|
-
dy = CLAMP_MARGIN - bounds.y;
|
|
316
|
-
}
|
|
317
|
-
// Bottom overflow
|
|
318
|
-
if (bounds.y + bounds.height + dy > svgHeight - CLAMP_MARGIN) {
|
|
319
|
-
dy = svgHeight - CLAMP_MARGIN - (bounds.y + bounds.height);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
if (dx === 0 && dy === 0) continue;
|
|
323
|
-
|
|
324
|
-
moveAnnotationBy(annotation, label, dx, dy, measure);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
@@ -1,333 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Annotation computation: converts spec-level annotations to pixel-positioned
|
|
3
|
-
* ResolvedAnnotation objects using the resolved scales.
|
|
4
|
-
*
|
|
5
|
-
* Handles three annotation types:
|
|
6
|
-
* - text: positioned at a data coordinate with an optional callout
|
|
7
|
-
* - range: a highlighted rectangle between two data values
|
|
8
|
-
* - refline: a horizontal or vertical reference line at a data value
|
|
9
|
-
*
|
|
10
|
-
* Supports fine-grained positioning via offset, anchor, connector, and zIndex.
|
|
11
|
-
* At compact breakpoints, annotations are simplified or hidden.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type {
|
|
15
|
-
LayoutStrategy,
|
|
16
|
-
Rect,
|
|
17
|
-
ResolvedAnnotation,
|
|
18
|
-
TextAnnotation,
|
|
19
|
-
} from '@opendata-ai/openchart-core';
|
|
20
|
-
import type { NormalizedChartSpec } from '../compiler/types';
|
|
21
|
-
import type { ResolvedScales } from '../layout/scales';
|
|
22
|
-
import {
|
|
23
|
-
clampAnnotationsToBounds,
|
|
24
|
-
nudgeAnnotationFromObstacles,
|
|
25
|
-
resolveAnnotationCollisions,
|
|
26
|
-
} from './collisions';
|
|
27
|
-
import { SUBTITLE_FONT_WEIGHT, subtitleFontSize } from './constants';
|
|
28
|
-
import type { AnnotationMeasureTextFn } from './geometry';
|
|
29
|
-
import { heuristicMeasure } from './geometry';
|
|
30
|
-
import { moveAnnotationTo } from './move';
|
|
31
|
-
import {
|
|
32
|
-
findBestPlacement,
|
|
33
|
-
isAutoPlacement,
|
|
34
|
-
normalizeObstacles,
|
|
35
|
-
type PlacementObstacle,
|
|
36
|
-
} from './placement';
|
|
37
|
-
import { resolvePosition } from './position';
|
|
38
|
-
import { resolveRangeAnnotation } from './resolve-range';
|
|
39
|
-
import { resolveRefLineAnnotation } from './resolve-refline';
|
|
40
|
-
import {
|
|
41
|
-
makeAnnotationLabelStyle,
|
|
42
|
-
resolveLedeFontWeight,
|
|
43
|
-
resolveTextAnnotation,
|
|
44
|
-
} from './resolve-text';
|
|
45
|
-
|
|
46
|
-
export interface AnnotationContext {
|
|
47
|
-
scales: ResolvedScales;
|
|
48
|
-
chartArea: Rect;
|
|
49
|
-
strategy: LayoutStrategy;
|
|
50
|
-
isDark: boolean;
|
|
51
|
-
obstacles: PlacementObstacle[];
|
|
52
|
-
svg: { width: number; height: number };
|
|
53
|
-
measure: AnnotationMeasureTextFn;
|
|
54
|
-
/** Theme font stack, so annotation text is measured and rendered in the same face. */
|
|
55
|
-
fontFamily?: string;
|
|
56
|
-
debugPlacement?: boolean;
|
|
57
|
-
/** When true, annotations that overlap are demoted to footnote markers instead of being hidden. */
|
|
58
|
-
autoThin?: boolean;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Compute resolved annotations from spec annotations using the resolved scales.
|
|
63
|
-
*
|
|
64
|
-
* Converts data-coordinate annotations to pixel-positioned ResolvedAnnotation
|
|
65
|
-
* objects. Supports offset, anchor, connector, and zIndex. At compact
|
|
66
|
-
* breakpoints, annotations are hidden (strategy says "tooltip-only").
|
|
67
|
-
*
|
|
68
|
-
* When obstacle rects are provided (e.g. legend bounds), text annotations
|
|
69
|
-
* that overlap with them are automatically repositioned using alternate
|
|
70
|
-
* anchor directions. After individual obstacle avoidance, annotation-to-
|
|
71
|
-
* annotation collisions are resolved using a greedy placement algorithm.
|
|
72
|
-
* Finally, labels are clamped to stay within the total SVG bounds.
|
|
73
|
-
*/
|
|
74
|
-
export function computeAnnotations(
|
|
75
|
-
spec: NormalizedChartSpec,
|
|
76
|
-
ctx: AnnotationContext,
|
|
77
|
-
): ResolvedAnnotation[];
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* @deprecated Use the context-object overload instead.
|
|
81
|
-
*/
|
|
82
|
-
export function computeAnnotations(
|
|
83
|
-
spec: NormalizedChartSpec,
|
|
84
|
-
scales: ResolvedScales,
|
|
85
|
-
chartArea: Rect,
|
|
86
|
-
strategy: LayoutStrategy,
|
|
87
|
-
isDark?: boolean,
|
|
88
|
-
obstacles?: Rect[],
|
|
89
|
-
svgDimensions?: { width: number; height: number },
|
|
90
|
-
): ResolvedAnnotation[];
|
|
91
|
-
|
|
92
|
-
export function computeAnnotations(
|
|
93
|
-
spec: NormalizedChartSpec,
|
|
94
|
-
scalesOrCtx: ResolvedScales | AnnotationContext,
|
|
95
|
-
chartArea?: Rect,
|
|
96
|
-
strategy?: LayoutStrategy,
|
|
97
|
-
isDark?: boolean,
|
|
98
|
-
obstacles?: Rect[],
|
|
99
|
-
svgDimensions?: { width: number; height: number },
|
|
100
|
-
): ResolvedAnnotation[] {
|
|
101
|
-
let ctx: AnnotationContext;
|
|
102
|
-
|
|
103
|
-
if ('measure' in scalesOrCtx) {
|
|
104
|
-
ctx = scalesOrCtx as AnnotationContext;
|
|
105
|
-
} else {
|
|
106
|
-
ctx = {
|
|
107
|
-
scales: scalesOrCtx as ResolvedScales,
|
|
108
|
-
chartArea: chartArea!,
|
|
109
|
-
strategy: strategy!,
|
|
110
|
-
isDark: isDark ?? false,
|
|
111
|
-
obstacles: obstacles ? normalizeObstacles(obstacles) : [],
|
|
112
|
-
svg: svgDimensions ?? { width: 0, height: 0 },
|
|
113
|
-
measure: heuristicMeasure,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const isCompact = ctx.strategy.annotationPosition === 'tooltip-only' && !ctx.autoThin;
|
|
118
|
-
|
|
119
|
-
const svgRect: Rect = { x: 0, y: 0, width: ctx.svg.width, height: ctx.svg.height };
|
|
120
|
-
const annotations: ResolvedAnnotation[] = [];
|
|
121
|
-
|
|
122
|
-
// Deferred auto-placement entries (resolved in Pass 2)
|
|
123
|
-
const autoQueue: Array<{
|
|
124
|
-
annotation: TextAnnotation;
|
|
125
|
-
anchorX: number;
|
|
126
|
-
anchorY: number;
|
|
127
|
-
resolved: ResolvedAnnotation;
|
|
128
|
-
index: number;
|
|
129
|
-
}> = [];
|
|
130
|
-
|
|
131
|
-
// ---- Pass 1: resolve explicit annotations and queue auto ones ----
|
|
132
|
-
for (const [specIndex, annotation] of spec.annotations.entries()) {
|
|
133
|
-
if (isCompact && annotation.responsive !== false) {
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
let resolved: ResolvedAnnotation | null = null;
|
|
137
|
-
|
|
138
|
-
switch (annotation.type) {
|
|
139
|
-
case 'text':
|
|
140
|
-
resolved = resolveTextAnnotation(
|
|
141
|
-
annotation,
|
|
142
|
-
ctx.scales,
|
|
143
|
-
ctx.chartArea,
|
|
144
|
-
ctx.isDark,
|
|
145
|
-
ctx.measure,
|
|
146
|
-
ctx.fontFamily,
|
|
147
|
-
);
|
|
148
|
-
if (resolved && ctx.svg.width > 0 && ctx.svg.height > 0 && isAutoPlacement(annotation)) {
|
|
149
|
-
const px = resolvePosition(annotation.x, ctx.scales.x);
|
|
150
|
-
const py = resolvePosition(annotation.y, ctx.scales.y);
|
|
151
|
-
if (px !== null && py !== null) {
|
|
152
|
-
autoQueue.push({
|
|
153
|
-
annotation,
|
|
154
|
-
anchorX: px,
|
|
155
|
-
anchorY: py,
|
|
156
|
-
resolved,
|
|
157
|
-
index: annotations.length,
|
|
158
|
-
});
|
|
159
|
-
// Same contract as the push below -- this branch `continue`s past it.
|
|
160
|
-
resolved.specIndex = specIndex;
|
|
161
|
-
annotations.push(resolved);
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
break;
|
|
166
|
-
case 'range':
|
|
167
|
-
resolved = resolveRangeAnnotation(
|
|
168
|
-
annotation,
|
|
169
|
-
ctx.scales,
|
|
170
|
-
ctx.chartArea,
|
|
171
|
-
ctx.isDark,
|
|
172
|
-
ctx.fontFamily,
|
|
173
|
-
);
|
|
174
|
-
break;
|
|
175
|
-
case 'refline':
|
|
176
|
-
resolved = resolveRefLineAnnotation(
|
|
177
|
-
annotation,
|
|
178
|
-
ctx.scales,
|
|
179
|
-
ctx.chartArea,
|
|
180
|
-
ctx.isDark,
|
|
181
|
-
ctx.fontFamily,
|
|
182
|
-
);
|
|
183
|
-
break;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (resolved) {
|
|
187
|
-
if (annotation.type === 'text' && ctx.obstacles.length > 0) {
|
|
188
|
-
nudgeAnnotationFromObstacles(
|
|
189
|
-
resolved,
|
|
190
|
-
annotation,
|
|
191
|
-
ctx.scales,
|
|
192
|
-
ctx.chartArea,
|
|
193
|
-
ctx.obstacles,
|
|
194
|
-
ctx.measure,
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
// The resolved array is filtered (an out-of-domain annotation resolves to
|
|
198
|
-
// nothing), so carry the spec index along -- it's the only stable way back
|
|
199
|
-
// to the authored annotation from here on.
|
|
200
|
-
resolved.specIndex = specIndex;
|
|
201
|
-
annotations.push(resolved);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// ---- Pass 2: scored placement search for auto annotations ----
|
|
206
|
-
if (autoQueue.length > 0 && ctx.svg.width > 0 && ctx.svg.height > 0) {
|
|
207
|
-
// Build the working obstacle list: base obstacles + explicitly placed annotations
|
|
208
|
-
const workingObstacles: PlacementObstacle[] = [...ctx.obstacles];
|
|
209
|
-
for (let i = 0; i < annotations.length; i++) {
|
|
210
|
-
const a = annotations[i];
|
|
211
|
-
if (a.bounds && !autoQueue.some((q) => q.index === i)) {
|
|
212
|
-
workingObstacles.push({ ...a.bounds, kind: 'annotation' } as PlacementObstacle);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
for (const entry of autoQueue) {
|
|
217
|
-
const { annotation, anchorX, anchorY, resolved } = entry;
|
|
218
|
-
// Must mirror resolveTextAnnotation exactly (lede weight, theme font):
|
|
219
|
-
// findBestPlacement measures with this style, and the resolved annotation
|
|
220
|
-
// renders with the other one. Any drift and the scored bounds are a lie.
|
|
221
|
-
const labelStyle = makeAnnotationLabelStyle(
|
|
222
|
-
annotation.fontSize,
|
|
223
|
-
resolveLedeFontWeight(annotation),
|
|
224
|
-
undefined,
|
|
225
|
-
ctx.isDark,
|
|
226
|
-
ctx.fontFamily,
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
const subtitleStyle = annotation.subtitle
|
|
230
|
-
? {
|
|
231
|
-
fontSize: subtitleFontSize(labelStyle.fontSize),
|
|
232
|
-
fontWeight: SUBTITLE_FONT_WEIGHT,
|
|
233
|
-
lineHeight: labelStyle.lineHeight,
|
|
234
|
-
fontFamily: labelStyle.fontFamily,
|
|
235
|
-
}
|
|
236
|
-
: undefined;
|
|
237
|
-
|
|
238
|
-
const result = findBestPlacement(
|
|
239
|
-
anchorX,
|
|
240
|
-
anchorY,
|
|
241
|
-
annotation.text,
|
|
242
|
-
{
|
|
243
|
-
fontSize: labelStyle.fontSize,
|
|
244
|
-
fontWeight: Number(labelStyle.fontWeight) || 400,
|
|
245
|
-
lineHeight: labelStyle.lineHeight,
|
|
246
|
-
fontFamily: labelStyle.fontFamily,
|
|
247
|
-
},
|
|
248
|
-
annotation.subtitle,
|
|
249
|
-
subtitleStyle,
|
|
250
|
-
workingObstacles,
|
|
251
|
-
ctx.chartArea,
|
|
252
|
-
svgRect,
|
|
253
|
-
ctx.measure,
|
|
254
|
-
ctx.debugPlacement,
|
|
255
|
-
);
|
|
256
|
-
|
|
257
|
-
// Commit the placement. `moveAnnotationTo` owns every piece of the block's
|
|
258
|
-
// geometry -- label, subtitle, both bounds caches, connector -- so this pass
|
|
259
|
-
// does not get to invent its own copy of any of them. It used to, and the
|
|
260
|
-
// copies drifted: this one re-derived the subtitle's baseline from a font-metric
|
|
261
|
-
// sum spelled `+ 2` while the resolver spelled it `SUBTITLE_GAP`, so a subtitled
|
|
262
|
-
// callout rendered 3px off from where the search had actually scored it.
|
|
263
|
-
if (resolved.label) {
|
|
264
|
-
moveAnnotationTo(
|
|
265
|
-
resolved,
|
|
266
|
-
resolved.label,
|
|
267
|
-
result.labelX,
|
|
268
|
-
result.labelY,
|
|
269
|
-
ctx.measure,
|
|
270
|
-
result.textAnchor,
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Add this annotation's bounds as obstacle for subsequent auto annotations.
|
|
275
|
-
// Read it back off the annotation rather than reusing `result.bounds`: the
|
|
276
|
-
// mover is the authority on the block's extent, and `result.bounds` is the
|
|
277
|
-
// search's own estimate of it. One of them has to be the source of truth.
|
|
278
|
-
const placedBounds = resolved.bounds ?? result.bounds;
|
|
279
|
-
workingObstacles.push({ ...placedBounds, kind: 'annotation' } as PlacementObstacle);
|
|
280
|
-
}
|
|
281
|
-
} else if (autoQueue.length > 0) {
|
|
282
|
-
// Fallback: no SVG dimensions, use legacy nudge for auto annotations
|
|
283
|
-
for (const entry of autoQueue) {
|
|
284
|
-
if (ctx.obstacles.length > 0) {
|
|
285
|
-
nudgeAnnotationFromObstacles(
|
|
286
|
-
entry.resolved,
|
|
287
|
-
entry.annotation,
|
|
288
|
-
ctx.scales,
|
|
289
|
-
ctx.chartArea,
|
|
290
|
-
ctx.obstacles,
|
|
291
|
-
ctx.measure,
|
|
292
|
-
);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Resolve annotation-to-annotation collisions for non-auto-placed annotations.
|
|
298
|
-
// When auto placement ran, those annotations already avoid each other via the
|
|
299
|
-
// obstacle-accumulation loop above, so only explicitly placed ones need this.
|
|
300
|
-
//
|
|
301
|
-
// There used to be a hand-rolled walk here that built a *compacted* pair of
|
|
302
|
-
// arrays (explicit annotations + their specs) and passed those. It was the
|
|
303
|
-
// second representation of the same list, and once `resolveAnnotationCollisions`
|
|
304
|
-
// started keying on the stamped `specIndex` — a GLOBAL index — the compacted
|
|
305
|
-
// spec array no longer matched it. Every lookup was off by the number of auto
|
|
306
|
-
// annotations, and the `type === 'text'` guard swallowed the resulting
|
|
307
|
-
// `undefined`, so collision avoidance silently did nothing the moment a single
|
|
308
|
-
// auto-placed annotation was present. Which is the *encouraged* authoring style:
|
|
309
|
-
// a bare `{ type: 'text' }` is auto-placed by definition.
|
|
310
|
-
//
|
|
311
|
-
// So don't rebuild the list. Pass the real one, and let the pass skip the
|
|
312
|
-
// auto-placed entries itself — `specIndex` already tells it which spec each
|
|
313
|
-
// resolved annotation came from.
|
|
314
|
-
const autoIndices = new Set(autoQueue.map((q) => q.index));
|
|
315
|
-
resolveAnnotationCollisions(
|
|
316
|
-
annotations,
|
|
317
|
-
spec.annotations,
|
|
318
|
-
ctx.scales,
|
|
319
|
-
ctx.chartArea,
|
|
320
|
-
ctx.measure,
|
|
321
|
-
autoQueue.length > 0 ? (i) => autoIndices.has(i) : undefined,
|
|
322
|
-
);
|
|
323
|
-
|
|
324
|
-
// Clamp labels that overflow the SVG boundary back inside
|
|
325
|
-
if (ctx.svg.width > 0 && ctx.svg.height > 0) {
|
|
326
|
-
clampAnnotationsToBounds(annotations, ctx.svg.width, ctx.svg.height, ctx.measure);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Sort by zIndex (lower first, undefined treated as 0)
|
|
330
|
-
annotations.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
|
|
331
|
-
|
|
332
|
-
return annotations;
|
|
333
|
-
}
|