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.
Files changed (34) hide show
  1. anywidget_vector/__init__.py +1 -1
  2. anywidget_vector/backends/__init__.py +103 -0
  3. anywidget_vector/backends/chroma/__init__.py +27 -0
  4. anywidget_vector/backends/chroma/client.py +60 -0
  5. anywidget_vector/backends/chroma/converter.py +86 -0
  6. anywidget_vector/backends/grafeo/__init__.py +20 -0
  7. anywidget_vector/backends/grafeo/client.py +33 -0
  8. anywidget_vector/backends/grafeo/converter.py +46 -0
  9. anywidget_vector/backends/lancedb/__init__.py +22 -0
  10. anywidget_vector/backends/lancedb/client.py +56 -0
  11. anywidget_vector/backends/lancedb/converter.py +71 -0
  12. anywidget_vector/backends/pinecone/__init__.py +21 -0
  13. anywidget_vector/backends/pinecone/client.js +45 -0
  14. anywidget_vector/backends/pinecone/converter.py +62 -0
  15. anywidget_vector/backends/qdrant/__init__.py +26 -0
  16. anywidget_vector/backends/qdrant/client.js +61 -0
  17. anywidget_vector/backends/qdrant/converter.py +83 -0
  18. anywidget_vector/backends/weaviate/__init__.py +33 -0
  19. anywidget_vector/backends/weaviate/client.js +50 -0
  20. anywidget_vector/backends/weaviate/converter.py +81 -0
  21. anywidget_vector/static/icons.js +14 -0
  22. anywidget_vector/traitlets.py +84 -0
  23. anywidget_vector/ui/__init__.py +206 -0
  24. anywidget_vector/ui/canvas.js +521 -0
  25. anywidget_vector/ui/constants.js +64 -0
  26. anywidget_vector/ui/properties.js +158 -0
  27. anywidget_vector/ui/settings.js +265 -0
  28. anywidget_vector/ui/styles.css +348 -0
  29. anywidget_vector/ui/toolbar.js +117 -0
  30. anywidget_vector/widget.py +187 -850
  31. {anywidget_vector-0.1.0.dist-info → anywidget_vector-0.2.1.dist-info}/METADATA +70 -3
  32. anywidget_vector-0.2.1.dist-info/RECORD +34 -0
  33. anywidget_vector-0.1.0.dist-info/RECORD +0 -6
  34. {anywidget_vector-0.1.0.dist-info → anywidget_vector-0.2.1.dist-info}/WHEEL +0 -0
@@ -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
- 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
- """
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
- _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
- """
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 = _ESM
571
- _css = _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 grid
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
- def __init__(
618
- self,
619
- points: list[dict[str, Any]] | None = None,
620
- **kwargs: Any,
621
- ) -> None:
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._click_callbacks: list[Callable] = []
624
- self._hover_callbacks: list[Callable] = []
625
- self._selection_callbacks: list[Callable] = []
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 and optional attributes."""
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 range(n):
658
- point: dict[str, Any] = {
181
+ for i, p in enumerate(pos_list):
182
+ point = {
659
183
  "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,
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 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):
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 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)
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
- @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)
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
- # === Dimensionality Reduction Adapters ===
253
+ # === Selection ===
814
254
 
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)
255
+ def select(self, point_ids: list[str]) -> None:
256
+ """Programmatically select points by ID."""
257
+ self.selected_points = point_ids
826
258
 
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)
259
+ def clear_selection(self) -> None:
260
+ """Clear all selected points."""
261
+ self.selected_points = []
838
262
 
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 ===
263
+ # === Camera ===
907
264
 
908
265
  def reset_camera(self) -> None:
909
- """Reset camera to default position."""
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 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 = []
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 data as JSON."""
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
- return _ensure_point_id(point, index)
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"):