anywidget-vector 0.1.0__py3-none-any.whl

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,6 @@
1
+ """Interactive 3D vector visualization for Python notebooks."""
2
+
3
+ from anywidget_vector.widget import VectorSpace
4
+
5
+ __all__ = ["VectorSpace"]
6
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,978 @@
1
+ """Main VectorSpace widget using anywidget and Three.js."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import anywidget
8
+ import traitlets
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Callable
12
+
13
+ _ESM = """
14
+ import * as THREE from "https://esm.sh/three@0.160.0";
15
+ import { OrbitControls } from "https://esm.sh/three@0.160.0/addons/controls/OrbitControls.js";
16
+
17
+ // Color scales
18
+ const COLOR_SCALES = {
19
+ viridis: [[0.267,0.004,0.329],[0.282,0.140,0.458],[0.253,0.265,0.530],[0.206,0.371,0.553],[0.163,0.471,0.558],[0.127,0.566,0.551],[0.134,0.658,0.518],[0.267,0.749,0.441],[0.478,0.821,0.318],[0.741,0.873,0.150],[0.993,0.906,0.144]],
20
+ plasma: [[0.050,0.030,0.528],[0.254,0.014,0.615],[0.417,0.001,0.658],[0.578,0.015,0.643],[0.716,0.135,0.538],[0.826,0.268,0.407],[0.906,0.411,0.271],[0.959,0.567,0.137],[0.981,0.733,0.106],[0.964,0.903,0.259],[0.940,0.975,0.131]],
21
+ inferno: [[0.001,0.000,0.014],[0.046,0.031,0.186],[0.140,0.046,0.357],[0.258,0.039,0.406],[0.366,0.071,0.432],[0.478,0.107,0.429],[0.591,0.148,0.404],[0.706,0.206,0.347],[0.815,0.290,0.259],[0.905,0.411,0.145],[0.969,0.565,0.026]],
22
+ magma: [[0.001,0.000,0.014],[0.035,0.028,0.144],[0.114,0.049,0.315],[0.206,0.053,0.431],[0.306,0.064,0.505],[0.413,0.086,0.531],[0.529,0.113,0.527],[0.654,0.158,0.501],[0.776,0.232,0.459],[0.878,0.338,0.418],[0.953,0.468,0.392]],
23
+ cividis: [[0.000,0.135,0.304],[0.000,0.179,0.345],[0.117,0.222,0.360],[0.214,0.263,0.365],[0.293,0.304,0.370],[0.366,0.345,0.375],[0.437,0.387,0.382],[0.509,0.429,0.393],[0.582,0.473,0.409],[0.659,0.520,0.431],[0.739,0.570,0.461]],
24
+ turbo: [[0.190,0.072,0.232],[0.254,0.265,0.530],[0.163,0.471,0.558],[0.134,0.658,0.518],[0.478,0.821,0.318],[0.741,0.873,0.150],[0.993,0.906,0.144],[0.988,0.652,0.198],[0.925,0.394,0.235],[0.796,0.177,0.214],[0.480,0.016,0.110]],
25
+ };
26
+
27
+ const CATEGORICAL_COLORS = [
28
+ "#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6",
29
+ "#06b6d4", "#f97316", "#84cc16", "#ec4899", "#14b8a6"
30
+ ];
31
+
32
+ // Shape geometries factory
33
+ const SHAPES = {
34
+ sphere: () => new THREE.SphereGeometry(1, 16, 16),
35
+ cube: () => new THREE.BoxGeometry(1, 1, 1),
36
+ cone: () => new THREE.ConeGeometry(0.7, 1.4, 16),
37
+ tetrahedron: () => new THREE.TetrahedronGeometry(1),
38
+ octahedron: () => new THREE.OctahedronGeometry(1),
39
+ cylinder: () => new THREE.CylinderGeometry(0.5, 0.5, 1, 16),
40
+ };
41
+
42
+ function getColorFromScale(value, scaleName, domain) {
43
+ const scale = COLOR_SCALES[scaleName] || COLOR_SCALES.viridis;
44
+ const [min, max] = domain || [0, 1];
45
+ const t = Math.max(0, Math.min(1, (value - min) / (max - min)));
46
+ const idx = t * (scale.length - 1);
47
+ const i = Math.floor(idx);
48
+ const f = idx - i;
49
+ if (i >= scale.length - 1) {
50
+ const c = scale[scale.length - 1];
51
+ return new THREE.Color(c[0], c[1], c[2]);
52
+ }
53
+ const c1 = scale[i], c2 = scale[i + 1];
54
+ return new THREE.Color(
55
+ c1[0] + f * (c2[0] - c1[0]),
56
+ c1[1] + f * (c2[1] - c1[1]),
57
+ c1[2] + f * (c2[2] - c1[2])
58
+ );
59
+ }
60
+
61
+ function hashString(str) {
62
+ let hash = 0;
63
+ for (let i = 0; i < str.length; i++) {
64
+ hash = ((hash << 5) - hash) + str.charCodeAt(i);
65
+ hash |= 0;
66
+ }
67
+ return Math.abs(hash);
68
+ }
69
+
70
+ function getCategoricalColor(value) {
71
+ const idx = hashString(String(value)) % CATEGORICAL_COLORS.length;
72
+ return new THREE.Color(CATEGORICAL_COLORS[idx]);
73
+ }
74
+
75
+ function render({ model, el }) {
76
+ let scene, camera, renderer, controls;
77
+ let pointsGroup;
78
+ let raycaster, mouse;
79
+ let hoveredObject = null;
80
+ let tooltip;
81
+ let axesGroup, gridHelper;
82
+ let animationId;
83
+
84
+ init();
85
+ animate();
86
+
87
+ function init() {
88
+ // Container
89
+ const container = document.createElement("div");
90
+ container.className = "anywidget-vector";
91
+ container.style.width = model.get("width") + "px";
92
+ container.style.height = model.get("height") + "px";
93
+ container.style.position = "relative";
94
+ el.appendChild(container);
95
+
96
+ // Scene
97
+ scene = new THREE.Scene();
98
+ scene.background = new THREE.Color(model.get("background"));
99
+
100
+ // Camera
101
+ const aspect = model.get("width") / model.get("height");
102
+ camera = new THREE.PerspectiveCamera(60, aspect, 0.01, 1000);
103
+ const camPos = model.get("camera_position") || [2, 2, 2];
104
+ camera.position.set(camPos[0], camPos[1], camPos[2]);
105
+
106
+ // Renderer
107
+ renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
108
+ renderer.setSize(model.get("width"), model.get("height"));
109
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
110
+ container.appendChild(renderer.domElement);
111
+
112
+ // Controls
113
+ controls = new OrbitControls(camera, renderer.domElement);
114
+ controls.enableDamping = true;
115
+ controls.dampingFactor = 0.05;
116
+ const target = model.get("camera_target") || [0, 0, 0];
117
+ controls.target.set(target[0], target[1], target[2]);
118
+ controls.addEventListener("change", onCameraChange);
119
+
120
+ // Lighting
121
+ const ambient = new THREE.AmbientLight(0xffffff, 0.6);
122
+ scene.add(ambient);
123
+ const directional = new THREE.DirectionalLight(0xffffff, 0.8);
124
+ directional.position.set(5, 10, 7);
125
+ scene.add(directional);
126
+
127
+ // Groups
128
+ pointsGroup = new THREE.Group();
129
+ scene.add(pointsGroup);
130
+ axesGroup = new THREE.Group();
131
+ scene.add(axesGroup);
132
+
133
+ // Setup
134
+ setupAxesAndGrid();
135
+ setupRaycaster(container);
136
+ setupTooltip(container);
137
+ createPoints();
138
+ bindModelEvents();
139
+ }
140
+
141
+ function setupAxesAndGrid() {
142
+ // Clear existing
143
+ while (axesGroup.children.length > 0) {
144
+ axesGroup.remove(axesGroup.children[0]);
145
+ }
146
+ if (gridHelper) {
147
+ scene.remove(gridHelper);
148
+ gridHelper = null;
149
+ }
150
+
151
+ if (model.get("show_axes")) {
152
+ const axes = new THREE.AxesHelper(1.2);
153
+ axesGroup.add(axes);
154
+
155
+ // Axis labels
156
+ const labels = model.get("axis_labels") || { x: "X", y: "Y", z: "Z" };
157
+ addAxisLabel(labels.x, [1.3, 0, 0], 0xff4444);
158
+ addAxisLabel(labels.y, [0, 1.3, 0], 0x44ff44);
159
+ addAxisLabel(labels.z, [0, 0, 1.3], 0x4444ff);
160
+ }
161
+
162
+ if (model.get("show_grid")) {
163
+ gridHelper = new THREE.GridHelper(2, model.get("grid_divisions") || 10, 0x444444, 0x333333);
164
+ scene.add(gridHelper);
165
+ }
166
+ }
167
+
168
+ function addAxisLabel(text, position, color) {
169
+ const canvas = document.createElement("canvas");
170
+ const ctx = canvas.getContext("2d");
171
+ canvas.width = 64;
172
+ canvas.height = 32;
173
+ ctx.font = "bold 24px Arial";
174
+ ctx.fillStyle = "#" + color.toString(16).padStart(6, "0");
175
+ ctx.textAlign = "center";
176
+ ctx.fillText(text, 32, 24);
177
+
178
+ const texture = new THREE.CanvasTexture(canvas);
179
+ const material = new THREE.SpriteMaterial({ map: texture });
180
+ const sprite = new THREE.Sprite(material);
181
+ sprite.position.set(position[0], position[1], position[2]);
182
+ sprite.scale.set(0.25, 0.125, 1);
183
+ axesGroup.add(sprite);
184
+ }
185
+
186
+ function createPoints() {
187
+ // Clear existing
188
+ while (pointsGroup.children.length > 0) {
189
+ const obj = pointsGroup.children[0];
190
+ if (obj.geometry) obj.geometry.dispose();
191
+ if (obj.material) obj.material.dispose();
192
+ pointsGroup.remove(obj);
193
+ }
194
+
195
+ const points = model.get("points") || [];
196
+ if (points.length === 0) return;
197
+
198
+ const colorField = model.get("color_field");
199
+ const colorScale = model.get("color_scale") || "viridis";
200
+ const colorDomain = model.get("color_domain");
201
+ const sizeField = model.get("size_field");
202
+ const sizeRange = model.get("size_range") || [0.02, 0.1];
203
+ const shapeField = model.get("shape_field");
204
+ const shapeMap = model.get("shape_map") || {};
205
+
206
+ // Compute color domain if needed
207
+ let computedColorDomain = colorDomain;
208
+ if (colorField && !colorDomain) {
209
+ const values = points.map(p => p[colorField]).filter(v => typeof v === "number");
210
+ if (values.length > 0) {
211
+ computedColorDomain = [Math.min(...values), Math.max(...values)];
212
+ }
213
+ }
214
+
215
+ // Compute size domain if needed
216
+ let sizeDomain = null;
217
+ if (sizeField) {
218
+ const values = points.map(p => p[sizeField]).filter(v => typeof v === "number");
219
+ if (values.length > 0) {
220
+ sizeDomain = [Math.min(...values), Math.max(...values)];
221
+ }
222
+ }
223
+
224
+ // Group points by shape for instanced rendering
225
+ const useInstancing = model.get("use_instancing") && points.length > 100;
226
+
227
+ if (useInstancing) {
228
+ createInstancedPoints(points, {
229
+ colorField, colorScale, computedColorDomain,
230
+ sizeField, sizeRange, sizeDomain,
231
+ shapeField, shapeMap
232
+ });
233
+ } else {
234
+ createIndividualPoints(points, {
235
+ colorField, colorScale, computedColorDomain,
236
+ sizeField, sizeRange, sizeDomain,
237
+ shapeField, shapeMap
238
+ });
239
+ }
240
+ }
241
+
242
+ function getPointColor(point, colorField, colorScale, colorDomain) {
243
+ if (point.color) {
244
+ return new THREE.Color(point.color);
245
+ }
246
+ if (colorField && point[colorField] !== undefined) {
247
+ const value = point[colorField];
248
+ if (typeof value === "number") {
249
+ return getColorFromScale(value, colorScale, colorDomain);
250
+ }
251
+ return getCategoricalColor(value);
252
+ }
253
+ return new THREE.Color(0x6366f1);
254
+ }
255
+
256
+ function getPointSize(point, sizeField, sizeRange, sizeDomain) {
257
+ if (point.size !== undefined) {
258
+ return point.size;
259
+ }
260
+ if (sizeField && point[sizeField] !== undefined && sizeDomain) {
261
+ const value = point[sizeField];
262
+ const [min, max] = sizeDomain;
263
+ const t = max > min ? (value - min) / (max - min) : 0.5;
264
+ return sizeRange[0] + t * (sizeRange[1] - sizeRange[0]);
265
+ }
266
+ return sizeRange[0] + (sizeRange[1] - sizeRange[0]) * 0.5;
267
+ }
268
+
269
+ function getPointShape(point, shapeField, shapeMap) {
270
+ if (point.shape && SHAPES[point.shape]) {
271
+ return point.shape;
272
+ }
273
+ if (shapeField && point[shapeField] !== undefined) {
274
+ const value = String(point[shapeField]);
275
+ if (shapeMap[value] && SHAPES[shapeMap[value]]) {
276
+ return shapeMap[value];
277
+ }
278
+ // Default shape rotation for unmapped categories
279
+ const shapes = Object.keys(SHAPES);
280
+ return shapes[hashString(value) % shapes.length];
281
+ }
282
+ return "sphere";
283
+ }
284
+
285
+ function createIndividualPoints(points, opts) {
286
+ points.forEach((point, idx) => {
287
+ const shape = getPointShape(point, opts.shapeField, opts.shapeMap);
288
+ const geometry = SHAPES[shape]();
289
+ const color = getPointColor(point, opts.colorField, opts.colorScale, opts.computedColorDomain);
290
+ const material = new THREE.MeshPhongMaterial({ color });
291
+ const mesh = new THREE.Mesh(geometry, material);
292
+
293
+ const size = getPointSize(point, opts.sizeField, opts.sizeRange, opts.sizeDomain);
294
+ mesh.scale.set(size, size, size);
295
+ mesh.position.set(
296
+ point.x ?? 0,
297
+ point.y ?? 0,
298
+ point.z ?? 0
299
+ );
300
+
301
+ mesh.userData = { pointIndex: idx, pointId: point.id || `point_${idx}` };
302
+ pointsGroup.add(mesh);
303
+ });
304
+ }
305
+
306
+ function createInstancedPoints(points, opts) {
307
+ // Group by shape
308
+ const groups = {};
309
+ points.forEach((point, idx) => {
310
+ const shape = getPointShape(point, opts.shapeField, opts.shapeMap);
311
+ if (!groups[shape]) groups[shape] = [];
312
+ groups[shape].push({ point, idx });
313
+ });
314
+
315
+ for (const [shape, items] of Object.entries(groups)) {
316
+ const geometry = SHAPES[shape]();
317
+ const material = new THREE.MeshPhongMaterial({ vertexColors: false });
318
+ const instancedMesh = new THREE.InstancedMesh(geometry, material, items.length);
319
+
320
+ const matrix = new THREE.Matrix4();
321
+ const color = new THREE.Color();
322
+ const colors = new Float32Array(items.length * 3);
323
+
324
+ items.forEach(({ point, idx }, i) => {
325
+ const size = getPointSize(point, opts.sizeField, opts.sizeRange, opts.sizeDomain);
326
+ const pointColor = getPointColor(point, opts.colorField, opts.colorScale, opts.computedColorDomain);
327
+
328
+ matrix.identity();
329
+ matrix.makeScale(size, size, size);
330
+ matrix.setPosition(point.x ?? 0, point.y ?? 0, point.z ?? 0);
331
+ instancedMesh.setMatrixAt(i, matrix);
332
+
333
+ colors[i * 3] = pointColor.r;
334
+ colors[i * 3 + 1] = pointColor.g;
335
+ colors[i * 3 + 2] = pointColor.b;
336
+ });
337
+
338
+ // Store color per instance using custom attribute
339
+ geometry.setAttribute("color", new THREE.InstancedBufferAttribute(colors, 3));
340
+ material.vertexColors = true;
341
+
342
+ instancedMesh.instanceMatrix.needsUpdate = true;
343
+ instancedMesh.userData = {
344
+ isInstanced: true,
345
+ pointIndices: items.map(({ idx }) => idx),
346
+ pointIds: items.map(({ point, idx }) => point.id || `point_${idx}`)
347
+ };
348
+ pointsGroup.add(instancedMesh);
349
+ }
350
+ }
351
+
352
+ function setupRaycaster(container) {
353
+ raycaster = new THREE.Raycaster();
354
+ mouse = new THREE.Vector2();
355
+
356
+ container.addEventListener("mousemove", onMouseMove);
357
+ container.addEventListener("click", onClick);
358
+ }
359
+
360
+ function onMouseMove(event) {
361
+ const rect = event.target.getBoundingClientRect();
362
+ mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
363
+ mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
364
+
365
+ raycaster.setFromCamera(mouse, camera);
366
+ const intersects = raycaster.intersectObjects(pointsGroup.children, true);
367
+
368
+ if (intersects.length > 0) {
369
+ const hit = intersects[0];
370
+ const points = model.get("points") || [];
371
+ let pointIndex, pointId;
372
+
373
+ if (hit.object.userData.isInstanced) {
374
+ const instanceId = hit.instanceId;
375
+ pointIndex = hit.object.userData.pointIndices[instanceId];
376
+ pointId = hit.object.userData.pointIds[instanceId];
377
+ } else {
378
+ pointIndex = hit.object.userData.pointIndex;
379
+ pointId = hit.object.userData.pointId;
380
+ }
381
+
382
+ const point = points[pointIndex];
383
+ if (point && (!hoveredObject || hoveredObject.pointId !== pointId)) {
384
+ hoveredObject = { pointIndex, pointId };
385
+ model.set("hovered_point", point);
386
+ model.save_changes();
387
+ showTooltip(event, point);
388
+ }
389
+ } else if (hoveredObject) {
390
+ hoveredObject = null;
391
+ model.set("hovered_point", null);
392
+ model.save_changes();
393
+ hideTooltip();
394
+ }
395
+ }
396
+
397
+ function onClick(event) {
398
+ const rect = event.target.getBoundingClientRect();
399
+ mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
400
+ mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
401
+
402
+ raycaster.setFromCamera(mouse, camera);
403
+ const intersects = raycaster.intersectObjects(pointsGroup.children, true);
404
+
405
+ if (intersects.length > 0) {
406
+ const hit = intersects[0];
407
+ const points = model.get("points") || [];
408
+ let pointIndex, pointId;
409
+
410
+ if (hit.object.userData.isInstanced) {
411
+ const instanceId = hit.instanceId;
412
+ pointIndex = hit.object.userData.pointIndices[instanceId];
413
+ pointId = hit.object.userData.pointIds[instanceId];
414
+ } else {
415
+ pointIndex = hit.object.userData.pointIndex;
416
+ pointId = hit.object.userData.pointId;
417
+ }
418
+
419
+ const point = points[pointIndex];
420
+ const selectionMode = model.get("selection_mode") || "click";
421
+ const currentSelection = model.get("selected_points") || [];
422
+
423
+ if (selectionMode === "click") {
424
+ model.set("selected_points", [pointId]);
425
+ } else {
426
+ // Toggle in multi-select mode
427
+ if (currentSelection.includes(pointId)) {
428
+ model.set("selected_points", currentSelection.filter(id => id !== pointId));
429
+ } else {
430
+ model.set("selected_points", [...currentSelection, pointId]);
431
+ }
432
+ }
433
+ model.save_changes();
434
+ } else {
435
+ // Click on empty space - clear selection
436
+ model.set("selected_points", []);
437
+ model.save_changes();
438
+ }
439
+ }
440
+
441
+ function setupTooltip(container) {
442
+ tooltip = document.createElement("div");
443
+ tooltip.className = "anywidget-vector-tooltip";
444
+ tooltip.style.cssText = `
445
+ position: absolute;
446
+ display: none;
447
+ background: rgba(0, 0, 0, 0.85);
448
+ color: white;
449
+ padding: 8px 12px;
450
+ border-radius: 4px;
451
+ font-size: 12px;
452
+ pointer-events: none;
453
+ z-index: 1000;
454
+ max-width: 250px;
455
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
456
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
457
+ `;
458
+ container.appendChild(tooltip);
459
+ }
460
+
461
+ function showTooltip(event, point) {
462
+ if (!model.get("show_tooltip")) return;
463
+
464
+ const fields = model.get("tooltip_fields") || ["label", "x", "y", "z"];
465
+ let html = "";
466
+
467
+ if (point.label) {
468
+ html += `<div style="font-weight: 600; margin-bottom: 4px;">${point.label}</div>`;
469
+ }
470
+
471
+ const rows = fields
472
+ .filter(f => f !== "label" && point[f] !== undefined)
473
+ .map(f => {
474
+ let value = point[f];
475
+ if (typeof value === "number") {
476
+ value = value.toFixed(3);
477
+ }
478
+ return `<div style="display: flex; justify-content: space-between; gap: 12px;"><span style="color: #999;">${f}:</span><span>${value}</span></div>`;
479
+ });
480
+
481
+ html += rows.join("");
482
+ tooltip.innerHTML = html;
483
+ tooltip.style.display = "block";
484
+
485
+ const rect = event.target.getBoundingClientRect();
486
+ const x = event.clientX - rect.left + 15;
487
+ const y = event.clientY - rect.top + 15;
488
+ tooltip.style.left = x + "px";
489
+ tooltip.style.top = y + "px";
490
+ }
491
+
492
+ function hideTooltip() {
493
+ tooltip.style.display = "none";
494
+ }
495
+
496
+ function onCameraChange() {
497
+ model.set("camera_position", [camera.position.x, camera.position.y, camera.position.z]);
498
+ model.set("camera_target", [controls.target.x, controls.target.y, controls.target.z]);
499
+ model.save_changes();
500
+ }
501
+
502
+ function bindModelEvents() {
503
+ model.on("change:points", createPoints);
504
+ model.on("change:background", () => {
505
+ scene.background = new THREE.Color(model.get("background"));
506
+ });
507
+ model.on("change:show_axes", setupAxesAndGrid);
508
+ model.on("change:show_grid", setupAxesAndGrid);
509
+ model.on("change:color_field", createPoints);
510
+ model.on("change:color_scale", createPoints);
511
+ model.on("change:color_domain", createPoints);
512
+ model.on("change:size_field", createPoints);
513
+ model.on("change:size_range", createPoints);
514
+ model.on("change:shape_field", createPoints);
515
+ model.on("change:shape_map", createPoints);
516
+
517
+ model.on("change:camera_position", () => {
518
+ const pos = model.get("camera_position");
519
+ if (pos) camera.position.set(pos[0], pos[1], pos[2]);
520
+ });
521
+ model.on("change:camera_target", () => {
522
+ const target = model.get("camera_target");
523
+ if (target) controls.target.set(target[0], target[1], target[2]);
524
+ });
525
+ }
526
+
527
+ function animate() {
528
+ animationId = requestAnimationFrame(animate);
529
+ controls.update();
530
+ renderer.render(scene, camera);
531
+ }
532
+
533
+ function cleanup() {
534
+ cancelAnimationFrame(animationId);
535
+ controls.dispose();
536
+ renderer.dispose();
537
+ scene.traverse((obj) => {
538
+ if (obj.geometry) obj.geometry.dispose();
539
+ if (obj.material) {
540
+ if (Array.isArray(obj.material)) {
541
+ obj.material.forEach(m => m.dispose());
542
+ } else {
543
+ obj.material.dispose();
544
+ }
545
+ }
546
+ });
547
+ }
548
+
549
+ return cleanup;
550
+ }
551
+
552
+ export default { render };
553
+ """
554
+
555
+ _CSS = """
556
+ .anywidget-vector {
557
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
558
+ border-radius: 8px;
559
+ overflow: hidden;
560
+ }
561
+ .anywidget-vector canvas {
562
+ display: block;
563
+ }
564
+ """
565
+
566
+
567
+ class VectorSpace(anywidget.AnyWidget):
568
+ """Interactive 3D vector visualization widget using Three.js."""
569
+
570
+ _esm = _ESM
571
+ _css = _CSS
572
+
573
+ # Data
574
+ points = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
575
+
576
+ # Display
577
+ width = traitlets.Int(default_value=800).tag(sync=True)
578
+ height = traitlets.Int(default_value=600).tag(sync=True)
579
+ background = traitlets.Unicode(default_value="#1a1a2e").tag(sync=True)
580
+
581
+ # Axes and grid
582
+ show_axes = traitlets.Bool(default_value=True).tag(sync=True)
583
+ show_grid = traitlets.Bool(default_value=True).tag(sync=True)
584
+ axis_labels = traitlets.Dict(default_value={"x": "X", "y": "Y", "z": "Z"}).tag(sync=True)
585
+ grid_divisions = traitlets.Int(default_value=10).tag(sync=True)
586
+
587
+ # Color
588
+ color_field = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True)
589
+ color_scale = traitlets.Unicode(default_value="viridis").tag(sync=True)
590
+ color_domain = traitlets.List(default_value=None, allow_none=True).tag(sync=True)
591
+
592
+ # Size
593
+ size_field = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True)
594
+ size_range = traitlets.List(default_value=[0.02, 0.1]).tag(sync=True)
595
+
596
+ # Shape
597
+ shape_field = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True)
598
+ shape_map = traitlets.Dict(default_value={}).tag(sync=True)
599
+
600
+ # Camera
601
+ camera_position = traitlets.List(default_value=[2, 2, 2]).tag(sync=True)
602
+ camera_target = traitlets.List(default_value=[0, 0, 0]).tag(sync=True)
603
+
604
+ # Interaction
605
+ selected_points = traitlets.List(default_value=[]).tag(sync=True)
606
+ hovered_point = traitlets.Dict(default_value=None, allow_none=True).tag(sync=True)
607
+ selection_mode = traitlets.Unicode(default_value="click").tag(sync=True)
608
+
609
+ # Tooltip
610
+ show_tooltip = traitlets.Bool(default_value=True).tag(sync=True)
611
+ tooltip_fields = traitlets.List(default_value=["label", "x", "y", "z"]).tag(sync=True)
612
+
613
+ # Performance
614
+ use_instancing = traitlets.Bool(default_value=True).tag(sync=True)
615
+ point_budget = traitlets.Int(default_value=100000).tag(sync=True)
616
+
617
+ def __init__(
618
+ self,
619
+ points: list[dict[str, Any]] | None = None,
620
+ **kwargs: Any,
621
+ ) -> None:
622
+ super().__init__(points=points or [], **kwargs)
623
+ self._click_callbacks: list[Callable] = []
624
+ self._hover_callbacks: list[Callable] = []
625
+ self._selection_callbacks: list[Callable] = []
626
+
627
+ # === Factory Methods ===
628
+
629
+ @classmethod
630
+ def from_dict(cls, data: dict[str, Any] | list[dict[str, Any]], **kwargs: Any) -> VectorSpace:
631
+ """Create from dict with 'points' key or list of point dicts."""
632
+ if isinstance(data, dict) and "points" in data:
633
+ points = data["points"]
634
+ elif isinstance(data, list):
635
+ points = data
636
+ else:
637
+ points = [data]
638
+ return cls(points=_normalize_points(points), **kwargs)
639
+
640
+ @classmethod
641
+ def from_arrays(
642
+ cls,
643
+ positions: Any,
644
+ *,
645
+ ids: list[str] | None = None,
646
+ colors: Any = None,
647
+ sizes: Any = None,
648
+ labels: list[str] | None = None,
649
+ metadata: list[dict[str, Any]] | None = None,
650
+ **kwargs: Any,
651
+ ) -> VectorSpace:
652
+ """Create from arrays of positions and optional attributes."""
653
+ pos_list = _to_list(positions)
654
+ n = len(pos_list)
655
+
656
+ points = []
657
+ for i in range(n):
658
+ point: dict[str, Any] = {
659
+ "id": ids[i] if ids else f"point_{i}",
660
+ "x": float(pos_list[i][0]),
661
+ "y": float(pos_list[i][1]),
662
+ "z": float(pos_list[i][2]) if len(pos_list[i]) > 2 else 0.0,
663
+ }
664
+ if colors is not None:
665
+ color_list = _to_list(colors)
666
+ point["color"] = color_list[i] if i < len(color_list) else None
667
+ if sizes is not None:
668
+ size_list = _to_list(sizes)
669
+ point["size"] = float(size_list[i]) if i < len(size_list) else None
670
+ if labels is not None and i < len(labels):
671
+ point["label"] = labels[i]
672
+ if metadata is not None and i < len(metadata):
673
+ point.update(metadata[i])
674
+ points.append(point)
675
+
676
+ return cls(points=points, **kwargs)
677
+
678
+ @classmethod
679
+ def from_numpy(
680
+ cls,
681
+ arr: Any,
682
+ *,
683
+ labels: list[str] | None = None,
684
+ **kwargs: Any,
685
+ ) -> VectorSpace:
686
+ """Create from numpy array (N, 3) or (N, D)."""
687
+ arr_list = _to_list(arr)
688
+ return cls.from_arrays(arr_list, labels=labels, **kwargs)
689
+
690
+ @classmethod
691
+ def from_dataframe(
692
+ cls,
693
+ df: Any,
694
+ *,
695
+ x: str = "x",
696
+ y: str = "y",
697
+ z: str = "z",
698
+ id_col: str | None = None,
699
+ color_col: str | None = None,
700
+ size_col: str | None = None,
701
+ shape_col: str | None = None,
702
+ label_col: str | None = None,
703
+ include_cols: list[str] | None = None,
704
+ **kwargs: Any,
705
+ ) -> VectorSpace:
706
+ """Create from pandas DataFrame with column mapping."""
707
+ points = []
708
+ for i, row in enumerate(df.to_dict("records")):
709
+ point: dict[str, Any] = {
710
+ "id": str(row[id_col]) if id_col and id_col in row else f"point_{i}",
711
+ "x": float(row[x]) if x in row else 0.0,
712
+ "y": float(row[y]) if y in row else 0.0,
713
+ "z": float(row[z]) if z in row else 0.0,
714
+ }
715
+ if label_col and label_col in row:
716
+ point["label"] = str(row[label_col])
717
+ if color_col and color_col in row:
718
+ point[color_col] = row[color_col]
719
+ if size_col and size_col in row:
720
+ point[size_col] = row[size_col]
721
+ if shape_col and shape_col in row:
722
+ point[shape_col] = row[shape_col]
723
+ if include_cols:
724
+ for col in include_cols:
725
+ if col in row:
726
+ point[col] = row[col]
727
+ points.append(point)
728
+
729
+ # Auto-set field mappings
730
+ if color_col and "color_field" not in kwargs:
731
+ kwargs["color_field"] = color_col
732
+ if size_col and "size_field" not in kwargs:
733
+ kwargs["size_field"] = size_col
734
+ if shape_col and "shape_field" not in kwargs:
735
+ kwargs["shape_field"] = shape_col
736
+
737
+ return cls(points=points, **kwargs)
738
+
739
+ # === Vector DB Adapters ===
740
+
741
+ @classmethod
742
+ def from_qdrant(
743
+ cls,
744
+ client: Any,
745
+ collection: str,
746
+ *,
747
+ limit: int = 1000,
748
+ with_vectors: bool = True,
749
+ scroll_filter: Any = None,
750
+ **kwargs: Any,
751
+ ) -> VectorSpace:
752
+ """Create from Qdrant collection."""
753
+ records, _ = client.scroll(
754
+ collection_name=collection,
755
+ limit=limit,
756
+ with_vectors=with_vectors,
757
+ scroll_filter=scroll_filter,
758
+ )
759
+ points = []
760
+ for record in records:
761
+ vec = record.vector if hasattr(record, "vector") else None
762
+ point: dict[str, Any] = {"id": str(record.id)}
763
+ if vec and len(vec) >= 3:
764
+ point["x"], point["y"], point["z"] = float(vec[0]), float(vec[1]), float(vec[2])
765
+ if hasattr(record, "payload") and record.payload:
766
+ point.update(record.payload)
767
+ points.append(point)
768
+ return cls(points=points, **kwargs)
769
+
770
+ @classmethod
771
+ def from_chroma(
772
+ cls,
773
+ collection: Any,
774
+ *,
775
+ n_results: int = 1000,
776
+ where: dict[str, Any] | None = None,
777
+ include: list[str] | None = None,
778
+ **kwargs: Any,
779
+ ) -> VectorSpace:
780
+ """Create from ChromaDB collection."""
781
+ include = include or ["embeddings", "metadatas"]
782
+ result = collection.get(limit=n_results, where=where, include=include)
783
+ points = []
784
+ ids = result.get("ids", [])
785
+ embeddings = result.get("embeddings", [])
786
+ metadatas = result.get("metadatas", [])
787
+
788
+ for i, id_ in enumerate(ids):
789
+ point: dict[str, Any] = {"id": str(id_)}
790
+ if embeddings and i < len(embeddings) and embeddings[i]:
791
+ vec = embeddings[i]
792
+ if len(vec) >= 3:
793
+ point["x"], point["y"], point["z"] = float(vec[0]), float(vec[1]), float(vec[2])
794
+ if metadatas and i < len(metadatas) and metadatas[i]:
795
+ point.update(metadatas[i])
796
+ points.append(point)
797
+ return cls(points=points, **kwargs)
798
+
799
+ @classmethod
800
+ def from_lancedb(
801
+ cls,
802
+ table: Any,
803
+ *,
804
+ limit: int = 1000,
805
+ **kwargs: Any,
806
+ ) -> VectorSpace:
807
+ """Create from LanceDB table."""
808
+ df = table.to_pandas()
809
+ if len(df) > limit:
810
+ df = df.head(limit)
811
+ return cls.from_dataframe(df, **kwargs)
812
+
813
+ # === Dimensionality Reduction Adapters ===
814
+
815
+ @classmethod
816
+ def from_umap(
817
+ cls,
818
+ embedding: Any,
819
+ *,
820
+ labels: list[str] | None = None,
821
+ metadata: list[dict[str, Any]] | None = None,
822
+ **kwargs: Any,
823
+ ) -> VectorSpace:
824
+ """Create from UMAP embedding (N, 3)."""
825
+ return cls.from_arrays(embedding, labels=labels, metadata=metadata, **kwargs)
826
+
827
+ @classmethod
828
+ def from_tsne(
829
+ cls,
830
+ embedding: Any,
831
+ *,
832
+ labels: list[str] | None = None,
833
+ metadata: list[dict[str, Any]] | None = None,
834
+ **kwargs: Any,
835
+ ) -> VectorSpace:
836
+ """Create from t-SNE embedding (N, 3)."""
837
+ return cls.from_arrays(embedding, labels=labels, metadata=metadata, **kwargs)
838
+
839
+ @classmethod
840
+ def from_pca(
841
+ cls,
842
+ embedding: Any,
843
+ *,
844
+ labels: list[str] | None = None,
845
+ explained_variance: list[float] | None = None,
846
+ **kwargs: Any,
847
+ ) -> VectorSpace:
848
+ """Create from PCA embedding (N, 3) with optional variance info."""
849
+ if explained_variance and len(explained_variance) >= 3:
850
+ kwargs.setdefault(
851
+ "axis_labels",
852
+ {
853
+ "x": f"PC1 ({explained_variance[0]:.1%})",
854
+ "y": f"PC2 ({explained_variance[1]:.1%})",
855
+ "z": f"PC3 ({explained_variance[2]:.1%})",
856
+ },
857
+ )
858
+ return cls.from_arrays(embedding, labels=labels, **kwargs)
859
+
860
+ # === Event Callbacks ===
861
+
862
+ def on_click(self, callback: Callable[[str, dict[str, Any]], None]) -> Callable:
863
+ """Register callback for point click: callback(point_id, point_data)."""
864
+ self._click_callbacks.append(callback)
865
+
866
+ def observer(change: dict[str, Any]) -> None:
867
+ selected = change["new"]
868
+ if selected and len(selected) > 0:
869
+ point_id = selected[-1] if isinstance(selected, list) else selected
870
+ point_data = next((p for p in self.points if p.get("id") == point_id), {})
871
+ for cb in self._click_callbacks:
872
+ cb(point_id, point_data)
873
+
874
+ self.observe(observer, names=["selected_points"])
875
+ return callback
876
+
877
+ def on_hover(self, callback: Callable[[str | None, dict[str, Any] | None], None]) -> Callable:
878
+ """Register callback for hover: callback(point_id, point_data)."""
879
+ self._hover_callbacks.append(callback)
880
+
881
+ def observer(change: dict[str, Any]) -> None:
882
+ point = change["new"]
883
+ if point:
884
+ for cb in self._hover_callbacks:
885
+ cb(point.get("id"), point)
886
+ else:
887
+ for cb in self._hover_callbacks:
888
+ cb(None, None)
889
+
890
+ self.observe(observer, names=["hovered_point"])
891
+ return callback
892
+
893
+ def on_selection(self, callback: Callable[[list[str], list[dict[str, Any]]], None]) -> Callable:
894
+ """Register callback for selection changes: callback(point_ids, points_data)."""
895
+ self._selection_callbacks.append(callback)
896
+
897
+ def observer(change: dict[str, Any]) -> None:
898
+ point_ids = change["new"] or []
899
+ point_data = [p for p in self.points if p.get("id") in point_ids]
900
+ for cb in self._selection_callbacks:
901
+ cb(point_ids, point_data)
902
+
903
+ self.observe(observer, names=["selected_points"])
904
+ return callback
905
+
906
+ # === Camera Control ===
907
+
908
+ def reset_camera(self) -> None:
909
+ """Reset camera to default position."""
910
+ self.camera_position = [2, 2, 2]
911
+ self.camera_target = [0, 0, 0]
912
+
913
+ def focus_on(self, point_ids: list[str]) -> None:
914
+ """Focus camera on specific points."""
915
+ if not point_ids:
916
+ return
917
+ matching = [p for p in self.points if p.get("id") in point_ids]
918
+ if not matching:
919
+ return
920
+ cx = sum(p.get("x", 0) for p in matching) / len(matching)
921
+ cy = sum(p.get("y", 0) for p in matching) / len(matching)
922
+ cz = sum(p.get("z", 0) for p in matching) / len(matching)
923
+ self.camera_target = [cx, cy, cz]
924
+ self.camera_position = [cx + 1.5, cy + 1.5, cz + 1.5]
925
+
926
+ # === Selection ===
927
+
928
+ def select(self, point_ids: list[str]) -> None:
929
+ """Programmatically select points."""
930
+ self.selected_points = point_ids
931
+
932
+ def clear_selection(self) -> None:
933
+ """Clear all selections."""
934
+ self.selected_points = []
935
+
936
+ # === Export ===
937
+
938
+ def to_json(self) -> str:
939
+ """Export points data as JSON."""
940
+ import json
941
+
942
+ return json.dumps(self.points)
943
+
944
+
945
+ # === Helper Functions ===
946
+
947
+
948
+ def _normalize_points(data: list[Any]) -> list[dict[str, Any]]:
949
+ """Normalize various point formats to standard dict format."""
950
+ return [_normalize_point(p, i) for i, p in enumerate(data)]
951
+
952
+
953
+ def _normalize_point(point: Any, index: int) -> dict[str, Any]:
954
+ """Convert a single point to standard format."""
955
+ if isinstance(point, dict):
956
+ return _ensure_point_id(point, index)
957
+ if hasattr(point, "__iter__") and hasattr(point, "__len__") and len(point) >= 2:
958
+ return {
959
+ "id": f"point_{index}",
960
+ "x": float(point[0]),
961
+ "y": float(point[1]),
962
+ "z": float(point[2]) if len(point) > 2 else 0.0,
963
+ }
964
+ raise ValueError(f"Cannot normalize point: {point}")
965
+
966
+
967
+ def _ensure_point_id(point: dict[str, Any], index: int) -> dict[str, Any]:
968
+ """Ensure point has an ID."""
969
+ if "id" not in point:
970
+ point = {**point, "id": f"point_{index}"}
971
+ return point
972
+
973
+
974
+ def _to_list(obj: Any) -> list[Any]:
975
+ """Convert numpy arrays or other iterables to lists."""
976
+ if hasattr(obj, "tolist"):
977
+ return obj.tolist()
978
+ return list(obj)
@@ -0,0 +1,292 @@
1
+ Metadata-Version: 2.4
2
+ Name: anywidget-vector
3
+ Version: 0.1.0
4
+ Summary: Interactive vector visualization for Python notebooks using anywidget
5
+ Project-URL: Homepage, https://grafeo.dev/
6
+ Project-URL: Repository, https://github.com/GrafeoDB/anywidget-vector
7
+ Author-email: "S.T. Grond" <widget@grafeo.dev>
8
+ License: Apache-2.0
9
+ Keywords: anywidget,jupyter,marimo,vector,visualization
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Jupyter
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Scientific/Engineering :: Visualization
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: anywidget>=0.9.21
22
+ Provides-Extra: all
23
+ Requires-Dist: chromadb>=0.4; extra == 'all'
24
+ Requires-Dist: numpy>=1.24; extra == 'all'
25
+ Requires-Dist: pandas>=2.0; extra == 'all'
26
+ Requires-Dist: qdrant-client>=1.0; extra == 'all'
27
+ Provides-Extra: chroma
28
+ Requires-Dist: chromadb>=0.4; extra == 'chroma'
29
+ Provides-Extra: dev
30
+ Requires-Dist: marimo>=0.19.7; extra == 'dev'
31
+ Requires-Dist: prek>=0.3.1; extra == 'dev'
32
+ Requires-Dist: pytest>=9.0.2; extra == 'dev'
33
+ Requires-Dist: ruff>=0.14.14; extra == 'dev'
34
+ Requires-Dist: ty>=0.0.14; extra == 'dev'
35
+ Provides-Extra: lancedb
36
+ Requires-Dist: lancedb>=0.1; extra == 'lancedb'
37
+ Provides-Extra: numpy
38
+ Requires-Dist: numpy>=1.24; extra == 'numpy'
39
+ Provides-Extra: pandas
40
+ Requires-Dist: pandas>=2.0; extra == 'pandas'
41
+ Provides-Extra: pinecone
42
+ Requires-Dist: pinecone-client>=3.0; extra == 'pinecone'
43
+ Provides-Extra: qdrant
44
+ Requires-Dist: qdrant-client>=1.0; extra == 'qdrant'
45
+ Provides-Extra: weaviate
46
+ Requires-Dist: weaviate-client>=4.0; extra == 'weaviate'
47
+ Description-Content-Type: text/markdown
48
+
49
+ # anywidget-vector
50
+
51
+ Interactive 3D vector visualization for Python notebooks.
52
+
53
+ Works with Marimo, Jupyter, VS Code, Colab, anywhere [anywidget](https://anywidget.dev/) runs.
54
+
55
+ ## Features
56
+
57
+ - **Universal** — One widget, every notebook environment
58
+ - **6D Visualization** — X, Y, Z position + Color, Shape, Size encoding
59
+ - **Backend-agnostic** — NumPy, pandas, Qdrant, Chroma, or raw dicts
60
+ - **Interactive** — Orbit, pan, zoom, click, hover, select
61
+ - **Customizable** — Color scales, shapes, sizes, themes
62
+ - **Performant** — Instanced rendering for large point clouds
63
+
64
+ ## Installation
65
+
66
+ ```bash
67
+ uv add anywidget-vector
68
+ ```
69
+
70
+ ## Quick Start
71
+
72
+ ```python
73
+ from anywidget_vector import VectorSpace
74
+
75
+ widget = VectorSpace(points=[
76
+ {"id": "a", "x": 0.5, "y": 0.3, "z": 0.8, "label": "Point A", "cluster": 0},
77
+ {"id": "b", "x": -0.2, "y": 0.7, "z": 0.1, "label": "Point B", "cluster": 1},
78
+ {"id": "c", "x": 0.1, "y": -0.4, "z": 0.6, "label": "Point C", "cluster": 0},
79
+ ])
80
+
81
+ widget
82
+ ```
83
+
84
+ ## Data Sources
85
+
86
+ ### Dictionary
87
+
88
+ ```python
89
+ from anywidget_vector import VectorSpace
90
+
91
+ widget = VectorSpace.from_dict({
92
+ "points": [
93
+ {"id": "a", "x": 0, "y": 0, "z": 0},
94
+ {"id": "b", "x": 1, "y": 1, "z": 1},
95
+ ]
96
+ })
97
+ ```
98
+
99
+ ### NumPy Arrays
100
+
101
+ ```python
102
+ import numpy as np
103
+ from anywidget_vector import VectorSpace
104
+
105
+ positions = np.random.randn(100, 3)
106
+ widget = VectorSpace.from_numpy(positions)
107
+ ```
108
+
109
+ ### pandas DataFrame
110
+
111
+ ```python
112
+ import pandas as pd
113
+ from anywidget_vector import VectorSpace
114
+
115
+ df = pd.DataFrame({
116
+ "x": [0.1, 0.5, 0.9],
117
+ "y": [0.2, 0.6, 0.3],
118
+ "z": [0.3, 0.1, 0.7],
119
+ "cluster": ["A", "B", "A"],
120
+ "size": [0.5, 1.0, 0.8],
121
+ })
122
+
123
+ widget = VectorSpace.from_dataframe(
124
+ df,
125
+ color_col="cluster",
126
+ size_col="size",
127
+ )
128
+ ```
129
+
130
+ ### UMAP / t-SNE / PCA
131
+
132
+ ```python
133
+ import umap
134
+ from anywidget_vector import VectorSpace
135
+
136
+ # Reduce high-dimensional data to 3D
137
+ embedding = umap.UMAP(n_components=3).fit_transform(high_dim_data)
138
+ widget = VectorSpace.from_umap(embedding, labels=labels)
139
+ ```
140
+
141
+ ### Qdrant
142
+
143
+ ```python
144
+ from qdrant_client import QdrantClient
145
+ from anywidget_vector import VectorSpace
146
+
147
+ client = QdrantClient("localhost", port=6333)
148
+ widget = VectorSpace.from_qdrant(client, "my_collection", limit=5000)
149
+ ```
150
+
151
+ ### ChromaDB
152
+
153
+ ```python
154
+ import chromadb
155
+ from anywidget_vector import VectorSpace
156
+
157
+ client = chromadb.Client()
158
+ collection = client.get_collection("embeddings")
159
+ widget = VectorSpace.from_chroma(collection)
160
+ ```
161
+
162
+ ## Visual Encoding
163
+
164
+ ### 6 Dimensions
165
+
166
+ | Dimension | Visual Channel | Example |
167
+ |-----------|---------------|---------|
168
+ | X | Horizontal position | `x` coordinate |
169
+ | Y | Vertical position | `y` coordinate |
170
+ | Z | Depth position | `z` coordinate |
171
+ | Color | Hue/gradient | Cluster, score |
172
+ | Shape | Geometry | Category, type |
173
+ | Size | Scale | Importance, count |
174
+
175
+ ### Color Scales
176
+
177
+ ```python
178
+ widget = VectorSpace(
179
+ points=data,
180
+ color_field="score", # Field to map
181
+ color_scale="viridis", # Scale: viridis, plasma, inferno, magma, cividis, turbo
182
+ color_domain=[0, 100], # Optional: explicit range
183
+ )
184
+ ```
185
+
186
+ ### Shapes
187
+
188
+ ```python
189
+ widget = VectorSpace(
190
+ points=data,
191
+ shape_field="category",
192
+ shape_map={
193
+ "type_a": "sphere", # Available: sphere, cube, cone,
194
+ "type_b": "cube", # tetrahedron, octahedron, cylinder
195
+ "type_c": "cone",
196
+ }
197
+ )
198
+ ```
199
+
200
+ ### Size
201
+
202
+ ```python
203
+ widget = VectorSpace(
204
+ points=data,
205
+ size_field="importance",
206
+ size_range=[0.02, 0.15], # Min/max point size
207
+ )
208
+ ```
209
+
210
+ ## Interactivity
211
+
212
+ ### Events
213
+
214
+ ```python
215
+ widget = VectorSpace(points=data)
216
+
217
+ @widget.on_click
218
+ def handle_click(point_id, point_data):
219
+ print(f"Clicked: {point_id}")
220
+ print(f"Data: {point_data}")
221
+
222
+ @widget.on_hover
223
+ def handle_hover(point_id, point_data):
224
+ if point_id:
225
+ print(f"Hovering: {point_id}")
226
+
227
+ @widget.on_selection
228
+ def handle_selection(point_ids, points_data):
229
+ print(f"Selected {len(point_ids)} points")
230
+ ```
231
+
232
+ ### Selection
233
+
234
+ ```python
235
+ widget.selected_points # Get current selection
236
+ widget.select(["a", "b"]) # Select points
237
+ widget.clear_selection() # Clear
238
+ ```
239
+
240
+ ### Camera
241
+
242
+ ```python
243
+ widget.camera_position # Get position [x, y, z]
244
+ widget.camera_target # Get target [x, y, z]
245
+ widget.reset_camera() # Reset to default
246
+ widget.focus_on(["a", "b"]) # Focus on specific points
247
+ ```
248
+
249
+ ## Options
250
+
251
+ ```python
252
+ widget = VectorSpace(
253
+ points=data,
254
+ width=1000,
255
+ height=700,
256
+ background="#1a1a2e", # Dark theme default
257
+ show_axes=True,
258
+ show_grid=True,
259
+ axis_labels={"x": "PC1", "y": "PC2", "z": "PC3"},
260
+ show_tooltip=True,
261
+ tooltip_fields=["label", "x", "y", "z", "cluster"],
262
+ selection_mode="click", # "click" or "multi"
263
+ use_instancing=True, # Performance: instanced rendering
264
+ )
265
+ ```
266
+
267
+ ## Export
268
+
269
+ ```python
270
+ widget.to_json() # Export points as JSON string
271
+ ```
272
+
273
+ ## Environment Support
274
+
275
+ | Environment | Supported |
276
+ |-------------|-----------|
277
+ | Marimo | ✅ |
278
+ | JupyterLab | ✅ |
279
+ | Jupyter Notebook | ✅ |
280
+ | VS Code | ✅ |
281
+ | Google Colab | ✅ |
282
+ | Databricks | ✅ |
283
+
284
+ ## Related
285
+
286
+ - [anywidget](https://anywidget.dev/) — Custom Jupyter widgets made easy
287
+ - [anywidget-graph](https://github.com/GrafeoDB/anywidget-graph) — Graph visualization widget
288
+ - [Three.js](https://threejs.org/) — 3D JavaScript library
289
+
290
+ ## License
291
+
292
+ Apache-2.0
@@ -0,0 +1,6 @@
1
+ anywidget_vector/__init__.py,sha256=UA1icM7Y50eVfCf8yambftuG8bxLMIjON68IVJDO4x8,162
2
+ anywidget_vector/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ anywidget_vector/widget.py,sha256=60AP_awiX6MUaYPIBRvfFBdFQsyqPIDjronMShQDIH0,34021
4
+ anywidget_vector-0.1.0.dist-info/METADATA,sha256=kVS4eGG1n0PCXNItyjPdKRTh0QpwWowXrfMv8ML7dWM,7333
5
+ anywidget_vector-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ anywidget_vector-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any