@ponchia/ui 0.4.1 → 0.5.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 +230 -8
- package/MIGRATIONS.json +92 -0
- package/README.md +9 -6
- package/annotations/index.d.ts +280 -0
- package/annotations/index.js +522 -0
- package/behaviors/carousel.js +197 -0
- package/behaviors/combobox.js +195 -0
- package/behaviors/command.js +187 -0
- package/behaviors/connectors.js +96 -0
- package/behaviors/crosshair.js +58 -0
- package/behaviors/dialog.js +73 -0
- package/behaviors/disclosure.js +25 -0
- package/behaviors/dismissible.js +24 -0
- package/behaviors/forms.js +158 -0
- package/behaviors/glyph.js +109 -0
- package/behaviors/index.d.ts +79 -0
- package/behaviors/index.js +18 -1409
- package/behaviors/internal.js +50 -0
- package/behaviors/legend.js +46 -0
- package/behaviors/menu.js +46 -0
- package/behaviors/popover.js +108 -0
- package/behaviors/spotlight.js +53 -0
- package/behaviors/table.js +109 -0
- package/behaviors/tabs.js +103 -0
- package/behaviors/theme.js +82 -0
- package/behaviors/toast.js +152 -0
- package/classes/index.d.ts +280 -2
- package/classes/index.js +313 -2
- package/connectors/index.d.ts +71 -0
- package/connectors/index.js +179 -0
- package/css/analytical.css +21 -0
- package/css/annotations.css +292 -0
- package/css/command.css +97 -0
- package/css/connectors.css +93 -0
- package/css/crosshair.css +100 -0
- package/css/feedback.css +51 -0
- package/css/fonts.css +11 -7
- package/css/generated.css +117 -0
- package/css/legend.css +268 -0
- package/css/marks.css +144 -0
- package/css/primitives.css +18 -0
- package/css/report.css +12 -31
- package/css/selection.css +46 -0
- package/css/sources.css +179 -0
- package/css/spotlight.css +104 -0
- package/css/state.css +121 -0
- package/css/tokens.css +25 -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/command.css +1 -0
- package/dist/css/connectors.css +1 -0
- package/dist/css/crosshair.css +1 -0
- package/dist/css/feedback.css +1 -1
- package/dist/css/fonts.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/primitives.css +1 -1
- package/dist/css/report.css +1 -1
- package/dist/css/selection.css +1 -0
- package/dist/css/sources.css +1 -0
- package/dist/css/spotlight.css +1 -0
- package/dist/css/state.css +1 -0
- package/dist/css/workbench.css +1 -0
- package/docs/adr/0003-theme-model.md +7 -4
- package/docs/annotations.md +345 -0
- package/docs/architecture.md +202 -0
- package/docs/command.md +95 -0
- package/docs/connectors.md +91 -0
- package/docs/crosshair.md +63 -0
- package/docs/generated.md +91 -0
- package/docs/legends.md +168 -0
- package/docs/marks.md +86 -0
- package/docs/reference.md +309 -3
- package/docs/reporting.md +49 -14
- package/docs/selection.md +40 -0
- package/docs/sources.md +110 -0
- package/docs/spotlight.md +78 -0
- package/docs/stability.md +16 -1
- package/docs/state.md +85 -0
- package/docs/usage.md +22 -0
- package/docs/workbench.md +72 -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/llms.txt +229 -6
- package/package.json +69 -4
- package/qwik/index.d.ts +5 -0
- package/qwik/index.js +20 -0
- package/react/index.d.ts +5 -0
- package/react/index.js +10 -0
- package/solid/index.d.ts +5 -0
- package/solid/index.js +10 -0
- package/tokens/index.js +9 -5
- 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,522 @@
|
|
|
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
|
+
import {
|
|
4
|
+
straightPath,
|
|
5
|
+
curvePath,
|
|
6
|
+
connectorPath,
|
|
7
|
+
arrowHead,
|
|
8
|
+
dotMark,
|
|
9
|
+
angleBetween,
|
|
10
|
+
} from '../connectors/index.js';
|
|
11
|
+
|
|
12
|
+
const PRECISION = 1000;
|
|
13
|
+
|
|
14
|
+
function finite(name, value, fallback) {
|
|
15
|
+
const v = value ?? fallback;
|
|
16
|
+
if (!Number.isFinite(v)) throw new TypeError(`${name} must be a finite number`);
|
|
17
|
+
return v;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function dimension(name, value, fallback) {
|
|
21
|
+
const v = finite(name, value, fallback);
|
|
22
|
+
if (v < 0) throw new RangeError(`${name} must be greater than or equal to 0`);
|
|
23
|
+
return v;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fmt(value) {
|
|
27
|
+
const rounded = Math.round((Object.is(value, -0) ? 0 : value) * PRECISION) / PRECISION;
|
|
28
|
+
return String(Object.is(rounded, -0) ? 0 : rounded);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function roundedNumber(value) {
|
|
32
|
+
const rounded = Math.round((Object.is(value, -0) ? 0 : value) * PRECISION) / PRECISION;
|
|
33
|
+
return Object.is(rounded, -0) ? 0 : rounded;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function point(x, y) {
|
|
37
|
+
return `${fmt(x)},${fmt(y)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function clamp(value, min, max) {
|
|
41
|
+
if (max < min) return min;
|
|
42
|
+
return Math.min(max, Math.max(min, value));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function circlePathAt(x, y, radius) {
|
|
46
|
+
const r = dimension('radius', radius);
|
|
47
|
+
if (r === 0) return '';
|
|
48
|
+
return `M${point(x, y - r)}A${fmt(r)},${fmt(r)} 0 1 1 ${point(x, y + r)}A${fmt(r)},${fmt(
|
|
49
|
+
r,
|
|
50
|
+
)} 0 1 1 ${point(x, y - r)}Z`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function samePoint(a, b) {
|
|
54
|
+
return fmt(a.x) === fmt(b.x) && fmt(a.y) === fmt(b.y);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateOffset(opts) {
|
|
58
|
+
return {
|
|
59
|
+
dx: finite('dx', opts?.dx),
|
|
60
|
+
dy: finite('dy', opts?.dy),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function trimForCircle(dx, dy, subject) {
|
|
65
|
+
const len = Math.hypot(dx, dy);
|
|
66
|
+
const radius = dimension('subject.radius', subject.radius);
|
|
67
|
+
const padding = dimension('subject.radiusPadding', subject.radiusPadding, 0);
|
|
68
|
+
const trim = radius + padding;
|
|
69
|
+
if (trim <= 0) return { x: 0, y: 0 };
|
|
70
|
+
if (trim >= len) return null;
|
|
71
|
+
return { x: (dx / len) * trim, y: (dy / len) * trim };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function trimForRect(dx, dy, subject) {
|
|
75
|
+
const width = dimension('subject.width', subject.width);
|
|
76
|
+
const height = dimension('subject.height', subject.height);
|
|
77
|
+
const padding = dimension('subject.padding', subject.padding, 0);
|
|
78
|
+
const x = finite('subject.x', subject.x, -width / 2);
|
|
79
|
+
const y = finite('subject.y', subject.y, -height / 2);
|
|
80
|
+
const minX = x - padding;
|
|
81
|
+
const minY = y - padding;
|
|
82
|
+
const maxX = x + width + padding;
|
|
83
|
+
const maxY = y + height + padding;
|
|
84
|
+
const candidates = [];
|
|
85
|
+
|
|
86
|
+
if (dx > 0) candidates.push(maxX / dx);
|
|
87
|
+
if (dx < 0) candidates.push(minX / dx);
|
|
88
|
+
if (dy > 0) candidates.push(maxY / dy);
|
|
89
|
+
if (dy < 0) candidates.push(minY / dy);
|
|
90
|
+
|
|
91
|
+
const t = Math.min(...candidates.filter((v) => Number.isFinite(v) && v > 0));
|
|
92
|
+
if (!Number.isFinite(t) || t <= 0) return { x: 0, y: 0 };
|
|
93
|
+
if (t >= 1) return null;
|
|
94
|
+
return { x: dx * t, y: dy * t };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function connectorStart(dx, dy, subject) {
|
|
98
|
+
if (!subject) return { x: 0, y: 0 };
|
|
99
|
+
if (subject.type === 'circle') return trimForCircle(dx, dy, subject);
|
|
100
|
+
if (subject.type === 'rect') return trimForRect(dx, dy, subject);
|
|
101
|
+
throw new TypeError('subject.type must be "circle" or "rect"');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function linePath(start, end) {
|
|
105
|
+
if (samePoint(start, end)) return '';
|
|
106
|
+
return `M${point(start.x, start.y)}L${point(end.x, end.y)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function annotationTransform({ x = 0, y = 0 } = {}) {
|
|
110
|
+
return `translate(${fmt(finite('x', x))}, ${fmt(finite('y', y))})`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function noteTransform({
|
|
114
|
+
dx,
|
|
115
|
+
dy,
|
|
116
|
+
x,
|
|
117
|
+
y,
|
|
118
|
+
align = 'start',
|
|
119
|
+
valign = 'top',
|
|
120
|
+
width = 0,
|
|
121
|
+
height = 0,
|
|
122
|
+
} = {}) {
|
|
123
|
+
let nx = finite('dx', dx, x ?? 0);
|
|
124
|
+
let ny = finite('dy', dy, y ?? 0);
|
|
125
|
+
const w = dimension('width', width);
|
|
126
|
+
const h = dimension('height', height);
|
|
127
|
+
|
|
128
|
+
if (align === 'middle') nx -= w / 2;
|
|
129
|
+
else if (align === 'end') nx -= w;
|
|
130
|
+
else if (align !== 'start') throw new TypeError('align must be "start", "middle" or "end"');
|
|
131
|
+
|
|
132
|
+
if (valign === 'middle') ny -= h / 2;
|
|
133
|
+
else if (valign === 'bottom') ny -= h;
|
|
134
|
+
else if (valign !== 'top') throw new TypeError('valign must be "top", "middle" or "bottom"');
|
|
135
|
+
|
|
136
|
+
return `translate(${fmt(nx)}, ${fmt(ny)})`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function candidatePlacement(side, gap) {
|
|
140
|
+
if (side === 'right') return { dx: gap, dy: 0, align: 'start', valign: 'middle' };
|
|
141
|
+
if (side === 'left') return { dx: -gap, dy: 0, align: 'end', valign: 'middle' };
|
|
142
|
+
if (side === 'top') return { dx: 0, dy: -gap, align: 'middle', valign: 'bottom' };
|
|
143
|
+
if (side === 'bottom') return { dx: 0, dy: gap, align: 'middle', valign: 'top' };
|
|
144
|
+
throw new TypeError('preferred must be "right", "left", "top" or "bottom"');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function placementOrder(preferred) {
|
|
148
|
+
if (preferred === 'right') return ['right', 'top', 'bottom', 'left'];
|
|
149
|
+
if (preferred === 'left') return ['left', 'top', 'bottom', 'right'];
|
|
150
|
+
if (preferred === 'top') return ['top', 'right', 'left', 'bottom'];
|
|
151
|
+
if (preferred === 'bottom') return ['bottom', 'right', 'left', 'top'];
|
|
152
|
+
throw new TypeError('preferred must be "right", "left", "top" or "bottom"');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function noteRect(x, y, width, height, placement) {
|
|
156
|
+
const anchorX = x + placement.dx;
|
|
157
|
+
const anchorY = y + placement.dy;
|
|
158
|
+
let left = anchorX;
|
|
159
|
+
let top = anchorY;
|
|
160
|
+
|
|
161
|
+
if (placement.align === 'middle') left -= width / 2;
|
|
162
|
+
else if (placement.align === 'end') left -= width;
|
|
163
|
+
|
|
164
|
+
if (placement.valign === 'middle') top -= height / 2;
|
|
165
|
+
else if (placement.valign === 'bottom') top -= height;
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
left,
|
|
169
|
+
top,
|
|
170
|
+
right: left + width,
|
|
171
|
+
bottom: top + height,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function notePlacement({
|
|
176
|
+
x = 0,
|
|
177
|
+
y = 0,
|
|
178
|
+
width,
|
|
179
|
+
height,
|
|
180
|
+
bounds,
|
|
181
|
+
padding = 8,
|
|
182
|
+
gap = 32,
|
|
183
|
+
preferred = 'right',
|
|
184
|
+
} = {}) {
|
|
185
|
+
const anchorX = finite('x', x);
|
|
186
|
+
const anchorY = finite('y', y);
|
|
187
|
+
const w = dimension('width', width);
|
|
188
|
+
const h = dimension('height', height);
|
|
189
|
+
const p = dimension('padding', padding);
|
|
190
|
+
const g = dimension('gap', gap);
|
|
191
|
+
const bx = finite('bounds.x', bounds?.x, 0);
|
|
192
|
+
const by = finite('bounds.y', bounds?.y, 0);
|
|
193
|
+
const bw = dimension('bounds.width', bounds?.width);
|
|
194
|
+
const bh = dimension('bounds.height', bounds?.height);
|
|
195
|
+
const minX = bx + p;
|
|
196
|
+
const minY = by + p;
|
|
197
|
+
const maxX = bx + bw - p;
|
|
198
|
+
const maxY = by + bh - p;
|
|
199
|
+
|
|
200
|
+
for (const side of placementOrder(preferred)) {
|
|
201
|
+
const placement = candidatePlacement(side, g);
|
|
202
|
+
const rect = noteRect(anchorX, anchorY, w, h, placement);
|
|
203
|
+
if (rect.left >= minX && rect.top >= minY && rect.right <= maxX && rect.bottom <= maxY) {
|
|
204
|
+
return {
|
|
205
|
+
dx: roundedNumber(placement.dx),
|
|
206
|
+
dy: roundedNumber(placement.dy),
|
|
207
|
+
align: placement.align,
|
|
208
|
+
valign: placement.valign,
|
|
209
|
+
transform: noteTransform({ ...placement, width: w, height: h }),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const fallback = candidatePlacement(preferred, g);
|
|
215
|
+
const rect = noteRect(anchorX, anchorY, w, h, fallback);
|
|
216
|
+
const left = clamp(rect.left, minX, maxX - w);
|
|
217
|
+
const top = clamp(rect.top, minY, maxY - h);
|
|
218
|
+
const dx = roundedNumber(left - anchorX);
|
|
219
|
+
const dy = roundedNumber(top - anchorY);
|
|
220
|
+
return {
|
|
221
|
+
dx,
|
|
222
|
+
dy,
|
|
223
|
+
align: 'start',
|
|
224
|
+
valign: 'top',
|
|
225
|
+
transform: noteTransform({ dx, dy }),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function circleSubjectPath({ radius } = {}) {
|
|
230
|
+
return circlePathAt(0, 0, radius);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function rectSubjectPath({ width, height, x, y, padding = 0 } = {}) {
|
|
234
|
+
const w = dimension('width', width);
|
|
235
|
+
const h = dimension('height', height);
|
|
236
|
+
const p = dimension('padding', padding);
|
|
237
|
+
if (w === 0 || h === 0) return '';
|
|
238
|
+
const left = finite('x', x, -w / 2) - p;
|
|
239
|
+
const top = finite('y', y, -h / 2) - p;
|
|
240
|
+
const right = left + w + p * 2;
|
|
241
|
+
const bottom = top + h + p * 2;
|
|
242
|
+
return `M${point(left, top)}H${fmt(right)}V${fmt(bottom)}H${fmt(left)}Z`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function thresholdPath({ x1 = 0, y1 = 0, x2, y2 } = {}) {
|
|
246
|
+
const start = { x: finite('x1', x1), y: finite('y1', y1) };
|
|
247
|
+
const end = { x: finite('x2', x2), y: finite('y2', y2) };
|
|
248
|
+
return linePath(start, end);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function axisThresholdPath({ orientation = 'horizontal', value = 0, start = 0, end } = {}) {
|
|
252
|
+
const v = finite('value', value);
|
|
253
|
+
const s = finite('start', start);
|
|
254
|
+
const e = finite('end', end);
|
|
255
|
+
if (orientation === 'horizontal') return thresholdPath({ x1: s, y1: v, x2: e, y2: v });
|
|
256
|
+
if (orientation === 'vertical') return thresholdPath({ x1: v, y1: s, x2: v, y2: e });
|
|
257
|
+
throw new TypeError('orientation must be "horizontal" or "vertical"');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function bracketSubjectPath({ x1, y1, x2, y2, depth = 12 } = {}) {
|
|
261
|
+
const start = { x: finite('x1', x1), y: finite('y1', y1) };
|
|
262
|
+
const end = { x: finite('x2', x2), y: finite('y2', y2) };
|
|
263
|
+
const d = finite('depth', depth);
|
|
264
|
+
if (samePoint(start, end) || d === 0) return linePath(start, end);
|
|
265
|
+
if (Math.abs(end.x - start.x) >= Math.abs(end.y - start.y)) {
|
|
266
|
+
return `M${point(start.x, start.y)}V${fmt(start.y + d)}H${fmt(end.x)}V${fmt(end.y)}`;
|
|
267
|
+
}
|
|
268
|
+
return `M${point(start.x, start.y)}H${fmt(start.x + d)}V${fmt(end.y)}H${fmt(end.x)}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function bandSubjectPath({ x = 0, y = 0, width, height, padding = 0 } = {}) {
|
|
272
|
+
return rectSubjectPath({ x, y, width, height, padding });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function slopeSubjectPath({ x1, y1, x2, y2 } = {}) {
|
|
276
|
+
return thresholdPath({ x1, y1, x2, y2 });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function comparisonBracePath({ x1, y1, x2, y2, depth = 14 } = {}) {
|
|
280
|
+
const start = { x: finite('x1', x1), y: finite('y1', y1) };
|
|
281
|
+
const end = { x: finite('x2', x2), y: finite('y2', y2) };
|
|
282
|
+
const d = finite('depth', depth);
|
|
283
|
+
if (samePoint(start, end) || d === 0) return linePath(start, end);
|
|
284
|
+
|
|
285
|
+
if (Math.abs(end.x - start.x) >= Math.abs(end.y - start.y)) {
|
|
286
|
+
const y = start.y;
|
|
287
|
+
const mid = (start.x + end.x) / 2;
|
|
288
|
+
const q = (end.x - start.x) / 4;
|
|
289
|
+
return `M${point(start.x, y)}C${point(start.x + q, y)} ${point(start.x + q, y + d)} ${point(
|
|
290
|
+
mid,
|
|
291
|
+
y + d,
|
|
292
|
+
)}C${point(mid, y + d)} ${point(mid, y + d * 2)} ${point(mid, y + d * 2)}C${point(
|
|
293
|
+
mid,
|
|
294
|
+
y + d,
|
|
295
|
+
)} ${point(end.x - q, y + d)} ${point(end.x - q, y)}C${point(end.x - q, y)} ${point(
|
|
296
|
+
end.x - q,
|
|
297
|
+
y,
|
|
298
|
+
)} ${point(end.x, y)}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const x = start.x;
|
|
302
|
+
const mid = (start.y + end.y) / 2;
|
|
303
|
+
const q = (end.y - start.y) / 4;
|
|
304
|
+
return `M${point(x, start.y)}C${point(x, start.y + q)} ${point(x + d, start.y + q)} ${point(
|
|
305
|
+
x + d,
|
|
306
|
+
mid,
|
|
307
|
+
)}C${point(x + d, mid)} ${point(x + d * 2, mid)} ${point(x + d * 2, mid)}C${point(
|
|
308
|
+
x + d,
|
|
309
|
+
mid,
|
|
310
|
+
)} ${point(x + d, end.y - q)} ${point(x, end.y - q)}C${point(x, end.y - q)} ${point(
|
|
311
|
+
x,
|
|
312
|
+
end.y - q,
|
|
313
|
+
)} ${point(x, end.y)}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function outlierClusterPath({ points, radius = 6 } = {}) {
|
|
317
|
+
if (!Array.isArray(points)) throw new TypeError('points must be an array');
|
|
318
|
+
return points
|
|
319
|
+
.map((p, i) =>
|
|
320
|
+
circlePathAt(finite(`points[${i}].x`, p?.x), finite(`points[${i}].y`, p?.y), radius),
|
|
321
|
+
)
|
|
322
|
+
.filter(Boolean)
|
|
323
|
+
.join('');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function timelineEventPath({ size = 10, direction = 'down' } = {}) {
|
|
327
|
+
const s = dimension('size', size);
|
|
328
|
+
if (s === 0) return '';
|
|
329
|
+
if (direction === 'down') return `M0,0L${point(s / 2, s)}H${fmt(-s / 2)}Z`;
|
|
330
|
+
if (direction === 'up') return `M0,0L${point(s / 2, -s)}H${fmt(-s / 2)}Z`;
|
|
331
|
+
if (direction === 'right') return `M0,0L${point(s, s / 2)}V${fmt(-s / 2)}Z`;
|
|
332
|
+
if (direction === 'left') return `M0,0L${point(-s, s / 2)}V${fmt(-s / 2)}Z`;
|
|
333
|
+
throw new TypeError('direction must be "up", "down", "left" or "right"');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function evidenceMarkerPath({ x = 0, y = 0, width = 36, height = 36, padding = 0 } = {}) {
|
|
337
|
+
const w = dimension('width', width);
|
|
338
|
+
const h = dimension('height', height);
|
|
339
|
+
const p = dimension('padding', padding);
|
|
340
|
+
if (w === 0 || h === 0) return '';
|
|
341
|
+
const cx = finite('x', x);
|
|
342
|
+
const cy = finite('y', y);
|
|
343
|
+
const left = cx - w / 2 - p;
|
|
344
|
+
const top = cy - h / 2 - p;
|
|
345
|
+
const right = left + w + p * 2;
|
|
346
|
+
const bottom = top + h + p * 2;
|
|
347
|
+
return `M${point(left, top)}H${fmt(right)}V${fmt(bottom)}H${fmt(left)}Z`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function connectorEndDot({ x, y, radius = 3 } = {}) {
|
|
351
|
+
return dotMark({ x: finite('x', x), y: finite('y', y) }, radius);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function connectorEndArrow({ x1 = 0, y1 = 0, x2, y2, size = 7 } = {}) {
|
|
355
|
+
const start = { x: finite('x1', x1), y: finite('y1', y1) };
|
|
356
|
+
const end = { x: finite('x2', x2), y: finite('y2', y2) };
|
|
357
|
+
const s = dimension('size', size);
|
|
358
|
+
if (s === 0 || (end.x === start.x && end.y === start.y)) return '';
|
|
359
|
+
return arrowHead(end, angleBetween(start, end), s);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function connectorLine(opts = {}) {
|
|
363
|
+
const { dx, dy } = validateOffset(opts);
|
|
364
|
+
if (dx === 0 && dy === 0) return '';
|
|
365
|
+
const start = connectorStart(dx, dy, opts.subject);
|
|
366
|
+
if (!start) return '';
|
|
367
|
+
const end = { x: dx, y: dy };
|
|
368
|
+
// Guard a trim that rounds onto the note anchor (straightPath has no guard).
|
|
369
|
+
if (samePoint(start, end)) return '';
|
|
370
|
+
return straightPath(start, end);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function connectorElbow(opts = {}) {
|
|
374
|
+
const { dx, dy } = validateOffset(opts);
|
|
375
|
+
if (dx === 0 && dy === 0) return '';
|
|
376
|
+
const start = connectorStart(dx, dy, opts.subject);
|
|
377
|
+
if (!start) return '';
|
|
378
|
+
const end = { x: dx, y: dy };
|
|
379
|
+
const vx = end.x - start.x;
|
|
380
|
+
const vy = end.y - start.y;
|
|
381
|
+
if (vx === 0 || vy === 0) return linePath(start, end);
|
|
382
|
+
|
|
383
|
+
const elbow =
|
|
384
|
+
Math.abs(vx) >= Math.abs(vy)
|
|
385
|
+
? { x: start.x + Math.sign(vx) * Math.abs(vy), y: end.y }
|
|
386
|
+
: { x: end.x, y: start.y + Math.sign(vy) * Math.abs(vx) };
|
|
387
|
+
|
|
388
|
+
if (samePoint(start, elbow) || samePoint(elbow, end)) return linePath(start, end);
|
|
389
|
+
return `M${point(start.x, start.y)}L${point(elbow.x, elbow.y)}L${point(end.x, end.y)}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function connectorCurve(opts = {}) {
|
|
393
|
+
const { dx, dy } = validateOffset(opts);
|
|
394
|
+
if (dx === 0 && dy === 0) return '';
|
|
395
|
+
const start = connectorStart(dx, dy, opts.subject);
|
|
396
|
+
if (!start) return '';
|
|
397
|
+
const end = { x: dx, y: dy };
|
|
398
|
+
if (samePoint(start, end)) return '';
|
|
399
|
+
// Annotation callouts use a gentler curve than the connectors default.
|
|
400
|
+
return curvePath(start, end, { curvature: 0.35 });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function annotationParts(opts = {}) {
|
|
404
|
+
const type = opts.type ?? 'callout';
|
|
405
|
+
const transform = annotationTransform({ x: opts.x ?? 0, y: opts.y ?? 0 });
|
|
406
|
+
const dx = finite('dx', opts.dx, 0);
|
|
407
|
+
const dy = finite('dy', opts.dy, 0);
|
|
408
|
+
const connectorSubject =
|
|
409
|
+
opts.subject?.type === 'circle' || opts.subject?.type === 'rect' ? opts.subject : undefined;
|
|
410
|
+
const connector =
|
|
411
|
+
type === 'curve'
|
|
412
|
+
? connectorCurve({ dx, dy, subject: connectorSubject })
|
|
413
|
+
: type === 'elbow'
|
|
414
|
+
? connectorElbow({ dx, dy, subject: connectorSubject })
|
|
415
|
+
: connectorLine({ dx, dy, subject: connectorSubject });
|
|
416
|
+
const note = noteTransform({ dx, dy });
|
|
417
|
+
let subject = '';
|
|
418
|
+
|
|
419
|
+
if (opts.subject?.type === 'circle') subject = circleSubjectPath(opts.subject);
|
|
420
|
+
else if (opts.subject?.type === 'rect') subject = rectSubjectPath(opts.subject);
|
|
421
|
+
else if (opts.subject?.type === 'threshold') subject = thresholdPath(opts.subject);
|
|
422
|
+
else if (opts.subject?.type === 'bracket') subject = bracketSubjectPath(opts.subject);
|
|
423
|
+
else if (opts.subject?.type === 'band') subject = bandSubjectPath(opts.subject);
|
|
424
|
+
else if (opts.subject?.type === 'slope') subject = slopeSubjectPath(opts.subject);
|
|
425
|
+
else if (opts.subject?.type === 'compare') subject = comparisonBracePath(opts.subject);
|
|
426
|
+
else if (opts.subject?.type === 'cluster') subject = outlierClusterPath(opts.subject);
|
|
427
|
+
else if (opts.subject?.type === 'axis') subject = axisThresholdPath(opts.subject);
|
|
428
|
+
else if (opts.subject?.type === 'timeline') subject = timelineEventPath(opts.subject);
|
|
429
|
+
else if (opts.subject?.type === 'evidence') subject = evidenceMarkerPath(opts.subject);
|
|
430
|
+
else if (opts.subject != null) throw new TypeError('unsupported subject.type');
|
|
431
|
+
|
|
432
|
+
return { transform, subject, connector, note };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Declutter labels along ONE axis: nudge overlapping labels apart so each keeps
|
|
437
|
+
* `gap` from its neighbours, sweeping up from `min`; if the run overflows `max`
|
|
438
|
+
* it slides up to fit. Deterministic and order-preserving — NOT a general 2-D
|
|
439
|
+
* collision solver (with more labels than the range holds, the overflow past
|
|
440
|
+
* `min` is the caller's to resolve: fewer labels, a longer axis, or rotation).
|
|
441
|
+
*
|
|
442
|
+
* `items`: `[{ pos, size }]` — `pos` is the desired centre coordinate along the
|
|
443
|
+
* axis, `size` the label's extent along it. Returns the adjusted centre per
|
|
444
|
+
* input item, in the original order.
|
|
445
|
+
*/
|
|
446
|
+
export function declutterLabels(items, opts = {}) {
|
|
447
|
+
if (!Array.isArray(items)) throw new TypeError('items must be an array');
|
|
448
|
+
const gap = dimension('gap', opts.gap, 0);
|
|
449
|
+
const min = opts.min == null ? -Infinity : finite('min', opts.min);
|
|
450
|
+
const max = opts.max == null ? Infinity : finite('max', opts.max);
|
|
451
|
+
if (max < min) throw new RangeError('max must be greater than or equal to min');
|
|
452
|
+
|
|
453
|
+
const nodes = items.map((it, index) => ({
|
|
454
|
+
index,
|
|
455
|
+
half: dimension('size', it?.size) / 2,
|
|
456
|
+
pos: finite('pos', it?.pos),
|
|
457
|
+
}));
|
|
458
|
+
const order = [...nodes].sort((a, b) => a.pos - b.pos);
|
|
459
|
+
|
|
460
|
+
let floor = min;
|
|
461
|
+
for (const n of order) {
|
|
462
|
+
const center = Math.max(n.pos, floor + n.half);
|
|
463
|
+
n.pos = center;
|
|
464
|
+
floor = center + n.half + gap;
|
|
465
|
+
}
|
|
466
|
+
if (max !== Infinity && order.length) {
|
|
467
|
+
const last = order[order.length - 1];
|
|
468
|
+
const overflow = last.pos + last.half - max;
|
|
469
|
+
if (overflow > 0) for (const n of order) n.pos -= overflow;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const out = new Array(nodes.length);
|
|
473
|
+
for (const n of nodes) out[n.index] = roundedNumber(n.pos);
|
|
474
|
+
return out;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Direct labeling: declutter labels along one axis and draw a leader line from
|
|
479
|
+
* each true anchor to its placed label. This is the 1-D core of Labella,
|
|
480
|
+
* completed with leaders via the shared connector kernel — deterministic and
|
|
481
|
+
* pure. It owns no scales (map data → figure coords first), no DOM, no
|
|
482
|
+
* nearest-anchor matching, and no 2-D placement; those stay the host's job.
|
|
483
|
+
*
|
|
484
|
+
* Each `items[i]` is `{ anchor: {x, y}, size, key? }`: `anchor` is the true
|
|
485
|
+
* data point in figure coordinates, `size` is the label's extent along the
|
|
486
|
+
* layout `axis`. Labels declutter along `axis` ('y' = a vertical column,
|
|
487
|
+
* default) and sit at the fixed `cross` coordinate on the other axis. Returns,
|
|
488
|
+
* in input order, the placed label point `{x, y}`, the echoed `anchor` and
|
|
489
|
+
* `key`, and the leader path `d` (anchor → label; `''` if they coincide) ready
|
|
490
|
+
* for a `<path class="ui-annotation__connector">`.
|
|
491
|
+
*/
|
|
492
|
+
export function directLabels(items, opts = {}) {
|
|
493
|
+
if (!Array.isArray(items)) throw new TypeError('items must be an array');
|
|
494
|
+
const axis = opts.axis === 'x' ? 'x' : 'y';
|
|
495
|
+
const cross = finite('cross', opts.cross, 0);
|
|
496
|
+
const shape = opts.shape === 'elbow' || opts.shape === 'curve' ? opts.shape : 'straight';
|
|
497
|
+
|
|
498
|
+
const anchors = items.map((it) => ({
|
|
499
|
+
anchor: { x: finite('anchor.x', it?.anchor?.x), y: finite('anchor.y', it?.anchor?.y) },
|
|
500
|
+
size: dimension('size', it?.size),
|
|
501
|
+
key: it?.key,
|
|
502
|
+
}));
|
|
503
|
+
|
|
504
|
+
const placed = declutterLabels(
|
|
505
|
+
anchors.map((a) => ({ pos: a.anchor[axis], size: a.size })),
|
|
506
|
+
{ gap: opts.gap, min: opts.min, max: opts.max },
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
return anchors.map((a, i) => {
|
|
510
|
+
const labelPoint = axis === 'y' ? { x: cross, y: placed[i] } : { x: placed[i], y: cross };
|
|
511
|
+
const d = samePoint(a.anchor, labelPoint)
|
|
512
|
+
? ''
|
|
513
|
+
: connectorPath({ from: a.anchor, to: labelPoint, shape });
|
|
514
|
+
return {
|
|
515
|
+
x: roundedNumber(labelPoint.x),
|
|
516
|
+
y: roundedNumber(labelPoint.y),
|
|
517
|
+
anchor: { x: roundedNumber(a.anchor.x), y: roundedNumber(a.anchor.y) },
|
|
518
|
+
key: a.key,
|
|
519
|
+
d,
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
}
|