@viamrobotics/motion-tools 1.32.0 → 1.33.1
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/App.svelte +17 -11
- package/dist/components/App.svelte.d.ts +14 -7
- 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 +42 -48
- package/dist/components/SceneProviders.svelte +0 -3
- package/dist/components/SelectedTransformControls.svelte +65 -47
- package/dist/components/overlay/Details.svelte +198 -224
- 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/components/overlay/settings/ConnectionSettings.svelte +42 -0
- package/dist/components/overlay/settings/ConnectionSettings.svelte.d.ts +18 -0
- package/dist/components/overlay/settings/DebugSettings.svelte +13 -0
- package/dist/components/{xr/frame-configure/Controllers.svelte.d.ts → overlay/settings/DebugSettings.svelte.d.ts} +3 -3
- package/dist/components/overlay/settings/PointcloudSettings.svelte +61 -0
- package/dist/components/overlay/settings/PointcloudSettings.svelte.d.ts +3 -0
- package/dist/components/overlay/settings/SceneSettings.svelte +110 -0
- package/dist/components/overlay/settings/SceneSettings.svelte.d.ts +18 -0
- package/dist/components/overlay/settings/Settings.svelte +27 -312
- package/dist/components/overlay/settings/Settings.svelte.d.ts +8 -1
- package/dist/components/overlay/settings/Tabs.svelte +5 -3
- package/dist/components/overlay/settings/Tabs.svelte.d.ts +3 -3
- package/dist/components/overlay/settings/VisionSettings.svelte +31 -0
- package/dist/components/overlay/settings/VisionSettings.svelte.d.ts +3 -0
- package/dist/components/overlay/settings/WeblabSettings.svelte +27 -0
- package/dist/components/overlay/settings/WeblabSettings.svelte.d.ts +18 -0
- package/dist/components/overlay/settings/WidgetSettings.svelte +49 -0
- package/dist/components/overlay/settings/WidgetSettings.svelte.d.ts +3 -0
- package/dist/components/overlay/widgets/FramePov.svelte +1 -12
- package/dist/ecs/traits.d.ts +1 -1
- package/dist/ecs/traits.js +1 -1
- package/dist/hooks/useWorldState.svelte.js +39 -50
- package/dist/{components/xr → plugins/XR}/ArmTeleop.svelte +3 -5
- package/dist/plugins/XR/DebugPanel.svelte +29 -0
- package/dist/plugins/XR/DebugPanel.svelte.d.ts +3 -0
- package/dist/plugins/XR/OriginMarker.svelte +341 -0
- package/dist/plugins/XR/PendingEditsPanel.svelte +60 -0
- package/dist/plugins/XR/PendingEditsPanel.svelte.d.ts +18 -0
- package/dist/plugins/XR/WristDisplay.svelte +60 -0
- package/dist/plugins/XR/WristDisplay.svelte.d.ts +19 -0
- package/dist/{components/xr → plugins/XR}/XR.svelte +69 -23
- package/dist/plugins/XR/XRPlugins.svelte +9 -0
- package/dist/plugins/XR/XRPlugins.svelte.d.ts +26 -0
- package/dist/plugins/XR/XRSettings.svelte +240 -0
- package/dist/plugins/XR/XRSettings.svelte.d.ts +3 -0
- package/dist/{components/xr → plugins/XR}/XRToast.svelte +6 -9
- package/dist/plugins/XR/debug.svelte.d.ts +7 -0
- package/dist/plugins/XR/debug.svelte.js +13 -0
- package/dist/plugins/XR/frame-configure/Controllers.svelte +413 -0
- package/dist/plugins/XR/teleop/Controllers.svelte.d.ts +3 -0
- package/dist/{components/xr → plugins/XR}/useAnchors.svelte.d.ts +4 -0
- package/dist/{components/xr → plugins/XR}/useAnchors.svelte.js +22 -0
- package/dist/plugins/XR/useOrigin.svelte.d.ts +24 -0
- package/dist/plugins/XR/useOrigin.svelte.js +50 -0
- package/dist/plugins/index.d.ts +2 -0
- package/dist/plugins/index.js +2 -0
- package/dist/three/OBBHelper.js +1 -0
- package/package.json +3 -1
- package/dist/components/xr/OriginMarker.svelte +0 -151
- package/dist/components/xr/XRControllerSettings.svelte +0 -242
- package/dist/components/xr/XRControllerSettings.svelte.d.ts +0 -3
- package/dist/components/xr/frame-configure/Controllers.svelte +0 -6
- package/dist/components/xr/useOrigin.svelte.d.ts +0 -9
- package/dist/components/xr/useOrigin.svelte.js +0 -27
- /package/dist/{components/xr → plugins/XR}/ArmTeleop.svelte.d.ts +0 -0
- /package/dist/{components/xr → plugins/XR}/BentPlaneGeometry.svelte +0 -0
- /package/dist/{components/xr → plugins/XR}/BentPlaneGeometry.svelte.d.ts +0 -0
- /package/dist/{components/xr → plugins/XR}/CameraFeed.svelte +0 -0
- /package/dist/{components/xr → plugins/XR}/CameraFeed.svelte.d.ts +0 -0
- /package/dist/{components/xr → plugins/XR}/JointLimitsWidget.svelte +0 -0
- /package/dist/{components/xr → plugins/XR}/JointLimitsWidget.svelte.d.ts +0 -0
- /package/dist/{components/xr → plugins/XR}/OriginMarker.svelte.d.ts +0 -0
- /package/dist/{components/xr → plugins/XR}/PointDistance.svelte +0 -0
- /package/dist/{components/xr → plugins/XR}/PointDistance.svelte.d.ts +0 -0
- /package/dist/{components/xr → plugins/XR}/XR.svelte.d.ts +0 -0
- /package/dist/{components/xr → plugins/XR}/XRConfigPanel.svelte +0 -0
- /package/dist/{components/xr → plugins/XR}/XRConfigPanel.svelte.d.ts +0 -0
- /package/dist/{components/xr → plugins/XR}/XRToast.svelte.d.ts +0 -0
- /package/dist/{components/xr/teleop → plugins/XR/frame-configure}/Controllers.svelte.d.ts +0 -0
- /package/dist/{components/xr → plugins/XR}/math.d.ts +0 -0
- /package/dist/{components/xr → plugins/XR}/math.js +0 -0
- /package/dist/{components/xr → plugins/XR}/teleop/Controllers.svelte +0 -0
- /package/dist/{components/xr → plugins/XR}/toasts.svelte.d.ts +0 -0
- /package/dist/{components/xr → plugins/XR}/toasts.svelte.js +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost function for a candidate label placement. Lower is better.
|
|
3
|
+
*
|
|
4
|
+
* The hierarchy `lineBox >> boxDot > boxBox > lineLine >> stick > spread > len`
|
|
5
|
+
* is near-lexicographic: a single leader passing under another label always
|
|
6
|
+
* outranks fixing every overlap a node could have against its <=24 neighbors,
|
|
7
|
+
* so the optimizer eliminates crossings first (the user's top priority), then
|
|
8
|
+
* dot coverage, then box overlaps, then tidies the radial fan.
|
|
9
|
+
*/
|
|
10
|
+
import { overlapAreaFrac, rectCircleOverlap, rectsOverlap, segmentRectPenetration, segmentsCross, } from './geometry';
|
|
11
|
+
export const W = {
|
|
12
|
+
/** DOMINANT — a leader passing under ANOTHER label's box. Requirement #1. */
|
|
13
|
+
lineBox: 1000,
|
|
14
|
+
/** Our box covering another node's dot. Worse than a box overlap. Requirement #4. */
|
|
15
|
+
boxDot: 200,
|
|
16
|
+
/** Two label boxes overlapping. Requirement #2. */
|
|
17
|
+
boxBox: 120,
|
|
18
|
+
/** Two leaders crossing — thin lines, mild. Supports the radial fan. */
|
|
19
|
+
lineLine: 60,
|
|
20
|
+
/** Sticky bonus for staying on the previous slot (anti flip-flop). Requirement #6. */
|
|
21
|
+
stick: 35,
|
|
22
|
+
/** Penalty when a slot's angle nearly coincides with a neighbor's leader angle. */
|
|
23
|
+
spread: 15,
|
|
24
|
+
/** Outward-fan preference: cheaper to point away from the local cluster. Requirement #3. */
|
|
25
|
+
radial: 0.8,
|
|
26
|
+
/** Leader length — keep labels close to their dot. Small. */
|
|
27
|
+
len: 0.6,
|
|
28
|
+
};
|
|
29
|
+
/** Slot angles closer than this (radians, ~18deg) are penalised for an even fan. */
|
|
30
|
+
const SPREAD_ANGLE = 0.314;
|
|
31
|
+
// Reused scratch — the solver is single-threaded and never re-enters evalConflict.
|
|
32
|
+
const boxA = { cx: 0, cy: 0, hw: 0, hh: 0 };
|
|
33
|
+
const boxB = { cx: 0, cy: 0, hw: 0, hh: 0 };
|
|
34
|
+
const boxPad = { cx: 0, cy: 0, hw: 0, hh: 0 };
|
|
35
|
+
const segA = { x1: 0, y1: 0, x2: 0, y2: 0 };
|
|
36
|
+
const segB = { x1: 0, y1: 0, x2: 0, y2: 0 };
|
|
37
|
+
/**
|
|
38
|
+
* The geometric "bad" terms only (leader-under-box, box-over-dot, box-box,
|
|
39
|
+
* leader-leader, angular spread) for placing `node` at slot `si` against its
|
|
40
|
+
* committed neighbors. This is the local-search objective AND its termination
|
|
41
|
+
* guard — exactly 0 when the node has no crossings/overlaps. The solver both
|
|
42
|
+
* selects and accepts moves on this value so it can never lock a node at a
|
|
43
|
+
* placement whose conflict another available slot would reduce.
|
|
44
|
+
*/
|
|
45
|
+
export function evalConflict(node, si, neighbors, config) {
|
|
46
|
+
const s = node.slots[si];
|
|
47
|
+
const cx = node.ax + s.dx;
|
|
48
|
+
const cy = node.ay + s.dy;
|
|
49
|
+
boxA.cx = cx;
|
|
50
|
+
boxA.cy = cy;
|
|
51
|
+
boxA.hw = node.w / 2;
|
|
52
|
+
boxA.hh = node.h / 2;
|
|
53
|
+
segA.x1 = node.ax;
|
|
54
|
+
segA.y1 = node.ay;
|
|
55
|
+
segA.x2 = cx;
|
|
56
|
+
segA.y2 = cy;
|
|
57
|
+
const halfPad = config.labelPadding / 2;
|
|
58
|
+
let cost = 0;
|
|
59
|
+
for (let t = 0; t < neighbors.length; t++) {
|
|
60
|
+
const m = neighbors[t];
|
|
61
|
+
const ms = m.slots[m.slotIndex];
|
|
62
|
+
const mx = m.ax + ms.dx;
|
|
63
|
+
const my = m.ay + ms.dy;
|
|
64
|
+
boxB.cx = mx;
|
|
65
|
+
boxB.cy = my;
|
|
66
|
+
boxB.hw = m.w / 2;
|
|
67
|
+
boxB.hh = m.h / 2;
|
|
68
|
+
segB.x1 = m.ax;
|
|
69
|
+
segB.y1 = m.ay;
|
|
70
|
+
segB.x2 = mx;
|
|
71
|
+
segB.y2 = my;
|
|
72
|
+
// Our leader under their box (expanded so a grazing line still reads as a crossing).
|
|
73
|
+
boxPad.cx = mx;
|
|
74
|
+
boxPad.cy = my;
|
|
75
|
+
boxPad.hw = m.w / 2 + halfPad;
|
|
76
|
+
boxPad.hh = m.h / 2 + halfPad;
|
|
77
|
+
const p1 = segmentRectPenetration(segA, boxPad);
|
|
78
|
+
if (p1 > 0)
|
|
79
|
+
cost += W.lineBox * p1;
|
|
80
|
+
// Their leader under our box.
|
|
81
|
+
boxPad.cx = cx;
|
|
82
|
+
boxPad.cy = cy;
|
|
83
|
+
boxPad.hw = node.w / 2 + halfPad;
|
|
84
|
+
boxPad.hh = node.h / 2 + halfPad;
|
|
85
|
+
const p2 = segmentRectPenetration(segB, boxPad);
|
|
86
|
+
if (p2 > 0)
|
|
87
|
+
cost += W.lineBox * p2;
|
|
88
|
+
// Our box covering their dot.
|
|
89
|
+
const dotClearance = m.dotR + config.dotPadding;
|
|
90
|
+
const d = rectCircleOverlap(boxA, m.ax, m.ay, dotClearance);
|
|
91
|
+
if (d > 0)
|
|
92
|
+
cost += W.boxDot * (d / dotClearance);
|
|
93
|
+
// Box-box overlap (binary dominates; area term provides a separation gradient).
|
|
94
|
+
if (rectsOverlap(boxA, boxB, config.labelPadding)) {
|
|
95
|
+
cost += W.boxBox + 2 * overlapAreaFrac(boxA, boxB);
|
|
96
|
+
}
|
|
97
|
+
// Leader-leader crossing.
|
|
98
|
+
if (segmentsCross(segA, segB))
|
|
99
|
+
cost += W.lineLine;
|
|
100
|
+
// Angular spread for an even fan.
|
|
101
|
+
let dd = Math.abs(s.angle - ms.angle);
|
|
102
|
+
if (dd > Math.PI)
|
|
103
|
+
dd = 2 * Math.PI - dd;
|
|
104
|
+
if (dd < SPREAD_ANGLE)
|
|
105
|
+
cost += W.spread;
|
|
106
|
+
}
|
|
107
|
+
return cost;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Conflict-independent "tidiness" of a slot: short leaders, pointing away from
|
|
111
|
+
* the local cluster centroid (radial fan-out), with a bonus for staying put.
|
|
112
|
+
* Used only as a tie-break among slots of equal conflict, never to override a
|
|
113
|
+
* conflict reduction.
|
|
114
|
+
*/
|
|
115
|
+
export function placementBias(node, si) {
|
|
116
|
+
const s = node.slots[si];
|
|
117
|
+
let cost = W.len * s.radius;
|
|
118
|
+
const cax = node.centroidX - node.ax;
|
|
119
|
+
const cay = node.centroidY - node.ay;
|
|
120
|
+
const cl = Math.sqrt(cax * cax + cay * cay) || 1;
|
|
121
|
+
const align = (Math.cos(s.angle) * -cax + Math.sin(s.angle) * -cay) / cl;
|
|
122
|
+
cost += W.radial * (1 - align) * s.radius;
|
|
123
|
+
if (si === node.prevSlotIndex)
|
|
124
|
+
cost -= W.stick;
|
|
125
|
+
return cost;
|
|
126
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
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 type { Camera } from 'three';
|
|
9
|
+
import type { LabelStore } from './labelStore.svelte';
|
|
10
|
+
import { type SolverConfig } from './types';
|
|
11
|
+
export interface LayoutDeps {
|
|
12
|
+
camera: {
|
|
13
|
+
current: Camera;
|
|
14
|
+
};
|
|
15
|
+
size: {
|
|
16
|
+
current: {
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
invalidate: () => void;
|
|
22
|
+
labels: LabelStore;
|
|
23
|
+
config?: Partial<SolverConfig>;
|
|
24
|
+
}
|
|
25
|
+
export declare function createLabelLayout(deps: LayoutDeps): {
|
|
26
|
+
frame: (delta: number) => void;
|
|
27
|
+
};
|
|
@@ -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
|
+
};
|