Interactive 3D vector visualization for Python notebooks.
Works with Marimo, Jupyter, VS Code, Colab, anywhere anywidget runs.
- Universal: One widget, every notebook environment
- 6D Visualization: X, Y, Z position + Color, Shape, Size encoding
- Backend-agnostic: NumPy, pandas, Grafeo, Qdrant, Chroma, Pinecone, Weaviate, LanceDB, or raw dicts
- Interactive: Orbit, pan, zoom, click, hover, box select
- Customizable: Color scales, shapes, sizes, themes
- Performant: Instanced rendering for large point clouds
uv add anywidget-vector
uv add "anywidget-vector[grafeo]" # with the embedded Grafeo backend (grafeo 0.6)Requires Python 3.12 or later. The grafeo extra installs grafeo 0.6 (grafeo>=0.6.0,<0.7); the browser-side WASM mode loads the matching @grafeo-db/wasm from a CDN. Upgrading from 0.3 with Grafeo:
see Upgrading to 0.4.
from anywidget_vector import VectorSpace
widget = VectorSpace(points=[
{"id": "a", "x": 0.5, "y": 0.3, "z": 0.8, "label": "Point A", "cluster": 0},
{"id": "b", "x": -0.2, "y": 0.7, "z": 0.1, "label": "Point B", "cluster": 1},
{"id": "c", "x": 0.1, "y": -0.4, "z": 0.6, "label": "Point C", "cluster": 0},
])
widgetwidget = VectorSpace.from_dict({
"points": [
{"id": "a", "x": 0, "y": 0, "z": 0},
{"id": "b", "x": 1, "y": 1, "z": 1},
]
})import numpy as np
positions = np.random.randn(100, 3)
widget = VectorSpace.from_numpy(positions)import pandas as pd
df = pd.DataFrame({
"x": [0.1, 0.5, 0.9],
"y": [0.2, 0.6, 0.3],
"z": [0.3, 0.1, 0.7],
"cluster": ["A", "B", "A"],
"size": [0.5, 1.0, 0.8],
})
widget = VectorSpace.from_dataframe(
df,
color_col="cluster",
size_col="size",
)import umap
embedding = umap.UMAP(n_components=3).fit_transform(high_dim_data)
widget = VectorSpace.from_umap(embedding, labels=labels)from qdrant_client import QdrantClient
client = QdrantClient("localhost", port=6333)
widget = VectorSpace.from_qdrant(client, "my_collection", limit=5000)import chromadb
client = chromadb.Client()
collection = client.get_collection("embeddings")
widget = VectorSpace.from_chroma(collection)from pinecone import Pinecone
pc = Pinecone(api_key="...")
index = pc.Index("my-index")
widget = VectorSpace.from_pinecone(index, limit=5000)import weaviate
client = weaviate.Client("http://localhost:8080")
widget = VectorSpace.from_weaviate(client, "Article", limit=5000)import lancedb
db = lancedb.connect("~/.lancedb")
table = db.open_table("vectors")
widget = VectorSpace.from_lancedb(table, limit=5000)| Dimension | Visual Channel | Example |
|---|---|---|
| X | Horizontal position | x coordinate |
| Y | Vertical position | y coordinate |
| Z | Depth position | z coordinate |
| Color | Hue/gradient | Cluster, score |
| Shape | Geometry | Category, type |
| Size | Scale | Importance, count |
widget = VectorSpace(
points=data,
color_field="score", # Field to map
color_scale="viridis", # Scale: viridis, plasma, inferno, magma, cividis, turbo
color_domain=[0, 100], # Optional: explicit range
)widget = VectorSpace(
points=data,
shape_field="category",
shape_map={
"type_a": "sphere", # Available: sphere, cube, cone,
"type_b": "cube", # tetrahedron, octahedron, cylinder
"type_c": "cone",
}
)widget = VectorSpace(
points=data,
size_field="importance",
size_range=[0.02, 0.15], # Min/max point size
)widget = VectorSpace(points=data)
@widget.on_click
def handle_click(point_id, point_data):
print(f"Clicked: {point_id}")
@widget.on_hover
def handle_hover(point_id, point_data):
if point_id:
print(f"Hovering: {point_id}")
@widget.on_selection
def handle_selection(point_ids, points_data):
print(f"Selected {len(point_ids)} points")widget.selected_points # Current selection
widget.select(["a", "b"]) # Select points
widget.clear_selection() # Clear
widget.selection_mode = "box" # Switch to box-select modewidget.camera_position # Get position [x, y, z]
widget.camera_target # Get target [x, y, z]
widget.reset_camera() # Reset to default
widget.focus_on(["a", "b"]) # Focus on specific pointsCompute distances and visualize similarity relationships between points.
| Metric | Description |
|---|---|
euclidean |
Straight-line distance (L2 norm) |
cosine |
Angle-based distance (1 - cosine similarity) |
manhattan |
Sum of absolute differences (L1 norm) |
dot_product |
Negative dot product (higher = closer) |
widget.color_by_distance("point_a")
widget.color_by_distance("point_a", metric="cosine")neighbors = widget.find_neighbors("point_a", k=5)
# Returns: [("point_b", 0.1), ("point_c", 0.2), ...]
neighbors = widget.find_neighbors("point_a", threshold=0.5)widget.show_neighbors("point_a", k=5)
widget.show_neighbors("point_a", threshold=0.3)
# Manual connection settings
widget = VectorSpace(
points=data,
show_connections=True,
k_neighbors=3,
distance_metric="cosine",
connection_color="#00ff00",
connection_opacity=0.5,
)distances = widget.compute_distances("point_a")
# Returns: {"point_b": 0.1, "point_c": 0.5, ...}
# Use high-dimensional vectors (not just x,y,z)
distances = widget.compute_distances(
"point_a",
metric="cosine",
vector_field="embedding"
)widget = VectorSpace(
points=data,
width=1000,
height=700,
dark_mode=None, # None (default) follows the page theme; True or False fixes it
background=None, # None (default) uses the theme's canvas color
show_axes=True,
show_grid=True,
axis_labels={"x": "PC1", "y": "PC2", "z": "PC3"},
show_tooltip=True,
tooltip_fields=["label", "x", "y", "z", "cluster"],
selection_mode="click", # "click", "multi", or "box"
use_instancing=True, # Performance: instanced rendering
)The widget follows the theme of the page it is on: a data-theme="dark" or "light" on the widget's closest ancestor,
then on <html> and <body>, then a dark or light class on <html> or <body>, then the operating system's
dark-mode setting. It keeps following as the page changes. dark_mode reports the theme in use.
A theme you choose stays: VectorSpace(dark_mode=False), widget.dark_mode = ... before or after the widget is shown
(also to the value it already reports), or the toggle in the settings panel. The canvas color follows the theme unless
you choose a background the same way. anywidget-graph and anywidget-archimate behave the same.
Configure a backend for interactive querying:
widget.set_backend("chroma", client=collection)
widget.set_backend("lancedb", client=table)
widget.set_backend("grafeo", client=db)Type a query in the toolbar and press Run (or Enter). An empty result clears the scene; errors show in the settings panel.
Connection settings, including apiKey, authToken, username and password in set_backend(...) and the
embedding key in the settings panel, are widget state: they reach the browser, which needs them for the browser-side
backends (Qdrant, Pinecone, Weaviate, grafeo-server), and Jupyter saves them in the notebook file when it saves widget
state. Use a read-only key with the narrowest scope the backend offers, keep it out of notebooks you share, or query
from Python (from_qdrant(...), set_backend("chroma", client=...) and the other Python-side clients keep their
credentials in the kernel).
Grafeo is the default backend. It runs in three modes, set with
grafeo_connection_mode:
| Mode | Where queries run | Setup |
|---|---|---|
embedded (default) |
In Python, on a grafeo.GrafeoDB |
uv add "anywidget-vector[grafeo]" and set_backend("grafeo", client=db) |
server |
Over HTTP, POST /query on grafeo-server |
grafeo_server_url="http://localhost:7474"; start the server with --cors-origins so the notebook's origin may call it |
wasm |
In the browser, in-memory | Nothing to install: @grafeo-db/wasm 0.6.0 loads from the jsDelivr CDN on the first query (the only code the widget loads from a CDN) |
import grafeo
from anywidget_vector import VectorSpace
db = grafeo.GrafeoDB()
db.create_node(["Doc"], {"title": "Graphs", "embedding": [0.9, 0.1, 0.0]})
db.create_node(["Doc"], {"title": "Vectors", "embedding": [0.1, 0.9, 0.2]})
db.create_vector_index("Doc", "embedding", metric="cosine")
widget = VectorSpace(color_field="distance")
widget.set_backend("grafeo", client=db)
widgetA k-nearest-neighbor query for the toolbar (GQL, one statement per query):
CALL grafeo.search.vector('Doc', 'embedding', [1.0, 0.0, 0.0], 10) YIELD node_id, distance
NEXT MATCH (n:Doc) WHERE id(n) = node_id RETURN n, distance ORDER BY distance
How results become points, the same in every mode:
- A node (
RETURN n) contributes its properties, its labels (labels) and its id (id). - An unaliased column is named after its expression (
n.embedding), and the widget reads it asembedding. - The first of
vector,embeddingorembeddingsbecomes the point'svector; its first three components are the coordinates unless the row also hasx,yandz. - A GQL number with a point (
0.5) is an exact decimal in Grafeo 0.6; the widget turns it into a float, also inside lists and maps, soRETURN 0.5 AS scorecolors and sizes points like any other number.
In wasm mode the database starts empty, so load data with one INSERT per query and create the index with
CREATE VECTOR INDEX doc_embedding ON :Doc(embedding) DIMENSION 3 METRIC 'cosine'. Store embeddings
as vector([...]) there: a plain list is not indexed. An INSERT returns no rows, so it clears the scene; run a
MATCH or a search afterwards to see the data.
A query vector with another number of dimensions than the index, or with NaN, is a query error that names the sizes (for example "the query vector has 2 dimensions; the index on :Doc(embedding) expects 3"); the points stay as they were.
The distance metrics of a Grafeo vector index (cosine, euclidean, dot_product, manhattan) give the
same values as the widget's distance_metric and compute_distances().
The browser engine version is set in one place, GRAFEO_WASM_VERSION in
src/anywidget_vector/backends/grafeo/embed.js, and moves in lockstep with the grafeo extra. Before a bump,
run node tests/js/wasm_smoke.mjs <unpacked @grafeo-db/wasm package>.
0.4 moves the Grafeo backend to Grafeo 0.6, which changes some query results. What to check:
- Install: the
grafeoextra needs grafeo 0.6;uv add "anywidget-vector[grafeo]"upgrades it. Thewasmmode loads@grafeo-db/wasm0.6 from the CDN by itself. - Database files: Grafeo 0.6 keeps every database in a single file. The first read-write open of a 0.5.x
database migrates it and keeps the old file as
<file>.pre-0.6; 0.5.x cannot open the migrated file, so stop every 0.5.x process first and keep the.pre-0.6copy until you no longer need to go back. Opening a directory that is not a 0.5.x database fails: pass a file path, such asdata/vectors.grafeo. - Numbers with a point:
0.1is an exact decimal in GQL, so0.0/0.0and1/0now fail the query (division by zero) instead of giving NaN or null, and integer overflow fails too. Write float literals with an exponent (0.0e0 / 0.0e0) where you want IEEE results. - Writes: a
WHEREright afterINSERT,CREATE,MERGEorDELETEis an error; put the condition before the write. AnINSERTalone returns no rows. - Ordering: GQL
ORDER BYputs nulls last in both directions; addNULLS FIRSTfor the old descending order. - Errors: a vector search with a query of the wrong size raises
grafeo.GrafeoError(Grafeo 0.5.44 panicked); the widget shows it as a query error either way.
See Grafeo's Upgrading to 0.6 for the full list.
widget.to_json() # Points as JSON string
widget.to_html() # Self-contained HTML string
widget.to_html(title="My Vectors") # Custom title
widget.save_html("vectors.html") # Write HTML to fileThree.js is bundled with the package (ui/vendor/three.min.js, built by scripts/vendor), so the widget and its
HTML export work offline and under a strict Content Security Policy. Only the wasm Grafeo mode loads code from a
CDN.
NaN and infinity cannot travel as JSON, so the widget replaces them with None in point values (with a
RuntimeWarning); a missing coordinate is drawn at 0.
| Environment | Supported |
|---|---|
| Marimo | Yes |
| JupyterLab | Yes |
| Jupyter Notebook | Yes |
| VS Code | Yes |
| Google Colab | Yes |
| Databricks | Yes |
- anywidget, custom Jupyter widgets made easy
- anywidget-graph, graph visualization widget
- Three.js, 3D JavaScript library
Apache-2.0
