@viamrobotics/motion-tools 1.33.0 → 1.33.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Entities/Entities.svelte +18 -25
- package/dist/components/Entities/Entities.svelte.d.ts +2 -17
- package/dist/components/Entities/Label.svelte +79 -13
- package/dist/components/Entities/Label.svelte.d.ts +2 -1
- package/dist/components/Entities/Labels.svelte +36 -0
- package/dist/components/Entities/Labels.svelte.d.ts +3 -0
- package/dist/components/Entities/LineDots.svelte +8 -3
- package/dist/components/Entities/labelLayout/applyTeleports.d.ts +9 -0
- package/dist/components/Entities/labelLayout/applyTeleports.js +39 -0
- package/dist/components/Entities/labelLayout/buildNeighborhood.d.ts +8 -0
- package/dist/components/Entities/labelLayout/buildNeighborhood.js +26 -0
- package/dist/components/Entities/labelLayout/cameraHash.d.ts +8 -0
- package/dist/components/Entities/labelLayout/cameraHash.js +25 -0
- package/dist/components/Entities/labelLayout/cost.d.ts +44 -0
- package/dist/components/Entities/labelLayout/cost.js +126 -0
- package/dist/components/Entities/labelLayout/createLabelLayout.d.ts +27 -0
- package/dist/components/Entities/labelLayout/createLabelLayout.js +194 -0
- package/dist/components/Entities/labelLayout/geometry.d.ts +20 -0
- package/dist/components/Entities/labelLayout/geometry.js +151 -0
- package/dist/components/Entities/labelLayout/labelStore.svelte.d.ts +17 -0
- package/dist/components/Entities/labelLayout/labelStore.svelte.js +28 -0
- package/dist/components/Entities/labelLayout/measure.d.ts +13 -0
- package/dist/components/Entities/labelLayout/measure.js +42 -0
- package/dist/components/Entities/labelLayout/slots.d.ts +11 -0
- package/dist/components/Entities/labelLayout/slots.js +47 -0
- package/dist/components/Entities/labelLayout/solve.d.ts +11 -0
- package/dist/components/Entities/labelLayout/solve.js +93 -0
- package/dist/components/Entities/labelLayout/spatialHash.d.ts +15 -0
- package/dist/components/Entities/labelLayout/spatialHash.js +53 -0
- package/dist/components/Entities/labelLayout/types.d.ts +105 -0
- package/dist/components/Entities/labelLayout/types.js +19 -0
- package/dist/components/Entities/labelLayout/writeBack.d.ts +20 -0
- package/dist/components/Entities/labelLayout/writeBack.js +51 -0
- package/dist/components/Scene.svelte +2 -1
- package/dist/components/SelectedTransformControls.svelte +65 -47
- package/dist/components/overlay/Details.svelte +210 -226
- package/dist/components/overlay/Details.svelte.d.ts +1 -1
- package/dist/components/overlay/Popover.svelte +6 -4
- package/dist/components/overlay/Popover.svelte.d.ts +6 -2
- package/dist/components/overlay/dashboard/Button.svelte +7 -2
- package/dist/components/overlay/dashboard/Button.svelte.d.ts +2 -1
- package/dist/components/overlay/details/AxesHelperDetails.svelte +32 -0
- package/dist/components/overlay/details/AxesHelperDetails.svelte.d.ts +7 -0
- package/dist/components/overlay/details/ColorDetails.svelte +35 -0
- package/dist/components/overlay/details/ColorDetails.svelte.d.ts +7 -0
- package/dist/components/overlay/details/GeometryDetails.svelte +104 -0
- package/dist/components/overlay/details/GeometryDetails.svelte.d.ts +7 -0
- package/dist/components/overlay/details/LineDetails/LineDetails.svelte +196 -0
- package/dist/components/overlay/details/LineDetails/LineDetails.svelte.d.ts +7 -0
- package/dist/components/overlay/details/LineDetails/linePositions.d.ts +3 -0
- package/dist/components/overlay/details/LineDetails/linePositions.js +30 -0
- package/dist/components/overlay/details/OpacityDetails.svelte +44 -0
- package/dist/components/overlay/details/OpacityDetails.svelte.d.ts +7 -0
- package/dist/components/overlay/details/PoseDetails.svelte +189 -0
- package/dist/components/overlay/details/PoseDetails.svelte.d.ts +14 -0
- package/dist/ecs/traits.d.ts +1 -1
- package/dist/ecs/traits.js +1 -1
- package/dist/hooks/usePartConfig.svelte.js +8 -6
- package/dist/hooks/useWorldState.svelte.js +94 -69
- package/package.json +4 -2
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The label layout engine. Owns node lifecycle and the per-frame pipeline:
|
|
3
|
+
* a cheap dirty gate, then (only when dirty) measure → build neighbourhood →
|
|
4
|
+
* warm-start → solve → handle teleports, and every animating frame ease toward
|
|
5
|
+
* the solved targets and write them to the DOM. It idles (no work, no
|
|
6
|
+
* invalidate) once the camera is still and every label has settled.
|
|
7
|
+
*/
|
|
8
|
+
import { applyTeleports } from './applyTeleports';
|
|
9
|
+
import { buildNeighborhood } from './buildNeighborhood';
|
|
10
|
+
import { cameraMatrixHash } from './cameraHash';
|
|
11
|
+
import { measureNode } from './measure';
|
|
12
|
+
import { generateSlots, hashString } from './slots';
|
|
13
|
+
import { solve } from './solve';
|
|
14
|
+
import { SpatialHash } from './spatialHash';
|
|
15
|
+
import { defaultSolverConfig } from './types';
|
|
16
|
+
import { lerpStep, writeBack } from './writeBack';
|
|
17
|
+
/** Clamp the frame delta so a long idle gap can't make the ease overshoot. */
|
|
18
|
+
const MAX_DELTA = 0.05;
|
|
19
|
+
/** Frames to keep retrying a failed measure after a real change before giving up (idle). */
|
|
20
|
+
const MAX_RETRY = 4;
|
|
21
|
+
export function createLabelLayout(deps) {
|
|
22
|
+
const config = { ...defaultSolverConfig, ...deps.config };
|
|
23
|
+
const nodesByLabel = new WeakMap();
|
|
24
|
+
const grid = new SpatialHash();
|
|
25
|
+
let activeNodes = [];
|
|
26
|
+
let bestSnap = new Int16Array(0);
|
|
27
|
+
let lastCamHash = -1;
|
|
28
|
+
let lastSetVersion = -1;
|
|
29
|
+
let pendingRetry = false;
|
|
30
|
+
let retryBudget = 0;
|
|
31
|
+
let animating = false;
|
|
32
|
+
function ensureNode(labelEl) {
|
|
33
|
+
const existing = nodesByLabel.get(labelEl);
|
|
34
|
+
if (existing)
|
|
35
|
+
return existing;
|
|
36
|
+
const textEl = labelEl.querySelector('.text');
|
|
37
|
+
const dotEl = labelEl.querySelector('.dot');
|
|
38
|
+
const lineEl = labelEl.querySelector('.link line');
|
|
39
|
+
if (!textEl || !dotEl || !lineEl)
|
|
40
|
+
return null;
|
|
41
|
+
textEl.style.willChange = 'transform';
|
|
42
|
+
const id = crypto.randomUUID();
|
|
43
|
+
const node = {
|
|
44
|
+
id,
|
|
45
|
+
idHash: hashString(id),
|
|
46
|
+
ax: 0,
|
|
47
|
+
ay: 0,
|
|
48
|
+
dotR: 0,
|
|
49
|
+
w: 0,
|
|
50
|
+
h: 0,
|
|
51
|
+
scale: 1,
|
|
52
|
+
cssDotW: 0,
|
|
53
|
+
dotLocalX: Number.NaN,
|
|
54
|
+
dotLocalY: Number.NaN,
|
|
55
|
+
slots: [],
|
|
56
|
+
geomKey: '',
|
|
57
|
+
crowded: false,
|
|
58
|
+
slotIndex: -1,
|
|
59
|
+
prevSlotIndex: -1,
|
|
60
|
+
cx: Number.NaN,
|
|
61
|
+
cy: Number.NaN,
|
|
62
|
+
tx: 0,
|
|
63
|
+
ty: 0,
|
|
64
|
+
settled: false,
|
|
65
|
+
conflict: 0,
|
|
66
|
+
locked: false,
|
|
67
|
+
centroidX: 0,
|
|
68
|
+
centroidY: 0,
|
|
69
|
+
prevAx: Number.NaN,
|
|
70
|
+
prevAy: Number.NaN,
|
|
71
|
+
neighbors: [],
|
|
72
|
+
labelEl,
|
|
73
|
+
textEl,
|
|
74
|
+
dotEl,
|
|
75
|
+
lineEl,
|
|
76
|
+
};
|
|
77
|
+
nodesByLabel.set(labelEl, node);
|
|
78
|
+
return node;
|
|
79
|
+
}
|
|
80
|
+
function geomKey(node) {
|
|
81
|
+
return `${Math.round(node.w)}_${Math.round(node.h)}_${Math.round(node.dotR)}`;
|
|
82
|
+
}
|
|
83
|
+
function nearestSlot(node) {
|
|
84
|
+
let best = 0;
|
|
85
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
86
|
+
for (let i = 0; i < node.slots.length; i++) {
|
|
87
|
+
const sx = node.ax + node.slots[i].dx;
|
|
88
|
+
const sy = node.ay + node.slots[i].dy;
|
|
89
|
+
const d = (sx - node.cx) ** 2 + (sy - node.cy) ** 2;
|
|
90
|
+
if (d < bestDist) {
|
|
91
|
+
bestDist = d;
|
|
92
|
+
best = i;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return best;
|
|
96
|
+
}
|
|
97
|
+
function solveLayout(width, height) {
|
|
98
|
+
pendingRetry = false;
|
|
99
|
+
activeNodes = [];
|
|
100
|
+
for (const el of deps.labels.current) {
|
|
101
|
+
// Skip detached or hidden islands (e.g. <HTML> sets display:none when an
|
|
102
|
+
// entity is behind the camera) without arming a retry — they have no
|
|
103
|
+
// client rects, and treating them as "not ready" would pin the
|
|
104
|
+
// on-demand loop re-solving every frame.
|
|
105
|
+
if (!el.isConnected || el.getClientRects().length === 0)
|
|
106
|
+
continue;
|
|
107
|
+
const node = ensureNode(el);
|
|
108
|
+
if (!node) {
|
|
109
|
+
pendingRetry = true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (measureNode(node))
|
|
113
|
+
activeNodes.push(node);
|
|
114
|
+
else
|
|
115
|
+
pendingRetry = true;
|
|
116
|
+
}
|
|
117
|
+
const nodes = activeNodes;
|
|
118
|
+
const n = nodes.length;
|
|
119
|
+
if (n === 0)
|
|
120
|
+
return;
|
|
121
|
+
buildNeighborhood(grid, nodes, config);
|
|
122
|
+
// Crowding (post-symmetrisation) drives adaptive slot density.
|
|
123
|
+
for (const node of nodes) {
|
|
124
|
+
const crowded = node.neighbors.length > config.crowdedThreshold;
|
|
125
|
+
const key = geomKey(node);
|
|
126
|
+
if (key !== node.geomKey || crowded !== node.crowded) {
|
|
127
|
+
node.slots = generateSlots(node, crowded, config);
|
|
128
|
+
node.geomKey = key;
|
|
129
|
+
node.crowded = crowded;
|
|
130
|
+
node.slotIndex = -1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Local cluster centroid for the outward-fan term.
|
|
134
|
+
for (const node of nodes) {
|
|
135
|
+
let sx = node.ax;
|
|
136
|
+
let sy = node.ay;
|
|
137
|
+
for (const m of node.neighbors) {
|
|
138
|
+
sx += m.ax;
|
|
139
|
+
sy += m.ay;
|
|
140
|
+
}
|
|
141
|
+
const count = node.neighbors.length + 1;
|
|
142
|
+
node.centroidX = sx / count;
|
|
143
|
+
node.centroidY = sy / count;
|
|
144
|
+
}
|
|
145
|
+
// Warm-start: fresh nodes start near their dot, then snap onto the lattice.
|
|
146
|
+
for (const node of nodes) {
|
|
147
|
+
if (Number.isNaN(node.cx)) {
|
|
148
|
+
node.cx = node.ax + (node.w / 2 + node.dotR + config.dotPadding);
|
|
149
|
+
node.cy = node.ay;
|
|
150
|
+
}
|
|
151
|
+
if (node.slotIndex < 0 || node.slotIndex >= node.slots.length) {
|
|
152
|
+
node.slotIndex = nearestSlot(node);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (bestSnap.length < n)
|
|
156
|
+
bestSnap = new Int16Array(n);
|
|
157
|
+
solve(nodes, config, bestSnap);
|
|
158
|
+
// A big camera jump (or a brand-new node) snaps labels to their solved
|
|
159
|
+
// target; everything else eases there from its current position.
|
|
160
|
+
applyTeleports(nodes, width, height, config);
|
|
161
|
+
}
|
|
162
|
+
function frame(delta) {
|
|
163
|
+
const { width, height } = deps.size.current;
|
|
164
|
+
const camHash = cameraMatrixHash(deps.camera.current, width, height);
|
|
165
|
+
const setVersion = deps.labels.version;
|
|
166
|
+
// A real change (camera/label-set) re-arms the retry window; pendingRetry can
|
|
167
|
+
// then drive a few more solves for genuinely-transient unmeasurable labels
|
|
168
|
+
// (first paint) without spinning forever on a persistently-unmeasurable one.
|
|
169
|
+
const changed = camHash !== lastCamHash || setVersion !== lastSetVersion;
|
|
170
|
+
if (changed)
|
|
171
|
+
retryBudget = MAX_RETRY;
|
|
172
|
+
const retrying = pendingRetry && retryBudget > 0 && !changed;
|
|
173
|
+
if (retrying)
|
|
174
|
+
retryBudget--;
|
|
175
|
+
const dirty = changed || retrying;
|
|
176
|
+
if (dirty) {
|
|
177
|
+
solveLayout(width, height);
|
|
178
|
+
lastCamHash = camHash;
|
|
179
|
+
lastSetVersion = setVersion;
|
|
180
|
+
}
|
|
181
|
+
if (!dirty && !animating)
|
|
182
|
+
return;
|
|
183
|
+
const dt = Math.min(delta, MAX_DELTA);
|
|
184
|
+
let moving = false;
|
|
185
|
+
for (const node of activeNodes) {
|
|
186
|
+
if (lerpStep(node, dt, config.settleEps))
|
|
187
|
+
moving = true;
|
|
188
|
+
writeBack(node);
|
|
189
|
+
}
|
|
190
|
+
animating = moving;
|
|
191
|
+
deps.invalidate();
|
|
192
|
+
}
|
|
193
|
+
return { frame };
|
|
194
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure 2D geometry primitives used by the label layout cost function.
|
|
3
|
+
*
|
|
4
|
+
* Rectangles are center + half-extents; segments are flat coordinates. None of
|
|
5
|
+
* these allocate, so they are safe to call inside the solver's hot loop.
|
|
6
|
+
*/
|
|
7
|
+
import type { Rect, Segment } from './types';
|
|
8
|
+
export declare function rectsOverlap(a: Rect, b: Rect, pad?: number): boolean;
|
|
9
|
+
/** Fraction of the smaller rectangle's area covered by the intersection, 0..1. */
|
|
10
|
+
export declare function overlapAreaFrac(a: Rect, b: Rect): number;
|
|
11
|
+
/**
|
|
12
|
+
* Continuous penetration: how much of the segment lies inside the rectangle,
|
|
13
|
+
* as a fraction of segment length (0 = disjoint, 1 = fully inside). Gives the
|
|
14
|
+
* optimizer a gradient that the boolean test lacks.
|
|
15
|
+
*/
|
|
16
|
+
export declare function segmentRectPenetration(s: Segment, r: Rect): number;
|
|
17
|
+
/** Proper crossing of two segments (shared endpoints / collinear touching excluded). */
|
|
18
|
+
export declare function segmentsCross(a: Segment, b: Segment): boolean;
|
|
19
|
+
/** Penetration depth (viewport px) of a circle into a rectangle, 0 if disjoint. */
|
|
20
|
+
export declare function rectCircleOverlap(r: Rect, cx: number, cy: number, rad: number): number;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure 2D geometry primitives used by the label layout cost function.
|
|
3
|
+
*
|
|
4
|
+
* Rectangles are center + half-extents; segments are flat coordinates. None of
|
|
5
|
+
* these allocate, so they are safe to call inside the solver's hot loop.
|
|
6
|
+
*/
|
|
7
|
+
export function rectsOverlap(a, b, pad = 0) {
|
|
8
|
+
return Math.abs(a.cx - b.cx) < a.hw + b.hw + pad && Math.abs(a.cy - b.cy) < a.hh + b.hh + pad;
|
|
9
|
+
}
|
|
10
|
+
/** Fraction of the smaller rectangle's area covered by the intersection, 0..1. */
|
|
11
|
+
export function overlapAreaFrac(a, b) {
|
|
12
|
+
const ox = Math.min(a.cx + a.hw, b.cx + b.hw) - Math.max(a.cx - a.hw, b.cx - b.hw);
|
|
13
|
+
if (ox <= 0)
|
|
14
|
+
return 0;
|
|
15
|
+
const oy = Math.min(a.cy + a.hh, b.cy + b.hh) - Math.max(a.cy - a.hh, b.cy - b.hh);
|
|
16
|
+
if (oy <= 0)
|
|
17
|
+
return 0;
|
|
18
|
+
return (ox * oy) / Math.min(4 * a.hw * a.hh, 4 * b.hw * b.hh);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Liang-Barsky clip of a segment against a rectangle.
|
|
22
|
+
* Returns the clipped length as a fraction of the full segment (0..1), or -1 if
|
|
23
|
+
* the segment misses the rectangle entirely.
|
|
24
|
+
*/
|
|
25
|
+
function clipFraction(s, r) {
|
|
26
|
+
const minX = r.cx - r.hw;
|
|
27
|
+
const maxX = r.cx + r.hw;
|
|
28
|
+
const minY = r.cy - r.hh;
|
|
29
|
+
const maxY = r.cy + r.hh;
|
|
30
|
+
const dx = s.x2 - s.x1;
|
|
31
|
+
const dy = s.y2 - s.y1;
|
|
32
|
+
let t0 = 0;
|
|
33
|
+
let t1 = 1;
|
|
34
|
+
let p;
|
|
35
|
+
let q;
|
|
36
|
+
let t;
|
|
37
|
+
// Four clip edges, inlined to avoid per-call allocation in the hot loop.
|
|
38
|
+
// Each edge is a (p, q) pair; the point is inside when p*t <= q holds.
|
|
39
|
+
p = -dx;
|
|
40
|
+
q = s.x1 - minX;
|
|
41
|
+
if (p === 0) {
|
|
42
|
+
if (q < 0)
|
|
43
|
+
return -1;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
t = q / p;
|
|
47
|
+
if (p < 0) {
|
|
48
|
+
if (t > t1)
|
|
49
|
+
return -1;
|
|
50
|
+
if (t > t0)
|
|
51
|
+
t0 = t;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
if (t < t0)
|
|
55
|
+
return -1;
|
|
56
|
+
if (t < t1)
|
|
57
|
+
t1 = t;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
p = dx;
|
|
61
|
+
q = maxX - s.x1;
|
|
62
|
+
if (p === 0) {
|
|
63
|
+
if (q < 0)
|
|
64
|
+
return -1;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
t = q / p;
|
|
68
|
+
if (p < 0) {
|
|
69
|
+
if (t > t1)
|
|
70
|
+
return -1;
|
|
71
|
+
if (t > t0)
|
|
72
|
+
t0 = t;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
if (t < t0)
|
|
76
|
+
return -1;
|
|
77
|
+
if (t < t1)
|
|
78
|
+
t1 = t;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
p = -dy;
|
|
82
|
+
q = s.y1 - minY;
|
|
83
|
+
if (p === 0) {
|
|
84
|
+
if (q < 0)
|
|
85
|
+
return -1;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
t = q / p;
|
|
89
|
+
if (p < 0) {
|
|
90
|
+
if (t > t1)
|
|
91
|
+
return -1;
|
|
92
|
+
if (t > t0)
|
|
93
|
+
t0 = t;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
if (t < t0)
|
|
97
|
+
return -1;
|
|
98
|
+
if (t < t1)
|
|
99
|
+
t1 = t;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
p = dy;
|
|
103
|
+
q = maxY - s.y1;
|
|
104
|
+
if (p === 0) {
|
|
105
|
+
if (q < 0)
|
|
106
|
+
return -1;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
t = q / p;
|
|
110
|
+
if (p < 0) {
|
|
111
|
+
if (t > t1)
|
|
112
|
+
return -1;
|
|
113
|
+
if (t > t0)
|
|
114
|
+
t0 = t;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
if (t < t0)
|
|
118
|
+
return -1;
|
|
119
|
+
if (t < t1)
|
|
120
|
+
t1 = t;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return t0 <= t1 ? t1 - t0 : -1;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Continuous penetration: how much of the segment lies inside the rectangle,
|
|
127
|
+
* as a fraction of segment length (0 = disjoint, 1 = fully inside). Gives the
|
|
128
|
+
* optimizer a gradient that the boolean test lacks.
|
|
129
|
+
*/
|
|
130
|
+
export function segmentRectPenetration(s, r) {
|
|
131
|
+
const f = clipFraction(s, r);
|
|
132
|
+
return Math.max(f, 0);
|
|
133
|
+
}
|
|
134
|
+
function ccw(ax, ay, bx, by, px, py) {
|
|
135
|
+
return (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
|
136
|
+
}
|
|
137
|
+
/** Proper crossing of two segments (shared endpoints / collinear touching excluded). */
|
|
138
|
+
export function segmentsCross(a, b) {
|
|
139
|
+
const d1 = ccw(b.x1, b.y1, b.x2, b.y2, a.x1, a.y1);
|
|
140
|
+
const d2 = ccw(b.x1, b.y1, b.x2, b.y2, a.x2, a.y2);
|
|
141
|
+
const d3 = ccw(a.x1, a.y1, a.x2, a.y2, b.x1, b.y1);
|
|
142
|
+
const d4 = ccw(a.x1, a.y1, a.x2, a.y2, b.x2, b.y2);
|
|
143
|
+
return d1 > 0 !== d2 > 0 && d3 > 0 !== d4 > 0;
|
|
144
|
+
}
|
|
145
|
+
/** Penetration depth (viewport px) of a circle into a rectangle, 0 if disjoint. */
|
|
146
|
+
export function rectCircleOverlap(r, cx, cy, rad) {
|
|
147
|
+
const nx = Math.max(Math.abs(cx - r.cx) - r.hw, 0);
|
|
148
|
+
const ny = Math.max(Math.abs(cy - r.cy) - r.hh, 0);
|
|
149
|
+
const d = Math.sqrt(nx * nx + ny * ny);
|
|
150
|
+
return Math.max(rad - d, 0);
|
|
151
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of live label root elements (`.label`) shared between the per-entity
|
|
3
|
+
* `Label.svelte` components (which add/remove themselves) and the layout engine
|
|
4
|
+
* (which reads them each solve).
|
|
5
|
+
*
|
|
6
|
+
* `version` is bumped on every structural or text change so the engine can cheaply
|
|
7
|
+
* detect that a re-solve is needed without diffing the DOM.
|
|
8
|
+
*/
|
|
9
|
+
export declare class LabelStore {
|
|
10
|
+
current: HTMLElement[];
|
|
11
|
+
version: number;
|
|
12
|
+
add(element: HTMLElement): void;
|
|
13
|
+
remove(element: HTMLElement): void;
|
|
14
|
+
/** Signal that a label's text (and therefore its measured size) may have changed. */
|
|
15
|
+
touch(): void;
|
|
16
|
+
}
|
|
17
|
+
export declare const labels: LabelStore;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of live label root elements (`.label`) shared between the per-entity
|
|
3
|
+
* `Label.svelte` components (which add/remove themselves) and the layout engine
|
|
4
|
+
* (which reads them each solve).
|
|
5
|
+
*
|
|
6
|
+
* `version` is bumped on every structural or text change so the engine can cheaply
|
|
7
|
+
* detect that a re-solve is needed without diffing the DOM.
|
|
8
|
+
*/
|
|
9
|
+
export class LabelStore {
|
|
10
|
+
current = $state([]);
|
|
11
|
+
version = $state(0);
|
|
12
|
+
add(element) {
|
|
13
|
+
this.current.push(element);
|
|
14
|
+
this.version += 1;
|
|
15
|
+
}
|
|
16
|
+
remove(element) {
|
|
17
|
+
const index = this.current.indexOf(element);
|
|
18
|
+
if (index !== -1) {
|
|
19
|
+
this.current.splice(index, 1);
|
|
20
|
+
this.version += 1;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Signal that a label's text (and therefore its measured size) may have changed. */
|
|
24
|
+
touch() {
|
|
25
|
+
this.version += 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export const labels = new LabelStore();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a label's on-screen geometry from the DOM. This is the only place that
|
|
3
|
+
* forces layout (getBoundingClientRect / getComputedStyle), and the engine calls
|
|
4
|
+
* it for every node in one batch at the top of a solve — all reads before any
|
|
5
|
+
* writes — so the per-frame read/write thrash of the old engine is gone.
|
|
6
|
+
*/
|
|
7
|
+
import type { LabelNode } from './types';
|
|
8
|
+
/**
|
|
9
|
+
* Refresh `node`'s anchor, size, and scale from the DOM.
|
|
10
|
+
* Returns false if the label isn't measurable yet (detached or not laid out),
|
|
11
|
+
* in which case the caller skips it this solve and retries.
|
|
12
|
+
*/
|
|
13
|
+
export declare function measureNode(node: LabelNode): boolean;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a label's on-screen geometry from the DOM. This is the only place that
|
|
3
|
+
* forces layout (getBoundingClientRect / getComputedStyle), and the engine calls
|
|
4
|
+
* it for every node in one batch at the top of a solve — all reads before any
|
|
5
|
+
* writes — so the per-frame read/write thrash of the old engine is gone.
|
|
6
|
+
*/
|
|
7
|
+
/** CSS size of the dot (`.dot` is Tailwind `h-2 w-2` = 8px); used only as a scale fallback. */
|
|
8
|
+
const DOT_CSS_FALLBACK = 8;
|
|
9
|
+
/**
|
|
10
|
+
* Refresh `node`'s anchor, size, and scale from the DOM.
|
|
11
|
+
* Returns false if the label isn't measurable yet (detached or not laid out),
|
|
12
|
+
* in which case the caller skips it this solve and retries.
|
|
13
|
+
*/
|
|
14
|
+
export function measureNode(node) {
|
|
15
|
+
if (!node.labelEl.isConnected)
|
|
16
|
+
return false;
|
|
17
|
+
const dot = node.dotEl.getBoundingClientRect();
|
|
18
|
+
const text = node.textEl.getBoundingClientRect();
|
|
19
|
+
if (dot.width === 0 || text.width === 0)
|
|
20
|
+
return false;
|
|
21
|
+
const dotCenterX = dot.left + dot.width / 2;
|
|
22
|
+
const dotCenterY = dot.top + dot.height / 2;
|
|
23
|
+
node.ax = dotCenterX;
|
|
24
|
+
node.ay = dotCenterY;
|
|
25
|
+
node.dotR = dot.width / 2;
|
|
26
|
+
node.w = text.width;
|
|
27
|
+
node.h = text.height;
|
|
28
|
+
if (node.cssDotW === 0) {
|
|
29
|
+
node.cssDotW = Number.parseFloat(getComputedStyle(node.dotEl).width) || DOT_CSS_FALLBACK;
|
|
30
|
+
}
|
|
31
|
+
const s = dot.width / node.cssDotW;
|
|
32
|
+
node.scale = Number.isFinite(s) && s > 1e-4 ? s : 1;
|
|
33
|
+
// The dot's center relative to the island origin (the 0x0 `.label` box placed
|
|
34
|
+
// at the projected 3D point) is a fixed CSS layout offset — measure it once so
|
|
35
|
+
// the leader line starts at the dot wherever the dot's CSS positions it.
|
|
36
|
+
if (Number.isNaN(node.dotLocalX)) {
|
|
37
|
+
const island = node.labelEl.getBoundingClientRect();
|
|
38
|
+
node.dotLocalX = (dotCenterX - island.left) / node.scale;
|
|
39
|
+
node.dotLocalY = (dotCenterY - island.top) / node.scale;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidate slot generation: concentric rings of radial directions around a dot.
|
|
3
|
+
*
|
|
4
|
+
* The inner radius per angle uses the AABB support function (tighter than a
|
|
5
|
+
* uniform half-diagonal, so cardinal placements sit closer), guaranteeing a
|
|
6
|
+
* node's own dot never enters its own box at any angle.
|
|
7
|
+
*/
|
|
8
|
+
import type { LabelNode, Slot, SolverConfig } from './types';
|
|
9
|
+
export declare const generateSlots: (node: LabelNode, crowded: boolean, config: SolverConfig) => Slot[];
|
|
10
|
+
/** FNV-1a hash of a string → unsigned 32-bit. */
|
|
11
|
+
export declare const hashString: (s: string) => number;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidate slot generation: concentric rings of radial directions around a dot.
|
|
3
|
+
*
|
|
4
|
+
* The inner radius per angle uses the AABB support function (tighter than a
|
|
5
|
+
* uniform half-diagonal, so cardinal placements sit closer), guaranteeing a
|
|
6
|
+
* node's own dot never enters its own box at any angle.
|
|
7
|
+
*/
|
|
8
|
+
import { W } from './cost';
|
|
9
|
+
export const generateSlots = (node, crowded, config) => {
|
|
10
|
+
const angles = crowded ? config.anglesPerRing * 2 : config.anglesPerRing;
|
|
11
|
+
const rings = crowded ? config.ringRadiiCrowded : config.ringRadii;
|
|
12
|
+
// Deterministic per-node phase so neighbouring dots don't expose identical angles.
|
|
13
|
+
const phase = ((node.idHash % angles) / angles) * ((2 * Math.PI) / angles);
|
|
14
|
+
const halfW = node.w / 2;
|
|
15
|
+
const halfH = node.h / 2;
|
|
16
|
+
const slots = [];
|
|
17
|
+
for (let ring = 0; ring < rings.length; ring++) {
|
|
18
|
+
// Half-step stagger on odd rings so leaders interleave instead of stacking.
|
|
19
|
+
const stagger = ring % 2 ? Math.PI / angles : 0;
|
|
20
|
+
for (let k = 0; k < angles; k++) {
|
|
21
|
+
const angle = phase + stagger + (k / angles) * 2 * Math.PI;
|
|
22
|
+
const ct = Math.cos(angle);
|
|
23
|
+
const st = Math.sin(angle);
|
|
24
|
+
const support = Math.abs(ct) * halfW + Math.abs(st) * halfH;
|
|
25
|
+
const radius = (support + node.dotR + config.dotPadding) * rings[ring];
|
|
26
|
+
slots.push({
|
|
27
|
+
dx: ct * radius,
|
|
28
|
+
dy: st * radius,
|
|
29
|
+
angle,
|
|
30
|
+
radius,
|
|
31
|
+
ring,
|
|
32
|
+
baseCost: W.len * radius,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
slots.sort((a, b) => a.baseCost - b.baseCost);
|
|
37
|
+
return slots;
|
|
38
|
+
};
|
|
39
|
+
/** FNV-1a hash of a string → unsigned 32-bit. */
|
|
40
|
+
export const hashString = (s) => {
|
|
41
|
+
let h = 2166136261;
|
|
42
|
+
for (let i = 0; i < s.length; i++) {
|
|
43
|
+
h ^= s.charCodeAt(i);
|
|
44
|
+
h = Math.imul(h, 16777619);
|
|
45
|
+
}
|
|
46
|
+
return h >>> 0;
|
|
47
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic slot assignment via priority-ordered conflict-graph local search.
|
|
3
|
+
*
|
|
4
|
+
* Warm-started from each node's previously committed slot (set by the engine
|
|
5
|
+
* before this runs), it repeatedly moves the worst-conflict node to its lowest
|
|
6
|
+
* cost slot, re-scoring only the affected neighbourhood, until no node has a
|
|
7
|
+
* conflict or the move budget is spent. A best-total snapshot is restored at the
|
|
8
|
+
* end so a re-solve can never regress below where it started.
|
|
9
|
+
*/
|
|
10
|
+
import type { LabelNode, SolverConfig } from './types';
|
|
11
|
+
export declare const solve: (nodes: LabelNode[], config: SolverConfig, bestSnap: Int16Array) => void;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { evalConflict, placementBias, W } from './cost';
|
|
2
|
+
/** Conflicts below this are treated as resolved (floating-point slack). */
|
|
3
|
+
const RESOLVED = 0.5;
|
|
4
|
+
/**
|
|
5
|
+
* The slot that minimizes conflict (crossings/overlaps), breaking ties by the
|
|
6
|
+
* tidiness bias. Selection and the caller's acceptance gate share the conflict
|
|
7
|
+
* objective, so a node is never locked at a placement another slot would
|
|
8
|
+
* improve. The baseCost early-out applies only once conflict is already
|
|
9
|
+
* resolved — while a node still conflicts, every slot is considered.
|
|
10
|
+
*/
|
|
11
|
+
const bestSlot = (node, config) => {
|
|
12
|
+
const neighbors = node.neighbors;
|
|
13
|
+
let index = node.slotIndex;
|
|
14
|
+
let conflict = evalConflict(node, node.slotIndex, neighbors, config);
|
|
15
|
+
let bias = placementBias(node, node.slotIndex);
|
|
16
|
+
for (let s = 0; s < node.slots.length; s++) {
|
|
17
|
+
if (s === node.slotIndex)
|
|
18
|
+
continue;
|
|
19
|
+
// Slots are sorted ascending by baseCost; once conflict is resolved only
|
|
20
|
+
// tidiness remains, so no farther slot can beat the incumbent's bias.
|
|
21
|
+
if (conflict <= RESOLVED && node.slots[s].baseCost - W.stick >= bias)
|
|
22
|
+
break;
|
|
23
|
+
const c = evalConflict(node, s, neighbors, config);
|
|
24
|
+
const b = placementBias(node, s);
|
|
25
|
+
if (c < conflict - 1e-6 || (c <= conflict + 1e-6 && b < bias)) {
|
|
26
|
+
index = s;
|
|
27
|
+
conflict = c;
|
|
28
|
+
bias = b;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { index, conflict };
|
|
32
|
+
};
|
|
33
|
+
export const solve = (nodes, config, bestSnap) => {
|
|
34
|
+
const n = nodes.length;
|
|
35
|
+
if (n === 0)
|
|
36
|
+
return;
|
|
37
|
+
for (const node of nodes)
|
|
38
|
+
node.prevSlotIndex = node.slotIndex;
|
|
39
|
+
const order = nodes.toSorted((a, b) => b.w * b.h - a.w * a.h || a.slots.length - b.slots.length || a.idHash - b.idHash);
|
|
40
|
+
let total = 0;
|
|
41
|
+
for (let i = 0; i < n; i++) {
|
|
42
|
+
const node = nodes[i];
|
|
43
|
+
node.conflict = evalConflict(node, node.slotIndex, node.neighbors, config);
|
|
44
|
+
node.locked = false;
|
|
45
|
+
total += node.conflict;
|
|
46
|
+
}
|
|
47
|
+
let bestE = total;
|
|
48
|
+
for (let i = 0; i < n; i++)
|
|
49
|
+
bestSnap[i] = nodes[i].slotIndex;
|
|
50
|
+
let budget = config.polishBudget;
|
|
51
|
+
while (budget > 0) {
|
|
52
|
+
let worst;
|
|
53
|
+
let worstConflict = RESOLVED;
|
|
54
|
+
for (let i = 0; i < n; i++) {
|
|
55
|
+
const node = order[i];
|
|
56
|
+
if (!node.locked && node.conflict > worstConflict) {
|
|
57
|
+
worstConflict = node.conflict;
|
|
58
|
+
worst = node;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!worst)
|
|
62
|
+
break;
|
|
63
|
+
const result = bestSlot(worst, config);
|
|
64
|
+
if (result.index !== worst.slotIndex && result.conflict < worst.conflict - RESOLVED) {
|
|
65
|
+
total += result.conflict - worst.conflict;
|
|
66
|
+
worst.slotIndex = result.index;
|
|
67
|
+
worst.conflict = result.conflict;
|
|
68
|
+
budget--;
|
|
69
|
+
// Moving `worst` changes the pairwise cost of its neighbors only.
|
|
70
|
+
for (const m of worst.neighbors) {
|
|
71
|
+
const before = m.conflict;
|
|
72
|
+
m.conflict = evalConflict(m, m.slotIndex, m.neighbors, config);
|
|
73
|
+
total += m.conflict - before;
|
|
74
|
+
m.locked = false;
|
|
75
|
+
}
|
|
76
|
+
if (total < bestE) {
|
|
77
|
+
bestE = total;
|
|
78
|
+
for (let i = 0; i < n; i++)
|
|
79
|
+
bestSnap[i] = nodes[i].slotIndex;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
worst.locked = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (let i = 0; i < n; i++) {
|
|
87
|
+
const node = nodes[i];
|
|
88
|
+
node.slotIndex = bestSnap[i];
|
|
89
|
+
const slot = node.slots[node.slotIndex];
|
|
90
|
+
node.tx = node.ax + slot.dx;
|
|
91
|
+
node.ty = node.ay + slot.dy;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uniform grid over label anchors, used to prune the O(n^2) cost evaluation to a
|
|
3
|
+
* bounded neighborhood. The cell size is chosen so any two labels whose boxes
|
|
4
|
+
* could possibly interact share or border a cell, so scanning the 3x3 block
|
|
5
|
+
* around a node finds every candidate.
|
|
6
|
+
*/
|
|
7
|
+
import type { LabelNode } from './types';
|
|
8
|
+
export declare class SpatialHash {
|
|
9
|
+
private cell;
|
|
10
|
+
private readonly buckets;
|
|
11
|
+
private static key;
|
|
12
|
+
build(nodes: LabelNode[], cell: number): void;
|
|
13
|
+
/** Nearest `max` nodes (by anchor distance) in the 3x3 cell block around `node`, excluding itself. */
|
|
14
|
+
queryNeighbors(node: LabelNode, max: number): LabelNode[];
|
|
15
|
+
}
|