@unrdf/spatial-kg 26.4.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/README.md +292 -0
- package/package.json +79 -0
- package/src/collaboration.mjs +231 -0
- package/src/gesture-controller.mjs +227 -0
- package/src/index.mjs +15 -0
- package/src/layout-3d.mjs +323 -0
- package/src/lod-manager.mjs +209 -0
- package/src/schemas.mjs +161 -0
- package/src/spatial-kg-engine.mjs +264 -0
- package/src/spatial-query.mjs +429 -0
- package/src/webxr-renderer.mjs +291 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Gesture Controller for VR/AR
|
|
3
|
+
* @module @unrdf/spatial-kg/gesture-controller
|
|
4
|
+
* @description Handle VR/AR gestures (point, grab, teleport, pinch)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { GestureEventSchema } from './schemas.mjs';
|
|
8
|
+
import { trace } from '@opentelemetry/api';
|
|
9
|
+
|
|
10
|
+
const tracer = trace.getTracer('@unrdf/spatial-kg/gesture-controller');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Gesture Controller
|
|
14
|
+
*/
|
|
15
|
+
export class GestureController {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} [options] - Controller options
|
|
18
|
+
* @param {number} [options.pinchThreshold=0.8] - Pinch detection threshold
|
|
19
|
+
* @param {number} [options.grabThreshold=0.9] - Grab detection threshold
|
|
20
|
+
*/
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
this.pinchThreshold = options.pinchThreshold || 0.8;
|
|
23
|
+
this.grabThreshold = options.grabThreshold || 0.9;
|
|
24
|
+
this.listeners = new Map();
|
|
25
|
+
this.activeGestures = new Map();
|
|
26
|
+
this.lastEvents = new Map();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Process controller input (from WebXR)
|
|
31
|
+
* @param {string} controllerId - Controller ID
|
|
32
|
+
* @param {Object} state - Controller state
|
|
33
|
+
* @param {Object} state.position - 3D position
|
|
34
|
+
* @param {Object} state.buttons - Button states
|
|
35
|
+
* @param {Object} [state.handPose] - Hand tracking data
|
|
36
|
+
* @returns {Array<Object>} Detected gestures
|
|
37
|
+
*/
|
|
38
|
+
processInput(controllerId, state) {
|
|
39
|
+
return tracer.startActiveSpan('gesture.process-input', span => {
|
|
40
|
+
try {
|
|
41
|
+
const gestures = [];
|
|
42
|
+
const timestamp = Date.now();
|
|
43
|
+
|
|
44
|
+
// Button-based gestures
|
|
45
|
+
if (state.buttons) {
|
|
46
|
+
// Select/trigger
|
|
47
|
+
if (state.buttons.trigger > 0.9) {
|
|
48
|
+
gestures.push(this._createGesture('select', controllerId, state.position, timestamp));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Grab/grip
|
|
52
|
+
if (state.buttons.grip > this.grabThreshold) {
|
|
53
|
+
gestures.push(
|
|
54
|
+
this._createGesture(
|
|
55
|
+
'grab',
|
|
56
|
+
controllerId,
|
|
57
|
+
state.position,
|
|
58
|
+
timestamp,
|
|
59
|
+
state.buttons.grip
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Teleport (thumbstick click)
|
|
65
|
+
if (state.buttons.thumbstick === 1) {
|
|
66
|
+
gestures.push(this._createGesture('teleport', controllerId, state.position, timestamp));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Hand tracking gestures
|
|
71
|
+
if (state.handPose) {
|
|
72
|
+
const pinch = this._detectPinch(state.handPose);
|
|
73
|
+
if (pinch.detected) {
|
|
74
|
+
gestures.push(
|
|
75
|
+
this._createGesture('pinch', controllerId, pinch.position, timestamp, pinch.strength)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Emit events
|
|
81
|
+
for (const gesture of gestures) {
|
|
82
|
+
this._emit(gesture);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
span.setAttributes({
|
|
86
|
+
'gesture.controller': controllerId,
|
|
87
|
+
'gesture.count': gestures.length,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return gestures;
|
|
91
|
+
} finally {
|
|
92
|
+
span.end();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create gesture event
|
|
99
|
+
* @private
|
|
100
|
+
*/
|
|
101
|
+
_createGesture(type, controller, position, timestamp, intensity) {
|
|
102
|
+
const gesture = {
|
|
103
|
+
type,
|
|
104
|
+
controller,
|
|
105
|
+
position,
|
|
106
|
+
timestamp,
|
|
107
|
+
intensity,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return GestureEventSchema.parse(gesture);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Detect pinch gesture from hand pose
|
|
115
|
+
* @private
|
|
116
|
+
*/
|
|
117
|
+
_detectPinch(handPose) {
|
|
118
|
+
if (!handPose.joints) {
|
|
119
|
+
return { detected: false };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Get thumb tip and index finger tip
|
|
123
|
+
const thumbTip = handPose.joints['thumb-tip'];
|
|
124
|
+
const indexTip = handPose.joints['index-finger-tip'];
|
|
125
|
+
|
|
126
|
+
if (!thumbTip || !indexTip) {
|
|
127
|
+
return { detected: false };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Calculate distance
|
|
131
|
+
const dx = thumbTip.position.x - indexTip.position.x;
|
|
132
|
+
const dy = thumbTip.position.y - indexTip.position.y;
|
|
133
|
+
const dz = thumbTip.position.z - indexTip.position.z;
|
|
134
|
+
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
135
|
+
|
|
136
|
+
// Pinch threshold (typically ~2cm)
|
|
137
|
+
const threshold = 0.02;
|
|
138
|
+
const detected = distance < threshold;
|
|
139
|
+
|
|
140
|
+
if (detected) {
|
|
141
|
+
// Strength based on how close fingers are
|
|
142
|
+
const strength = Math.max(0, 1 - distance / threshold);
|
|
143
|
+
|
|
144
|
+
// Midpoint between fingers
|
|
145
|
+
const position = {
|
|
146
|
+
x: (thumbTip.position.x + indexTip.position.x) / 2,
|
|
147
|
+
y: (thumbTip.position.y + indexTip.position.y) / 2,
|
|
148
|
+
z: (thumbTip.position.z + indexTip.position.z) / 2,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return { detected: true, strength, position };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { detected: false };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Register gesture event listener
|
|
159
|
+
* @param {string} gestureType - Gesture type
|
|
160
|
+
* @param {Function} callback - Event handler
|
|
161
|
+
* @returns {Function} Unsubscribe function
|
|
162
|
+
*/
|
|
163
|
+
on(gestureType, callback) {
|
|
164
|
+
if (!this.listeners.has(gestureType)) {
|
|
165
|
+
this.listeners.set(gestureType, new Set());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.listeners.get(gestureType).add(callback);
|
|
169
|
+
|
|
170
|
+
return () => {
|
|
171
|
+
const listeners = this.listeners.get(gestureType);
|
|
172
|
+
if (listeners) {
|
|
173
|
+
listeners.delete(callback);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Emit gesture event to listeners
|
|
180
|
+
* @private
|
|
181
|
+
*/
|
|
182
|
+
_emit(gesture) {
|
|
183
|
+
const listeners = this.listeners.get(gesture.type);
|
|
184
|
+
if (listeners) {
|
|
185
|
+
for (const callback of listeners) {
|
|
186
|
+
try {
|
|
187
|
+
callback(gesture);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.error(`Gesture listener error:`, error);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Store last event
|
|
195
|
+
this.lastEvents.set(`${gesture.controller}-${gesture.type}`, gesture);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get last gesture of type
|
|
200
|
+
* @param {string} controller - Controller ID
|
|
201
|
+
* @param {string} type - Gesture type
|
|
202
|
+
* @returns {Object|null} Last gesture event
|
|
203
|
+
*/
|
|
204
|
+
getLastGesture(controller, type) {
|
|
205
|
+
return this.lastEvents.get(`${controller}-${type}`) || null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Clear all listeners
|
|
210
|
+
* @returns {void}
|
|
211
|
+
*/
|
|
212
|
+
clear() {
|
|
213
|
+
this.listeners.clear();
|
|
214
|
+
this.activeGestures.clear();
|
|
215
|
+
this.lastEvents.clear();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Simulate gesture (for testing)
|
|
220
|
+
* @param {Object} gesture - Gesture event
|
|
221
|
+
* @returns {void}
|
|
222
|
+
*/
|
|
223
|
+
simulateGesture(gesture) {
|
|
224
|
+
const validated = GestureEventSchema.parse(gesture);
|
|
225
|
+
this._emit(validated);
|
|
226
|
+
}
|
|
227
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Spatial Knowledge Graph - Main Exports
|
|
3
|
+
* @module @unrdf/spatial-kg
|
|
4
|
+
* @description WebXR-enabled 3D visualization and navigation of RDF knowledge graphs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { SpatialKGEngine } from './spatial-kg-engine.mjs';
|
|
8
|
+
export { Layout3D } from './layout-3d.mjs';
|
|
9
|
+
export { WebXRRenderer } from './webxr-renderer.mjs';
|
|
10
|
+
export { SpatialQueryEngine } from './spatial-query.mjs';
|
|
11
|
+
export { GestureController } from './gesture-controller.mjs';
|
|
12
|
+
export { LODManager } from './lod-manager.mjs';
|
|
13
|
+
export { CollaborationManager } from './collaboration.mjs';
|
|
14
|
+
|
|
15
|
+
export * from './schemas.mjs';
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file 3D Force-Directed Graph Layout
|
|
3
|
+
* @module @unrdf/spatial-kg/layout-3d
|
|
4
|
+
* @description Implements 3D Fruchterman-Reingold force-directed layout algorithm
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Node3DSchema, Edge3DSchema, SpatialGraphConfigSchema } from './schemas.mjs';
|
|
8
|
+
import { trace } from '@opentelemetry/api';
|
|
9
|
+
|
|
10
|
+
const tracer = trace.getTracer('@unrdf/spatial-kg/layout-3d');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Calculate distance between two 3D points
|
|
14
|
+
* @param {Object} a - First point with x, y, z
|
|
15
|
+
* @param {Object} b - Second point with x, y, z
|
|
16
|
+
* @returns {number} Euclidean distance
|
|
17
|
+
*/
|
|
18
|
+
function distance3D(a, b) {
|
|
19
|
+
const dx = a.x - b.x;
|
|
20
|
+
const dy = a.y - b.y;
|
|
21
|
+
const dz = a.z - b.z;
|
|
22
|
+
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 3D Force-Directed Layout Engine
|
|
27
|
+
*/
|
|
28
|
+
export class Layout3D {
|
|
29
|
+
/**
|
|
30
|
+
* @param {Object} config - Layout configuration
|
|
31
|
+
*/
|
|
32
|
+
constructor(config = {}) {
|
|
33
|
+
const validated = SpatialGraphConfigSchema.parse({ layout: config });
|
|
34
|
+
this.config = validated.layout;
|
|
35
|
+
this.nodes = new Map();
|
|
36
|
+
this.edges = [];
|
|
37
|
+
this.iteration = 0;
|
|
38
|
+
this.temperature = 1.0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Add node to layout
|
|
43
|
+
* @param {Object} node - Node data
|
|
44
|
+
* @returns {void}
|
|
45
|
+
*/
|
|
46
|
+
addNode(node) {
|
|
47
|
+
const validated = Node3DSchema.parse(node);
|
|
48
|
+
|
|
49
|
+
// Initialize position if not set
|
|
50
|
+
if (!validated.position) {
|
|
51
|
+
validated.position = {
|
|
52
|
+
x: (Math.random() - 0.5) * 100,
|
|
53
|
+
y: (Math.random() - 0.5) * 100,
|
|
54
|
+
z: (Math.random() - 0.5) * 100,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Initialize velocity
|
|
59
|
+
validated.velocity = validated.velocity || { x: 0, y: 0, z: 0 };
|
|
60
|
+
|
|
61
|
+
this.nodes.set(validated.id, validated);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Add edge to layout
|
|
66
|
+
* @param {Object} edge - Edge data
|
|
67
|
+
* @returns {void}
|
|
68
|
+
*/
|
|
69
|
+
addEdge(edge) {
|
|
70
|
+
const validated = Edge3DSchema.parse(edge);
|
|
71
|
+
this.edges.push(validated);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Calculate repulsion force between nodes
|
|
76
|
+
* @param {Object} nodeA - First node
|
|
77
|
+
* @param {Object} nodeB - Second node
|
|
78
|
+
* @returns {Object} Force vector
|
|
79
|
+
* @private
|
|
80
|
+
*/
|
|
81
|
+
_calculateRepulsion(nodeA, nodeB) {
|
|
82
|
+
const dist = distance3D(nodeA.position, nodeB.position);
|
|
83
|
+
if (dist < 0.01) return { x: 0, y: 0, z: 0 };
|
|
84
|
+
|
|
85
|
+
const force = (this.config.repulsionStrength * this.temperature) / (dist * dist);
|
|
86
|
+
const dx = nodeA.position.x - nodeB.position.x;
|
|
87
|
+
const dy = nodeA.position.y - nodeB.position.y;
|
|
88
|
+
const dz = nodeA.position.z - nodeB.position.z;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
x: (dx / dist) * force,
|
|
92
|
+
y: (dy / dist) * force,
|
|
93
|
+
z: (dz / dist) * force,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Calculate attraction force along edge
|
|
99
|
+
* @param {Object} edge - Edge connecting nodes
|
|
100
|
+
* @returns {Object} Force vectors for source and target
|
|
101
|
+
* @private
|
|
102
|
+
*/
|
|
103
|
+
_calculateAttraction(edge) {
|
|
104
|
+
const source = this.nodes.get(edge.source);
|
|
105
|
+
const target = this.nodes.get(edge.target);
|
|
106
|
+
|
|
107
|
+
if (!source || !target) {
|
|
108
|
+
return { source: { x: 0, y: 0, z: 0 }, target: { x: 0, y: 0, z: 0 } };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const dist = distance3D(source.position, target.position);
|
|
112
|
+
if (dist < 0.01) return { source: { x: 0, y: 0, z: 0 }, target: { x: 0, y: 0, z: 0 } };
|
|
113
|
+
|
|
114
|
+
const force = this.config.attractionStrength * dist * edge.strength;
|
|
115
|
+
const dx = target.position.x - source.position.x;
|
|
116
|
+
const dy = target.position.y - source.position.y;
|
|
117
|
+
const dz = target.position.z - source.position.z;
|
|
118
|
+
|
|
119
|
+
const forceVec = {
|
|
120
|
+
x: (dx / dist) * force,
|
|
121
|
+
y: (dy / dist) * force,
|
|
122
|
+
z: (dz / dist) * force,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
source: forceVec,
|
|
127
|
+
target: { x: -forceVec.x, y: -forceVec.y, z: -forceVec.z },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Calculate gravity force toward center
|
|
133
|
+
* @param {Object} node - Node to apply gravity to
|
|
134
|
+
* @returns {Object} Force vector
|
|
135
|
+
* @private
|
|
136
|
+
*/
|
|
137
|
+
_calculateGravity(node) {
|
|
138
|
+
if (!this.config.centerForce) return { x: 0, y: 0, z: 0 };
|
|
139
|
+
|
|
140
|
+
const dist = Math.sqrt(node.position.x ** 2 + node.position.y ** 2 + node.position.z ** 2);
|
|
141
|
+
|
|
142
|
+
if (dist < 0.01) return { x: 0, y: 0, z: 0 };
|
|
143
|
+
|
|
144
|
+
const force = this.config.gravityStrength * dist;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
x: -(node.position.x / dist) * force,
|
|
148
|
+
y: -(node.position.y / dist) * force,
|
|
149
|
+
z: -(node.position.z / dist) * force,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Execute one iteration of layout algorithm
|
|
155
|
+
* @returns {number} Total displacement (convergence metric)
|
|
156
|
+
*/
|
|
157
|
+
step() {
|
|
158
|
+
return tracer.startActiveSpan('layout3d.step', span => {
|
|
159
|
+
try {
|
|
160
|
+
const forces = new Map();
|
|
161
|
+
|
|
162
|
+
// Initialize forces
|
|
163
|
+
for (const [id] of this.nodes) {
|
|
164
|
+
forces.set(id, { x: 0, y: 0, z: 0 });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Calculate repulsion forces (all pairs)
|
|
168
|
+
const nodeArray = Array.from(this.nodes.values());
|
|
169
|
+
for (let i = 0; i < nodeArray.length; i++) {
|
|
170
|
+
for (let j = i + 1; j < nodeArray.length; j++) {
|
|
171
|
+
const repulsion = this._calculateRepulsion(nodeArray[i], nodeArray[j]);
|
|
172
|
+
|
|
173
|
+
const forceA = forces.get(nodeArray[i].id);
|
|
174
|
+
forceA.x += repulsion.x;
|
|
175
|
+
forceA.y += repulsion.y;
|
|
176
|
+
forceA.z += repulsion.z;
|
|
177
|
+
|
|
178
|
+
const forceB = forces.get(nodeArray[j].id);
|
|
179
|
+
forceB.x -= repulsion.x;
|
|
180
|
+
forceB.y -= repulsion.y;
|
|
181
|
+
forceB.z -= repulsion.z;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Calculate attraction forces (edges)
|
|
186
|
+
for (const edge of this.edges) {
|
|
187
|
+
const attraction = this._calculateAttraction(edge);
|
|
188
|
+
|
|
189
|
+
const sourceForce = forces.get(edge.source);
|
|
190
|
+
if (sourceForce) {
|
|
191
|
+
sourceForce.x += attraction.source.x;
|
|
192
|
+
sourceForce.y += attraction.source.y;
|
|
193
|
+
sourceForce.z += attraction.source.z;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const targetForce = forces.get(edge.target);
|
|
197
|
+
if (targetForce) {
|
|
198
|
+
targetForce.x += attraction.target.x;
|
|
199
|
+
targetForce.y += attraction.target.y;
|
|
200
|
+
targetForce.z += attraction.target.z;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Calculate gravity forces
|
|
205
|
+
for (const node of this.nodes.values()) {
|
|
206
|
+
const gravity = this._calculateGravity(node);
|
|
207
|
+
const force = forces.get(node.id);
|
|
208
|
+
force.x += gravity.x;
|
|
209
|
+
force.y += gravity.y;
|
|
210
|
+
force.z += gravity.z;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Apply forces and update positions
|
|
214
|
+
let totalDisplacement = 0;
|
|
215
|
+
|
|
216
|
+
for (const [id, node] of this.nodes) {
|
|
217
|
+
if (node.fixed) continue;
|
|
218
|
+
|
|
219
|
+
const force = forces.get(id);
|
|
220
|
+
|
|
221
|
+
// Update velocity with damping
|
|
222
|
+
node.velocity.x = node.velocity.x * 0.8 + force.x;
|
|
223
|
+
node.velocity.y = node.velocity.y * 0.8 + force.y;
|
|
224
|
+
node.velocity.z = node.velocity.z * 0.8 + force.z;
|
|
225
|
+
|
|
226
|
+
// Limit velocity
|
|
227
|
+
const speed = Math.sqrt(
|
|
228
|
+
node.velocity.x ** 2 + node.velocity.y ** 2 + node.velocity.z ** 2
|
|
229
|
+
);
|
|
230
|
+
const maxSpeed = 10 * this.temperature;
|
|
231
|
+
if (speed > maxSpeed) {
|
|
232
|
+
node.velocity.x = (node.velocity.x / speed) * maxSpeed;
|
|
233
|
+
node.velocity.y = (node.velocity.y / speed) * maxSpeed;
|
|
234
|
+
node.velocity.z = (node.velocity.z / speed) * maxSpeed;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Update position
|
|
238
|
+
node.position.x += node.velocity.x * this.temperature;
|
|
239
|
+
node.position.y += node.velocity.y * this.temperature;
|
|
240
|
+
node.position.z += node.velocity.z * this.temperature;
|
|
241
|
+
|
|
242
|
+
totalDisplacement +=
|
|
243
|
+
Math.abs(node.velocity.x) + Math.abs(node.velocity.y) + Math.abs(node.velocity.z);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Cool down
|
|
247
|
+
this.temperature *= this.config.cooldown;
|
|
248
|
+
this.iteration++;
|
|
249
|
+
|
|
250
|
+
span.setAttributes({
|
|
251
|
+
'layout.iteration': this.iteration,
|
|
252
|
+
'layout.displacement': totalDisplacement,
|
|
253
|
+
'layout.temperature': this.temperature,
|
|
254
|
+
'layout.nodes': this.nodes.size,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
return totalDisplacement;
|
|
258
|
+
} finally {
|
|
259
|
+
span.end();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Run layout until convergence or max iterations
|
|
266
|
+
* @param {number} [maxIterations] - Maximum iterations (default from config)
|
|
267
|
+
* @returns {Promise<number>} Final displacement
|
|
268
|
+
*/
|
|
269
|
+
async run(maxIterations) {
|
|
270
|
+
return tracer.startActiveSpan('layout3d.run', async span => {
|
|
271
|
+
try {
|
|
272
|
+
const max = maxIterations || this.config.iterations;
|
|
273
|
+
let displacement = Infinity;
|
|
274
|
+
|
|
275
|
+
for (let i = 0; i < max && displacement > 0.1; i++) {
|
|
276
|
+
displacement = this.step();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
span.setAttributes({
|
|
280
|
+
'layout.iterations': this.iteration,
|
|
281
|
+
'layout.final_displacement': displacement,
|
|
282
|
+
'layout.converged': displacement <= 0.1,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return displacement;
|
|
286
|
+
} finally {
|
|
287
|
+
span.end();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Get all node positions
|
|
294
|
+
* @returns {Map<string, Object>} Map of node ID to position
|
|
295
|
+
*/
|
|
296
|
+
getPositions() {
|
|
297
|
+
const positions = new Map();
|
|
298
|
+
for (const [id, node] of this.nodes) {
|
|
299
|
+
positions.set(id, { ...node.position });
|
|
300
|
+
}
|
|
301
|
+
return positions;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Reset layout to initial state
|
|
306
|
+
* @returns {void}
|
|
307
|
+
*/
|
|
308
|
+
reset() {
|
|
309
|
+
this.iteration = 0;
|
|
310
|
+
this.temperature = 1.0;
|
|
311
|
+
|
|
312
|
+
for (const node of this.nodes.values()) {
|
|
313
|
+
if (!node.fixed) {
|
|
314
|
+
node.position.x = (Math.random() - 0.5) * 100;
|
|
315
|
+
node.position.y = (Math.random() - 0.5) * 100;
|
|
316
|
+
node.position.z = (Math.random() - 0.5) * 100;
|
|
317
|
+
node.velocity.x = 0;
|
|
318
|
+
node.velocity.y = 0;
|
|
319
|
+
node.velocity.z = 0;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|