Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Changelog

## 0.3.0 (unreleased)

### New Features

- **Distance metrics sidebar**: Distance section with metric selector (euclidean, cosine, manhattan, dot product), configurable k-neighbors (0-50), and live nearest-neighbor display for the selected point
- **Dimension mapping display**: Sidebar now shows active visual mapping fields: color field, size range, shape field and unique shape count

### Improvements

- Improved sidebar styling and layout

## 0.2.8 (2026-03-15)

### New Features

- **6D parallel coordinates example**: Fashion-MNIST demo with PCA across 6+ dimensions, interactive axis mapping, and shape mapping to categorical labels

## 0.2.7 (2026-03-15)

### New Features

- **Dark/light mode toggle**: `dark_mode` trait with real-time CSS color scheme switching and theme-aware styling across all panels

## 0.2.6 (2026-03-15)

### New Features

- **NumPy array support**: `add_numpy()` method for adding points from arrays with automatic PCA projection for high-dimensional data (D > 3)
- **Dimensionality reduction**: `project()` method with PCA, t-SNE and UMAP support; `set_vectors()` for storing high-dimensional vectors separately
- **Example scripts**: Projection demo and numpy integration examples

### Improvements

- High-dimensional vector storage without syncing to JavaScript
- Coordinate normalization after projection
- Added demo screenshot to README

## 0.2.5 (2026-03-04)

### Bug Fixes

- Fixed lasso selection stability issues
- Removed problematic custom traitlets implementation

### Improvements

- Added keyboard shortcuts: F to fit view, Escape to clear selection
- Better visual feedback for selection modes
- Cleaner toolbar layout with integrated mode controls

## 0.2.4 (2026-03-02)

### New Features

- **Zoom and pan controls**: Zoom in, zoom out and fit-to-view buttons in the toolbar
- **Box selection mode**: New selection mode alongside click and multi-select, with visual feedback during selection
- **HTML export**: `to_html()` and `save_html()` methods for self-contained HTML output with embedded data and Three.js

### Improvements

- Improved camera fitting algorithm for better initial view framing
- Updated README with comprehensive usage examples
- Updated dependencies

## 0.2.3 (2026-02-12)

### New Features

- **Sidebar explorer**: Three collapsible sections: collections browser, dimensions explorer (point count, dimensionality, axis ranges) and clusters explorer (grouped by color field with sizes)
- **Browser-side Grafeo backend**: JavaScript client for Grafeo server and WASM-embedded queries
- **Demo module**: Reference implementation showing various widget features

### Improvements

- Consistency pass across all backend modules
- Ruff configuration tuned for marimo compatibility

## 0.2.2 (2026-02-07)

### New Features

- **Streaming factory methods**: `from_numpy()`, `from_umap()` and `from_arrays()` constructors for various data formats

### Improvements

- Added Apache 2.0 license
- Expanded test suite with 194 additional test cases
- ESM bundle aggregation for widget JavaScript

## 0.2.1 (2026-02-03)

### New Features

- **Multi-backend architecture**: Six backends: Qdrant, Pinecone, Weaviate (browser-side REST), Chroma, LanceDB, Grafeo (Python-side)
- **Modular UI**: Separated rendering into canvas, settings, properties and toolbar components
- **Backend factory methods**: `from_qdrant()`, `from_pinecone()`, `from_weaviate()`, `from_chroma()`, `from_lancedb()`, `from_grafeo()`
- **Event system**: `on_click()`, `on_hover()` and `on_selection()` decorators with callback signatures
- **CI/CD**: GitHub Actions pipelines for testing and PyPI publishing

### Improvements

- Refactored monolithic widget into modular component architecture
- Added README with usage examples

## 0.1.0 (2026-02-03)

- Initial release: Three.js-based interactive 3D vector visualization with instanced rendering, six shape types, continuous and categorical color mapping, orbit controls, hover tooltips, axes/grid display and raycast-based selection
80 changes: 52 additions & 28 deletions examples/fashion-mnist-parallel-coords-6d.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# Based on the Fashion MNIST Parallel Coordinates notebook by the marimo team:
# https://github.com/marimo-team/gallery-examples/blob/main/notebooks/wigglystuff/fashion-mnist-parallel-coords.py
#
# Extended with anywidget-vector 3D scatter view and 6D dimension mapping.
#
# /// script
# requires-python = ">=3.12"
# dependencies = [
Expand All @@ -14,7 +19,7 @@

import marimo

__generated_with = "0.20.2"
__generated_with = "0.19.7"
app = marimo.App(width="full")


Expand All @@ -36,7 +41,7 @@ def _():
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
# Fashion MNIST: Parallel Coordinates + 3D Vector View
# Fashion MNIST: Parallel Coordinates + 6D Vector View

Brush the parallel coordinates axes to filter. The 3D scatter updates in real time.
Use the dropdowns to map PCA dimensions to visual channels.
Expand All @@ -62,18 +67,9 @@ def _(fetch_openml, np):
8: "Bag",
9: "Ankle boot",
}
LABEL_COLORS = {
"T-shirt/top": "#6366f1",
"Trouser": "#f59e0b",
"Pullover": "#10b981",
"Dress": "#ef4444",
"Coat": "#8b5cf6",
"Sandal": "#06b6d4",
"Shirt": "#f97316",
"Sneaker": "#84cc16",
"Bag": "#ec4899",
"Ankle boot": "#14b8a6",
}
# d3 schemeCategory10, assigned alphabetically to match wigglystuff
_d3 = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"]
LABEL_COLORS = {_name: _d3[_i] for _i, _name in enumerate(sorted(label_names.values()))}
SHAPE_NAMES = ["sphere", "cube", "cone", "octahedron", "cylinder", "tetrahedron"]
return LABEL_COLORS, SHAPE_NAMES, images, label_names, labels

Expand Down Expand Up @@ -110,8 +106,8 @@ def _(


@app.cell
def _(ParallelCoordinates, df, mo):
widget = mo.ui.anywidget(ParallelCoordinates(df, color_by="label"))
def _(LABEL_COLORS, ParallelCoordinates, df, mo):
widget = mo.ui.anywidget(ParallelCoordinates(df, color_by="label", color_map=LABEL_COLORS))
widget
return (widget,)

Expand All @@ -122,9 +118,9 @@ def _(mo, n_components_slider):
x_dim = mo.ui.dropdown(options=pcs, value="PC1", label="X")
y_dim = mo.ui.dropdown(options=pcs, value="PC2", label="Y")
z_dim = mo.ui.dropdown(options=pcs, value="PC3", label="Z")
size_dim = mo.ui.dropdown(options=["none", *pcs], value="none", label="Size")
size_dim = mo.ui.dropdown(options=["none", *pcs], value="PC4", label="Size")
color_dim = mo.ui.dropdown(options=["label", *pcs], value="label", label="Color")
shape_dim = mo.ui.dropdown(options=["label", "none"], value="none", label="Shape")
shape_dim = mo.ui.dropdown(options=["label", "none", *pcs], value="PC5", label="Shape")
mo.hstack([x_dim, y_dim, z_dim, size_dim, color_dim, shape_dim], gap=0.5)
return color_dim, shape_dim, size_dim, x_dim, y_dim, z_dim

Expand All @@ -140,6 +136,7 @@ def _(
label_names,
labels,
mo,
np,
shape_dim,
size_dim,
x_dim,
Expand All @@ -152,8 +149,23 @@ def _pc(name):
_xi, _yi, _zi = _pc(x_dim.value), _pc(y_dim.value), _pc(z_dim.value)
_color_pc = None if color_dim.value == "label" else _pc(color_dim.value)
_size_pc = None if size_dim.value == "none" else _pc(size_dim.value)
_shape_mode = shape_dim.value # "label", "none", or a PC name

# Shape mapping: label-based or PC-based (binned into 6 shape buckets)
_unique_labels = sorted(set(label_names.values()))
_shape_map = {_n: SHAPE_NAMES[_j % len(SHAPE_NAMES)] for _j, _n in enumerate(_unique_labels)}
_shape_bins = None
if _shape_mode not in ("label", "none"):
_shape_col = components[:, _pc(_shape_mode)]
_quantiles = np.percentile(_shape_col, np.linspace(0, 100, len(SHAPE_NAMES) + 1))
_shape_bins = list(zip(_quantiles[:-1], _quantiles[1:], SHAPE_NAMES, strict=False))
_shape_map = {_s: _s for _s in SHAPE_NAMES}

def _get_shape_bin(_val):
for _lo, _hi, _s in _shape_bins:
if _val <= _hi:
return _s
return SHAPE_NAMES[-1]

vs_points = []
for _i in range(len(components)):
Expand All @@ -171,8 +183,10 @@ def _pc(name):
_p["color_val"] = float(components[_i, _color_pc])
if _size_pc is not None:
_p["size_val"] = float(components[_i, _size_pc])
if shape_dim.value == "label":
if _shape_mode == "label":
_p["shape_cat"] = _name
elif _shape_bins is not None:
_p["shape_cat"] = _get_shape_bin(components[_i, _pc(_shape_mode)])
vs_points.append(_p)

_vs_kwargs = {
Expand All @@ -183,25 +197,23 @@ def _pc(name):
_vs_kwargs["color_scale"] = "viridis"
if _size_pc is not None:
_vs_kwargs["size_field"] = "size_val"
_vs_kwargs["size_range"] = [0.01, 0.06]
if shape_dim.value == "label":
_vs_kwargs["size_range"] = [0.01, 0.03]
if _shape_mode != "none":
_vs_kwargs["shape_field"] = "shape_cat"
_vs_kwargs["shape_map"] = _shape_map

vs_widget = VectorSpace(
points=vs_points,
width=1200,
width=1600,
height=500,
dark_mode=False,
background="#fafafa",
show_toolbar=True,
show_settings=True,
show_properties=False,
**_vs_kwargs,
)
vs = mo.ui.anywidget(vs_widget)
vs
return vs, vs_points, vs_widget
return vs_points, vs_widget


@app.cell
Expand All @@ -225,17 +237,29 @@ def _(LABEL_COLORS, color_dim, mo, vs_points, vs_widget, widget):


@app.cell
def _(idx, images, label_names, labels, np, plt, widget):
def _(LABEL_COLORS, idx, images, label_names, labels, np, plt, vs_widget, widget):
_filtered = widget.widget.filtered_indices
_sample_idx = np.array(_filtered[:10]) if len(_filtered) >= 10 else np.array(_filtered)

_fig, _axes = plt.subplots(1, len(_sample_idx), figsize=(2 * len(_sample_idx), 2))
# Which points are selected in the 3D view?
_selected_ids = set(vs_widget.selected_points or [])

_fig, _axes = plt.subplots(1, len(_sample_idx), figsize=(2 * len(_sample_idx), 2.4))
if len(_sample_idx) == 1:
_axes = [_axes]
for _ax, _si in zip(_axes, _sample_idx, strict=False):
_name = label_names[labels[idx[_si]]]
_ax.imshow(images[idx[_si]].reshape(28, 28), cmap="gray")
_ax.set_title(label_names[labels[idx[_si]]], fontsize=9)
_ax.set_title(_name, fontsize=9)
_ax.axis("off")
# Highlight if selected in 3D view
if f"p_{_si}" in _selected_ids:
_color = LABEL_COLORS.get(_name, "#0880ea")
for _spine in _ax.spines.values():
_spine.set_visible(True)
_spine.set_color(_color)
_spine.set_linewidth(3)
_ax.set_title(_name, fontsize=9, fontweight="bold", color=_color)
plt.tight_layout()
_fig
return
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "anywidget-vector"
version = "0.2.8"
version = "0.3.0"
description = "Interactive 3D vector visualization with query UI for vector databases"
readme = "README.md"
license = "Apache-2.0"
Expand All @@ -26,10 +26,10 @@ dependencies = [

[project.optional-dependencies]
dev = [
"prek>=0.3.2",
"prek>=0.3",
"pytest>=9.0.2",
"ruff>=0.15",
"ty>=0.0.20",
"ty>=0.0",
"marimo>=0.20",
]
pandas = ["pandas>=2.0"]
Expand Down
39 changes: 35 additions & 4 deletions src/anywidget_vector/ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,47 @@ def get_esm() -> str:
function render({{ model, el }}) {{
const wrapper = document.createElement("div");
wrapper.className = "avs-wrapper";
if (model.get("dark_mode")) wrapper.classList.add("avs-dark");
wrapper.style.width = model.get("width") + "px";
wrapper.style.height = model.get("height") + "px";
el.appendChild(wrapper);

model.on("change:dark_mode", () => {{
const dark = model.get("dark_mode");
// Auto-detect host theme (marimo uses Tailwind class="dark" on <html>)
function detectHostDark() {{
const html = document.documentElement;
if (html.classList.contains("dark")) return true;
if (html.dataset.theme === "dark") return true;
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) return true;
return false;
}}

function applyTheme(dark) {{
wrapper.classList.toggle("avs-dark", dark);
model.set("background", dark ? "#1a1a2e" : "#fafafa");
model.set("background", dark ? "#181c1a" : "#ffffff");
model.save_changes();
}}

// Detect if we're inside a themed host (marimo, jupyter, etc.)
const hasHostTheme = !!el.getRootNode()?.host?.tagName?.startsWith("MARIMO-");
if (hasHostTheme) {{
wrapper.classList.add("avs-auto-theme");
model.set("dark_mode", detectHostDark());
applyTheme(detectHostDark());
// Watch host for live theme changes
const themeObserver = new MutationObserver(() => {{
const dark = detectHostDark();
if (model.get("dark_mode") !== dark) {{
model.set("dark_mode", dark);
applyTheme(dark);
}}
}});
themeObserver.observe(document.documentElement, {{ attributes: true, attributeFilter: ["class", "data-theme"] }});
}} else {{
applyTheme(model.get("dark_mode"));
}}

// Manual toggle from settings panel still works
model.on("change:dark_mode", () => {{
applyTheme(model.get("dark_mode"));
}});

let sidebar = null;
Expand Down
Loading
Loading