@ponchia/ui 0.4.1 → 0.6.0
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/CHANGELOG.md +552 -8
- package/MIGRATIONS.json +106 -0
- package/README.md +34 -8
- package/annotations/index.d.ts +402 -0
- package/annotations/index.d.ts.map +1 -0
- package/annotations/index.js +792 -0
- package/behaviors/carousel.js +198 -0
- package/behaviors/combobox.js +226 -0
- package/behaviors/command.js +190 -0
- package/behaviors/connectors.js +95 -0
- package/behaviors/crosshair.js +57 -0
- package/behaviors/dialog.js +74 -0
- package/behaviors/disclosure.js +26 -0
- package/behaviors/dismissible.js +25 -0
- package/behaviors/forms.js +186 -0
- package/behaviors/glyph.js +108 -0
- package/behaviors/index.d.ts +79 -0
- package/behaviors/index.js +18 -1409
- package/behaviors/internal.js +97 -0
- package/behaviors/legend.js +67 -0
- package/behaviors/menu.js +47 -0
- package/behaviors/popover.js +179 -0
- package/behaviors/spotlight.js +52 -0
- package/behaviors/table.js +136 -0
- package/behaviors/tabs.js +103 -0
- package/behaviors/theme.js +84 -0
- package/behaviors/toast.js +164 -0
- package/classes/classes.json +1857 -0
- package/classes/index.d.ts +306 -13
- package/classes/index.js +339 -12
- package/classes/vscode.css-custom-data.json +12 -0
- package/connectors/index.d.ts +191 -0
- package/connectors/index.d.ts.map +1 -0
- package/connectors/index.js +275 -0
- package/css/analytical.css +21 -0
- package/css/annotations.css +292 -0
- package/css/app.css +43 -13
- package/css/base.css +15 -10
- package/css/command.css +97 -0
- package/css/connectors.css +110 -0
- package/css/content.css +7 -1
- package/css/crosshair.css +100 -0
- package/css/dataviz.css +5 -1
- package/css/disclosure.css +38 -6
- package/css/dots.css +57 -0
- package/css/feedback.css +111 -2
- package/css/fonts.css +11 -7
- package/css/forms.css +42 -1
- package/css/generated.css +117 -0
- package/css/legend.css +272 -0
- package/css/marks.css +174 -0
- package/css/motion.css +24 -44
- package/css/navigation.css +7 -0
- package/css/overlay.css +31 -1
- package/css/primitives.css +109 -5
- package/css/report.css +39 -81
- package/css/selection.css +46 -0
- package/css/site.css +16 -2
- package/css/sources.css +221 -0
- package/css/spotlight.css +104 -0
- package/css/state.css +121 -0
- package/css/tokens.css +60 -37
- package/css/workbench.css +83 -0
- package/dist/bronto.css +1 -1
- package/dist/css/analytical.css +1 -0
- package/dist/css/annotations.css +1 -0
- package/dist/css/app.css +1 -1
- package/dist/css/base.css +1 -1
- package/dist/css/command.css +1 -0
- package/dist/css/connectors.css +1 -0
- package/dist/css/content.css +1 -1
- package/dist/css/crosshair.css +1 -0
- package/dist/css/disclosure.css +1 -1
- package/dist/css/dots.css +1 -1
- package/dist/css/feedback.css +1 -1
- package/dist/css/fonts.css +1 -1
- package/dist/css/forms.css +1 -1
- package/dist/css/generated.css +1 -0
- package/dist/css/legend.css +1 -0
- package/dist/css/marks.css +1 -0
- package/dist/css/motion.css +1 -1
- package/dist/css/navigation.css +1 -1
- package/dist/css/overlay.css +1 -1
- package/dist/css/primitives.css +1 -1
- package/dist/css/report.css +1 -1
- package/dist/css/selection.css +1 -0
- package/dist/css/site.css +1 -1
- package/dist/css/sources.css +1 -0
- package/dist/css/spotlight.css +1 -0
- package/dist/css/state.css +1 -0
- package/dist/css/tokens.css +1 -1
- package/dist/css/workbench.css +1 -0
- package/docs/adr/0003-theme-model.md +7 -4
- package/docs/annotations.md +425 -0
- package/docs/architecture.md +246 -0
- package/docs/command.md +95 -0
- package/docs/connectors.md +91 -0
- package/docs/contrast.md +116 -92
- package/docs/crosshair.md +63 -0
- package/docs/d2.md +195 -0
- package/docs/generated.md +91 -0
- package/docs/legends.md +184 -0
- package/docs/marks.md +93 -0
- package/docs/mermaid.md +152 -0
- package/docs/reference.md +385 -23
- package/docs/reporting.md +436 -63
- package/docs/selection.md +40 -0
- package/docs/sources.md +137 -0
- package/docs/spotlight.md +78 -0
- package/docs/stability.md +24 -2
- package/docs/state.md +85 -0
- package/docs/usage.md +123 -4
- package/docs/vega.md +225 -0
- package/docs/workbench.md +78 -0
- package/fonts/doto-400.woff2 +0 -0
- package/fonts/doto-500.woff2 +0 -0
- package/fonts/doto-600.woff2 +0 -0
- package/fonts/doto-700.woff2 +0 -0
- package/fonts/doto-800.woff2 +0 -0
- package/fonts/doto-900.woff2 +0 -0
- package/glyphs/glyphs.js +6 -4
- package/llms.txt +362 -14
- package/package.json +115 -12
- package/qwik/index.d.ts +42 -54
- package/qwik/index.d.ts.map +1 -0
- package/qwik/index.js +75 -3
- package/react/index.d.ts +39 -56
- package/react/index.d.ts.map +1 -0
- package/react/index.js +67 -3
- package/solid/index.d.ts +64 -56
- package/solid/index.d.ts.map +1 -0
- package/solid/index.js +70 -3
- package/tokens/d2.d.ts +38 -0
- package/tokens/d2.js +71 -0
- package/tokens/d2.json +43 -0
- package/tokens/index.d.ts +5 -5
- package/tokens/index.js +23 -5
- package/tokens/index.json +9 -0
- package/tokens/mermaid.d.ts +23 -0
- package/tokens/mermaid.js +181 -0
- package/tokens/mermaid.json +163 -0
- package/tokens/resolved.json +45 -1
- package/tokens/skins.js +3 -2
- package/tokens/tokens.dtcg.json +26 -0
- package/tokens/vega.d.ts +34 -0
- package/tokens/vega.js +155 -0
- package/tokens/vega.json +179 -0
- package/fonts/doto-400.ttf +0 -0
- package/fonts/doto-500.ttf +0 -0
- package/fonts/doto-600.ttf +0 -0
- package/fonts/doto-700.ttf +0 -0
- package/fonts/doto-800.ttf +0 -0
- package/fonts/doto-900.ttf +0 -0
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
// Shared SVG geometry primitives live in the connectors kernel; annotations
|
|
2
|
+
// (figure callouts) build on them so a line/curve/arrow/dot is drawn one way.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @ponchia/ui — SVG annotation geometry helpers.
|
|
6
|
+
*
|
|
7
|
+
* The public types below are JSDoc `@typedef`s; the shipped `index.d.ts` is
|
|
8
|
+
* generated from them (and these signatures) by `tsc --emitDeclarationOnly`.
|
|
9
|
+
*
|
|
10
|
+
* @typedef {{ x: number, y: number }} AnnotationPoint
|
|
11
|
+
* @typedef {{ dx: number, dy: number }} AnnotationOffset
|
|
12
|
+
* @typedef {'callout' | 'elbow' | 'curve'} AnnotationConnectorType
|
|
13
|
+
* @typedef {'start' | 'middle' | 'end'} AnnotationAlign
|
|
14
|
+
* @typedef {'top' | 'middle' | 'bottom'} AnnotationValign
|
|
15
|
+
* @typedef {'horizontal' | 'vertical'} AxisOrientation
|
|
16
|
+
* @typedef {'up' | 'down' | 'left' | 'right'} TimelineDirection
|
|
17
|
+
*
|
|
18
|
+
* @typedef {object} CircleSubject
|
|
19
|
+
* @property {'circle'} type
|
|
20
|
+
* @property {number} radius
|
|
21
|
+
* @property {number} [radiusPadding]
|
|
22
|
+
*
|
|
23
|
+
* @typedef {object} RectSubject
|
|
24
|
+
* @property {'rect'} type
|
|
25
|
+
* @property {number} width
|
|
26
|
+
* @property {number} height
|
|
27
|
+
* @property {number} [x]
|
|
28
|
+
* @property {number} [y]
|
|
29
|
+
* @property {number} [padding]
|
|
30
|
+
*
|
|
31
|
+
* @typedef {CircleSubject | RectSubject} ConnectorSubject
|
|
32
|
+
*
|
|
33
|
+
* @typedef {AnnotationOffset & { subject?: ConnectorSubject, mid?: number }} ConnectorOptions
|
|
34
|
+
*
|
|
35
|
+
* @typedef {object} CircleSubjectOptions
|
|
36
|
+
* @property {number} radius
|
|
37
|
+
*
|
|
38
|
+
* @typedef {object} RectSubjectOptions
|
|
39
|
+
* @property {number} width
|
|
40
|
+
* @property {number} height
|
|
41
|
+
* @property {number} [x]
|
|
42
|
+
* @property {number} [y]
|
|
43
|
+
* @property {number} [padding]
|
|
44
|
+
*
|
|
45
|
+
* @typedef {object} ThresholdOptions
|
|
46
|
+
* @property {number} [x1]
|
|
47
|
+
* @property {number} [y1]
|
|
48
|
+
* @property {number} x2
|
|
49
|
+
* @property {number} y2
|
|
50
|
+
*
|
|
51
|
+
* @typedef {object} AxisThresholdOptions
|
|
52
|
+
* @property {AxisOrientation} [orientation]
|
|
53
|
+
* @property {number} [value]
|
|
54
|
+
* @property {number} [start]
|
|
55
|
+
* @property {number} end
|
|
56
|
+
*
|
|
57
|
+
* @typedef {object} BracketSubjectOptions
|
|
58
|
+
* @property {number} x1
|
|
59
|
+
* @property {number} y1
|
|
60
|
+
* @property {number} x2
|
|
61
|
+
* @property {number} y2
|
|
62
|
+
* @property {number} [depth]
|
|
63
|
+
*
|
|
64
|
+
* @typedef {object} BandSubjectOptions
|
|
65
|
+
* @property {number} [x]
|
|
66
|
+
* @property {number} [y]
|
|
67
|
+
* @property {number} width
|
|
68
|
+
* @property {number} height
|
|
69
|
+
* @property {number} [padding]
|
|
70
|
+
*
|
|
71
|
+
* @typedef {object} SlopeSubjectOptions
|
|
72
|
+
* @property {number} x1
|
|
73
|
+
* @property {number} y1
|
|
74
|
+
* @property {number} x2
|
|
75
|
+
* @property {number} y2
|
|
76
|
+
*
|
|
77
|
+
* @typedef {object} ComparisonBraceOptions
|
|
78
|
+
* @property {number} x1
|
|
79
|
+
* @property {number} y1
|
|
80
|
+
* @property {number} x2
|
|
81
|
+
* @property {number} y2
|
|
82
|
+
* @property {number} [depth]
|
|
83
|
+
*
|
|
84
|
+
* @typedef {object} OutlierClusterOptions
|
|
85
|
+
* @property {AnnotationPoint[]} points
|
|
86
|
+
* @property {number} [radius]
|
|
87
|
+
*
|
|
88
|
+
* @typedef {object} TimelineEventOptions
|
|
89
|
+
* @property {number} [size]
|
|
90
|
+
* @property {TimelineDirection} [direction]
|
|
91
|
+
*
|
|
92
|
+
* @typedef {object} EvidenceMarkerOptions
|
|
93
|
+
* @property {number} [x]
|
|
94
|
+
* @property {number} [y]
|
|
95
|
+
* @property {number} [width]
|
|
96
|
+
* @property {number} [height]
|
|
97
|
+
* @property {number} [padding]
|
|
98
|
+
*
|
|
99
|
+
* @typedef {AnnotationPoint & { radius?: number }} ConnectorEndDotOptions
|
|
100
|
+
*
|
|
101
|
+
* @typedef {object} ConnectorEndArrowOptions
|
|
102
|
+
* @property {number} [x1]
|
|
103
|
+
* @property {number} [y1]
|
|
104
|
+
* @property {number} x2
|
|
105
|
+
* @property {number} y2
|
|
106
|
+
* @property {number} [size]
|
|
107
|
+
* @property {number} [spread] Half-angle of the arrowhead in radians (default
|
|
108
|
+
* 0.32 ≈ a crisp 37° included angle). Larger = blunter.
|
|
109
|
+
*
|
|
110
|
+
* @typedef {object} NoteTransformOptions
|
|
111
|
+
* @property {number} [dx]
|
|
112
|
+
* @property {number} [dy]
|
|
113
|
+
* @property {number} [x]
|
|
114
|
+
* @property {number} [y]
|
|
115
|
+
* @property {AnnotationAlign} [align]
|
|
116
|
+
* @property {AnnotationValign} [valign]
|
|
117
|
+
* @property {number} [width]
|
|
118
|
+
* @property {number} [height]
|
|
119
|
+
*
|
|
120
|
+
* @typedef {object} AnnotationBounds
|
|
121
|
+
* @property {number} [x]
|
|
122
|
+
* @property {number} [y]
|
|
123
|
+
* @property {number} width
|
|
124
|
+
* @property {number} height
|
|
125
|
+
*
|
|
126
|
+
* @typedef {object} NotePlacementOptions
|
|
127
|
+
* @property {number} [x]
|
|
128
|
+
* @property {number} [y]
|
|
129
|
+
* @property {number} width
|
|
130
|
+
* @property {number} height
|
|
131
|
+
* @property {AnnotationBounds} bounds
|
|
132
|
+
* @property {number} [padding]
|
|
133
|
+
* @property {number} [gap]
|
|
134
|
+
* @property {'right' | 'left' | 'top' | 'bottom'} [preferred]
|
|
135
|
+
* @property {number} [inset] Extra margin (user units) the note must keep from
|
|
136
|
+
* the bounds edge, on top of `padding`. Reserve the note's title stroke-halo
|
|
137
|
+
* (~3) or a leader stub so a placement that "fits" doesn't clip. Default 0.
|
|
138
|
+
*
|
|
139
|
+
* @typedef {object} NotePlacement
|
|
140
|
+
* @property {number} dx
|
|
141
|
+
* @property {number} dy
|
|
142
|
+
* @property {AnnotationAlign} align
|
|
143
|
+
* @property {AnnotationValign} valign
|
|
144
|
+
* @property {string} transform
|
|
145
|
+
*
|
|
146
|
+
* @typedef {(
|
|
147
|
+
* | CircleSubject
|
|
148
|
+
* | RectSubject
|
|
149
|
+
* | ({ type: 'threshold' } & ThresholdOptions)
|
|
150
|
+
* | ({ type: 'bracket' } & BracketSubjectOptions)
|
|
151
|
+
* | ({ type: 'band' } & BandSubjectOptions)
|
|
152
|
+
* | ({ type: 'slope' } & SlopeSubjectOptions)
|
|
153
|
+
* | ({ type: 'compare' } & ComparisonBraceOptions)
|
|
154
|
+
* | ({ type: 'cluster' } & OutlierClusterOptions)
|
|
155
|
+
* | ({ type: 'axis' } & AxisThresholdOptions)
|
|
156
|
+
* | ({ type: 'timeline' } & TimelineEventOptions)
|
|
157
|
+
* | ({ type: 'evidence' } & EvidenceMarkerOptions)
|
|
158
|
+
* )} AnnotationPartsSubject
|
|
159
|
+
*
|
|
160
|
+
* @typedef {object} AnnotationPartsOptions
|
|
161
|
+
* @property {AnnotationConnectorType} [type]
|
|
162
|
+
* @property {number} [x]
|
|
163
|
+
* @property {number} [y]
|
|
164
|
+
* @property {number} [dx]
|
|
165
|
+
* @property {number} [dy]
|
|
166
|
+
* @property {AnnotationPartsSubject} [subject]
|
|
167
|
+
*
|
|
168
|
+
* @typedef {object} AnnotationParts
|
|
169
|
+
* @property {string} transform
|
|
170
|
+
* @property {string} subject
|
|
171
|
+
* @property {string} connector
|
|
172
|
+
* @property {string} note
|
|
173
|
+
*
|
|
174
|
+
* @typedef {object} DeclutterLabelItem
|
|
175
|
+
* @property {number} pos Desired centre coordinate along the axis.
|
|
176
|
+
* @property {number} size The label's extent along the axis.
|
|
177
|
+
*
|
|
178
|
+
* @typedef {object} DeclutterLabelsOptions
|
|
179
|
+
* @property {number} [gap] Minimum gap kept between adjacent labels. Default 0.
|
|
180
|
+
* @property {number} [min] Lower bound of the axis. Default -Infinity.
|
|
181
|
+
* @property {number} [max] Upper bound of the axis. Default Infinity.
|
|
182
|
+
*
|
|
183
|
+
* @typedef {object} DirectLabelItem
|
|
184
|
+
* @property {AnnotationPoint} anchor The true data point the label refers to (figure coordinates).
|
|
185
|
+
* @property {number} size The label's extent along the layout axis.
|
|
186
|
+
* @property {string | number} [key] Optional identifier, echoed back on the matching output (input order).
|
|
187
|
+
*
|
|
188
|
+
* @typedef {object} DirectLabelsOptions
|
|
189
|
+
* @property {'x' | 'y'} [axis] Axis the labels declutter along. 'y' = a vertical column. Default 'y'.
|
|
190
|
+
* @property {number} [cross] Fixed coordinate on the other axis where the label column/row sits. Default 0.
|
|
191
|
+
* @property {number} [gap] Minimum gap kept between adjacent labels. Default 0.
|
|
192
|
+
* @property {number} [min] Lower bound of the layout axis. Default -Infinity.
|
|
193
|
+
* @property {number} [max] Upper bound of the layout axis. Default Infinity.
|
|
194
|
+
* @property {'straight' | 'elbow' | 'curve'} [shape] Leader-line shape. Default 'straight'.
|
|
195
|
+
*
|
|
196
|
+
* @typedef {object} DirectLabel
|
|
197
|
+
* @property {number} x Placed label point — the leader's label-side end.
|
|
198
|
+
* @property {number} y
|
|
199
|
+
* @property {AnnotationPoint} anchor The echoed input anchor.
|
|
200
|
+
* @property {string | number} [key] The echoed input key, if any.
|
|
201
|
+
* @property {string} d SVG path for the leader (anchor → label point); '' if they coincide.
|
|
202
|
+
*/
|
|
203
|
+
|
|
204
|
+
import {
|
|
205
|
+
straightPath,
|
|
206
|
+
curvePath,
|
|
207
|
+
connectorPath,
|
|
208
|
+
elbowPath,
|
|
209
|
+
arrowHead,
|
|
210
|
+
dotMark,
|
|
211
|
+
angleBetween,
|
|
212
|
+
// Shared scalar/geometry kernel — single source of truth (was copy-pasted,
|
|
213
|
+
// and the local clamp had silently diverged from the connectors one).
|
|
214
|
+
PRECISION,
|
|
215
|
+
finite,
|
|
216
|
+
dimension,
|
|
217
|
+
fmt,
|
|
218
|
+
point,
|
|
219
|
+
clamp,
|
|
220
|
+
} from '../connectors/index.js';
|
|
221
|
+
|
|
222
|
+
function roundedNumber(value) {
|
|
223
|
+
const rounded = Math.round((Object.is(value, -0) ? 0 : value) * PRECISION) / PRECISION;
|
|
224
|
+
return Object.is(rounded, -0) ? 0 : rounded;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function circlePathAt(x, y, radius) {
|
|
228
|
+
const r = dimension('radius', radius);
|
|
229
|
+
if (r === 0) return '';
|
|
230
|
+
return `M${point(x, y - r)}A${fmt(r)},${fmt(r)} 0 1 1 ${point(x, y + r)}A${fmt(r)},${fmt(
|
|
231
|
+
r,
|
|
232
|
+
)} 0 1 1 ${point(x, y - r)}Z`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function samePoint(a, b) {
|
|
236
|
+
return fmt(a.x) === fmt(b.x) && fmt(a.y) === fmt(b.y);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function validateOffset(opts) {
|
|
240
|
+
return {
|
|
241
|
+
dx: finite('dx', opts?.dx),
|
|
242
|
+
dy: finite('dy', opts?.dy),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function trimForCircle(dx, dy, subject) {
|
|
247
|
+
const len = Math.hypot(dx, dy);
|
|
248
|
+
const radius = dimension('subject.radius', subject.radius);
|
|
249
|
+
const padding = dimension('subject.radiusPadding', subject.radiusPadding, 0);
|
|
250
|
+
const trim = radius + padding;
|
|
251
|
+
if (trim <= 0) return { x: 0, y: 0 };
|
|
252
|
+
if (trim >= len) return null;
|
|
253
|
+
return { x: (dx / len) * trim, y: (dy / len) * trim };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function trimForRect(dx, dy, subject) {
|
|
257
|
+
const width = dimension('subject.width', subject.width);
|
|
258
|
+
const height = dimension('subject.height', subject.height);
|
|
259
|
+
const padding = dimension('subject.padding', subject.padding, 0);
|
|
260
|
+
const x = finite('subject.x', subject.x, -width / 2);
|
|
261
|
+
const y = finite('subject.y', subject.y, -height / 2);
|
|
262
|
+
const minX = x - padding;
|
|
263
|
+
const minY = y - padding;
|
|
264
|
+
const maxX = x + width + padding;
|
|
265
|
+
const maxY = y + height + padding;
|
|
266
|
+
const candidates = [];
|
|
267
|
+
|
|
268
|
+
if (dx > 0) candidates.push(maxX / dx);
|
|
269
|
+
if (dx < 0) candidates.push(minX / dx);
|
|
270
|
+
if (dy > 0) candidates.push(maxY / dy);
|
|
271
|
+
if (dy < 0) candidates.push(minY / dy);
|
|
272
|
+
|
|
273
|
+
const t = Math.min(...candidates.filter((v) => Number.isFinite(v) && v > 0));
|
|
274
|
+
if (!Number.isFinite(t) || t <= 0) return { x: 0, y: 0 };
|
|
275
|
+
if (t >= 1) return null;
|
|
276
|
+
return { x: dx * t, y: dy * t };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function connectorStart(dx, dy, subject) {
|
|
280
|
+
if (!subject) return { x: 0, y: 0 };
|
|
281
|
+
if (subject.type === 'circle') return trimForCircle(dx, dy, subject);
|
|
282
|
+
if (subject.type === 'rect') return trimForRect(dx, dy, subject);
|
|
283
|
+
throw new TypeError('subject.type must be "circle" or "rect"');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function linePath(start, end) {
|
|
287
|
+
if (samePoint(start, end)) return '';
|
|
288
|
+
return `M${point(start.x, start.y)}L${point(end.x, end.y)}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* @param {Partial<AnnotationPoint>} [point]
|
|
293
|
+
* @returns {string}
|
|
294
|
+
*/
|
|
295
|
+
export function annotationTransform({ x = 0, y = 0 } = {}) {
|
|
296
|
+
return `translate(${fmt(finite('x', x))}, ${fmt(finite('y', y))})`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @param {NoteTransformOptions} [options]
|
|
301
|
+
* @returns {string}
|
|
302
|
+
*/
|
|
303
|
+
export function noteTransform({
|
|
304
|
+
dx,
|
|
305
|
+
dy,
|
|
306
|
+
x,
|
|
307
|
+
y,
|
|
308
|
+
align = 'start',
|
|
309
|
+
valign = 'top',
|
|
310
|
+
width = 0,
|
|
311
|
+
height = 0,
|
|
312
|
+
} = {}) {
|
|
313
|
+
let nx = finite('dx', dx, x ?? 0);
|
|
314
|
+
let ny = finite('dy', dy, y ?? 0);
|
|
315
|
+
const w = dimension('width', width);
|
|
316
|
+
const h = dimension('height', height);
|
|
317
|
+
|
|
318
|
+
if (align === 'middle') nx -= w / 2;
|
|
319
|
+
else if (align === 'end') nx -= w;
|
|
320
|
+
else if (align !== 'start') throw new TypeError('align must be "start", "middle" or "end"');
|
|
321
|
+
|
|
322
|
+
if (valign === 'middle') ny -= h / 2;
|
|
323
|
+
else if (valign === 'bottom') ny -= h;
|
|
324
|
+
else if (valign !== 'top') throw new TypeError('valign must be "top", "middle" or "bottom"');
|
|
325
|
+
|
|
326
|
+
return `translate(${fmt(nx)}, ${fmt(ny)})`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function candidatePlacement(side, gap) {
|
|
330
|
+
if (side === 'right') return { dx: gap, dy: 0, align: 'start', valign: 'middle' };
|
|
331
|
+
if (side === 'left') return { dx: -gap, dy: 0, align: 'end', valign: 'middle' };
|
|
332
|
+
if (side === 'top') return { dx: 0, dy: -gap, align: 'middle', valign: 'bottom' };
|
|
333
|
+
if (side === 'bottom') return { dx: 0, dy: gap, align: 'middle', valign: 'top' };
|
|
334
|
+
throw new TypeError('preferred must be "right", "left", "top" or "bottom"');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function placementOrder(preferred) {
|
|
338
|
+
if (preferred === 'right') return ['right', 'top', 'bottom', 'left'];
|
|
339
|
+
if (preferred === 'left') return ['left', 'top', 'bottom', 'right'];
|
|
340
|
+
if (preferred === 'top') return ['top', 'right', 'left', 'bottom'];
|
|
341
|
+
if (preferred === 'bottom') return ['bottom', 'right', 'left', 'top'];
|
|
342
|
+
throw new TypeError('preferred must be "right", "left", "top" or "bottom"');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function noteRect(x, y, width, height, placement) {
|
|
346
|
+
const anchorX = x + placement.dx;
|
|
347
|
+
const anchorY = y + placement.dy;
|
|
348
|
+
let left = anchorX;
|
|
349
|
+
let top = anchorY;
|
|
350
|
+
|
|
351
|
+
if (placement.align === 'middle') left -= width / 2;
|
|
352
|
+
else if (placement.align === 'end') left -= width;
|
|
353
|
+
|
|
354
|
+
if (placement.valign === 'middle') top -= height / 2;
|
|
355
|
+
else if (placement.valign === 'bottom') top -= height;
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
left,
|
|
359
|
+
top,
|
|
360
|
+
right: left + width,
|
|
361
|
+
bottom: top + height,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* @param {NotePlacementOptions} options
|
|
367
|
+
* @returns {NotePlacement}
|
|
368
|
+
*/
|
|
369
|
+
export function notePlacement({
|
|
370
|
+
x = 0,
|
|
371
|
+
y = 0,
|
|
372
|
+
width,
|
|
373
|
+
height,
|
|
374
|
+
bounds,
|
|
375
|
+
padding = 8,
|
|
376
|
+
gap = 32,
|
|
377
|
+
preferred = 'right',
|
|
378
|
+
inset = 0,
|
|
379
|
+
} = {}) {
|
|
380
|
+
const anchorX = finite('x', x);
|
|
381
|
+
const anchorY = finite('y', y);
|
|
382
|
+
const w = dimension('width', width);
|
|
383
|
+
const h = dimension('height', height);
|
|
384
|
+
const p = dimension('padding', padding);
|
|
385
|
+
const g = dimension('gap', gap);
|
|
386
|
+
const ins = dimension('inset', inset, 0);
|
|
387
|
+
const bx = finite('bounds.x', bounds?.x, 0);
|
|
388
|
+
const by = finite('bounds.y', bounds?.y, 0);
|
|
389
|
+
const bw = dimension('bounds.width', bounds?.width);
|
|
390
|
+
const bh = dimension('bounds.height', bounds?.height);
|
|
391
|
+
// `inset` reserves an extra margin (e.g. the title stroke-halo) inside the
|
|
392
|
+
// padded bounds, so a placement that "just fits" doesn't clip the halo/leader.
|
|
393
|
+
const minX = bx + p + ins;
|
|
394
|
+
const minY = by + p + ins;
|
|
395
|
+
const maxX = bx + bw - p - ins;
|
|
396
|
+
const maxY = by + bh - p - ins;
|
|
397
|
+
|
|
398
|
+
for (const side of placementOrder(preferred)) {
|
|
399
|
+
const placement = candidatePlacement(side, g);
|
|
400
|
+
const rect = noteRect(anchorX, anchorY, w, h, placement);
|
|
401
|
+
if (rect.left >= minX && rect.top >= minY && rect.right <= maxX && rect.bottom <= maxY) {
|
|
402
|
+
return {
|
|
403
|
+
dx: roundedNumber(placement.dx),
|
|
404
|
+
dy: roundedNumber(placement.dy),
|
|
405
|
+
align: placement.align,
|
|
406
|
+
valign: placement.valign,
|
|
407
|
+
transform: noteTransform({ ...placement, width: w, height: h }),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const fallback = candidatePlacement(preferred, g);
|
|
413
|
+
const rect = noteRect(anchorX, anchorY, w, h, fallback);
|
|
414
|
+
const left = clamp(rect.left, minX, maxX - w);
|
|
415
|
+
const top = clamp(rect.top, minY, maxY - h);
|
|
416
|
+
const dx = roundedNumber(left - anchorX);
|
|
417
|
+
const dy = roundedNumber(top - anchorY);
|
|
418
|
+
return {
|
|
419
|
+
dx,
|
|
420
|
+
dy,
|
|
421
|
+
align: 'start',
|
|
422
|
+
valign: 'top',
|
|
423
|
+
transform: noteTransform({ dx, dy }),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* @param {CircleSubjectOptions} options
|
|
429
|
+
* @returns {string}
|
|
430
|
+
*/
|
|
431
|
+
export function circleSubjectPath({ radius } = {}) {
|
|
432
|
+
return circlePathAt(0, 0, radius);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* @param {RectSubjectOptions} options
|
|
437
|
+
* @returns {string}
|
|
438
|
+
*/
|
|
439
|
+
export function rectSubjectPath({ width, height, x, y, padding = 0 } = {}) {
|
|
440
|
+
const w = dimension('width', width);
|
|
441
|
+
const h = dimension('height', height);
|
|
442
|
+
const p = dimension('padding', padding);
|
|
443
|
+
if (w === 0 || h === 0) return '';
|
|
444
|
+
const left = finite('x', x, -w / 2) - p;
|
|
445
|
+
const top = finite('y', y, -h / 2) - p;
|
|
446
|
+
const right = left + w + p * 2;
|
|
447
|
+
const bottom = top + h + p * 2;
|
|
448
|
+
return `M${point(left, top)}H${fmt(right)}V${fmt(bottom)}H${fmt(left)}Z`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* @param {ThresholdOptions} options
|
|
453
|
+
* @returns {string}
|
|
454
|
+
*/
|
|
455
|
+
export function thresholdPath({ x1 = 0, y1 = 0, x2, y2 } = {}) {
|
|
456
|
+
const start = { x: finite('x1', x1), y: finite('y1', y1) };
|
|
457
|
+
const end = { x: finite('x2', x2), y: finite('y2', y2) };
|
|
458
|
+
return linePath(start, end);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* @param {AxisThresholdOptions} options
|
|
463
|
+
* @returns {string}
|
|
464
|
+
*/
|
|
465
|
+
export function axisThresholdPath({ orientation = 'horizontal', value = 0, start = 0, end } = {}) {
|
|
466
|
+
const v = finite('value', value);
|
|
467
|
+
const s = finite('start', start);
|
|
468
|
+
const e = finite('end', end);
|
|
469
|
+
if (orientation === 'horizontal') return thresholdPath({ x1: s, y1: v, x2: e, y2: v });
|
|
470
|
+
if (orientation === 'vertical') return thresholdPath({ x1: v, y1: s, x2: v, y2: e });
|
|
471
|
+
throw new TypeError('orientation must be "horizontal" or "vertical"');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* @param {BracketSubjectOptions} options
|
|
476
|
+
* @returns {string}
|
|
477
|
+
*/
|
|
478
|
+
export function bracketSubjectPath({ x1, y1, x2, y2, depth = 12 } = {}) {
|
|
479
|
+
const start = { x: finite('x1', x1), y: finite('y1', y1) };
|
|
480
|
+
const end = { x: finite('x2', x2), y: finite('y2', y2) };
|
|
481
|
+
const d = finite('depth', depth);
|
|
482
|
+
if (samePoint(start, end) || d === 0) return linePath(start, end);
|
|
483
|
+
if (Math.abs(end.x - start.x) >= Math.abs(end.y - start.y)) {
|
|
484
|
+
return `M${point(start.x, start.y)}V${fmt(start.y + d)}H${fmt(end.x)}V${fmt(end.y)}`;
|
|
485
|
+
}
|
|
486
|
+
return `M${point(start.x, start.y)}H${fmt(start.x + d)}V${fmt(end.y)}H${fmt(end.x)}`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* @param {BandSubjectOptions} options
|
|
491
|
+
* @returns {string}
|
|
492
|
+
*/
|
|
493
|
+
export function bandSubjectPath({ x = 0, y = 0, width, height, padding = 0 } = {}) {
|
|
494
|
+
return rectSubjectPath({ x, y, width, height, padding });
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* @param {SlopeSubjectOptions} options
|
|
499
|
+
* @returns {string}
|
|
500
|
+
*/
|
|
501
|
+
export function slopeSubjectPath({ x1, y1, x2, y2 } = {}) {
|
|
502
|
+
return thresholdPath({ x1, y1, x2, y2 });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* @param {ComparisonBraceOptions} options
|
|
507
|
+
* @returns {string}
|
|
508
|
+
*/
|
|
509
|
+
export function comparisonBracePath({ x1, y1, x2, y2, depth = 14 } = {}) {
|
|
510
|
+
const start = { x: finite('x1', x1), y: finite('y1', y1) };
|
|
511
|
+
const end = { x: finite('x2', x2), y: finite('y2', y2) };
|
|
512
|
+
const d = finite('depth', depth);
|
|
513
|
+
if (samePoint(start, end) || d === 0) return linePath(start, end);
|
|
514
|
+
|
|
515
|
+
if (Math.abs(end.x - start.x) >= Math.abs(end.y - start.y)) {
|
|
516
|
+
const y = start.y;
|
|
517
|
+
const mid = (start.x + end.x) / 2;
|
|
518
|
+
const q = (end.x - start.x) / 4;
|
|
519
|
+
return `M${point(start.x, y)}C${point(start.x + q, y)} ${point(start.x + q, y + d)} ${point(
|
|
520
|
+
mid,
|
|
521
|
+
y + d,
|
|
522
|
+
)}C${point(mid, y + d)} ${point(mid, y + d * 2)} ${point(mid, y + d * 2)}C${point(
|
|
523
|
+
mid,
|
|
524
|
+
y + d,
|
|
525
|
+
)} ${point(end.x - q, y + d)} ${point(end.x - q, y)}C${point(end.x - q, y)} ${point(
|
|
526
|
+
end.x - q,
|
|
527
|
+
y,
|
|
528
|
+
)} ${point(end.x, y)}`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const x = start.x;
|
|
532
|
+
const mid = (start.y + end.y) / 2;
|
|
533
|
+
const q = (end.y - start.y) / 4;
|
|
534
|
+
return `M${point(x, start.y)}C${point(x, start.y + q)} ${point(x + d, start.y + q)} ${point(
|
|
535
|
+
x + d,
|
|
536
|
+
mid,
|
|
537
|
+
)}C${point(x + d, mid)} ${point(x + d * 2, mid)} ${point(x + d * 2, mid)}C${point(
|
|
538
|
+
x + d,
|
|
539
|
+
mid,
|
|
540
|
+
)} ${point(x + d, end.y - q)} ${point(x, end.y - q)}C${point(x, end.y - q)} ${point(
|
|
541
|
+
x,
|
|
542
|
+
end.y - q,
|
|
543
|
+
)} ${point(x, end.y)}`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* @param {OutlierClusterOptions} options
|
|
548
|
+
* @returns {string}
|
|
549
|
+
*/
|
|
550
|
+
export function outlierClusterPath({ points, radius = 6 } = {}) {
|
|
551
|
+
if (!Array.isArray(points)) throw new TypeError('points must be an array');
|
|
552
|
+
return points
|
|
553
|
+
.map((p, i) =>
|
|
554
|
+
circlePathAt(finite(`points[${i}].x`, p?.x), finite(`points[${i}].y`, p?.y), radius),
|
|
555
|
+
)
|
|
556
|
+
.filter(Boolean)
|
|
557
|
+
.join('');
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* @param {TimelineEventOptions} [options]
|
|
562
|
+
* @returns {string}
|
|
563
|
+
*/
|
|
564
|
+
export function timelineEventPath({ size = 10, direction = 'down' } = {}) {
|
|
565
|
+
const s = dimension('size', size);
|
|
566
|
+
if (s === 0) return '';
|
|
567
|
+
if (direction === 'down') return `M0,0L${point(s / 2, s)}H${fmt(-s / 2)}Z`;
|
|
568
|
+
if (direction === 'up') return `M0,0L${point(s / 2, -s)}H${fmt(-s / 2)}Z`;
|
|
569
|
+
if (direction === 'right') return `M0,0L${point(s, s / 2)}V${fmt(-s / 2)}Z`;
|
|
570
|
+
if (direction === 'left') return `M0,0L${point(-s, s / 2)}V${fmt(-s / 2)}Z`;
|
|
571
|
+
throw new TypeError('direction must be "up", "down", "left" or "right"');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* @param {EvidenceMarkerOptions} [options]
|
|
576
|
+
* @returns {string}
|
|
577
|
+
*/
|
|
578
|
+
export function evidenceMarkerPath({ x = 0, y = 0, width = 36, height = 36, padding = 0 } = {}) {
|
|
579
|
+
const w = dimension('width', width);
|
|
580
|
+
const h = dimension('height', height);
|
|
581
|
+
const p = dimension('padding', padding);
|
|
582
|
+
if (w === 0 || h === 0) return '';
|
|
583
|
+
const cx = finite('x', x);
|
|
584
|
+
const cy = finite('y', y);
|
|
585
|
+
const left = cx - w / 2 - p;
|
|
586
|
+
const top = cy - h / 2 - p;
|
|
587
|
+
const right = left + w + p * 2;
|
|
588
|
+
const bottom = top + h + p * 2;
|
|
589
|
+
return `M${point(left, top)}H${fmt(right)}V${fmt(bottom)}H${fmt(left)}Z`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* @param {ConnectorEndDotOptions} options
|
|
594
|
+
* @returns {string}
|
|
595
|
+
*/
|
|
596
|
+
export function connectorEndDot({ x, y, radius = 3 } = {}) {
|
|
597
|
+
return dotMark({ x: finite('x', x), y: finite('y', y) }, radius);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* @param {ConnectorEndArrowOptions} options
|
|
602
|
+
* @returns {string}
|
|
603
|
+
*/
|
|
604
|
+
export function connectorEndArrow({ x1 = 0, y1 = 0, x2, y2, size = 8, spread = 0.32 } = {}) {
|
|
605
|
+
const start = { x: finite('x1', x1), y: finite('y1', y1) };
|
|
606
|
+
const end = { x: finite('x2', x2), y: finite('y2', y2) };
|
|
607
|
+
const s = dimension('size', size);
|
|
608
|
+
if (s === 0 || (end.x === start.x && end.y === start.y)) return '';
|
|
609
|
+
return arrowHead(end, angleBetween(start, end), s, spread);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* @param {ConnectorOptions} opts
|
|
614
|
+
* @returns {string}
|
|
615
|
+
*/
|
|
616
|
+
export function connectorLine(opts = {}) {
|
|
617
|
+
const { dx, dy } = validateOffset(opts);
|
|
618
|
+
if (dx === 0 && dy === 0) return '';
|
|
619
|
+
const start = connectorStart(dx, dy, opts.subject);
|
|
620
|
+
if (!start) return '';
|
|
621
|
+
const end = { x: dx, y: dy };
|
|
622
|
+
// Guard a trim that rounds onto the note anchor (straightPath has no guard).
|
|
623
|
+
if (samePoint(start, end)) return '';
|
|
624
|
+
return straightPath(start, end);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* @param {ConnectorOptions} opts
|
|
629
|
+
* @returns {string}
|
|
630
|
+
*/
|
|
631
|
+
export function connectorElbow(opts = {}) {
|
|
632
|
+
const { dx, dy } = validateOffset(opts);
|
|
633
|
+
if (dx === 0 && dy === 0) return '';
|
|
634
|
+
const start = connectorStart(dx, dy, opts.subject);
|
|
635
|
+
if (!start) return '';
|
|
636
|
+
const end = { x: dx, y: dy };
|
|
637
|
+
if (samePoint(start, end)) return '';
|
|
638
|
+
// A true right-angle dogleg (H/V/H), turning on the dominant axis at `mid`
|
|
639
|
+
// (0..1, default 0.5). Delegated to the connectors geometry kernel so an
|
|
640
|
+
// annotation leader and a node connector draw the same elbow. (The former
|
|
641
|
+
// inline form turned by min(|dx|,|dy|), i.e. a 45° chamfer that read as a
|
|
642
|
+
// diagonal stub, not an elbow — which the `stroke-linejoin` bevel assumes.)
|
|
643
|
+
return elbowPath(start, end, { mid: opts.mid });
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* @param {ConnectorOptions} opts
|
|
648
|
+
* @returns {string}
|
|
649
|
+
*/
|
|
650
|
+
export function connectorCurve(opts = {}) {
|
|
651
|
+
const { dx, dy } = validateOffset(opts);
|
|
652
|
+
if (dx === 0 && dy === 0) return '';
|
|
653
|
+
const start = connectorStart(dx, dy, opts.subject);
|
|
654
|
+
if (!start) return '';
|
|
655
|
+
const end = { x: dx, y: dy };
|
|
656
|
+
if (samePoint(start, end)) return '';
|
|
657
|
+
// Annotation callouts use a gentler curve than the connectors default.
|
|
658
|
+
return curvePath(start, end, { curvature: 0.35 });
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* @param {AnnotationPartsOptions} [opts]
|
|
663
|
+
* @returns {AnnotationParts}
|
|
664
|
+
*/
|
|
665
|
+
export function annotationParts(opts = {}) {
|
|
666
|
+
const type = opts.type ?? 'callout';
|
|
667
|
+
const transform = annotationTransform({ x: opts.x ?? 0, y: opts.y ?? 0 });
|
|
668
|
+
const dx = finite('dx', opts.dx, 0);
|
|
669
|
+
const dy = finite('dy', opts.dy, 0);
|
|
670
|
+
const connectorSubject =
|
|
671
|
+
opts.subject?.type === 'circle' || opts.subject?.type === 'rect' ? opts.subject : undefined;
|
|
672
|
+
const connector =
|
|
673
|
+
type === 'curve'
|
|
674
|
+
? connectorCurve({ dx, dy, subject: connectorSubject })
|
|
675
|
+
: type === 'elbow'
|
|
676
|
+
? connectorElbow({ dx, dy, subject: connectorSubject })
|
|
677
|
+
: connectorLine({ dx, dy, subject: connectorSubject });
|
|
678
|
+
const note = noteTransform({ dx, dy });
|
|
679
|
+
let subject = '';
|
|
680
|
+
|
|
681
|
+
if (opts.subject?.type === 'circle') subject = circleSubjectPath(opts.subject);
|
|
682
|
+
else if (opts.subject?.type === 'rect') subject = rectSubjectPath(opts.subject);
|
|
683
|
+
else if (opts.subject?.type === 'threshold') subject = thresholdPath(opts.subject);
|
|
684
|
+
else if (opts.subject?.type === 'bracket') subject = bracketSubjectPath(opts.subject);
|
|
685
|
+
else if (opts.subject?.type === 'band') subject = bandSubjectPath(opts.subject);
|
|
686
|
+
else if (opts.subject?.type === 'slope') subject = slopeSubjectPath(opts.subject);
|
|
687
|
+
else if (opts.subject?.type === 'compare') subject = comparisonBracePath(opts.subject);
|
|
688
|
+
else if (opts.subject?.type === 'cluster') subject = outlierClusterPath(opts.subject);
|
|
689
|
+
else if (opts.subject?.type === 'axis') subject = axisThresholdPath(opts.subject);
|
|
690
|
+
else if (opts.subject?.type === 'timeline') subject = timelineEventPath(opts.subject);
|
|
691
|
+
else if (opts.subject?.type === 'evidence') subject = evidenceMarkerPath(opts.subject);
|
|
692
|
+
else if (opts.subject != null) throw new TypeError('unsupported subject.type');
|
|
693
|
+
|
|
694
|
+
return { transform, subject, connector, note };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Declutter labels along ONE axis: nudge overlapping labels apart so each keeps
|
|
699
|
+
* `gap` from its neighbours, sweeping up from `min`; if the run overflows `max`
|
|
700
|
+
* it slides up to fit. Deterministic and order-preserving — NOT a general 2-D
|
|
701
|
+
* collision solver (with more labels than the range holds, the overflow past
|
|
702
|
+
* `min` is the caller's to resolve: fewer labels, a longer axis, or rotation).
|
|
703
|
+
*
|
|
704
|
+
* `items`: `[{ pos, size }]` — `pos` is the desired centre coordinate along the
|
|
705
|
+
* axis, `size` the label's extent along it. Returns the adjusted centre per
|
|
706
|
+
* input item, in the original order.
|
|
707
|
+
*
|
|
708
|
+
* @param {DeclutterLabelItem[]} items
|
|
709
|
+
* @param {DeclutterLabelsOptions} [opts]
|
|
710
|
+
* @returns {number[]}
|
|
711
|
+
*/
|
|
712
|
+
export function declutterLabels(items, opts = {}) {
|
|
713
|
+
if (!Array.isArray(items)) throw new TypeError('items must be an array');
|
|
714
|
+
const gap = dimension('gap', opts.gap, 0);
|
|
715
|
+
const min = opts.min == null ? -Infinity : finite('min', opts.min);
|
|
716
|
+
const max = opts.max == null ? Infinity : finite('max', opts.max);
|
|
717
|
+
if (max < min) throw new RangeError('max must be greater than or equal to min');
|
|
718
|
+
|
|
719
|
+
const nodes = items.map((it, index) => ({
|
|
720
|
+
index,
|
|
721
|
+
half: dimension('size', it?.size) / 2,
|
|
722
|
+
pos: finite('pos', it?.pos),
|
|
723
|
+
}));
|
|
724
|
+
const order = [...nodes].sort((a, b) => a.pos - b.pos);
|
|
725
|
+
|
|
726
|
+
let floor = min;
|
|
727
|
+
for (const n of order) {
|
|
728
|
+
const center = Math.max(n.pos, floor + n.half);
|
|
729
|
+
n.pos = center;
|
|
730
|
+
floor = center + n.half + gap;
|
|
731
|
+
}
|
|
732
|
+
if (max !== Infinity && order.length) {
|
|
733
|
+
const last = order[order.length - 1];
|
|
734
|
+
const overflow = last.pos + last.half - max;
|
|
735
|
+
if (overflow > 0) for (const n of order) n.pos -= overflow;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const out = new Array(nodes.length);
|
|
739
|
+
for (const n of nodes) out[n.index] = roundedNumber(n.pos);
|
|
740
|
+
return out;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Direct labeling: declutter labels along one axis and draw a leader line from
|
|
745
|
+
* each true anchor to its placed label. This is the 1-D core of Labella,
|
|
746
|
+
* completed with leaders via the shared connector kernel — deterministic and
|
|
747
|
+
* pure. It owns no scales (map data → figure coords first), no DOM, no
|
|
748
|
+
* nearest-anchor matching, and no 2-D placement; those stay the host's job.
|
|
749
|
+
*
|
|
750
|
+
* Each `items[i]` is `{ anchor: {x, y}, size, key? }`: `anchor` is the true
|
|
751
|
+
* data point in figure coordinates, `size` is the label's extent along the
|
|
752
|
+
* layout `axis`. Labels declutter along `axis` ('y' = a vertical column,
|
|
753
|
+
* default) and sit at the fixed `cross` coordinate on the other axis. Returns,
|
|
754
|
+
* in input order, the placed label point `{x, y}`, the echoed `anchor` and
|
|
755
|
+
* `key`, and the leader path `d` (anchor → label; `''` if they coincide) ready
|
|
756
|
+
* for a `<path class="ui-annotation__connector">`.
|
|
757
|
+
*
|
|
758
|
+
* @param {DirectLabelItem[]} items
|
|
759
|
+
* @param {DirectLabelsOptions} [opts]
|
|
760
|
+
* @returns {DirectLabel[]}
|
|
761
|
+
*/
|
|
762
|
+
export function directLabels(items, opts = {}) {
|
|
763
|
+
if (!Array.isArray(items)) throw new TypeError('items must be an array');
|
|
764
|
+
const axis = opts.axis === 'x' ? 'x' : 'y';
|
|
765
|
+
const cross = finite('cross', opts.cross, 0);
|
|
766
|
+
const shape = opts.shape === 'elbow' || opts.shape === 'curve' ? opts.shape : 'straight';
|
|
767
|
+
|
|
768
|
+
const anchors = items.map((it) => ({
|
|
769
|
+
anchor: { x: finite('anchor.x', it?.anchor?.x), y: finite('anchor.y', it?.anchor?.y) },
|
|
770
|
+
size: dimension('size', it?.size),
|
|
771
|
+
key: it?.key,
|
|
772
|
+
}));
|
|
773
|
+
|
|
774
|
+
const placed = declutterLabels(
|
|
775
|
+
anchors.map((a) => ({ pos: a.anchor[axis], size: a.size })),
|
|
776
|
+
{ gap: opts.gap, min: opts.min, max: opts.max },
|
|
777
|
+
);
|
|
778
|
+
|
|
779
|
+
return anchors.map((a, i) => {
|
|
780
|
+
const labelPoint = axis === 'y' ? { x: cross, y: placed[i] } : { x: placed[i], y: cross };
|
|
781
|
+
const d = samePoint(a.anchor, labelPoint)
|
|
782
|
+
? ''
|
|
783
|
+
: connectorPath({ from: a.anchor, to: labelPoint, shape });
|
|
784
|
+
return {
|
|
785
|
+
x: roundedNumber(labelPoint.x),
|
|
786
|
+
y: roundedNumber(labelPoint.y),
|
|
787
|
+
anchor: { x: roundedNumber(a.anchor.x), y: roundedNumber(a.anchor.y) },
|
|
788
|
+
key: a.key,
|
|
789
|
+
d,
|
|
790
|
+
};
|
|
791
|
+
});
|
|
792
|
+
}
|