anywidget-vector 0.1.0__tar.gz → 0.2.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anywidget-vector
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Interactive vector visualization for Python notebooks using anywidget
5
5
  Project-URL: Homepage, https://grafeo.dev/
6
6
  Project-URL: Repository, https://github.com/GrafeoDB/anywidget-vector
@@ -264,6 +264,73 @@ widget = VectorSpace(
264
264
  )
265
265
  ```
266
266
 
267
+ ## Distance Metrics
268
+
269
+ Compute distances and visualize similarity relationships between points.
270
+
271
+ ### Supported Metrics
272
+
273
+ | Metric | Description |
274
+ |--------|-------------|
275
+ | `euclidean` | Straight-line distance (L2 norm) |
276
+ | `cosine` | Angle-based distance (1 - cosine similarity) |
277
+ | `manhattan` | Sum of absolute differences (L1 norm) |
278
+ | `dot_product` | Negative dot product (higher = closer) |
279
+
280
+ ### Color by Distance
281
+
282
+ ```python
283
+ # Color points by distance from a reference
284
+ widget.color_by_distance("point_a")
285
+ widget.color_by_distance("point_a", metric="cosine")
286
+ ```
287
+
288
+ ### Find Neighbors
289
+
290
+ ```python
291
+ # Find k nearest neighbors
292
+ neighbors = widget.find_neighbors("point_a", k=5)
293
+ # Returns: [("point_b", 0.1), ("point_c", 0.2), ...]
294
+
295
+ # Find neighbors within distance threshold
296
+ neighbors = widget.find_neighbors("point_a", threshold=0.5)
297
+ ```
298
+
299
+ ### Show Connections
300
+
301
+ ```python
302
+ # Draw lines to k-nearest neighbors
303
+ widget.show_neighbors("point_a", k=5)
304
+
305
+ # Draw lines to all points within threshold
306
+ widget.show_neighbors("point_a", threshold=0.3)
307
+
308
+ # Manual connection settings
309
+ widget = VectorSpace(
310
+ points=data,
311
+ show_connections=True,
312
+ k_neighbors=3,
313
+ distance_metric="cosine",
314
+ connection_color="#00ff00",
315
+ connection_opacity=0.5,
316
+ )
317
+ ```
318
+
319
+ ### Compute Distances
320
+
321
+ ```python
322
+ # Get distances from reference to all points
323
+ distances = widget.compute_distances("point_a")
324
+ # Returns: {"point_b": 0.1, "point_c": 0.5, ...}
325
+
326
+ # Use high-dimensional vectors (not just x,y,z)
327
+ distances = widget.compute_distances(
328
+ "point_a",
329
+ metric="cosine",
330
+ vector_field="embedding" # Use full embedding vector
331
+ )
332
+ ```
333
+
267
334
  ## Export
268
335
 
269
336
  ```python
@@ -216,6 +216,73 @@ widget = VectorSpace(
216
216
  )
217
217
  ```
218
218
 
219
+ ## Distance Metrics
220
+
221
+ Compute distances and visualize similarity relationships between points.
222
+
223
+ ### Supported Metrics
224
+
225
+ | Metric | Description |
226
+ |--------|-------------|
227
+ | `euclidean` | Straight-line distance (L2 norm) |
228
+ | `cosine` | Angle-based distance (1 - cosine similarity) |
229
+ | `manhattan` | Sum of absolute differences (L1 norm) |
230
+ | `dot_product` | Negative dot product (higher = closer) |
231
+
232
+ ### Color by Distance
233
+
234
+ ```python
235
+ # Color points by distance from a reference
236
+ widget.color_by_distance("point_a")
237
+ widget.color_by_distance("point_a", metric="cosine")
238
+ ```
239
+
240
+ ### Find Neighbors
241
+
242
+ ```python
243
+ # Find k nearest neighbors
244
+ neighbors = widget.find_neighbors("point_a", k=5)
245
+ # Returns: [("point_b", 0.1), ("point_c", 0.2), ...]
246
+
247
+ # Find neighbors within distance threshold
248
+ neighbors = widget.find_neighbors("point_a", threshold=0.5)
249
+ ```
250
+
251
+ ### Show Connections
252
+
253
+ ```python
254
+ # Draw lines to k-nearest neighbors
255
+ widget.show_neighbors("point_a", k=5)
256
+
257
+ # Draw lines to all points within threshold
258
+ widget.show_neighbors("point_a", threshold=0.3)
259
+
260
+ # Manual connection settings
261
+ widget = VectorSpace(
262
+ points=data,
263
+ show_connections=True,
264
+ k_neighbors=3,
265
+ distance_metric="cosine",
266
+ connection_color="#00ff00",
267
+ connection_opacity=0.5,
268
+ )
269
+ ```
270
+
271
+ ### Compute Distances
272
+
273
+ ```python
274
+ # Get distances from reference to all points
275
+ distances = widget.compute_distances("point_a")
276
+ # Returns: {"point_b": 0.1, "point_c": 0.5, ...}
277
+
278
+ # Use high-dimensional vectors (not just x,y,z)
279
+ distances = widget.compute_distances(
280
+ "point_a",
281
+ metric="cosine",
282
+ vector_field="embedding" # Use full embedding vector
283
+ )
284
+ ```
285
+
219
286
  ## Export
220
287
 
221
288
  ```python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "anywidget-vector"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Interactive vector visualization for Python notebooks using anywidget"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -3,4 +3,4 @@
3
3
  from anywidget_vector.widget import VectorSpace
4
4
 
5
5
  __all__ = ["VectorSpace"]
6
- __version__ = "0.1.0"
6
+ __version__ = "0.2.0"
@@ -39,6 +39,33 @@ const SHAPES = {
39
39
  cylinder: () => new THREE.CylinderGeometry(0.5, 0.5, 1, 16),
40
40
  };
41
41
 
42
+ // Distance metrics
43
+ const DISTANCE_METRICS = {
44
+ euclidean: (a, b) => {
45
+ const dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
46
+ return Math.sqrt(dx*dx + dy*dy + dz*dz);
47
+ },
48
+ cosine: (a, b) => {
49
+ const dot = a.x*b.x + a.y*b.y + a.z*b.z;
50
+ const magA = Math.sqrt(a.x*a.x + a.y*a.y + a.z*a.z);
51
+ const magB = Math.sqrt(b.x*b.x + b.y*b.y + b.z*b.z);
52
+ if (magA === 0 || magB === 0) return 1;
53
+ return 1 - (dot / (magA * magB)); // Convert similarity to distance
54
+ },
55
+ manhattan: (a, b) => {
56
+ return Math.abs(a.x - b.x) + Math.abs(a.y - b.y) + Math.abs(a.z - b.z);
57
+ },
58
+ dot_product: (a, b) => {
59
+ // Negative dot product as distance (higher dot = closer)
60
+ return -(a.x*b.x + a.y*b.y + a.z*b.z);
61
+ },
62
+ };
63
+
64
+ function computeDistance(p1, p2, metric) {
65
+ const fn = DISTANCE_METRICS[metric] || DISTANCE_METRICS.euclidean;
66
+ return fn(p1, p2);
67
+ }
68
+
42
69
  function getColorFromScale(value, scaleName, domain) {
43
70
  const scale = COLOR_SCALES[scaleName] || COLOR_SCALES.viridis;
44
71
  const [min, max] = domain || [0, 1];
@@ -74,7 +101,7 @@ function getCategoricalColor(value) {
74
101
 
75
102
  function render({ model, el }) {
76
103
  let scene, camera, renderer, controls;
77
- let pointsGroup;
104
+ let pointsGroup, connectionsGroup;
78
105
  let raycaster, mouse;
79
106
  let hoveredObject = null;
80
107
  let tooltip;
@@ -127,6 +154,8 @@ function render({ model, el }) {
127
154
  // Groups
128
155
  pointsGroup = new THREE.Group();
129
156
  scene.add(pointsGroup);
157
+ connectionsGroup = new THREE.Group();
158
+ scene.add(connectionsGroup);
130
159
  axesGroup = new THREE.Group();
131
160
  scene.add(axesGroup);
132
161
 
@@ -135,6 +164,7 @@ function render({ model, el }) {
135
164
  setupRaycaster(container);
136
165
  setupTooltip(container);
137
166
  createPoints();
167
+ createConnections();
138
168
  bindModelEvents();
139
169
  }
140
170
 
@@ -349,6 +379,98 @@ function render({ model, el }) {
349
379
  }
350
380
  }
351
381
 
382
+ function createConnections() {
383
+ // Clear existing connections
384
+ while (connectionsGroup.children.length > 0) {
385
+ const obj = connectionsGroup.children[0];
386
+ if (obj.geometry) obj.geometry.dispose();
387
+ if (obj.material) obj.material.dispose();
388
+ connectionsGroup.remove(obj);
389
+ }
390
+
391
+ const points = model.get("points") || [];
392
+ const showConnections = model.get("show_connections");
393
+ const kNeighbors = model.get("k_neighbors") || 0;
394
+ const distanceThreshold = model.get("distance_threshold");
395
+ const referencePoint = model.get("reference_point");
396
+ const distanceMetric = model.get("distance_metric") || "euclidean";
397
+ const connectionColor = model.get("connection_color") || "#ffffff";
398
+ const connectionOpacity = model.get("connection_opacity") || 0.3;
399
+
400
+ if (!showConnections || points.length < 2) return;
401
+
402
+ const material = new THREE.LineBasicMaterial({
403
+ color: new THREE.Color(connectionColor),
404
+ transparent: true,
405
+ opacity: connectionOpacity,
406
+ });
407
+
408
+ // If reference point is set, connect to k-nearest or within threshold
409
+ if (referencePoint) {
410
+ const refIdx = points.findIndex(p => p.id === referencePoint);
411
+ if (refIdx === -1) return;
412
+ const ref = points[refIdx];
413
+
414
+ // Compute distances from reference
415
+ const distances = points.map((p, i) => ({
416
+ idx: i,
417
+ point: p,
418
+ dist: i === refIdx ? Infinity : computeDistance(ref, p, distanceMetric)
419
+ })).filter(d => d.dist !== Infinity);
420
+
421
+ // Sort by distance
422
+ distances.sort((a, b) => a.dist - b.dist);
423
+
424
+ // Select neighbors
425
+ let neighbors;
426
+ if (distanceThreshold !== null && distanceThreshold !== undefined) {
427
+ neighbors = distances.filter(d => d.dist <= distanceThreshold);
428
+ } else if (kNeighbors > 0) {
429
+ neighbors = distances.slice(0, kNeighbors);
430
+ } else {
431
+ return;
432
+ }
433
+
434
+ // Draw lines
435
+ neighbors.forEach(n => {
436
+ const geometry = new THREE.BufferGeometry();
437
+ const positions = new Float32Array([
438
+ ref.x ?? 0, ref.y ?? 0, ref.z ?? 0,
439
+ n.point.x ?? 0, n.point.y ?? 0, n.point.z ?? 0
440
+ ]);
441
+ geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
442
+ const line = new THREE.Line(geometry, material);
443
+ connectionsGroup.add(line);
444
+ });
445
+ } else if (kNeighbors > 0) {
446
+ // Connect each point to its k-nearest neighbors
447
+ points.forEach((p, i) => {
448
+ const distances = points.map((other, j) => ({
449
+ idx: j,
450
+ point: other,
451
+ dist: i === j ? Infinity : computeDistance(p, other, distanceMetric)
452
+ })).filter(d => d.dist !== Infinity);
453
+
454
+ distances.sort((a, b) => a.dist - b.dist);
455
+ const neighbors = distances.slice(0, kNeighbors);
456
+
457
+ neighbors.forEach(n => {
458
+ // Only draw if i < n.idx to avoid duplicate lines
459
+ if (i < n.idx) {
460
+ const geometry = new THREE.BufferGeometry();
461
+ const positions = new Float32Array([
462
+ p.x ?? 0, p.y ?? 0, p.z ?? 0,
463
+ n.point.x ?? 0, n.point.y ?? 0, n.point.z ?? 0
464
+ ]);
465
+ geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
466
+ const line = new THREE.Line(geometry, material);
467
+ connectionsGroup.add(line);
468
+ }
469
+ });
470
+ });
471
+ }
472
+ }
473
+
352
474
  function setupRaycaster(container) {
353
475
  raycaster = new THREE.Raycaster();
354
476
  mouse = new THREE.Vector2();
@@ -500,7 +622,7 @@ function render({ model, el }) {
500
622
  }
501
623
 
502
624
  function bindModelEvents() {
503
- model.on("change:points", createPoints);
625
+ model.on("change:points", () => { createPoints(); createConnections(); });
504
626
  model.on("change:background", () => {
505
627
  scene.background = new THREE.Color(model.get("background"));
506
628
  });
@@ -514,6 +636,15 @@ function render({ model, el }) {
514
636
  model.on("change:shape_field", createPoints);
515
637
  model.on("change:shape_map", createPoints);
516
638
 
639
+ // Distance/connection related
640
+ model.on("change:show_connections", createConnections);
641
+ model.on("change:k_neighbors", createConnections);
642
+ model.on("change:distance_threshold", createConnections);
643
+ model.on("change:reference_point", createConnections);
644
+ model.on("change:distance_metric", createConnections);
645
+ model.on("change:connection_color", createConnections);
646
+ model.on("change:connection_opacity", createConnections);
647
+
517
648
  model.on("change:camera_position", () => {
518
649
  const pos = model.get("camera_position");
519
650
  if (pos) camera.position.set(pos[0], pos[1], pos[2]);
@@ -614,6 +745,19 @@ class VectorSpace(anywidget.AnyWidget):
614
745
  use_instancing = traitlets.Bool(default_value=True).tag(sync=True)
615
746
  point_budget = traitlets.Int(default_value=100000).tag(sync=True)
616
747
 
748
+ # Distance metrics and connections
749
+ distance_metric = traitlets.Unicode(default_value="euclidean").tag(
750
+ sync=True
751
+ ) # euclidean, cosine, manhattan, dot_product
752
+ show_connections = traitlets.Bool(default_value=False).tag(sync=True)
753
+ k_neighbors = traitlets.Int(default_value=0).tag(sync=True) # k-nearest neighbors to connect
754
+ distance_threshold = traitlets.Float(default_value=None, allow_none=True).tag(
755
+ sync=True
756
+ ) # max distance for connections
757
+ reference_point = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True) # point ID to measure from
758
+ connection_color = traitlets.Unicode(default_value="#ffffff").tag(sync=True)
759
+ connection_opacity = traitlets.Float(default_value=0.3).tag(sync=True)
760
+
617
761
  def __init__(
618
762
  self,
619
763
  points: list[dict[str, Any]] | None = None,
@@ -933,6 +1077,145 @@ class VectorSpace(anywidget.AnyWidget):
933
1077
  """Clear all selections."""
934
1078
  self.selected_points = []
935
1079
 
1080
+ # === Distance Methods ===
1081
+
1082
+ def compute_distances(
1083
+ self,
1084
+ reference_id: str,
1085
+ metric: str | None = None,
1086
+ vector_field: str = "vector",
1087
+ ) -> dict[str, float]:
1088
+ """Compute distances from a reference point to all other points.
1089
+
1090
+ Args:
1091
+ reference_id: ID of the reference point
1092
+ metric: Distance metric (euclidean, cosine, manhattan, dot_product).
1093
+ If None, uses self.distance_metric
1094
+ vector_field: Field containing the full vector (for high-dim distance).
1095
+ Falls back to x,y,z if not present.
1096
+
1097
+ Returns:
1098
+ Dict mapping point ID to distance from reference
1099
+ """
1100
+ metric = metric or self.distance_metric
1101
+ ref_point = next((p for p in self.points if p.get("id") == reference_id), None)
1102
+ if not ref_point:
1103
+ return {}
1104
+
1105
+ ref_vec = self._get_vector(ref_point, vector_field)
1106
+ distances = {}
1107
+
1108
+ for point in self.points:
1109
+ if point.get("id") == reference_id:
1110
+ continue
1111
+ vec = self._get_vector(point, vector_field)
1112
+ distances[point.get("id")] = self._compute_distance(ref_vec, vec, metric)
1113
+
1114
+ return distances
1115
+
1116
+ def find_neighbors(
1117
+ self,
1118
+ reference_id: str,
1119
+ k: int | None = None,
1120
+ threshold: float | None = None,
1121
+ metric: str | None = None,
1122
+ vector_field: str = "vector",
1123
+ ) -> list[tuple[str, float]]:
1124
+ """Find nearest neighbors of a reference point.
1125
+
1126
+ Args:
1127
+ reference_id: ID of the reference point
1128
+ k: Number of neighbors to return (if None, uses threshold)
1129
+ threshold: Maximum distance (if None, uses k)
1130
+ metric: Distance metric to use
1131
+ vector_field: Field containing the full vector
1132
+
1133
+ Returns:
1134
+ List of (point_id, distance) tuples, sorted by distance
1135
+ """
1136
+ distances = self.compute_distances(reference_id, metric, vector_field)
1137
+ sorted_distances = sorted(distances.items(), key=lambda x: x[1])
1138
+
1139
+ if threshold is not None:
1140
+ return [(pid, d) for pid, d in sorted_distances if d <= threshold]
1141
+ elif k is not None:
1142
+ return sorted_distances[:k]
1143
+ else:
1144
+ return sorted_distances
1145
+
1146
+ def color_by_distance(
1147
+ self,
1148
+ reference_id: str,
1149
+ metric: str | None = None,
1150
+ vector_field: str = "vector",
1151
+ ) -> None:
1152
+ """Color points by distance from a reference point.
1153
+
1154
+ Updates points with a '_distance' field and sets color_field to use it.
1155
+ """
1156
+ distances = self.compute_distances(reference_id, metric, vector_field)
1157
+
1158
+ # Update points with distance field
1159
+ updated_points = []
1160
+ for point in self.points:
1161
+ point_copy = dict(point)
1162
+ pid = point.get("id")
1163
+ if pid == reference_id:
1164
+ point_copy["_distance"] = 0.0
1165
+ elif pid in distances:
1166
+ point_copy["_distance"] = distances[pid]
1167
+ updated_points.append(point_copy)
1168
+
1169
+ self.points = updated_points
1170
+ self.color_field = "_distance"
1171
+ self.reference_point = reference_id
1172
+
1173
+ def show_neighbors(
1174
+ self,
1175
+ reference_id: str,
1176
+ k: int | None = None,
1177
+ threshold: float | None = None,
1178
+ ) -> None:
1179
+ """Show connections to nearest neighbors of a reference point."""
1180
+ self.reference_point = reference_id
1181
+ self.show_connections = True
1182
+ if k is not None:
1183
+ self.k_neighbors = k
1184
+ if threshold is not None:
1185
+ self.distance_threshold = threshold
1186
+
1187
+ def _get_vector(self, point: dict[str, Any], vector_field: str) -> list[float]:
1188
+ """Extract vector from point, falling back to x,y,z."""
1189
+ if vector_field in point and point[vector_field]:
1190
+ vec = point[vector_field]
1191
+ return list(vec) if hasattr(vec, "__iter__") else [vec]
1192
+ return [point.get("x", 0), point.get("y", 0), point.get("z", 0)]
1193
+
1194
+ def _compute_distance(self, v1: list[float], v2: list[float], metric: str) -> float:
1195
+ """Compute distance between two vectors."""
1196
+ import math
1197
+
1198
+ # Ensure same length
1199
+ n = min(len(v1), len(v2))
1200
+ v1, v2 = v1[:n], v2[:n]
1201
+
1202
+ if metric == "euclidean":
1203
+ return math.sqrt(sum((a - b) ** 2 for a, b in zip(v1, v2)))
1204
+ elif metric == "cosine":
1205
+ dot = sum(a * b for a, b in zip(v1, v2))
1206
+ mag1 = math.sqrt(sum(a * a for a in v1))
1207
+ mag2 = math.sqrt(sum(b * b for b in v2))
1208
+ if mag1 == 0 or mag2 == 0:
1209
+ return 1.0
1210
+ return 1 - (dot / (mag1 * mag2))
1211
+ elif metric == "manhattan":
1212
+ return sum(abs(a - b) for a, b in zip(v1, v2))
1213
+ elif metric == "dot_product":
1214
+ return -sum(a * b for a, b in zip(v1, v2))
1215
+ else:
1216
+ # Default to euclidean
1217
+ return math.sqrt(sum((a - b) ** 2 for a, b in zip(v1, v2)))
1218
+
936
1219
  # === Export ===
937
1220
 
938
1221
  def to_json(self) -> str:
@@ -175,3 +175,128 @@ class TestTraitlets:
175
175
  shape_map = {"type_a": "cube", "type_b": "cone"}
176
176
  widget = VectorSpace(shape_map=shape_map)
177
177
  assert widget.shape_map == shape_map
178
+
179
+
180
+ class TestDistanceMetrics:
181
+ """Test distance computation methods."""
182
+
183
+ def test_default_distance_metric(self):
184
+ """Test default distance metric is euclidean."""
185
+ widget = VectorSpace()
186
+ assert widget.distance_metric == "euclidean"
187
+
188
+ def test_compute_distances_euclidean(self):
189
+ """Test euclidean distance computation."""
190
+ widget = VectorSpace(
191
+ points=[
192
+ {"id": "a", "x": 0, "y": 0, "z": 0},
193
+ {"id": "b", "x": 1, "y": 0, "z": 0},
194
+ {"id": "c", "x": 0, "y": 1, "z": 0},
195
+ ]
196
+ )
197
+ distances = widget.compute_distances("a", metric="euclidean")
198
+ assert abs(distances["b"] - 1.0) < 0.001
199
+ assert abs(distances["c"] - 1.0) < 0.001
200
+
201
+ def test_compute_distances_manhattan(self):
202
+ """Test manhattan distance computation."""
203
+ widget = VectorSpace(
204
+ points=[
205
+ {"id": "a", "x": 0, "y": 0, "z": 0},
206
+ {"id": "b", "x": 1, "y": 1, "z": 1},
207
+ ]
208
+ )
209
+ distances = widget.compute_distances("a", metric="manhattan")
210
+ assert abs(distances["b"] - 3.0) < 0.001
211
+
212
+ def test_compute_distances_cosine(self):
213
+ """Test cosine distance computation."""
214
+ widget = VectorSpace(
215
+ points=[
216
+ {"id": "a", "x": 1, "y": 0, "z": 0},
217
+ {"id": "b", "x": 1, "y": 0, "z": 0}, # Same direction
218
+ {"id": "c", "x": -1, "y": 0, "z": 0}, # Opposite direction
219
+ ]
220
+ )
221
+ distances = widget.compute_distances("a", metric="cosine")
222
+ assert abs(distances["b"] - 0.0) < 0.001 # Identical = 0 distance
223
+ assert abs(distances["c"] - 2.0) < 0.001 # Opposite = 2 distance
224
+
225
+ def test_find_neighbors_k(self):
226
+ """Test finding k nearest neighbors."""
227
+ widget = VectorSpace(
228
+ points=[
229
+ {"id": "a", "x": 0, "y": 0, "z": 0},
230
+ {"id": "b", "x": 1, "y": 0, "z": 0},
231
+ {"id": "c", "x": 2, "y": 0, "z": 0},
232
+ {"id": "d", "x": 3, "y": 0, "z": 0},
233
+ ]
234
+ )
235
+ neighbors = widget.find_neighbors("a", k=2)
236
+ assert len(neighbors) == 2
237
+ assert neighbors[0][0] == "b" # Closest
238
+ assert neighbors[1][0] == "c" # Second closest
239
+
240
+ def test_find_neighbors_threshold(self):
241
+ """Test finding neighbors within threshold."""
242
+ widget = VectorSpace(
243
+ points=[
244
+ {"id": "a", "x": 0, "y": 0, "z": 0},
245
+ {"id": "b", "x": 1, "y": 0, "z": 0},
246
+ {"id": "c", "x": 5, "y": 0, "z": 0},
247
+ ]
248
+ )
249
+ neighbors = widget.find_neighbors("a", threshold=2.0)
250
+ assert len(neighbors) == 1
251
+ assert neighbors[0][0] == "b"
252
+
253
+ def test_color_by_distance(self):
254
+ """Test coloring points by distance."""
255
+ widget = VectorSpace(
256
+ points=[
257
+ {"id": "a", "x": 0, "y": 0, "z": 0},
258
+ {"id": "b", "x": 1, "y": 0, "z": 0},
259
+ ]
260
+ )
261
+ widget.color_by_distance("a")
262
+ assert widget.color_field == "_distance"
263
+ assert widget.reference_point == "a"
264
+ assert widget.points[0]["_distance"] == 0.0
265
+ assert widget.points[1]["_distance"] == 1.0
266
+
267
+ def test_show_neighbors(self):
268
+ """Test showing neighbor connections."""
269
+ widget = VectorSpace(
270
+ points=[
271
+ {"id": "a", "x": 0, "y": 0, "z": 0},
272
+ {"id": "b", "x": 1, "y": 0, "z": 0},
273
+ ]
274
+ )
275
+ widget.show_neighbors("a", k=1)
276
+ assert widget.show_connections is True
277
+ assert widget.reference_point == "a"
278
+ assert widget.k_neighbors == 1
279
+
280
+
281
+ class TestConnectionTraits:
282
+ """Test connection-related traits."""
283
+
284
+ def test_default_connections_off(self):
285
+ """Test connections are off by default."""
286
+ widget = VectorSpace()
287
+ assert widget.show_connections is False
288
+ assert widget.k_neighbors == 0
289
+ assert widget.distance_threshold is None
290
+
291
+ def test_connection_settings(self):
292
+ """Test connection trait settings."""
293
+ widget = VectorSpace(
294
+ show_connections=True,
295
+ k_neighbors=5,
296
+ connection_color="#ff0000",
297
+ connection_opacity=0.5,
298
+ )
299
+ assert widget.show_connections is True
300
+ assert widget.k_neighbors == 5
301
+ assert widget.connection_color == "#ff0000"
302
+ assert widget.connection_opacity == 0.5
@@ -50,7 +50,7 @@ wheels = [
50
50
 
51
51
  [[package]]
52
52
  name = "anywidget-vector"
53
- version = "0.1.0"
53
+ version = "0.2.0"
54
54
  source = { editable = "." }
55
55
  dependencies = [
56
56
  { name = "anywidget" },