anywidget-vector 0.3.0__tar.gz → 0.3.1__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 (51) hide show
  1. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/CHANGELOG.md +20 -0
  2. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/PKG-INFO +1 -1
  3. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/examples/fashion-mnist-parallel-coords-6d.py +45 -22
  4. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/pyproject.toml +1 -1
  5. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/static/icons.js +1 -0
  6. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/ui/__init__.py +21 -12
  7. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/ui/canvas.js +275 -60
  8. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/ui/properties.js +8 -11
  9. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/ui/sidebar.js +10 -0
  10. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/widget.py +7 -3
  11. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/tests/test_widget.py +2 -2
  12. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/uv.lock +1 -1
  13. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/.github/workflows/ci.yml +0 -0
  14. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/.github/workflows/pypi.yml +0 -0
  15. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/.gitignore +0 -0
  16. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/.python-version +0 -0
  17. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/LICENSE +0 -0
  18. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/README.md +0 -0
  19. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/assets/demo.png +0 -0
  20. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/examples/demo.py +0 -0
  21. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/examples/test_add_numpy.py +0 -0
  22. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/examples/test_projection.py +0 -0
  23. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/__init__.py +0 -0
  24. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/__init__.py +0 -0
  25. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/chroma/__init__.py +0 -0
  26. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/chroma/client.py +0 -0
  27. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/chroma/converter.py +0 -0
  28. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/grafeo/__init__.py +0 -0
  29. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/grafeo/browser.js +0 -0
  30. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/grafeo/client.py +0 -0
  31. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/grafeo/converter.py +0 -0
  32. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/grafeo/embed.js +0 -0
  33. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/lancedb/__init__.py +0 -0
  34. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/lancedb/client.py +0 -0
  35. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/lancedb/converter.py +0 -0
  36. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/pinecone/__init__.py +0 -0
  37. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/pinecone/client.js +0 -0
  38. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/pinecone/converter.py +0 -0
  39. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/qdrant/__init__.py +0 -0
  40. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/qdrant/client.js +0 -0
  41. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/qdrant/converter.py +0 -0
  42. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/weaviate/__init__.py +0 -0
  43. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/weaviate/client.js +0 -0
  44. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/backends/weaviate/converter.py +0 -0
  45. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/demo.py +0 -0
  46. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/py.typed +0 -0
  47. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/ui/constants.js +0 -0
  48. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/ui/settings.js +0 -0
  49. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/ui/styles.css +0 -0
  50. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/src/anywidget_vector/ui/toolbar.js +0 -0
  51. {anywidget_vector-0.3.0 → anywidget_vector-0.3.1}/tests/__init__.py +0 -0
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 (2026-03-16)
4
+
5
+ ### New Features
6
+
7
+ - **Lasso selection**: New lasso mode for freehand point selection in the 3D canvas, with point-in-polygon hit testing
8
+ - **Selection highlighting**: Selected points show wireframe highlight rings and non-selected points are dimmed to 15% brightness
9
+ - **Auto-sizing**: Width defaults to 100% (fill container), height defaults to 1:1 aspect ratio. Both can still be set to fixed pixel values
10
+
11
+ ### Bug Fixes
12
+
13
+ - **Properties panel**: Panel is now always created (previously skipped when `show_properties=False`), so the toolbar button always works. Starts closed unless `show_properties=True`
14
+ - **Distance metric feedback**: Changing the metric now auto-enables k=5 connections when a point is selected, giving immediate visual feedback
15
+
16
+ ### Improvements
17
+
18
+ - Reduced scroll zoom sensitivity (`zoomSpeed` halved to 0.5)
19
+ - Search filter dimming increased from 12% to 6% opacity for clearer visual contrast
20
+ - Camera no longer resets on every point update (only on initial load)
21
+ - Cross-widget filtering: example updated so VectorSpace lasso selection feeds back into ParallelCoordinates dimming via marimo reactivity
22
+
3
23
  ## 0.3.0 (2026-03-16)
4
24
 
5
25
  ### New Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anywidget-vector
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -13,13 +13,13 @@
13
13
  # "wigglystuff==0.2.37",
14
14
  # "matplotlib==3.10.8",
15
15
  # "pandas==3.0.1",
16
- # "anywidget-vector==0.2.7",
16
+ # "anywidget-vector==0.3.1",
17
17
  # ]
18
18
  # ///
19
19
 
20
20
  import marimo
21
21
 
22
- __generated_with = "0.19.7"
22
+ __generated_with = "0.20.2"
23
23
  app = marimo.App(width="full")
24
24
 
25
25
 
@@ -34,7 +34,6 @@ def _():
34
34
  from wigglystuff import ParallelCoordinates
35
35
 
36
36
  from anywidget_vector import VectorSpace
37
-
38
37
  return PCA, ParallelCoordinates, VectorSpace, fetch_openml, mo, np, pl, plt
39
38
 
40
39
 
@@ -43,7 +42,7 @@ def _(mo):
43
42
  mo.md(r"""
44
43
  # Fashion MNIST: Parallel Coordinates + 6D Vector View
45
44
 
46
- Brush the parallel coordinates axes to filter. The 3D scatter updates in real time.
45
+ Brush the parallel coordinates axes to filter. The 3D (xyz)+3D(color/size/shape) scatter updates in real time.
47
46
  Use the dropdowns to map PCA dimensions to visual channels.
48
47
  """)
49
48
  return
@@ -77,7 +76,7 @@ def _(fetch_openml, np):
77
76
  @app.cell
78
77
  def _(mo):
79
78
  n_samples_slider = mo.ui.slider(start=500, stop=5000, step=500, value=2000, label="Samples")
80
- n_components_slider = mo.ui.slider(start=3, stop=15, step=1, value=8, label="PCA dims")
79
+ n_components_slider = mo.ui.slider(start=3, stop=15, step=1, value=5, label="PCA dims")
81
80
  mo.hstack([n_samples_slider, n_components_slider])
82
81
  return n_components_slider, n_samples_slider
83
82
 
@@ -204,8 +203,7 @@ def _(
204
203
 
205
204
  vs_widget = VectorSpace(
206
205
  points=vs_points,
207
- width=1600,
208
- height=500,
206
+ height=900,
209
207
  show_toolbar=True,
210
208
  show_settings=True,
211
209
  show_properties=False,
@@ -213,36 +211,62 @@ def _(
213
211
  )
214
212
  vs = mo.ui.anywidget(vs_widget)
215
213
  vs
216
- return vs_points, vs_widget
214
+ return vs, vs_points, vs_widget
217
215
 
218
216
 
219
217
  @app.cell
220
218
  def _(LABEL_COLORS, color_dim, mo, vs_points, vs_widget, widget):
221
- _filtered = set(widget.widget.filtered_indices)
222
- _dim_color = "#d1d5db"
219
+ # ParallelCoordinates -> VectorSpace (one-way)
220
+ # Uses vs_widget (raw) to avoid triggering vs-dependent cells
221
+ _par_selected = list(widget.widget.selected_indices or [])
222
+ _par_filtered = set(widget.widget.filtered_indices)
223
+ _active_set = set(_par_selected) if _par_selected else _par_filtered
223
224
  _updated = []
224
225
  for _i, _p in enumerate(vs_points):
226
+ if _i not in _active_set:
227
+ continue
225
228
  _new_p = dict(_p)
226
- if _i in _filtered:
227
- if color_dim.value == "label":
228
- _new_p["color"] = LABEL_COLORS[_p["label"]]
229
- else:
230
- _new_p.pop("color", None)
229
+ if color_dim.value == "label":
230
+ _new_p["color"] = LABEL_COLORS[_p["label"]]
231
231
  else:
232
- _new_p["color"] = _dim_color
232
+ _new_p.pop("color", None)
233
233
  _updated.append(_new_p)
234
234
  vs_widget.points = _updated
235
- mo.md(f"**{len(_filtered)}** / {len(vs_points)} points selected")
235
+ mo.md(f"**{len(_updated)}** / {len(vs_points)} points selected")
236
+ return
237
+
238
+
239
+ @app.cell
240
+ def _(LABEL_COLORS, ParallelCoordinates, df, mo, vs):
241
+ # VectorSpace -> filtered ParallelCoordinates (reactive via vs.value)
242
+ _selected = list(vs.widget.selected_points or [])
243
+ if _selected:
244
+ _indices = sorted(int(sid.split("_")[1]) for sid in _selected)
245
+ _filtered_df = df[_indices]
246
+ _par = ParallelCoordinates(_filtered_df, color_by="label", color_map=LABEL_COLORS)
247
+ mo.vstack([
248
+ mo.md(f"**{len(_indices)}** points selected in 3D view"),
249
+ mo.ui.anywidget(_par),
250
+ ])
251
+ else:
252
+ mo.md("*Lasso or box-select points in the 3D view to filter*")
236
253
  return
237
254
 
238
255
 
239
256
  @app.cell
240
- def _(LABEL_COLORS, idx, images, label_names, labels, np, plt, vs_widget, widget):
257
+ def _(LABEL_COLORS, idx, images, label_names, labels, np, plt, vs, widget):
258
+ _selected_ids = set(vs.widget.selected_points or [])
241
259
  _filtered = widget.widget.filtered_indices
242
- _sample_idx = np.array(_filtered[:10]) if len(_filtered) >= 10 else np.array(_filtered)
243
260
 
244
- # Which points are selected in the 3D view?
245
- _selected_ids = set(vs_widget.selected_points or [])
261
+ if _selected_ids:
262
+ _show = [
263
+ _i for _i in range(len(idx))
264
+ if f"p_{_i}" in _selected_ids
265
+ ][:10]
266
+ else:
267
+ _show = list(_filtered[:10])
268
+
269
+ _sample_idx = np.array(_show) if len(_show) > 0 else np.array([0])
246
270
 
247
271
  _fig, _axes = plt.subplots(1, len(_sample_idx), figsize=(2 * len(_sample_idx), 2.4))
248
272
  if len(_sample_idx) == 1:
@@ -252,7 +276,6 @@ def _(LABEL_COLORS, idx, images, label_names, labels, np, plt, vs_widget, widget
252
276
  _ax.imshow(images[idx[_si]].reshape(28, 28), cmap="gray")
253
277
  _ax.set_title(_name, fontsize=9)
254
278
  _ax.axis("off")
255
- # Highlight if selected in 3D view
256
279
  if f"p_{_si}" in _selected_ids:
257
280
  _color = LABEL_COLORS.get(_name, "#0880ea")
258
281
  for _spine in _ax.spines.values():
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "anywidget-vector"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "Interactive 3D vector visualization with query UI for vector databases"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -16,4 +16,5 @@ export const ICONS = {
16
16
  zoomFit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>',
17
17
  cursor: '<svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M4 2l12 14h-5l-2.3 5.5L6 20.3 8.3 15H4V2z"/></svg>',
18
18
  boxSelect: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 2"><rect x="3" y="3" width="18" height="18" rx="1"/></svg>',
19
+ lasso: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="10" rx="9" ry="7"/><path d="M12 17v3M12 20l-2 2"/></svg>',
19
20
  };
@@ -122,8 +122,16 @@ async function executeBackendQuery(model) {{
122
122
  function render({{ model, el }}) {{
123
123
  const wrapper = document.createElement("div");
124
124
  wrapper.className = "avs-wrapper";
125
- wrapper.style.width = model.get("width") + "px";
126
- wrapper.style.height = model.get("height") + "px";
125
+
126
+ const _w = model.get("width");
127
+ const _h = model.get("height");
128
+ wrapper.style.width = _w > 0 ? _w + "px" : "100%";
129
+ if (_h > 0) {{
130
+ wrapper.style.height = _h + "px";
131
+ }} else {{
132
+ wrapper.style.aspectRatio = "1 / 1";
133
+ }}
134
+
127
135
  el.appendChild(wrapper);
128
136
 
129
137
  // Auto-detect host theme (marimo uses Tailwind class="dark" on <html>)
@@ -175,7 +183,7 @@ function render({{ model, el }}) {{
175
183
  onRunQuery: () => runQuery(),
176
184
  onFilterInput: (text) => {{
177
185
  if (canvas) {{
178
- const result = canvas.applyFilter(text);
186
+ const result = canvas.applyFilter(text, false);
179
187
  if (toolbarUI) toolbarUI.setFilterCount(result.matched, result.total);
180
188
  }}
181
189
  }},
@@ -215,21 +223,22 @@ function render({{ model, el }}) {{
215
223
  main.appendChild(settingsPanel.element);
216
224
  }}
217
225
 
218
- if (model.get("show_properties")) {{
219
- propertiesPanel = createPropertiesPanel(model, {{
220
- onClose: () => propertiesPanel?.close(),
221
- }});
222
- main.appendChild(propertiesPanel.element);
223
- }}
226
+ propertiesPanel = createPropertiesPanel(model, {{
227
+ onClose: () => propertiesPanel?.close(),
228
+ }});
229
+ main.appendChild(propertiesPanel.element);
230
+ if (model.get("show_properties")) propertiesPanel.open();
224
231
 
225
- const canvas = createCanvas(model, canvasContainer, {{}});
232
+ const canvas = createCanvas(model, canvasContainer, {{
233
+ onHover: (point) => propertiesPanel?.setHoveredPoint(point),
234
+ }});
226
235
 
227
236
  async function runQuery() {{
228
237
  const query = model.get("query_input") || "";
229
238
 
230
- // Always apply client-side filter
239
+ // Apply hard client-side filter (completely hide non-matching)
231
240
  if (canvas) {{
232
- const result = canvas.applyFilter(query);
241
+ const result = canvas.applyFilter(query, true);
233
242
  if (toolbarUI) toolbarUI.setFilterCount(result.matched, result.total);
234
243
  }}
235
244
 
@@ -40,8 +40,17 @@ export function createCanvas(model, container, callbacks) {
40
40
  let animationId;
41
41
  let resizeObserver;
42
42
  let selectionRect, boxOverlay;
43
+ let selectionGroup;
44
+ let lassoOverlayEl, lassoSvg, lassoPathEl;
45
+ let isLassoing = false, lassoCoords = [];
43
46
  let currentMode = model.get("selection_mode") || "click";
44
47
 
48
+ function setSelection(ids) {
49
+ model.set("selected_points", ids);
50
+ model.set("_selection_version", (model.get("_selection_version") || 0) + 1);
51
+ model.save_changes();
52
+ }
53
+
45
54
  init();
46
55
  animate();
47
56
 
@@ -80,10 +89,21 @@ export function createCanvas(model, container, callbacks) {
80
89
  // Controls
81
90
  controls = new OrbitControls(camera, renderer.domElement);
82
91
  controls.enableDamping = true;
83
- controls.dampingFactor = 0.05;
92
+ controls.dampingFactor = 0.08;
93
+ controls.zoomSpeed = 0.3;
94
+ controls.enableZoom = false; // disable built-in scroll zoom
84
95
  const target = model.get("camera_target") || [0, 0, 0];
85
96
  controls.target.set(target[0], target[1], target[2]);
86
- controls.addEventListener("change", onCameraChange);
97
+
98
+ // Custom smooth scroll zoom with reduced sensitivity
99
+ renderer.domElement.addEventListener("wheel", (e) => {
100
+ e.preventDefault();
101
+ const factor = 1 + e.deltaY * 0.0003;
102
+ const offset = camera.position.clone().sub(controls.target);
103
+ offset.multiplyScalar(factor);
104
+ camera.position.copy(controls.target).add(offset);
105
+ controls.update();
106
+ }, { passive: false });
87
107
 
88
108
  // Lighting
89
109
  scene.add(new THREE.AmbientLight(0xffffff, 0.6));
@@ -96,6 +116,8 @@ export function createCanvas(model, container, callbacks) {
96
116
  scene.add(pointsGroup);
97
117
  connectionsGroup = new THREE.Group();
98
118
  scene.add(connectionsGroup);
119
+ selectionGroup = new THREE.Group();
120
+ scene.add(selectionGroup);
99
121
  axesGroup = new THREE.Group();
100
122
  scene.add(axesGroup);
101
123
 
@@ -105,6 +127,7 @@ export function createCanvas(model, container, callbacks) {
105
127
  setupTooltip();
106
128
  setupZoomControls();
107
129
  setupBoxSelection();
130
+ setupLassoSelection();
108
131
  setupKeyboardShortcuts();
109
132
  createPoints();
110
133
  createConnections();
@@ -123,9 +146,9 @@ export function createCanvas(model, container, callbacks) {
123
146
  e.preventDefault();
124
147
  fitToView();
125
148
  } else if (e.key === "Escape") {
126
- model.set("selected_points", []);
127
- model.set("hovered_point", null);
128
- model.save_changes();
149
+ hoveredObject = null;
150
+ hideTooltip();
151
+ setSelection([]);
129
152
  }
130
153
  });
131
154
  }
@@ -402,14 +425,12 @@ export function createCanvas(model, container, callbacks) {
402
425
  const point = points[pointIndex];
403
426
  if (point && (!hoveredObject || hoveredObject.pointId !== pointId)) {
404
427
  hoveredObject = { pointIndex, pointId };
405
- model.set("hovered_point", point);
406
- model.save_changes();
428
+ callbacks.onHover?.(point);
407
429
  showTooltip(event, point);
408
430
  }
409
431
  } else if (hoveredObject) {
410
432
  hoveredObject = null;
411
- model.set("hovered_point", null);
412
- model.save_changes();
433
+ callbacks.onHover?.(null);
413
434
  hideTooltip();
414
435
  }
415
436
  }
@@ -438,18 +459,16 @@ export function createCanvas(model, container, callbacks) {
438
459
  const currentSelection = model.get("selected_points") || [];
439
460
 
440
461
  if (selectionMode === "click") {
441
- model.set("selected_points", [pointId]);
462
+ setSelection([pointId]);
442
463
  } else if (selectionMode === "multi") {
443
464
  if (currentSelection.includes(pointId)) {
444
- model.set("selected_points", currentSelection.filter(id => id !== pointId));
465
+ setSelection(currentSelection.filter(id => id !== pointId));
445
466
  } else {
446
- model.set("selected_points", [...currentSelection, pointId]);
467
+ setSelection([...currentSelection, pointId]);
447
468
  }
448
469
  }
449
- model.save_changes();
450
470
  } else {
451
- model.set("selected_points", []);
452
- model.save_changes();
471
+ setSelection([]);
453
472
  }
454
473
  }
455
474
 
@@ -518,25 +537,28 @@ export function createCanvas(model, container, callbacks) {
518
537
  boxBtn.innerHTML = ICONS.boxSelect;
519
538
  boxBtn.title = "Box select mode";
520
539
 
540
+ const lassoBtn = document.createElement("button");
541
+ lassoBtn.className = "avs-zoom-btn avs-mode-btn";
542
+ lassoBtn.innerHTML = ICONS.lasso;
543
+ lassoBtn.title = "Lasso select mode";
544
+
521
545
  function setActiveMode(mode) {
522
546
  currentMode = mode;
523
- model.set("selection_mode", mode === "box" ? "box" : "click");
547
+ model.set("selection_mode", mode);
524
548
  model.save_changes();
525
549
 
526
- clickBtn.classList.toggle("avs-mode-active", mode !== "box");
550
+ clickBtn.classList.toggle("avs-mode-active", mode === "click");
527
551
  boxBtn.classList.toggle("avs-mode-active", mode === "box");
552
+ lassoBtn.classList.toggle("avs-mode-active", mode === "lasso");
528
553
 
529
- // Toggle orbit controls: disabled in box mode so drag draws the rectangle
530
- controls.enabled = (mode !== "box");
531
-
532
- // Show/hide box overlay
533
- if (boxOverlay) {
534
- boxOverlay.style.display = (mode === "box") ? "block" : "none";
535
- }
554
+ controls.enabled = (mode === "click");
555
+ if (boxOverlay) boxOverlay.style.display = (mode === "box") ? "block" : "none";
556
+ if (lassoOverlayEl) lassoOverlayEl.style.display = (mode === "lasso") ? "block" : "none";
536
557
  }
537
558
 
538
559
  clickBtn.addEventListener("click", () => setActiveMode("click"));
539
560
  boxBtn.addEventListener("click", () => setActiveMode("box"));
561
+ lassoBtn.addEventListener("click", () => setActiveMode("lasso"));
540
562
 
541
563
  // Sync with model changes from Python side
542
564
  model.on("change:selection_mode", () => {
@@ -546,6 +568,7 @@ export function createCanvas(model, container, callbacks) {
546
568
 
547
569
  modeGroup.appendChild(clickBtn);
548
570
  modeGroup.appendChild(boxBtn);
571
+ modeGroup.appendChild(lassoBtn);
549
572
 
550
573
  zoomControls.appendChild(modeGroup);
551
574
  zoomControls.appendChild(zoomInBtn);
@@ -619,8 +642,7 @@ export function createCanvas(model, container, callbacks) {
619
642
  { x1, y1, x2, y2 },
620
643
  rect.width, rect.height
621
644
  );
622
- model.set("selected_points", selectedIds);
623
- model.save_changes();
645
+ setSelection(selectedIds);
624
646
  });
625
647
  }
626
648
 
@@ -659,14 +681,8 @@ export function createCanvas(model, container, callbacks) {
659
681
  tooltip.style.display = "none";
660
682
  }
661
683
 
662
- function onCameraChange() {
663
- model.set("camera_position", [camera.position.x, camera.position.y, camera.position.z]);
664
- model.set("camera_target", [controls.target.x, controls.target.y, controls.target.z]);
665
- model.save_changes();
666
- }
667
-
668
684
  function bindModelEvents() {
669
- model.on("change:points", () => { createPoints(); createConnections(); fitToView(); });
685
+ model.on("change:points", () => { createPoints(); createConnections(); updateSelectionHighlight(); });
670
686
  model.on("change:background", () => { scene.background = new THREE.Color(model.get("background")); });
671
687
  model.on("change:show_axes", setupAxesAndGrid);
672
688
  model.on("change:show_grid", setupAxesAndGrid);
@@ -684,6 +700,7 @@ export function createCanvas(model, container, callbacks) {
684
700
  model.on("change:distance_metric", createConnections);
685
701
  model.on("change:connection_color", createConnections);
686
702
  model.on("change:connection_opacity", createConnections);
703
+ model.on("change:selected_points", updateSelectionHighlight);
687
704
  model.on("change:camera_position", () => {
688
705
  const pos = model.get("camera_position");
689
706
  if (pos) camera.position.set(pos[0], pos[1], pos[2]);
@@ -714,32 +731,43 @@ export function createCanvas(model, container, callbacks) {
714
731
  });
715
732
  }
716
733
 
717
- function applyFilter(filterText) {
734
+ function applyFilter(filterText, hard) {
718
735
  const points = model.get("points") || [];
719
736
  const filter = (filterText || "").toLowerCase().trim();
720
737
  const total = points.length;
721
738
 
722
739
  if (!filter) {
723
- // Reset all to full opacity
740
+ // Reset all to visible
724
741
  pointsGroup.children.forEach(obj => {
725
742
  if (obj.userData.isInstanced) {
726
- // Restore original colors from stored data
743
+ if (obj.userData._filterOrigMatrices) {
744
+ const m = new THREE.Matrix4();
745
+ for (let i = 0; i < obj.userData.pointIndices.length; i++) {
746
+ m.fromArray(obj.userData._filterOrigMatrices, i * 16);
747
+ obj.setMatrixAt(i, m);
748
+ }
749
+ obj.instanceMatrix.needsUpdate = true;
750
+ obj.userData._filterOrigMatrices = null;
751
+ }
727
752
  if (obj.userData._originalColors) {
728
753
  const attr = obj.geometry.getAttribute("color");
729
754
  attr.array.set(obj.userData._originalColors);
730
755
  attr.needsUpdate = true;
756
+ obj.userData._originalColors = null;
757
+ }
758
+ } else {
759
+ obj.visible = true;
760
+ if (obj.material) {
761
+ obj.material.opacity = 1;
762
+ obj.material.transparent = false;
763
+ obj.material.needsUpdate = true;
731
764
  }
732
- } else if (obj.material) {
733
- obj.material.opacity = 1;
734
- obj.material.transparent = false;
735
- obj.material.needsUpdate = true;
736
765
  }
737
766
  });
738
767
  return { matched: total, total };
739
768
  }
740
769
 
741
770
  let matched = 0;
742
- // Build match set from points data
743
771
  const matchSet = new Set();
744
772
  points.forEach((point, idx) => {
745
773
  if (pointMatchesFilter(point, filter)) {
@@ -748,32 +776,219 @@ export function createCanvas(model, container, callbacks) {
748
776
  }
749
777
  });
750
778
 
779
+ if (hard) {
780
+ // Hard filter: completely hide non-matching
781
+ const zeroMatrix = new THREE.Matrix4().makeScale(0, 0, 0);
782
+ pointsGroup.children.forEach(obj => {
783
+ if (obj.userData.isInstanced) {
784
+ const indices = obj.userData.pointIndices;
785
+ if (!obj.userData._filterOrigMatrices) {
786
+ const buf = new Float32Array(indices.length * 16);
787
+ const m = new THREE.Matrix4();
788
+ for (let i = 0; i < indices.length; i++) {
789
+ obj.getMatrixAt(i, m);
790
+ m.toArray(buf, i * 16);
791
+ }
792
+ obj.userData._filterOrigMatrices = buf;
793
+ }
794
+ const m = new THREE.Matrix4();
795
+ for (let i = 0; i < indices.length; i++) {
796
+ if (matchSet.has(indices[i])) {
797
+ m.fromArray(obj.userData._filterOrigMatrices, i * 16);
798
+ } else {
799
+ m.copy(zeroMatrix);
800
+ }
801
+ obj.setMatrixAt(i, m);
802
+ }
803
+ obj.instanceMatrix.needsUpdate = true;
804
+ } else {
805
+ obj.visible = matchSet.has(obj.userData.pointIndex);
806
+ }
807
+ });
808
+ } else {
809
+ // Soft filter: dim non-matching
810
+ pointsGroup.children.forEach(obj => {
811
+ if (obj.userData.isInstanced) {
812
+ const indices = obj.userData.pointIndices;
813
+ const attr = obj.geometry.getAttribute("color");
814
+ if (!obj.userData._originalColors) {
815
+ obj.userData._originalColors = new Float32Array(attr.array);
816
+ }
817
+ for (let i = 0; i < indices.length; i++) {
818
+ const dim = matchSet.has(indices[i]) ? 1.0 : 0.08;
819
+ attr.array[i * 3] = obj.userData._originalColors[i * 3] * dim;
820
+ attr.array[i * 3 + 1] = obj.userData._originalColors[i * 3 + 1] * dim;
821
+ attr.array[i * 3 + 2] = obj.userData._originalColors[i * 3 + 2] * dim;
822
+ }
823
+ attr.needsUpdate = true;
824
+ } else if (obj.material) {
825
+ const matches = matchSet.has(obj.userData.pointIndex);
826
+ obj.material.transparent = !matches;
827
+ obj.material.opacity = matches ? 1.0 : 0.08;
828
+ obj.material.needsUpdate = true;
829
+ }
830
+ });
831
+ }
832
+
833
+ return { matched, total };
834
+ }
835
+
836
+ function setupLassoSelection() {
837
+ // SVG overlay for drawing the lasso path
838
+ lassoSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
839
+ lassoSvg.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;z-index:16;display:none;pointer-events:none;";
840
+ container.appendChild(lassoSvg);
841
+
842
+ lassoPathEl = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
843
+ lassoPathEl.setAttribute("fill", "rgba(99, 102, 241, 0.1)");
844
+ lassoPathEl.setAttribute("stroke", "#6366f1");
845
+ lassoPathEl.setAttribute("stroke-width", "2");
846
+ lassoPathEl.setAttribute("stroke-dasharray", "4 2");
847
+ lassoSvg.appendChild(lassoPathEl);
848
+
849
+ // Transparent overlay to capture mouse events in lasso mode
850
+ lassoOverlayEl = document.createElement("div");
851
+ lassoOverlayEl.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;z-index:15;display:none;cursor:crosshair;";
852
+ container.appendChild(lassoOverlayEl);
853
+
854
+ lassoOverlayEl.addEventListener("mousedown", (e) => {
855
+ if (currentMode !== "lasso") return;
856
+ e.preventDefault();
857
+ isLassoing = true;
858
+ lassoCoords = [];
859
+ const rect = container.getBoundingClientRect();
860
+ lassoCoords.push([e.clientX - rect.left, e.clientY - rect.top]);
861
+ lassoSvg.style.display = "block";
862
+ lassoPathEl.setAttribute("points", "");
863
+ });
864
+
865
+ lassoOverlayEl.addEventListener("mousemove", (e) => {
866
+ if (!isLassoing) return;
867
+ const rect = container.getBoundingClientRect();
868
+ lassoCoords.push([e.clientX - rect.left, e.clientY - rect.top]);
869
+ lassoPathEl.setAttribute("points", lassoCoords.map(([x, y]) => `${x},${y}`).join(" "));
870
+ });
871
+
872
+ lassoOverlayEl.addEventListener("mouseup", () => {
873
+ if (!isLassoing) return;
874
+ isLassoing = false;
875
+ lassoSvg.style.display = "none";
876
+
877
+ if (lassoCoords.length < 3) return;
878
+
879
+ // Close the polygon
880
+ lassoCoords.push(lassoCoords[0]);
881
+
882
+ const rect = container.getBoundingClientRect();
883
+ const selectedIds = [];
884
+ const points = model.get("points") || [];
885
+ points.forEach((p, idx) => {
886
+ const pos = new THREE.Vector3(p.x ?? 0, p.y ?? 0, p.z ?? 0).project(camera);
887
+ const sx = (pos.x * 0.5 + 0.5) * rect.width;
888
+ const sy = (-pos.y * 0.5 + 0.5) * rect.height;
889
+ if (pointInPolygon(sx, sy, lassoCoords)) {
890
+ selectedIds.push(p.id || `point_${idx}`);
891
+ }
892
+ });
893
+
894
+ setSelection(selectedIds);
895
+ lassoCoords = [];
896
+ });
897
+ }
898
+
899
+ function pointInPolygon(x, y, polygon) {
900
+ let inside = false;
901
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
902
+ const [xi, yi] = polygon[i];
903
+ const [xj, yj] = polygon[j];
904
+ if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) {
905
+ inside = !inside;
906
+ }
907
+ }
908
+ return inside;
909
+ }
910
+
911
+ function updateSelectionHighlight() {
912
+ // Clear previous highlight rings
913
+ while (selectionGroup.children.length > 0) {
914
+ const obj = selectionGroup.children[0];
915
+ if (obj.geometry) obj.geometry.dispose();
916
+ if (obj.material) obj.material.dispose();
917
+ selectionGroup.remove(obj);
918
+ }
919
+
920
+ const selectedIds = model.get("selected_points") || [];
921
+ const points = model.get("points") || [];
922
+
923
+ if (selectedIds.length === 0) {
924
+ // Restore all points: show everything
925
+ pointsGroup.children.forEach(obj => {
926
+ if (obj.userData.isInstanced) {
927
+ // Restore original instance matrices
928
+ if (obj.userData._selOrigMatrices) {
929
+ const m = new THREE.Matrix4();
930
+ for (let i = 0; i < obj.userData.pointIds.length; i++) {
931
+ m.fromArray(obj.userData._selOrigMatrices, i * 16);
932
+ obj.setMatrixAt(i, m);
933
+ }
934
+ obj.instanceMatrix.needsUpdate = true;
935
+ obj.userData._selOrigMatrices = null;
936
+ }
937
+ } else {
938
+ obj.visible = true;
939
+ }
940
+ });
941
+ return;
942
+ }
943
+
944
+ const selectedSet = new Set(selectedIds);
945
+ const zeroMatrix = new THREE.Matrix4().makeScale(0, 0, 0);
946
+
947
+ // Hide non-selected points completely
751
948
  pointsGroup.children.forEach(obj => {
752
949
  if (obj.userData.isInstanced) {
753
- // Dim non-matching instances by darkening their color
754
- const indices = obj.userData.pointIndices;
755
- const attr = obj.geometry.getAttribute("color");
756
- if (!obj.userData._originalColors) {
757
- obj.userData._originalColors = new Float32Array(attr.array);
950
+ const pointIds = obj.userData.pointIds;
951
+ // Back up original matrices on first selection
952
+ if (!obj.userData._selOrigMatrices) {
953
+ const buf = new Float32Array(pointIds.length * 16);
954
+ const m = new THREE.Matrix4();
955
+ for (let i = 0; i < pointIds.length; i++) {
956
+ obj.getMatrixAt(i, m);
957
+ m.toArray(buf, i * 16);
958
+ }
959
+ obj.userData._selOrigMatrices = buf;
758
960
  }
759
- for (let i = 0; i < indices.length; i++) {
760
- const matches = matchSet.has(indices[i]);
761
- const dim = matches ? 1.0 : 0.12;
762
- attr.array[i * 3] = obj.userData._originalColors[i * 3] * dim;
763
- attr.array[i * 3 + 1] = obj.userData._originalColors[i * 3 + 1] * dim;
764
- attr.array[i * 3 + 2] = obj.userData._originalColors[i * 3 + 2] * dim;
961
+ // Scale non-selected instances to zero
962
+ const m = new THREE.Matrix4();
963
+ for (let i = 0; i < pointIds.length; i++) {
964
+ if (selectedSet.has(pointIds[i])) {
965
+ m.fromArray(obj.userData._selOrigMatrices, i * 16);
966
+ } else {
967
+ m.copy(zeroMatrix);
968
+ }
969
+ obj.setMatrixAt(i, m);
765
970
  }
766
- attr.needsUpdate = true;
767
- } else if (obj.material) {
768
- const idx = obj.userData.pointIndex;
769
- const matches = matchSet.has(idx);
770
- obj.material.transparent = !matches;
771
- obj.material.opacity = matches ? 1.0 : 0.12;
772
- obj.material.needsUpdate = true;
971
+ obj.instanceMatrix.needsUpdate = true;
972
+ } else {
973
+ obj.visible = selectedSet.has(obj.userData.pointId);
773
974
  }
774
975
  });
775
976
 
776
- return { matched, total };
977
+ // Zoom to fit selected points
978
+ const selectedPoints = points.filter(p => selectedSet.has(p.id));
979
+ if (selectedPoints.length > 0) {
980
+ const box = new THREE.Box3();
981
+ selectedPoints.forEach(p => box.expandByPoint(new THREE.Vector3(p.x ?? 0, p.y ?? 0, p.z ?? 0)));
982
+ const center = box.getCenter(new THREE.Vector3());
983
+ const size = box.getSize(new THREE.Vector3()).length() || 0.1;
984
+ const distance = size / (2 * Math.tan(Math.PI * camera.fov / 360));
985
+ controls.target.copy(center);
986
+ camera.position.copy(center.clone().add(new THREE.Vector3(0, 0, distance * 0.55)));
987
+ camera.near = Math.max(0.01, distance * 0.001);
988
+ camera.far = Math.max(1000, distance * 10);
989
+ camera.updateProjectionMatrix();
990
+ controls.update();
991
+ }
777
992
  }
778
993
 
779
994
  return { cleanup, applyFilter };
@@ -46,23 +46,20 @@ export function createPropertiesPanel(model, callbacks) {
46
46
  }
47
47
  });
48
48
 
49
- // Update on hover
50
- model.on("change:hovered_point", () => {
51
- const hovered = model.get("hovered_point");
52
- const selectedIds = model.get("selected_points") || [];
53
- if (hovered && selectedIds.length === 0) {
54
- showPointProperties(content, hovered, true);
55
- } else if (selectedIds.length === 0) {
56
- showEmpty(content);
57
- }
58
- });
59
-
60
49
  return {
61
50
  element: panel,
62
51
  open: () => panel.classList.add("avs-open"),
63
52
  close: () => panel.classList.remove("avs-open"),
64
53
  toggle: () => panel.classList.toggle("avs-open"),
65
54
  isOpen: () => panel.classList.contains("avs-open"),
55
+ setHoveredPoint: (point) => {
56
+ const selectedIds = model.get("selected_points") || [];
57
+ if (point && selectedIds.length === 0) {
58
+ showPointProperties(content, point, true);
59
+ } else if (!point && selectedIds.length === 0) {
60
+ showEmpty(content);
61
+ }
62
+ },
66
63
  };
67
64
  }
68
65
 
@@ -206,6 +206,16 @@ export function createSidebar(model, callbacks) {
206
206
  });
207
207
  metricSelect.addEventListener("change", () => {
208
208
  model.set("distance_metric", metricSelect.value);
209
+ // Auto-enable connections when a point is selected
210
+ const selected = model.get("selected_points") || [];
211
+ if (selected.length === 1) {
212
+ model.set("reference_point", selected[0]);
213
+ if (!model.get("show_connections") || model.get("k_neighbors") === 0) {
214
+ model.set("k_neighbors", 5);
215
+ model.set("show_connections", true);
216
+ kInput.value = 5;
217
+ }
218
+ }
209
219
  model.save_changes();
210
220
  });
211
221
  metricGroup.appendChild(metricSelect);
@@ -34,8 +34,9 @@ class VectorSpace(anywidget.AnyWidget):
34
34
  points = traitlets.List(trait=traitlets.Dict()).tag(sync=True)
35
35
 
36
36
  # === Display ===
37
- width = traitlets.Int(default_value=800).tag(sync=True)
38
- height = traitlets.Int(default_value=600).tag(sync=True)
37
+ # width=0 means "auto" (fill container). height=0 means "match width" (1:1 ratio).
38
+ width = traitlets.Int(default_value=0).tag(sync=True)
39
+ height = traitlets.Int(default_value=0).tag(sync=True)
39
40
  background = traitlets.Unicode(default_value="#1a1a2e").tag(sync=True)
40
41
 
41
42
  # === Theme ===
@@ -66,8 +67,11 @@ class VectorSpace(anywidget.AnyWidget):
66
67
 
67
68
  # === Interaction ===
68
69
  selected_points = traitlets.List(default_value=[]).tag(sync=True)
70
+ _selection_version = traitlets.Int(default_value=0).tag(sync=True)
69
71
  hovered_point = traitlets.Dict(default_value=None, allow_none=True).tag(sync=True)
70
- selection_mode = traitlets.CaselessStrEnum(values=["click", "multi", "box"], default_value="click").tag(sync=True)
72
+ selection_mode = traitlets.CaselessStrEnum(values=["click", "multi", "box", "lasso"], default_value="click").tag(
73
+ sync=True
74
+ )
71
75
 
72
76
  # === Tooltip ===
73
77
  show_tooltip = traitlets.Bool(default_value=True).tag(sync=True)
@@ -12,8 +12,8 @@ class TestVectorSpaceInit:
12
12
  """Test empty initialization."""
13
13
  widget = VectorSpace()
14
14
  assert widget.points == []
15
- assert widget.width == 800
16
- assert widget.height == 600
15
+ assert widget.width == 0
16
+ assert widget.height == 0
17
17
 
18
18
  def test_with_points(self):
19
19
  """Test initialization with points."""
@@ -50,7 +50,7 @@ wheels = [
50
50
 
51
51
  [[package]]
52
52
  name = "anywidget-vector"
53
- version = "0.3.0"
53
+ version = "0.3.1"
54
54
  source = { editable = "." }
55
55
  dependencies = [
56
56
  { name = "anywidget" },