anywidget-vector 0.1.0__py3-none-any.whl → 0.2.1__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.
- anywidget_vector/__init__.py +1 -1
- anywidget_vector/backends/__init__.py +103 -0
- anywidget_vector/backends/chroma/__init__.py +27 -0
- anywidget_vector/backends/chroma/client.py +60 -0
- anywidget_vector/backends/chroma/converter.py +86 -0
- anywidget_vector/backends/grafeo/__init__.py +20 -0
- anywidget_vector/backends/grafeo/client.py +33 -0
- anywidget_vector/backends/grafeo/converter.py +46 -0
- anywidget_vector/backends/lancedb/__init__.py +22 -0
- anywidget_vector/backends/lancedb/client.py +56 -0
- anywidget_vector/backends/lancedb/converter.py +71 -0
- anywidget_vector/backends/pinecone/__init__.py +21 -0
- anywidget_vector/backends/pinecone/client.js +45 -0
- anywidget_vector/backends/pinecone/converter.py +62 -0
- anywidget_vector/backends/qdrant/__init__.py +26 -0
- anywidget_vector/backends/qdrant/client.js +61 -0
- anywidget_vector/backends/qdrant/converter.py +83 -0
- anywidget_vector/backends/weaviate/__init__.py +33 -0
- anywidget_vector/backends/weaviate/client.js +50 -0
- anywidget_vector/backends/weaviate/converter.py +81 -0
- anywidget_vector/static/icons.js +14 -0
- anywidget_vector/traitlets.py +84 -0
- anywidget_vector/ui/__init__.py +206 -0
- anywidget_vector/ui/canvas.js +521 -0
- anywidget_vector/ui/constants.js +64 -0
- anywidget_vector/ui/properties.js +158 -0
- anywidget_vector/ui/settings.js +265 -0
- anywidget_vector/ui/styles.css +348 -0
- anywidget_vector/ui/toolbar.js +117 -0
- anywidget_vector/widget.py +187 -850
- {anywidget_vector-0.1.0.dist-info → anywidget_vector-0.2.1.dist-info}/METADATA +70 -3
- anywidget_vector-0.2.1.dist-info/RECORD +34 -0
- anywidget_vector-0.1.0.dist-info/RECORD +0 -6
- {anywidget_vector-0.1.0.dist-info → anywidget_vector-0.2.1.dist-info}/WHEEL +0 -0
anywidget_vector/widget.py
CHANGED
|
@@ -2,627 +2,156 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
5
7
|
from typing import TYPE_CHECKING, Any
|
|
6
8
|
|
|
7
9
|
import anywidget
|
|
8
10
|
import traitlets
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
"""
|
|
12
|
+
from anywidget_vector.backends import is_python_backend
|
|
13
|
+
from anywidget_vector.backends.chroma.client import execute_query as chroma_query
|
|
14
|
+
from anywidget_vector.backends.grafeo.client import execute_query as grafeo_query
|
|
15
|
+
from anywidget_vector.backends.lancedb.client import execute_query as lancedb_query
|
|
16
|
+
from anywidget_vector.ui import get_css, get_esm
|
|
554
17
|
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
"""
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
pass
|
|
565
20
|
|
|
566
21
|
|
|
567
22
|
class VectorSpace(anywidget.AnyWidget):
|
|
568
|
-
"""Interactive 3D vector visualization widget using Three.js.
|
|
23
|
+
"""Interactive 3D vector visualization widget using Three.js.
|
|
24
|
+
|
|
25
|
+
Supports multiple vector database backends with native query formats:
|
|
26
|
+
- Qdrant, Pinecone, Weaviate (browser-side REST)
|
|
27
|
+
- Chroma, LanceDB, Grafeo (Python-side)
|
|
28
|
+
"""
|
|
569
29
|
|
|
570
|
-
_esm =
|
|
571
|
-
_css =
|
|
30
|
+
_esm = get_esm()
|
|
31
|
+
_css = get_css()
|
|
572
32
|
|
|
573
|
-
# Data
|
|
33
|
+
# === Data ===
|
|
574
34
|
points = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
|
|
575
35
|
|
|
576
|
-
# Display
|
|
36
|
+
# === Display ===
|
|
577
37
|
width = traitlets.Int(default_value=800).tag(sync=True)
|
|
578
38
|
height = traitlets.Int(default_value=600).tag(sync=True)
|
|
579
39
|
background = traitlets.Unicode(default_value="#1a1a2e").tag(sync=True)
|
|
580
40
|
|
|
581
|
-
# Axes and
|
|
41
|
+
# === Axes and Grid ===
|
|
582
42
|
show_axes = traitlets.Bool(default_value=True).tag(sync=True)
|
|
583
43
|
show_grid = traitlets.Bool(default_value=True).tag(sync=True)
|
|
584
44
|
axis_labels = traitlets.Dict(default_value={"x": "X", "y": "Y", "z": "Z"}).tag(sync=True)
|
|
585
45
|
grid_divisions = traitlets.Int(default_value=10).tag(sync=True)
|
|
586
46
|
|
|
587
|
-
# Color
|
|
47
|
+
# === Color Mapping ===
|
|
588
48
|
color_field = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True)
|
|
589
49
|
color_scale = traitlets.Unicode(default_value="viridis").tag(sync=True)
|
|
590
50
|
color_domain = traitlets.List(default_value=None, allow_none=True).tag(sync=True)
|
|
591
51
|
|
|
592
|
-
# Size
|
|
52
|
+
# === Size Mapping ===
|
|
593
53
|
size_field = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True)
|
|
594
54
|
size_range = traitlets.List(default_value=[0.02, 0.1]).tag(sync=True)
|
|
595
55
|
|
|
596
|
-
# Shape
|
|
56
|
+
# === Shape Mapping ===
|
|
597
57
|
shape_field = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True)
|
|
598
58
|
shape_map = traitlets.Dict(default_value={}).tag(sync=True)
|
|
599
59
|
|
|
600
|
-
# Camera
|
|
60
|
+
# === Camera ===
|
|
601
61
|
camera_position = traitlets.List(default_value=[2, 2, 2]).tag(sync=True)
|
|
602
62
|
camera_target = traitlets.List(default_value=[0, 0, 0]).tag(sync=True)
|
|
603
63
|
|
|
604
|
-
# Interaction
|
|
64
|
+
# === Interaction ===
|
|
605
65
|
selected_points = traitlets.List(default_value=[]).tag(sync=True)
|
|
606
66
|
hovered_point = traitlets.Dict(default_value=None, allow_none=True).tag(sync=True)
|
|
607
67
|
selection_mode = traitlets.Unicode(default_value="click").tag(sync=True)
|
|
608
68
|
|
|
609
|
-
# Tooltip
|
|
69
|
+
# === Tooltip ===
|
|
610
70
|
show_tooltip = traitlets.Bool(default_value=True).tag(sync=True)
|
|
611
71
|
tooltip_fields = traitlets.List(default_value=["label", "x", "y", "z"]).tag(sync=True)
|
|
612
72
|
|
|
613
|
-
# Performance
|
|
73
|
+
# === Performance ===
|
|
614
74
|
use_instancing = traitlets.Bool(default_value=True).tag(sync=True)
|
|
615
75
|
point_budget = traitlets.Int(default_value=100000).tag(sync=True)
|
|
616
76
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
77
|
+
# === Distance and Connections ===
|
|
78
|
+
distance_metric = traitlets.Unicode(default_value="euclidean").tag(sync=True)
|
|
79
|
+
show_connections = traitlets.Bool(default_value=False).tag(sync=True)
|
|
80
|
+
k_neighbors = traitlets.Int(default_value=0).tag(sync=True)
|
|
81
|
+
distance_threshold = traitlets.Float(default_value=None, allow_none=True).tag(sync=True)
|
|
82
|
+
reference_point = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True)
|
|
83
|
+
connection_color = traitlets.Unicode(default_value="#ffffff").tag(sync=True)
|
|
84
|
+
connection_opacity = traitlets.Float(default_value=0.3).tag(sync=True)
|
|
85
|
+
|
|
86
|
+
# === UI ===
|
|
87
|
+
show_toolbar = traitlets.Bool(default_value=False).tag(sync=True)
|
|
88
|
+
show_settings = traitlets.Bool(default_value=False).tag(sync=True)
|
|
89
|
+
show_properties = traitlets.Bool(default_value=False).tag(sync=True)
|
|
90
|
+
|
|
91
|
+
# === Backend ===
|
|
92
|
+
backend = traitlets.Unicode(default_value="qdrant").tag(sync=True)
|
|
93
|
+
backend_config = traitlets.Dict(default_value={}).tag(sync=True)
|
|
94
|
+
connection_status = traitlets.Unicode(default_value="disconnected").tag(sync=True)
|
|
95
|
+
|
|
96
|
+
# === Query (native format per backend) ===
|
|
97
|
+
query_input = traitlets.Unicode(default_value="").tag(sync=True)
|
|
98
|
+
query_error = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True)
|
|
99
|
+
_execute_query = traitlets.Int(default_value=0).tag(sync=True)
|
|
100
|
+
|
|
101
|
+
def __init__(self, points: list[dict[str, Any]] | None = None, **kwargs: Any) -> None:
|
|
622
102
|
super().__init__(points=points or [], **kwargs)
|
|
623
|
-
self.
|
|
624
|
-
self.
|
|
625
|
-
|
|
103
|
+
self._backend_client: Any = None
|
|
104
|
+
self.observe(self._on_execute_query, names=["_execute_query"])
|
|
105
|
+
|
|
106
|
+
# === Backend Configuration ===
|
|
107
|
+
|
|
108
|
+
def set_backend(self, backend: str, client: Any = None, **config: Any) -> VectorSpace:
|
|
109
|
+
"""Configure backend for querying.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
backend: Backend name (qdrant, pinecone, weaviate, chroma, lancedb, grafeo)
|
|
113
|
+
client: Client object for Python-side backends
|
|
114
|
+
**config: Connection config (url, apiKey, collection, etc.)
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Self for chaining
|
|
118
|
+
"""
|
|
119
|
+
self.backend = backend
|
|
120
|
+
self._backend_client = client
|
|
121
|
+
self.backend_config = config
|
|
122
|
+
self.show_toolbar = True
|
|
123
|
+
self.show_settings = True
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def _on_execute_query(self, change: dict[str, Any]) -> None:
|
|
127
|
+
"""Handle query execution for Python-side backends."""
|
|
128
|
+
if change["new"] == 0 or not is_python_backend(self.backend):
|
|
129
|
+
return
|
|
130
|
+
try:
|
|
131
|
+
self.connection_status = "connecting"
|
|
132
|
+
results = self._execute_python_query()
|
|
133
|
+
if results:
|
|
134
|
+
self.points = results
|
|
135
|
+
self.connection_status = "connected"
|
|
136
|
+
except Exception as e:
|
|
137
|
+
self.query_error = str(e)
|
|
138
|
+
self.connection_status = "error"
|
|
139
|
+
|
|
140
|
+
def _execute_python_query(self) -> list[dict[str, Any]]:
|
|
141
|
+
"""Execute query using Python-side backend."""
|
|
142
|
+
if not self._backend_client:
|
|
143
|
+
raise ValueError("Backend not configured. Call set_backend() first.")
|
|
144
|
+
|
|
145
|
+
query = self.query_input
|
|
146
|
+
|
|
147
|
+
if self.backend == "chroma":
|
|
148
|
+
return chroma_query(self._backend_client, query)
|
|
149
|
+
elif self.backend == "lancedb":
|
|
150
|
+
return lancedb_query(self._backend_client, query)
|
|
151
|
+
elif self.backend == "grafeo":
|
|
152
|
+
return grafeo_query(self._backend_client, query)
|
|
153
|
+
|
|
154
|
+
raise ValueError(f"Unknown Python backend: {self.backend}")
|
|
626
155
|
|
|
627
156
|
# === Factory Methods ===
|
|
628
157
|
|
|
@@ -643,302 +172,115 @@ class VectorSpace(anywidget.AnyWidget):
|
|
|
643
172
|
positions: Any,
|
|
644
173
|
*,
|
|
645
174
|
ids: list[str] | None = None,
|
|
646
|
-
colors: Any = None,
|
|
647
|
-
sizes: Any = None,
|
|
648
175
|
labels: list[str] | None = None,
|
|
649
|
-
metadata: list[dict[str, Any]] | None = None,
|
|
650
176
|
**kwargs: Any,
|
|
651
177
|
) -> VectorSpace:
|
|
652
|
-
"""Create from arrays of positions
|
|
178
|
+
"""Create from arrays of positions."""
|
|
653
179
|
pos_list = _to_list(positions)
|
|
654
|
-
n = len(pos_list)
|
|
655
|
-
|
|
656
180
|
points = []
|
|
657
|
-
for i in
|
|
658
|
-
point
|
|
181
|
+
for i, p in enumerate(pos_list):
|
|
182
|
+
point = {
|
|
659
183
|
"id": ids[i] if ids else f"point_{i}",
|
|
660
|
-
"x": float(
|
|
661
|
-
"y": float(
|
|
662
|
-
"z": float(
|
|
184
|
+
"x": float(p[0]),
|
|
185
|
+
"y": float(p[1]),
|
|
186
|
+
"z": float(p[2]) if len(p) > 2 else 0.0,
|
|
663
187
|
}
|
|
664
|
-
if
|
|
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):
|
|
188
|
+
if labels:
|
|
671
189
|
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
190
|
points.append(point)
|
|
768
191
|
return cls(points=points, **kwargs)
|
|
769
192
|
|
|
770
193
|
@classmethod
|
|
771
|
-
def
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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)
|
|
194
|
+
def from_dataframe(cls, df: Any, *, x: str = "x", y: str = "y", z: str = "z", **kwargs: Any) -> VectorSpace:
|
|
195
|
+
"""Create from pandas DataFrame."""
|
|
196
|
+
points = [
|
|
197
|
+
{"id": f"point_{i}", "x": float(row[x]), "y": float(row[y]), "z": float(row.get(z, 0)), **row}
|
|
198
|
+
for i, row in enumerate(df.to_dict("records"))
|
|
199
|
+
]
|
|
797
200
|
return cls(points=points, **kwargs)
|
|
798
201
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
""
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
202
|
+
# === Distance Methods ===
|
|
203
|
+
|
|
204
|
+
def compute_distances(self, reference_id: str, metric: str | None = None) -> dict[str, float]:
|
|
205
|
+
"""Compute distances from reference point to all others."""
|
|
206
|
+
metric = metric or self.distance_metric
|
|
207
|
+
ref = next((p for p in self.points if p.get("id") == reference_id), None)
|
|
208
|
+
if not ref:
|
|
209
|
+
return {}
|
|
210
|
+
return {p.get("id"): self._distance(ref, p, metric) for p in self.points if p.get("id") != reference_id}
|
|
211
|
+
|
|
212
|
+
def find_neighbors(
|
|
213
|
+
self, reference_id: str, k: int | None = None, threshold: float | None = None
|
|
214
|
+
) -> list[tuple[str, float]]:
|
|
215
|
+
"""Find nearest neighbors of a reference point."""
|
|
216
|
+
distances = sorted(self.compute_distances(reference_id).items(), key=lambda x: x[1])
|
|
217
|
+
if threshold is not None:
|
|
218
|
+
return [(pid, d) for pid, d in distances if d <= threshold]
|
|
219
|
+
return distances[:k] if k else distances
|
|
220
|
+
|
|
221
|
+
def color_by_distance(self, reference_id: str) -> None:
|
|
222
|
+
"""Color points by distance from reference."""
|
|
223
|
+
distances = self.compute_distances(reference_id)
|
|
224
|
+
self.points = [{**p, "_distance": distances.get(p.get("id"), 0)} for p in self.points]
|
|
225
|
+
self.color_field = "_distance"
|
|
226
|
+
self.reference_point = reference_id
|
|
227
|
+
|
|
228
|
+
def show_neighbors(self, reference_id: str, k: int | None = None, threshold: float | None = None) -> None:
|
|
229
|
+
"""Show connections to nearest neighbors."""
|
|
230
|
+
self.reference_point = reference_id
|
|
231
|
+
self.show_connections = True
|
|
232
|
+
if k:
|
|
233
|
+
self.k_neighbors = k
|
|
234
|
+
if threshold:
|
|
235
|
+
self.distance_threshold = threshold
|
|
236
|
+
|
|
237
|
+
def _distance(self, p1: dict, p2: dict, metric: str) -> float:
|
|
238
|
+
"""Compute distance between two points."""
|
|
239
|
+
x1, y1, z1 = p1.get("x", 0), p1.get("y", 0), p1.get("z", 0)
|
|
240
|
+
x2, y2, z2 = p2.get("x", 0), p2.get("y", 0), p2.get("z", 0)
|
|
241
|
+
if metric == "euclidean":
|
|
242
|
+
return math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2 + (z1 - z2) ** 2)
|
|
243
|
+
elif metric == "cosine":
|
|
244
|
+
dot = x1 * x2 + y1 * y2 + z1 * z2
|
|
245
|
+
m1, m2 = math.sqrt(x1 * x1 + y1 * y1 + z1 * z1), math.sqrt(x2 * x2 + y2 * y2 + z2 * z2)
|
|
246
|
+
return 1 - (dot / (m1 * m2)) if m1 and m2 else 1
|
|
247
|
+
elif metric == "manhattan":
|
|
248
|
+
return abs(x1 - x2) + abs(y1 - y2) + abs(z1 - z2)
|
|
249
|
+
return math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2 + (z1 - z2) ** 2)
|
|
250
|
+
|
|
251
|
+
# === Camera ===
|
|
812
252
|
|
|
813
|
-
# ===
|
|
253
|
+
# === Selection ===
|
|
814
254
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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)
|
|
255
|
+
def select(self, point_ids: list[str]) -> None:
|
|
256
|
+
"""Programmatically select points by ID."""
|
|
257
|
+
self.selected_points = point_ids
|
|
826
258
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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)
|
|
259
|
+
def clear_selection(self) -> None:
|
|
260
|
+
"""Clear all selected points."""
|
|
261
|
+
self.selected_points = []
|
|
838
262
|
|
|
839
|
-
|
|
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 ===
|
|
263
|
+
# === Camera ===
|
|
907
264
|
|
|
908
265
|
def reset_camera(self) -> None:
|
|
909
|
-
"""Reset camera to default
|
|
266
|
+
"""Reset camera to default."""
|
|
910
267
|
self.camera_position = [2, 2, 2]
|
|
911
268
|
self.camera_target = [0, 0, 0]
|
|
912
269
|
|
|
913
270
|
def focus_on(self, point_ids: list[str]) -> None:
|
|
914
271
|
"""Focus camera on specific points."""
|
|
915
|
-
if
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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 = []
|
|
272
|
+
pts = [p for p in self.points if p.get("id") in point_ids]
|
|
273
|
+
if pts:
|
|
274
|
+
cx = sum(p.get("x", 0) for p in pts) / len(pts)
|
|
275
|
+
cy = sum(p.get("y", 0) for p in pts) / len(pts)
|
|
276
|
+
cz = sum(p.get("z", 0) for p in pts) / len(pts)
|
|
277
|
+
self.camera_target = [cx, cy, cz]
|
|
278
|
+
self.camera_position = [cx + 1.5, cy + 1.5, cz + 1.5]
|
|
935
279
|
|
|
936
280
|
# === Export ===
|
|
937
281
|
|
|
938
282
|
def to_json(self) -> str:
|
|
939
|
-
"""Export points
|
|
940
|
-
import json
|
|
941
|
-
|
|
283
|
+
"""Export points as JSON."""
|
|
942
284
|
return json.dumps(self.points)
|
|
943
285
|
|
|
944
286
|
|
|
@@ -953,7 +295,9 @@ def _normalize_points(data: list[Any]) -> list[dict[str, Any]]:
|
|
|
953
295
|
def _normalize_point(point: Any, index: int) -> dict[str, Any]:
|
|
954
296
|
"""Convert a single point to standard format."""
|
|
955
297
|
if isinstance(point, dict):
|
|
956
|
-
|
|
298
|
+
if "id" not in point:
|
|
299
|
+
point = {**point, "id": f"point_{index}"}
|
|
300
|
+
return point
|
|
957
301
|
if hasattr(point, "__iter__") and hasattr(point, "__len__") and len(point) >= 2:
|
|
958
302
|
return {
|
|
959
303
|
"id": f"point_{index}",
|
|
@@ -964,13 +308,6 @@ def _normalize_point(point: Any, index: int) -> dict[str, Any]:
|
|
|
964
308
|
raise ValueError(f"Cannot normalize point: {point}")
|
|
965
309
|
|
|
966
310
|
|
|
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
311
|
def _to_list(obj: Any) -> list[Any]:
|
|
975
312
|
"""Convert numpy arrays or other iterables to lists."""
|
|
976
313
|
if hasattr(obj, "tolist"):
|