anywidget-vector 0.2.4__tar.gz → 0.2.6__tar.gz

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 (50) hide show
  1. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/PKG-INFO +6 -1
  2. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/README.md +2 -0
  3. anywidget_vector-0.2.6/assets/demo.png +0 -0
  4. anywidget_vector-0.2.6/examples/test_add_numpy.py +98 -0
  5. anywidget_vector-0.2.6/examples/test_projection.py +98 -0
  6. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/pyproject.toml +2 -1
  7. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/__init__.py +1 -1
  8. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/ui/__init__.py +1 -1
  9. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/ui/canvas.js +37 -18
  10. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/ui/styles.css +14 -32
  11. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/ui/toolbar.js +11 -0
  12. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/widget.py +186 -0
  13. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/uv.lock +218 -12
  14. anywidget_vector-0.2.4/src/anywidget_vector/traitlets.py +0 -84
  15. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/.github/workflows/ci.yml +0 -0
  16. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/.github/workflows/pypi.yml +0 -0
  17. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/.gitignore +0 -0
  18. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/.python-version +0 -0
  19. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/LICENSE +0 -0
  20. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/examples/demo.py +0 -0
  21. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/__init__.py +0 -0
  22. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/chroma/__init__.py +0 -0
  23. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/chroma/client.py +0 -0
  24. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/chroma/converter.py +0 -0
  25. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/grafeo/__init__.py +0 -0
  26. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/grafeo/browser.js +0 -0
  27. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/grafeo/client.py +0 -0
  28. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/grafeo/converter.py +0 -0
  29. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/grafeo/embed.js +0 -0
  30. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/lancedb/__init__.py +0 -0
  31. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/lancedb/client.py +0 -0
  32. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/lancedb/converter.py +0 -0
  33. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/pinecone/__init__.py +0 -0
  34. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/pinecone/client.js +0 -0
  35. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/pinecone/converter.py +0 -0
  36. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/qdrant/__init__.py +0 -0
  37. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/qdrant/client.js +0 -0
  38. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/qdrant/converter.py +0 -0
  39. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/weaviate/__init__.py +0 -0
  40. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/weaviate/client.js +0 -0
  41. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/backends/weaviate/converter.py +0 -0
  42. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/demo.py +0 -0
  43. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/py.typed +0 -0
  44. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/static/icons.js +0 -0
  45. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/ui/constants.js +0 -0
  46. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/ui/properties.js +0 -0
  47. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/ui/settings.js +0 -0
  48. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/src/anywidget_vector/ui/sidebar.js +0 -0
  49. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/tests/__init__.py +0 -0
  50. {anywidget_vector-0.2.4 → anywidget_vector-0.2.6}/tests/test_widget.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anywidget-vector
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Interactive 3D vector visualization with query UI for vector databases
5
5
  Project-URL: Homepage, https://grafeo.dev/
6
6
  Project-URL: Repository, https://github.com/GrafeoDB/anywidget-vector
@@ -48,6 +48,9 @@ Provides-Extra: pandas
48
48
  Requires-Dist: pandas>=2.0; extra == 'pandas'
49
49
  Provides-Extra: pinecone
50
50
  Requires-Dist: pinecone-client>=3.0; extra == 'pinecone'
51
+ Provides-Extra: projection
52
+ Requires-Dist: scikit-learn>=1.3; extra == 'projection'
53
+ Requires-Dist: umap-learn>=0.5; extra == 'projection'
51
54
  Provides-Extra: qdrant
52
55
  Requires-Dist: qdrant-client>=1.0; extra == 'qdrant'
53
56
  Provides-Extra: weaviate
@@ -60,6 +63,8 @@ Interactive 3D vector visualization for Python notebooks.
60
63
 
61
64
  Works with Marimo, Jupyter, VS Code, Colab, anywhere [anywidget](https://anywidget.dev/) runs.
62
65
 
66
+ ![anywidget-vector demo](assets/demo.png)
67
+
63
68
  ## Features
64
69
 
65
70
  - **Universal**: One widget, every notebook environment
@@ -4,6 +4,8 @@ Interactive 3D vector visualization for Python notebooks.
4
4
 
5
5
  Works with Marimo, Jupyter, VS Code, Colab, anywhere [anywidget](https://anywidget.dev/) runs.
6
6
 
7
+ ![anywidget-vector demo](assets/demo.png)
8
+
7
9
  ## Features
8
10
 
9
11
  - **Universal**: One widget, every notebook environment
Binary file
@@ -0,0 +1,98 @@
1
+ # /// script
2
+ # requires-python = ">=3.12"
3
+ # dependencies = [
4
+ # "anywidget-vector==0.2.5",
5
+ # "marimo",
6
+ # "numpy==2.4.3",
7
+ # ]
8
+ # ///
9
+
10
+ import marimo
11
+
12
+ __generated_with = "0.18.4"
13
+ app = marimo.App(width="medium")
14
+
15
+
16
+ @app.cell
17
+ def _():
18
+ import marimo as mo
19
+
20
+ return (mo,)
21
+
22
+
23
+ @app.cell
24
+ def _():
25
+ import numpy as np
26
+
27
+ from anywidget_vector import VectorSpace
28
+
29
+ return VectorSpace, np
30
+
31
+
32
+ @app.cell(hide_code=True)
33
+ def _(mo):
34
+ mo.md("""
35
+ # Test: `add_numpy`
36
+ """)
37
+ return
38
+
39
+
40
+ @app.cell
41
+ def _(VectorSpace):
42
+ # Start with an empty widget
43
+ w = VectorSpace(
44
+ width=900,
45
+ height=500,
46
+ background="#0f0f1a",
47
+ color_field="group",
48
+ )
49
+ w
50
+ return (w,)
51
+
52
+
53
+ @app.cell
54
+ def _(mo, np, w):
55
+ # Add two numpy clusters
56
+ rng = np.random.default_rng(42)
57
+
58
+ cluster_a = rng.normal(loc=[0.3, 0.3, 0.3], scale=0.1, size=(50, 3))
59
+ cluster_b = rng.normal(loc=[0.7, 0.7, 0.7], scale=0.1, size=(50, 3))
60
+
61
+ w.add_numpy(
62
+ cluster_a,
63
+ labels=[f"A-{i}" for i in range(len(cluster_a))],
64
+ metadata={"group": ["alpha"] * len(cluster_a)},
65
+ ).add_numpy(
66
+ cluster_b,
67
+ labels=[f"B-{i}" for i in range(len(cluster_b))],
68
+ metadata={"group": ["beta"] * len(cluster_b)},
69
+ )
70
+
71
+ mo.md(f"Added **{len(w.points)}** points total")
72
+ return
73
+
74
+
75
+ @app.cell
76
+ def _(mo, np, w):
77
+ # Add a third cluster with 2D data (z defaults to 0)
78
+ rng2 = np.random.default_rng(99)
79
+ flat = rng2.normal(loc=[0.5, 0.5], scale=0.08, size=(30, 2))
80
+
81
+ w.add_numpy(flat, metadata={"group": ["flat"] * len(flat)})
82
+
83
+ mo.md(f"Now **{len(w.points)}** points (added 30 flat 2D points)")
84
+ return
85
+
86
+
87
+ @app.cell
88
+ def _(mo, w):
89
+ # Verify point IDs are unique
90
+ ids = [p["id"] for p in w.points]
91
+ unique = len(set(ids))
92
+ status = "All IDs unique" if unique == len(ids) else f"DUPLICATE IDS: {len(ids) - unique} collisions"
93
+ mo.md(f"Points: {len(ids)}, Unique IDs: {unique}: **{status}**")
94
+ return
95
+
96
+
97
+ if __name__ == "__main__":
98
+ app.run()
@@ -0,0 +1,98 @@
1
+ # /// script
2
+ # requires-python = ">=3.12"
3
+ # dependencies = [
4
+ # "anywidget-vector",
5
+ # "marimo",
6
+ # "numpy",
7
+ # "scikit-learn",
8
+ # ]
9
+ # ///
10
+
11
+ import marimo
12
+
13
+ __generated_with = "0.20.0"
14
+ app = marimo.App(width="medium")
15
+
16
+
17
+ @app.cell
18
+ def _():
19
+ import marimo as mo
20
+
21
+ return (mo,)
22
+
23
+
24
+ @app.cell
25
+ def _():
26
+ import numpy as np
27
+
28
+ from anywidget_vector import VectorSpace
29
+
30
+ return VectorSpace, np
31
+
32
+
33
+ @app.cell(hide_code=True)
34
+ def _(mo):
35
+ mo.md("""# Interactive Projection Demo
36
+
37
+ High-dimensional data projected to 3D. Switch projections live with the dropdown.
38
+ """)
39
+ return
40
+
41
+
42
+ @app.cell
43
+ def _(np):
44
+ # Generate 50-dimensional clustered data
45
+ rng = np.random.default_rng(42)
46
+
47
+ def make_cluster(center, n=80, noise=0.3):
48
+ return rng.normal(loc=center, scale=noise, size=(n, len(center)))
49
+
50
+ n_dims = 50
51
+ centers = [rng.uniform(-2, 2, size=n_dims) for _ in range(5)]
52
+ cluster_names = ["alpha", "beta", "gamma", "delta", "epsilon"]
53
+
54
+ vectors = np.vstack([make_cluster(c) for c in centers])
55
+ groups = [name for name, c in zip(cluster_names, centers, strict=True) for _ in range(80)]
56
+ labels = [f"{g}-{i}" for i, g in enumerate(groups)]
57
+
58
+ f"Generated {len(vectors)} points in {n_dims}D across 5 clusters"
59
+ return vectors, groups, labels
60
+
61
+
62
+ @app.cell
63
+ def _(mo):
64
+ method = mo.ui.dropdown(
65
+ options=["pca", "tsne"],
66
+ value="pca",
67
+ label="Projection method",
68
+ )
69
+ method
70
+ return (method,)
71
+
72
+
73
+ @app.cell
74
+ def _(VectorSpace, groups, labels, method, vectors):
75
+ w = VectorSpace(
76
+ width=900,
77
+ height=500,
78
+ background="#0f0f1a",
79
+ color_field="group",
80
+ )
81
+ w.add_numpy(
82
+ vectors,
83
+ labels=labels,
84
+ metadata={"group": groups},
85
+ )
86
+ w.project(method.value)
87
+ w
88
+ return (w,)
89
+
90
+
91
+ @app.cell
92
+ def _(mo, method, w):
93
+ mo.md(f"Showing **{method.value.upper()}** projection of {len(w.points)} points")
94
+ return
95
+
96
+
97
+ if __name__ == "__main__":
98
+ app.run()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "anywidget-vector"
3
- version = "0.2.4"
3
+ version = "0.2.6"
4
4
  description = "Interactive 3D vector visualization with query UI for vector databases"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -40,6 +40,7 @@ pinecone = ["pinecone-client>=3.0"]
40
40
  weaviate = ["weaviate-client>=4.0"]
41
41
  lancedb = ["lancedb>=0.1"]
42
42
  grafeo = ["grafeo>=0.5"]
43
+ projection = ["scikit-learn>=1.3", "umap-learn>=0.5"]
43
44
  embed = [] # Marker extra — WASM loads from CDN on demand
44
45
  demo = [] # Marker extra — enables demo mode with pre-populated dataset
45
46
  all = [
@@ -3,4 +3,4 @@
3
3
  from anywidget_vector.widget import VectorSpace
4
4
 
5
5
  __all__ = ["VectorSpace"]
6
- __version__ = "0.2.3"
6
+ __version__ = "0.2.5"
@@ -123,6 +123,7 @@ function render({{ model, el }}) {{
123
123
  const wrapper = document.createElement("div");
124
124
  wrapper.className = "avs-wrapper";
125
125
  wrapper.style.width = model.get("width") + "px";
126
+ wrapper.style.height = model.get("height") + "px";
126
127
  el.appendChild(wrapper);
127
128
 
128
129
  let sidebar = null;
@@ -154,7 +155,6 @@ function render({{ model, el }}) {{
154
155
 
155
156
  const main = document.createElement("div");
156
157
  main.className = "avs-main";
157
- main.style.height = model.get("height") + "px";
158
158
  wrapper.appendChild(main);
159
159
 
160
160
  // Sidebar (left, explorer panel)
@@ -50,15 +50,19 @@ export function createCanvas(model, container, callbacks) {
50
50
  scene = new THREE.Scene();
51
51
  scene.background = new THREE.Color(model.get("background"));
52
52
 
53
+ // Use actual container dimensions when available, fall back to model values
54
+ const initW = container.clientWidth || model.get("width");
55
+ const initH = container.clientHeight || model.get("height");
56
+
53
57
  // Camera
54
- const aspect = model.get("width") / model.get("height");
58
+ const aspect = initW / initH;
55
59
  camera = new THREE.PerspectiveCamera(60, aspect, 0.01, 1000);
56
60
  const camPos = model.get("camera_position") || [2, 2, 2];
57
61
  camera.position.set(camPos[0], camPos[1], camPos[2]);
58
62
 
59
63
  // Renderer
60
64
  renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
61
- renderer.setSize(model.get("width"), model.get("height"));
65
+ renderer.setSize(initW, initH);
62
66
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
63
67
  container.appendChild(renderer.domElement);
64
68
 
@@ -100,14 +104,32 @@ export function createCanvas(model, container, callbacks) {
100
104
  setupRaycaster();
101
105
  setupTooltip();
102
106
  setupZoomControls();
103
- setupModeControls();
104
107
  setupBoxSelection();
108
+ setupKeyboardShortcuts();
105
109
  createPoints();
106
110
  createConnections();
107
111
  fitToView();
108
112
  bindModelEvents();
109
113
  }
110
114
 
115
+ function setupKeyboardShortcuts() {
116
+ container.tabIndex = 0;
117
+ container.style.outline = "none";
118
+ container.addEventListener("keydown", (e) => {
119
+ const tag = e.target.tagName;
120
+ if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
121
+
122
+ if (e.key === "f" || e.key === "F") {
123
+ e.preventDefault();
124
+ fitToView();
125
+ } else if (e.key === "Escape") {
126
+ model.set("selected_points", []);
127
+ model.set("hovered_point", null);
128
+ model.save_changes();
129
+ }
130
+ });
131
+ }
132
+
111
133
  function setupAxesAndGrid() {
112
134
  while (axesGroup.children.length > 0) axesGroup.remove(axesGroup.children[0]);
113
135
  if (gridHelper) { scene.remove(gridHelper); gridHelper = null; }
@@ -471,27 +493,19 @@ export function createCanvas(model, container, callbacks) {
471
493
  zoomFitBtn.title = "Fit to view";
472
494
  zoomFitBtn.addEventListener("click", fitToView);
473
495
 
474
- zoomControls.appendChild(zoomInBtn);
475
- zoomControls.appendChild(zoomOutBtn);
476
- zoomControls.appendChild(zoomFitBtn);
477
- container.appendChild(zoomControls);
478
- }
479
-
480
- function setupModeControls() {
496
+ // Mode switcher (above zoom buttons, inside same column)
481
497
  const modeGroup = document.createElement("div");
482
498
  modeGroup.className = "avs-mode-group";
483
499
 
484
500
  const clickBtn = document.createElement("button");
485
- clickBtn.className = "avs-mode-btn avs-mode-active";
501
+ clickBtn.className = "avs-zoom-btn avs-mode-btn avs-mode-active";
486
502
  clickBtn.innerHTML = ICONS.cursor;
487
503
  clickBtn.title = "Click / multi-select mode";
488
- clickBtn.dataset.mode = "click";
489
504
 
490
505
  const boxBtn = document.createElement("button");
491
- boxBtn.className = "avs-mode-btn";
506
+ boxBtn.className = "avs-zoom-btn avs-mode-btn";
492
507
  boxBtn.innerHTML = ICONS.boxSelect;
493
508
  boxBtn.title = "Box select mode";
494
- boxBtn.dataset.mode = "box";
495
509
 
496
510
  function setActiveMode(mode) {
497
511
  currentMode = mode;
@@ -513,15 +527,20 @@ export function createCanvas(model, container, callbacks) {
513
527
  clickBtn.addEventListener("click", () => setActiveMode("click"));
514
528
  boxBtn.addEventListener("click", () => setActiveMode("box"));
515
529
 
516
- modeGroup.appendChild(clickBtn);
517
- modeGroup.appendChild(boxBtn);
518
- container.appendChild(modeGroup);
519
-
520
530
  // Sync with model changes from Python side
521
531
  model.on("change:selection_mode", () => {
522
532
  const mode = model.get("selection_mode") || "click";
523
533
  if (mode !== currentMode) setActiveMode(mode);
524
534
  });
535
+
536
+ modeGroup.appendChild(clickBtn);
537
+ modeGroup.appendChild(boxBtn);
538
+
539
+ zoomControls.appendChild(modeGroup);
540
+ zoomControls.appendChild(zoomInBtn);
541
+ zoomControls.appendChild(zoomOutBtn);
542
+ zoomControls.appendChild(zoomFitBtn);
543
+ container.appendChild(zoomControls);
525
544
  }
526
545
 
527
546
  function setupBoxSelection() {
@@ -47,7 +47,7 @@
47
47
  display: flex;
48
48
  flex-direction: column;
49
49
  gap: 2px;
50
- z-index: 10;
50
+ z-index: 25;
51
51
  }
52
52
 
53
53
  .avs-zoom-btn {
@@ -87,37 +87,11 @@
87
87
 
88
88
  /* === Mode Switcher === */
89
89
  .avs-mode-group {
90
- position: absolute;
91
- bottom: 12px;
92
- left: 12px;
93
90
  display: flex;
94
91
  gap: 2px;
95
- z-index: 10;
96
- }
97
-
98
- .avs-mode-btn {
99
- width: 32px;
100
- height: 32px;
101
- border: 1px solid var(--avs-border);
102
- background: var(--avs-bg);
103
- color: var(--avs-text);
104
- cursor: pointer;
105
- display: flex;
106
- align-items: center;
107
- justify-content: center;
108
- border-radius: 4px;
109
- transition: all 0.15s;
110
- padding: 0;
111
- }
112
-
113
- .avs-mode-btn:hover {
114
- background: var(--avs-surface);
115
- border-color: var(--avs-text-muted);
116
- }
117
-
118
- .avs-mode-btn svg {
119
- width: 16px;
120
- height: 16px;
92
+ margin-bottom: 4px;
93
+ border-bottom: 1px solid rgba(255,255,255,0.15);
94
+ padding-bottom: 6px;
121
95
  }
122
96
 
123
97
  .avs-mode-btn.avs-mode-active {
@@ -159,6 +133,14 @@
159
133
  color: var(--avs-text-muted);
160
134
  }
161
135
 
136
+ .avs-count-badge {
137
+ font-size: 11px;
138
+ color: var(--avs-text-muted);
139
+ white-space: nowrap;
140
+ flex-shrink: 0;
141
+ font-variant-numeric: tabular-nums;
142
+ }
143
+
162
144
  .avs-filter-count {
163
145
  font-size: 11px;
164
146
  font-weight: 600;
@@ -291,11 +273,11 @@
291
273
  width: 280px;
292
274
  }
293
275
 
294
- .avs-panel-left {
276
+ .avs-panel-left.avs-open {
295
277
  border-right: 1px solid var(--avs-border);
296
278
  }
297
279
 
298
- .avs-panel-right {
280
+ .avs-panel-right.avs-open {
299
281
  border-left: 1px solid var(--avs-border);
300
282
  }
301
283
 
@@ -39,6 +39,17 @@ export function createToolbar(model, callbacks) {
39
39
  filterCount.style.display = "none";
40
40
  toolbar.appendChild(filterCount);
41
41
 
42
+ // Point count badge
43
+ const countBadge = document.createElement("span");
44
+ countBadge.className = "avs-count-badge";
45
+ function updatePointCount() {
46
+ const pts = model.get("points") || [];
47
+ countBadge.textContent = `${pts.length} points`;
48
+ }
49
+ updatePointCount();
50
+ model.on("change:points", updatePointCount);
51
+ toolbar.appendChild(countBadge);
52
+
42
53
  // Run button
43
54
  const runBtn = document.createElement("button");
44
55
  runBtn.className = "avs-btn avs-btn-primary";
@@ -109,6 +109,7 @@ class VectorSpace(anywidget.AnyWidget):
109
109
  def __init__(self, points: list[dict[str, Any]] | None = None, **kwargs: Any) -> None:
110
110
  super().__init__(points=points or [], **kwargs)
111
111
  self._backend_client: Any = None
112
+ self._vectors: Any = None # High-dim vectors for projection (numpy array, not synced to JS)
112
113
  self.observe(self._on_execute_query, names=["_execute_query"])
113
114
 
114
115
  # === Backend Configuration ===
@@ -209,6 +210,158 @@ class VectorSpace(anywidget.AnyWidget):
209
210
  self.observe(_handler, names=["selected_points"])
210
211
  return callback
211
212
 
213
+ # === Add Points ===
214
+
215
+ def add_points(self, data: list[dict[str, Any]]) -> VectorSpace:
216
+ """Append point dicts to the existing points.
217
+
218
+ Args:
219
+ data: List of point dicts with at least x, y keys.
220
+
221
+ Returns:
222
+ Self for chaining.
223
+ """
224
+ self.points = [*self.points, *_normalize_points(data)]
225
+ return self
226
+
227
+ def add_numpy(
228
+ self,
229
+ positions: Any,
230
+ *,
231
+ ids: list[str] | None = None,
232
+ labels: list[str] | None = None,
233
+ metadata: dict[str, list[Any]] | None = None,
234
+ ) -> VectorSpace:
235
+ """Append points from a NumPy array.
236
+
237
+ For arrays with D > 3 columns, the full vectors are stored internally
238
+ and PCA is used for the initial 3D coordinates. Call ``project()`` to
239
+ switch projection methods interactively.
240
+
241
+ Args:
242
+ positions: Array of shape (N, 2), (N, 3), or (N, D) for high-dim data.
243
+ ids: Optional list of point IDs.
244
+ labels: Optional list of labels.
245
+ metadata: Optional dict mapping field names to per-point value lists.
246
+
247
+ Returns:
248
+ Self for chaining.
249
+ """
250
+ import numpy as np
251
+
252
+ arr = np.asarray(positions, dtype=np.float64)
253
+ n_samples, n_dims = arr.shape
254
+
255
+ # High-dimensional: store vectors and project to 3D
256
+ if n_dims > 3:
257
+ if self._vectors is not None:
258
+ self._vectors = np.vstack([self._vectors, arr])
259
+ else:
260
+ self._vectors = arr
261
+ coords = _pca(arr, n_components=3)
262
+ else:
263
+ coords = arr
264
+
265
+ offset = len(self.points)
266
+ new_points = []
267
+ for i in range(n_samples):
268
+ point: dict[str, Any] = {
269
+ "id": ids[i] if ids else f"point_{offset + i}",
270
+ "x": float(coords[i, 0]),
271
+ "y": float(coords[i, 1]),
272
+ "z": float(coords[i, 2]) if coords.shape[1] > 2 else 0.0,
273
+ }
274
+ if labels:
275
+ point["label"] = labels[i]
276
+ if metadata:
277
+ for key, values in metadata.items():
278
+ point[key] = values[i]
279
+ new_points.append(point)
280
+ self.points = [*self.points, *new_points]
281
+ return self
282
+
283
+ # === Projection ===
284
+
285
+ def set_vectors(self, vectors: Any) -> VectorSpace:
286
+ """Store high-dimensional vectors for use with ``project()``.
287
+
288
+ Must have the same number of rows as ``self.points``.
289
+
290
+ Args:
291
+ vectors: Array-like of shape (N, D).
292
+
293
+ Returns:
294
+ Self for chaining.
295
+ """
296
+ import numpy as np
297
+
298
+ self._vectors = np.asarray(vectors, dtype=np.float64)
299
+ return self
300
+
301
+ def project(self, method: str = "pca", *, n_components: int = 3, **kwargs: Any) -> VectorSpace:
302
+ """Reproject point coordinates using dimensionality reduction.
303
+
304
+ Reads vectors from ``set_vectors()`` / ``add_numpy()`` (for D > 3), or
305
+ falls back to extracting the ``vector`` field from each point dict.
306
+
307
+ Supported methods: ``pca``, ``tsne``, ``umap``.
308
+
309
+ Args:
310
+ method: Projection algorithm name.
311
+ n_components: Target dimensions (2 or 3).
312
+ **kwargs: Passed to the underlying algorithm.
313
+
314
+ Returns:
315
+ Self for chaining.
316
+ """
317
+ vectors = self._resolve_vectors()
318
+ n_components = min(n_components, vectors.shape[1])
319
+
320
+ if method == "pca":
321
+ coords = _pca(vectors, n_components=n_components)
322
+ elif method == "tsne":
323
+ coords = _tsne(vectors, n_components=n_components, **kwargs)
324
+ elif method == "umap":
325
+ coords = _umap(vectors, n_components=n_components, **kwargs)
326
+ else:
327
+ raise ValueError(f"Unknown projection method: {method!r}. Use 'pca', 'tsne', or 'umap'.")
328
+
329
+ # Normalize to roughly [-1, 1] so the scene stays well-framed
330
+ for col in range(coords.shape[1]):
331
+ c = coords[:, col]
332
+ lo, hi = c.min(), c.max()
333
+ if hi - lo > 0:
334
+ coords[:, col] = 2 * (c - lo) / (hi - lo) - 1
335
+
336
+ points = [dict(p) for p in self.points]
337
+ for i, p in enumerate(points):
338
+ p["x"] = float(coords[i, 0])
339
+ p["y"] = float(coords[i, 1])
340
+ p["z"] = float(coords[i, 2]) if n_components >= 3 else 0.0
341
+ self.points = points
342
+ return self
343
+
344
+ def _resolve_vectors(self) -> Any:
345
+ """Get the vector matrix for projection."""
346
+ import numpy as np
347
+
348
+ if self._vectors is not None:
349
+ if len(self._vectors) != len(self.points):
350
+ raise ValueError(
351
+ f"Stored vectors ({len(self._vectors)}) don't match point count ({len(self.points)}). "
352
+ "Call set_vectors() again after modifying points."
353
+ )
354
+ return self._vectors
355
+
356
+ # Fall back to point["vector"] fields
357
+ vecs = [p.get("vector") for p in self.points]
358
+ if all(v is not None for v in vecs):
359
+ return np.array(vecs, dtype=np.float64)
360
+
361
+ raise ValueError(
362
+ "No vectors available. Use set_vectors(), add_numpy() with D > 3, or ensure points have a 'vector' field."
363
+ )
364
+
212
365
  # === Factory Methods ===
213
366
 
214
367
  @classmethod
@@ -923,3 +1076,36 @@ def _to_list(obj: Any) -> list[Any]:
923
1076
  if hasattr(obj, "tolist"):
924
1077
  return obj.tolist()
925
1078
  return list(obj)
1079
+
1080
+
1081
+ # === Projection Helpers ===
1082
+
1083
+
1084
+ def _pca(vectors: Any, n_components: int = 3) -> Any:
1085
+ """PCA via SVD (numpy only, no sklearn needed)."""
1086
+ import numpy as np
1087
+
1088
+ x = np.asarray(vectors, dtype=np.float64)
1089
+ x_centered = x - x.mean(axis=0)
1090
+ _u, _s, vt = np.linalg.svd(x_centered, full_matrices=False)
1091
+ return x_centered @ vt[:n_components].T
1092
+
1093
+
1094
+ def _tsne(vectors: Any, n_components: int = 3, **kwargs: Any) -> Any:
1095
+ """t-SNE projection (requires scikit-learn)."""
1096
+ try:
1097
+ from sklearn.manifold import TSNE
1098
+ except ImportError:
1099
+ raise ImportError("t-SNE requires scikit-learn: uv add scikit-learn") from None
1100
+ kwargs.setdefault("perplexity", min(30.0, len(vectors) - 1))
1101
+ return TSNE(n_components=n_components, **kwargs).fit_transform(vectors)
1102
+
1103
+
1104
+ def _umap(vectors: Any, n_components: int = 3, **kwargs: Any) -> Any:
1105
+ """UMAP projection (requires umap-learn)."""
1106
+ try:
1107
+ from umap import UMAP
1108
+ except ImportError:
1109
+ raise ImportError("UMAP requires umap-learn: uv add umap-learn") from None
1110
+ kwargs.setdefault("n_neighbors", min(15, len(vectors) - 1))
1111
+ return UMAP(n_components=n_components, **kwargs).fit_transform(vectors)