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.
|
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,,
|