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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to this project will be documented in this file.

## [0.5.24] - 2026-08-10

### Fixed

- `EdgeDraw` now redraws when `names` changes from Python, so nodes can be added or removed at runtime (e.g. `widget.names = widget.names + ["e"]`). Previously the frontend read `names` only once at init and never reacted, so Python-side node changes never appeared. Surviving nodes keep their position, links that pointed at a removed node are pruned (and synced back), and the force simulation restarts. Note that traitlets only fires on reassignment — an in-place `widget.names.append(...)` still won't sync; build a new list instead.

## [0.5.23] - 2026-07-28

### Added
Expand Down
35 changes: 32 additions & 3 deletions demos/edgedraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@
# requires-python = ">=3.10"
# dependencies = [
# "marimo",
# "wigglystuff==0.3.1",
# "wigglystuff==0.5.24",
# ]
# ///

import marimo

__generated_with = "0.18.4"
__generated_with = "0.23.16"
app = marimo.App()


@app.cell
def _():
import marimo as mo

return (mo,)


Expand Down Expand Up @@ -84,8 +86,35 @@ def _(widget):
return


@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
## Editing nodes from Python

You can add or remove nodes at runtime by updating `widget.names`.
Traitlets only notices a change when you **reassign** the list, so build a
new list (`widget.names = widget.names + ["e"]`) rather than mutating it in
place with `.append()` — an in-place mutation never syncs to the drawing.
""")
return


@app.cell
def _():
def _(mo):
name_input = mo.ui.text(placeholder="node name")
add_button = mo.ui.run_button(label="Add node", kind="success")
remove_button = mo.ui.run_button(label="Remove node", kind="danger")
mo.hstack([name_input, add_button, remove_button], justify="start")
return add_button, name_input, remove_button


@app.cell
def _(add_button, name_input, remove_button, widget):
name = name_input.value.strip()
if add_button.value and name and name not in widget.names:
widget.names = widget.names + [name]
if remove_button.value and name in widget.names:
widget.names = [n for n in widget.names if n != name]
return


Expand Down
55 changes: 50 additions & 5 deletions js/edgedraw.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ function render({model, el}){

// Sample nodes
const names = model.get("names");
const nodes = names.map((name) => ({ id: name, x: 100, y: 100 }));
const nodeOrder = new Map(names.map((name, index) => [name, index]));
const nodeById = new Map(nodes.map((node) => [node.id, node]));
let nodes = names.map((name) => ({ id: name, x: 100, y: 100 }));
let nodeOrder = new Map(names.map((name, index) => [name, index]));
let nodeById = new Map(nodes.map((node) => [node.id, node]));

let links = [];
let selectedNode = null;
Expand Down Expand Up @@ -66,15 +66,15 @@ function render({model, el}){
const nodeGroup = svg.append("g");

// Draw nodes
const node = nodeGroup.selectAll(".node")
let node = nodeGroup.selectAll(".node")
.data(nodes)
.join("circle")
.attr("class", "node")
.attr("r", 10)
.on("click", handleNodeClick);

// Add labels
const labels = nodeGroup.selectAll(".label")
let labels = nodeGroup.selectAll(".label")
.data(nodes)
.join("text")
.attr("class", "label")
Expand Down Expand Up @@ -196,6 +196,51 @@ function render({model, el}){
simulation.force("link").links(links);
updateLinks();
});

model.on("change:names", () => {
const updatedNames = model.get("names");

// Reuse existing node objects (positions/velocities) for surviving
// names; new names spawn in the middle and let the simulation settle.
const previousById = nodeById;
nodes = updatedNames.map((name) =>
previousById.get(name) || { id: name, x: width / 2, y: height / 2 }
);
nodeOrder = new Map(updatedNames.map((name, index) => [name, index]));
nodeById = new Map(nodes.map((node) => [node.id, node]));

if (selectedNode && !nodeById.has(selectedNode.id)) {
selectedNode = null;
}

// Drop any links that referenced removed nodes.
links = hydrateLinks(model.get("links"));

node = nodeGroup.selectAll(".node")
.data(nodes, (d) => d.id)
.join("circle")
.attr("class", "node")
.attr("r", 10)
.on("click", handleNodeClick);
node.call(nodeDrag);

labels = nodeGroup.selectAll(".label")
.data(nodes, (d) => d.id)
.join("text")
.attr("class", "label")
.attr("dx", 15)
.attr("dy", 4)
.text(d => d.id);

simulation.nodes(nodes);
simulation.force("link").links(links);
simulation.alpha(1).restart();
updateLinks();

// Keep Python's `links` consistent when removing a node prunes edges.
model.set("links", links.map(l => ({source: l.source.id, target: l.target.id})));
model.save_changes();
});
};

export default { render };
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "wigglystuff"
version = "0.5.23"
version = "0.5.24"
description = "Collection of Anywidget Widgets"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion wigglystuff/static/edgedraw.js

Large diffs are not rendered by default.

Loading