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

## New features

* Added the ability to set the selection mode (gesture) of the `GraphWidget` from Python, via the `selection_mode` render option or the `GraphWidget.set_selection_mode` method.

## Bug fixes

## Improvements
Expand Down
3 changes: 3 additions & 0 deletions docs/source/api-reference/render_options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@

.. autoenum:: neo4j_viz.Renderer
:members:

.. autoenum:: neo4j_viz.SelectionMode
:members:
10 changes: 8 additions & 2 deletions js-applet/src/graph-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type GraphOptions = {
pan?: { x: number; y: number };
layoutOptions?: Record<string, unknown>;
showLayoutButton: boolean;
selectionMode?: Gesture;
};

export type WidgetData = {
Expand Down Expand Up @@ -167,9 +168,14 @@ function GraphWidget() {
const [height] = useModelState<WidgetData["height"]>("height");
const [width] = useModelState<WidgetData["width"]>("width");
const [theme] = useModelState<WidgetData["theme"]>("theme");
const [gesture, setGesture] = useState<Gesture>("single");
const { layout, nvlOptions, zoom, pan, layoutOptions, showLayoutButton } =
const { layout, nvlOptions, zoom, pan, layoutOptions, showLayoutButton, selectionMode } =
options ?? {};
// `gesture` is locally controlled so the GestureSelectButton stays interactive, but it is
// seeded from (and re-synced to) the Python-provided `selectionMode` when that changes.
const [gesture, setGesture] = useState<Gesture>(selectionMode ?? "single");
useEffect(() => {
if (selectionMode) setGesture(selectionMode);
}, [selectionMode]);
const setLayout = (layout: Layout) => {
setOptions({ ...options, layout });
};
Expand Down
2 changes: 2 additions & 0 deletions python-wrapper/src/neo4j_viz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Packing,
PanPosition,
Renderer,
SelectionMode,
WidgetOptions,
)
from .relationship import Relationship
Expand All @@ -26,6 +27,7 @@
"CaptionAlignment",
"Layout",
"Renderer",
"SelectionMode",
"ForceDirectedLayoutOptions",
"HierarchicalLayoutOptions",
"Direction",
Expand Down
67 changes: 49 additions & 18 deletions python-wrapper/src/neo4j_viz/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,45 @@ def check(self, renderer: Renderer, num_nodes: int) -> None:
)


_LAYOUT_TO_JS: dict[str, str] = {
Layout.FORCE_DIRECTED.value: "d3Force",
Layout.HIERARCHICAL.value: "hierarchical",
Layout.COORDINATE.value: "free",
Layout.GRID.value: "grid",
@enum_tools.documentation.document_enum
class SelectionMode(str, Enum):
"""
The selection mode (a.k.a. gesture) that determines how dragging on the canvas behaves.
"""

PAN = "single"
"""
Drag the canvas to pan around the graph; click to select individual nodes and relationships.
This is the default. (Shown as "Individual" in the widget UI.)
"""
BOX = "box"
"""
Drag to draw a rectangular region that selects all nodes and relationships within it.
"""
LASSO = "lasso"
"""
Drag to draw a freehand region that selects all nodes and relationships within it.
"""


@enum_tools.documentation.document_enum
class WidgetLayout(str, Enum):
"""The layout values in the JS/NVL wire format, as stored in :class:`WidgetOptions`."""

D3_FORCE = "d3Force"
HIERARCHICAL = "hierarchical"
FREE = "free"
GRID = "grid"
CIRCULAR = "circular"


# Maps the Python-facing `Layout` to the JS/NVL wire format stored in `WidgetOptions`.
_LAYOUT_TO_JS: dict[Layout, WidgetLayout] = {
Layout.FORCE_DIRECTED: WidgetLayout.D3_FORCE,
Layout.HIERARCHICAL: WidgetLayout.HIERARCHICAL,
Layout.COORDINATE: WidgetLayout.FREE,
Layout.GRID: WidgetLayout.GRID,
Layout.CIRCULAR: WidgetLayout.CIRCULAR,
}


Expand Down Expand Up @@ -183,16 +217,18 @@ class WidgetOptions(
):
"""The render options consumed by the ``GraphWidget``."""

layout: Optional[str] = None
layout: Optional[WidgetLayout] = None
layout_options: Optional[dict[str, Any]] = None
nvl_options: Optional[NvlOptions] = None
zoom: Optional[float] = None
pan: Optional[PanPosition] = None
show_layout_button: Optional[bool] = None
selection_mode: Optional[SelectionMode] = None

def to_json(self) -> dict[str, Any]:
"""Serialize to the camelCase dict the frontend consumes, dropping unset fields."""
return self.model_dump(exclude_none=True)
# mode="json" renders the `WidgetLayout`/`SelectionMode` enums as their string values.
return self.model_dump(mode="json", exclude_none=True)


class RenderOptions(BaseModel, extra="allow"):
Expand All @@ -216,6 +252,8 @@ class RenderOptions(BaseModel, extra="allow"):
min_zoom: Optional[float] = Field(None, serialization_alias="minZoom", description="The minimum zoom level allowed")
allow_dynamic_min_zoom: Optional[bool] = Field(None, serialization_alias="allowDynamicMinZoom")

selection_mode: Optional[SelectionMode] = Field(None, serialization_alias="selectionMode")

show_layout_button: bool = False

@model_validator(mode="after")
Expand All @@ -233,17 +271,10 @@ def to_widget_options(self) -> WidgetOptions:
result = WidgetOptions()

if self.layout is not None:
match self.layout:
case Layout.FORCE_DIRECTED:
result.layout = "d3Force"
case Layout.HIERARCHICAL:
result.layout = "hierarchical"
case Layout.COORDINATE:
result.layout = "free"
case Layout.GRID:
result.layout = "grid"
case Layout.CIRCULAR:
result.layout = "circular"
result.layout = _LAYOUT_TO_JS[self.layout]

if self.selection_mode is not None:
result.selection_mode = self.selection_mode

if self.layout_options is not None:
result.layout_options = self.layout_options.model_dump(exclude_none=True)
Expand Down
78 changes: 39 additions & 39 deletions python-wrapper/src/neo4j_viz/resources/nvl_entrypoint/index.html

Large diffs are not rendered by default.

Loading
Loading