@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.
@@ -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
+ }