@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.
Files changed (153) hide show
  1. package/CHANGELOG.md +552 -8
  2. package/MIGRATIONS.json +106 -0
  3. package/README.md +34 -8
  4. package/annotations/index.d.ts +402 -0
  5. package/annotations/index.d.ts.map +1 -0
  6. package/annotations/index.js +792 -0
  7. package/behaviors/carousel.js +198 -0
  8. package/behaviors/combobox.js +226 -0
  9. package/behaviors/command.js +190 -0
  10. package/behaviors/connectors.js +95 -0
  11. package/behaviors/crosshair.js +57 -0
  12. package/behaviors/dialog.js +74 -0
  13. package/behaviors/disclosure.js +26 -0
  14. package/behaviors/dismissible.js +25 -0
  15. package/behaviors/forms.js +186 -0
  16. package/behaviors/glyph.js +108 -0
  17. package/behaviors/index.d.ts +79 -0
  18. package/behaviors/index.js +18 -1409
  19. package/behaviors/internal.js +97 -0
  20. package/behaviors/legend.js +67 -0
  21. package/behaviors/menu.js +47 -0
  22. package/behaviors/popover.js +179 -0
  23. package/behaviors/spotlight.js +52 -0
  24. package/behaviors/table.js +136 -0
  25. package/behaviors/tabs.js +103 -0
  26. package/behaviors/theme.js +84 -0
  27. package/behaviors/toast.js +164 -0
  28. package/classes/classes.json +1857 -0
  29. package/classes/index.d.ts +306 -13
  30. package/classes/index.js +339 -12
  31. package/classes/vscode.css-custom-data.json +12 -0
  32. package/connectors/index.d.ts +191 -0
  33. package/connectors/index.d.ts.map +1 -0
  34. package/connectors/index.js +275 -0
  35. package/css/analytical.css +21 -0
  36. package/css/annotations.css +292 -0
  37. package/css/app.css +43 -13
  38. package/css/base.css +15 -10
  39. package/css/command.css +97 -0
  40. package/css/connectors.css +110 -0
  41. package/css/content.css +7 -1
  42. package/css/crosshair.css +100 -0
  43. package/css/dataviz.css +5 -1
  44. package/css/disclosure.css +38 -6
  45. package/css/dots.css +57 -0
  46. package/css/feedback.css +111 -2
  47. package/css/fonts.css +11 -7
  48. package/css/forms.css +42 -1
  49. package/css/generated.css +117 -0
  50. package/css/legend.css +272 -0
  51. package/css/marks.css +174 -0
  52. package/css/motion.css +24 -44
  53. package/css/navigation.css +7 -0
  54. package/css/overlay.css +31 -1
  55. package/css/primitives.css +109 -5
  56. package/css/report.css +39 -81
  57. package/css/selection.css +46 -0
  58. package/css/site.css +16 -2
  59. package/css/sources.css +221 -0
  60. package/css/spotlight.css +104 -0
  61. package/css/state.css +121 -0
  62. package/css/tokens.css +60 -37
  63. package/css/workbench.css +83 -0
  64. package/dist/bronto.css +1 -1
  65. package/dist/css/analytical.css +1 -0
  66. package/dist/css/annotations.css +1 -0
  67. package/dist/css/app.css +1 -1
  68. package/dist/css/base.css +1 -1
  69. package/dist/css/command.css +1 -0
  70. package/dist/css/connectors.css +1 -0
  71. package/dist/css/content.css +1 -1
  72. package/dist/css/crosshair.css +1 -0
  73. package/dist/css/disclosure.css +1 -1
  74. package/dist/css/dots.css +1 -1
  75. package/dist/css/feedback.css +1 -1
  76. package/dist/css/fonts.css +1 -1
  77. package/dist/css/forms.css +1 -1
  78. package/dist/css/generated.css +1 -0
  79. package/dist/css/legend.css +1 -0
  80. package/dist/css/marks.css +1 -0
  81. package/dist/css/motion.css +1 -1
  82. package/dist/css/navigation.css +1 -1
  83. package/dist/css/overlay.css +1 -1
  84. package/dist/css/primitives.css +1 -1
  85. package/dist/css/report.css +1 -1
  86. package/dist/css/selection.css +1 -0
  87. package/dist/css/site.css +1 -1
  88. package/dist/css/sources.css +1 -0
  89. package/dist/css/spotlight.css +1 -0
  90. package/dist/css/state.css +1 -0
  91. package/dist/css/tokens.css +1 -1
  92. package/dist/css/workbench.css +1 -0
  93. package/docs/adr/0003-theme-model.md +7 -4
  94. package/docs/annotations.md +425 -0
  95. package/docs/architecture.md +246 -0
  96. package/docs/command.md +95 -0
  97. package/docs/connectors.md +91 -0
  98. package/docs/contrast.md +116 -92
  99. package/docs/crosshair.md +63 -0
  100. package/docs/d2.md +195 -0
  101. package/docs/generated.md +91 -0
  102. package/docs/legends.md +184 -0
  103. package/docs/marks.md +93 -0
  104. package/docs/mermaid.md +152 -0
  105. package/docs/reference.md +385 -23
  106. package/docs/reporting.md +436 -63
  107. package/docs/selection.md +40 -0
  108. package/docs/sources.md +137 -0
  109. package/docs/spotlight.md +78 -0
  110. package/docs/stability.md +24 -2
  111. package/docs/state.md +85 -0
  112. package/docs/usage.md +123 -4
  113. package/docs/vega.md +225 -0
  114. package/docs/workbench.md +78 -0
  115. package/fonts/doto-400.woff2 +0 -0
  116. package/fonts/doto-500.woff2 +0 -0
  117. package/fonts/doto-600.woff2 +0 -0
  118. package/fonts/doto-700.woff2 +0 -0
  119. package/fonts/doto-800.woff2 +0 -0
  120. package/fonts/doto-900.woff2 +0 -0
  121. package/glyphs/glyphs.js +6 -4
  122. package/llms.txt +362 -14
  123. package/package.json +115 -12
  124. package/qwik/index.d.ts +42 -54
  125. package/qwik/index.d.ts.map +1 -0
  126. package/qwik/index.js +75 -3
  127. package/react/index.d.ts +39 -56
  128. package/react/index.d.ts.map +1 -0
  129. package/react/index.js +67 -3
  130. package/solid/index.d.ts +64 -56
  131. package/solid/index.d.ts.map +1 -0
  132. package/solid/index.js +70 -3
  133. package/tokens/d2.d.ts +38 -0
  134. package/tokens/d2.js +71 -0
  135. package/tokens/d2.json +43 -0
  136. package/tokens/index.d.ts +5 -5
  137. package/tokens/index.js +23 -5
  138. package/tokens/index.json +9 -0
  139. package/tokens/mermaid.d.ts +23 -0
  140. package/tokens/mermaid.js +181 -0
  141. package/tokens/mermaid.json +163 -0
  142. package/tokens/resolved.json +45 -1
  143. package/tokens/skins.js +3 -2
  144. package/tokens/tokens.dtcg.json +26 -0
  145. package/tokens/vega.d.ts +34 -0
  146. package/tokens/vega.js +155 -0
  147. package/tokens/vega.json +179 -0
  148. package/fonts/doto-400.ttf +0 -0
  149. package/fonts/doto-500.ttf +0 -0
  150. package/fonts/doto-600.ttf +0 -0
  151. package/fonts/doto-700.ttf +0 -0
  152. package/fonts/doto-800.ttf +0 -0
  153. 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
+ }