Skip to content

Commit

Permalink
ENH: Add graphviz_widget_simple function that doesn't include the P…
Browse files Browse the repository at this point in the history
…ython widgets (#18)

* ENH: Add graphviz_widget_simple function that doesn't include the Python widgets

* More tests
  • Loading branch information
basnijholt authored Dec 16, 2024
1 parent e3e95cf commit d576cb7
Show file tree
Hide file tree
Showing 3 changed files with 190 additions and 2 deletions.
13 changes: 12 additions & 1 deletion js/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,25 @@ async function render({ model, el }) {
renderQueue = renderQueue.then(() => {
return new Promise((resolve) => {
Logger.debug(`Widget ${widgetId}: Starting graph render`);
const zoomEnabled = model.get('enable_zoom');
d3graphvizInstance
.engine("dot")
.fade(false)
.tweenPaths(false)
.tweenShapes(false)
.zoomScaleExtent([0, Infinity])
.zoom(true)
.zoomScaleExtent(zoomEnabled ? [0, Infinity] : [1, 1])
.zoom(zoomEnabled)
.on("end", () => {
Logger.debug(`Widget ${widgetId}: Render complete`);
const svg = $(`#${widgetId}`).data("graphviz.svg");
if (svg) {
svg.setup();
// If zoom is disabled, remove zoom behavior completely
if (!zoomEnabled) {
// Remove zoom behavior from the SVG
d3.select(`#${widgetId} svg`).on(".zoom", null);
}
Logger.info(`Widget ${widgetId}: Setup successful`);
} else {
// This sometimes happens and I haven't been able to figure out why
Expand Down Expand Up @@ -148,6 +155,10 @@ async function render({ model, el }) {
updateDirection(model.get("selected_direction"));
});

model.on("change:enable_zoom", async () => {
await renderGraph(model.get("dot_source"));
});

model.on("msg:custom", (msg) => {
if (msg.action === "reset_zoom") {
resetGraph();
Expand Down
61 changes: 61 additions & 0 deletions src/graphviz_anywidget/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,51 @@ class GraphvizAnyWidget(anywidget.AnyWidget):
selected_direction = traitlets.Unicode("bidirectional").tag(sync=True)
search_type = traitlets.Unicode("included").tag(sync=True)
case_sensitive = traitlets.Bool(False).tag(sync=True) # noqa: FBT003
enable_zoom = traitlets.Bool(True).tag(sync=True)


def graphviz_widget(
dot_source: str = "digraph { a -> b; b -> c; c -> a; }",
) -> ipywidgets.VBox:
"""Create a full-featured interactive Graphviz visualization widget.
Parameters
----------
dot_source
The DOT language string representing the graph.
Default is a simple cyclic graph: "digraph { a -> b; b -> c; c -> a; }"
Returns
-------
ipywidgets.VBox
A widget container with the following components:
- Reset zoom button
- Direction selector (bidirectional/downstream/upstream/single)
- Search functionality with type selection and case sensitivity
- Interactive graph visualization
Notes
-----
The widget provides the following interactive features:
- Zoom and pan functionality
- Node/edge search with regex support
- Directional graph traversal
- Interactive highlighting
- Case-sensitive search option
Examples
--------
>>> from graphviz_anywidget import graphviz_widget
>>> dot = '''
... digraph {
... a -> b;
... b -> c;
... c -> a;
... }
... '''
>>> widget = graphviz_widget(dot)
>>> widget # Display in notebook
"""
widget = GraphvizAnyWidget(dot_source=dot_source)
reset_button = ipywidgets.Button(description="Reset Zoom")
direction_selector = ipywidgets.Dropdown(
Expand Down Expand Up @@ -93,3 +133,24 @@ def toggle_case_sensitive(change: dict) -> None:
widget,
],
)


def graphviz_widget_simple(
dot_source: str = "digraph { a -> b; b -> c; c -> a; }",
enable_zoom: bool = True,
) -> GraphvizAnyWidget:
"""Create a simple Graphviz widget with optional zooming functionality.
Parameters
----------
dot_source
The DOT string representing the graph
enable_zoom
Whether to enable zoom functionality
Returns
-------
GraphvizAnyWidget
A widget displaying the graph with optional zoom functionality
"""
return GraphvizAnyWidget(dot_source=dot_source, enable_zoom=enable_zoom)
118 changes: 117 additions & 1 deletion tests/test_widget.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,123 @@
from graphviz_anywidget import GraphvizAnyWidget
import pytest
from graphviz_anywidget import (
GraphvizAnyWidget,
graphviz_widget_simple,
graphviz_widget,
)
from ipywidgets import VBox, HBox, Button, Dropdown, Text, ToggleButton


def test_graphviz_anywidget() -> None:
dot_string = "digraph { a -> b; b -> c; c -> a; }"
widget = GraphvizAnyWidget(dot_source=dot_string)
assert widget.dot_source == dot_string


# Test data
SIMPLE_DOT = "digraph { a -> b; }"
COMPLEX_DOT = """
digraph {
a -> b;
b -> c;
c -> d;
d -> a;
a -> c;
}
"""


def test_graphviz_anywidget_initialization() -> None:
"""Test basic widget initialization."""
widget = GraphvizAnyWidget(dot_source=SIMPLE_DOT)
assert widget.dot_source == SIMPLE_DOT
assert widget.selected_direction == "bidirectional"
assert widget.search_type == "included"
assert widget.case_sensitive is False
assert widget.enable_zoom is True


def test_graphviz_widget_simple_default() -> None:
"""Test simple widget with default settings."""
widget = graphviz_widget_simple()
assert isinstance(widget, GraphvizAnyWidget)
assert widget.enable_zoom is True
assert widget.dot_source != ""


def test_graphviz_widget_simple_custom() -> None:
"""Test simple widget with custom settings."""
widget = graphviz_widget_simple(dot_source=COMPLEX_DOT, enable_zoom=False)
assert isinstance(widget, GraphvizAnyWidget)
assert widget.enable_zoom is False
assert widget.dot_source == COMPLEX_DOT


def test_graphviz_widget_full_structure() -> None:
"""Test full widget structure and components."""
widget = graphviz_widget(COMPLEX_DOT)

# Test overall structure
assert isinstance(widget, VBox)
assert len(widget.children) == 3

# Test control row 1 (reset and direction)
control_row1 = widget.children[0]
assert isinstance(control_row1, HBox)
assert len(control_row1.children) == 2
assert isinstance(control_row1.children[0], Button) # Reset button
assert isinstance(control_row1.children[1], Dropdown) # Direction selector

# Test control row 2 (search controls)
control_row2 = widget.children[1]
assert isinstance(control_row2, HBox)
assert len(control_row2.children) == 3
assert isinstance(control_row2.children[0], Text) # Search input
assert isinstance(control_row2.children[1], Dropdown) # Search type
assert isinstance(control_row2.children[2], ToggleButton) # Case sensitive

# Test graph widget
assert isinstance(widget.children[2], GraphvizAnyWidget)


def test_graphviz_widget_direction_options() -> None:
"""Test direction selector options in full widget."""
widget = graphviz_widget()
direction_selector = widget.children[0].children[1]
assert set(direction_selector.options) == {
"bidirectional",
"downstream",
"upstream",
"single",
}


def test_graphviz_widget_search_type_options() -> None:
"""Test search type options in full widget."""
widget = graphviz_widget()
search_type_selector = widget.children[1].children[1]
assert set(search_type_selector.options) == {"exact", "included", "regex"}


def test_graphviz_widget_invalid_dot() -> None:
"""Test widget behavior with invalid DOT source."""
invalid_dot = "invalid dot source"
widget = graphviz_widget(invalid_dot)
assert widget.children[2].dot_source == invalid_dot


@pytest.mark.parametrize(
"dot_source",
[
"", # Empty string
"digraph {}", # Empty graph
SIMPLE_DOT, # Simple graph
COMPLEX_DOT, # Complex graph
],
)
def test_graphviz_widget_various_inputs(dot_source: str) -> None:
"""Test widget with various DOT source inputs."""
simple_widget = graphviz_widget_simple(dot_source)
full_widget = graphviz_widget(dot_source)

assert simple_widget.dot_source == dot_source
assert full_widget.children[2].dot_source == dot_source

0 comments on commit d576cb7

Please sign in to comment.