diff --git a/docs/HierarchicalCanvasGraph.md b/docs/HierarchicalCanvasGraph.md new file mode 100644 index 0000000..960ddf0 --- /dev/null +++ b/docs/HierarchicalCanvasGraph.md @@ -0,0 +1,706 @@ +# HierarchicalCanvasGraph + +## Introduction + +The HierarchicalCanvasGraph component provides a canvas-based hierarchical graph visualization with interactive features. It displays nodes and edges in a tree layout using the Reingold-Tilford algorithm, with built-in support for expand/collapse, zoom/pan, and filtering. + +**Key features:** + +- Canvas-based rendering for smooth performance with large graphs +- Expand/collapse nodes with children +- Zoom and pan with mouse wheel and drag +- Search/filter nodes by text, type, or kind +- Click to select nodes +- Stable zoom maintained on container resize +- Persistent state (collapsed nodes, view transform, filters) +- Event handlers for node interactions + +**Common use cases:** + +- Visualizing class hierarchies and inheritance trees +- Displaying dependency graphs +- Exploring file/folder structures +- Showing organizational charts +- Debugging object instance relationships + +## Quick Start + +Here's a minimal example showing a simple hierarchy: + +```python +from fasthtml.common import * +from myfasthtml.controls.HierarchicalCanvasGraph import ( + HierarchicalCanvasGraph, + HierarchicalCanvasGraphConf +) +from myfasthtml.core.instances import RootInstance +from myfasthtml.myfastapp import create_app + +app, rt = create_app() + +@rt("/") +def index(session): + root = RootInstance(session) + + # Define nodes and edges + nodes = [ + {"id": "root", "label": "Root", "description": "Base class", "type": "Class", "kind": "base"}, + {"id": "child1", "label": "Child 1", "description": "First derived class", "type": "Class", "kind": "derived"}, + {"id": "child2", "label": "Child 2", "description": "Second derived class", "type": "Class", "kind": "derived"}, + ] + + edges = [ + {"from": "root", "to": "child1"}, + {"from": "root", "to": "child2"}, + ] + + # Create graph + conf = HierarchicalCanvasGraphConf(nodes=nodes, edges=edges) + graph = HierarchicalCanvasGraph(root, conf) + + return Titled("Graph Example", graph) + +serve() +``` + +This creates a complete hierarchical graph with: + +- Three nodes in a simple parent-child relationship +- Automatic tree layout with expand/collapse controls +- Built-in zoom/pan navigation +- Search filter bar + +**Note:** The graph automatically saves collapsed state and zoom/pan position across sessions. + +## Basic Usage + +### Visual Structure + +``` +┌─────────────────────────────────────────┐ +│ Filter instances... [x] │ ← Query filter bar +├─────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ │ +│ │ Root │ [±] │ ← Node label (bold) +│ │ Description │ │ ← Description (gray, smaller) +│ └──────────────┘ │ +│ │ │ +│ ┌─────┴─────┐ │ +│ │ │ │ +│ ┌─────────┐ ┌─────────┐ │ ← Child nodes +│ │Child 1 │ │Child 2 │ │ +│ │Details │ │Details │ │ +│ └─────────┘ └─────────┘ │ +│ │ +│ • Dot grid background │ ← Canvas area +│ • Mouse wheel to zoom │ +│ • Drag to pan │ +│ │ +└─────────────────────────────────────────┘ +``` + +### Creating the Graph + +**Step 1: Define nodes** + +Each node is a dictionary with: + +```python +nodes = [ + { + "id": "unique_id", # Required: unique identifier + "label": "Display Name", # Required: shown in the node (main line) + "description": "Details...", # Optional: shown below label (smaller, gray) + "type": "ClassName", # Optional: shown as badge + "kind": "category", # Optional: affects border color + } +] +``` + +**Note:** Nodes with `description` are taller (54px vs 36px) to accommodate the second line of text. + +**Step 2: Define edges** + +Each edge connects two nodes: + +```python +edges = [ + {"from": "parent_id", "to": "child_id"} +] +``` + +**Step 3: Create configuration** + +```python +from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraphConf + +conf = HierarchicalCanvasGraphConf( + nodes=nodes, + edges=edges, + events_handlers=None # Optional: dict of event handlers +) +``` + +**Step 4: Instantiate component** + +```python +graph = HierarchicalCanvasGraph(parent, conf, _id="custom-id") +``` + +### Configuration + +| Parameter | Type | Description | Default | +|-------------------|-----------------------|--------------------------------------------------|---------| +| `nodes` | `list[dict]` | List of node dictionaries | - | +| `edges` | `list[dict]` | List of edge dictionaries | - | +| `events_handlers` | `Optional[dict]` | Event name to Command object mapping | `None` | + +### Node Properties + +| Property | Type | Required | Description | +|---------------|-------|----------|------------------------------------------------| +| `id` | `str` | Yes | Unique node identifier | +| `label` | `str` | Yes | Display text shown in the node (main line) | +| `description` | `str` | No | Secondary text shown below label (smaller, gray) | +| `type` | `str` | No | Type badge shown on node (clickable filter) | +| `kind` | `str` | No | Category determining border color/style | + +### Edge Properties + +| Property | Type | Required | Description | +|----------|-------|----------|--------------------------------| +| `from` | `str` | Yes | Source node ID | +| `to` | `str` | Yes | Target node ID | + +## Advanced Features + +### Filtering + +The component provides three types of filtering: + +**1. Text search** (via filter bar) + +Searches in node `id`, `label`, `type`, and `kind` fields: + +```python +# User types in filter bar +# Automatically filters matching nodes +# Non-matching nodes are dimmed +``` + +**2. Type filter** (click node badge) + +Click a node's type badge to filter by that type: + +```python +# Clicking "Class" badge shows only nodes with type="Class" +# Clicking again clears the filter +``` + +**3. Kind filter** (click node border) + +Click a node's border to filter by kind: + +```python +# Clicking border of kind="base" shows only base nodes +# Clicking again clears the filter +``` + +**Filter behavior:** + +- Only one filter type active at a time +- Filters dim non-matching nodes (opacity reduced) +- Matching nodes and their ancestors remain fully visible +- Clear filter by clicking same badge/border or clearing search text + +### Events + +The component supports two event types via `events_handlers`: + +**select_node**: Fired when clicking a node (not the toggle button) + +```python +from myfasthtml.core.commands import Command + +def on_select(node_id=None): + print(f"Selected node: {node_id}") + return f"Selected: {node_id}" + +conf = HierarchicalCanvasGraphConf( + nodes=nodes, + edges=edges, + events_handlers={ + "select_node": Command( + "SelectNode", + "Handle node selection", + on_select + ) + } +) +``` + +**toggle_node**: Fired when clicking expand/collapse button + +```python +def on_toggle(node_id=None): + print(f"Toggled node: {node_id}") + return "Toggled" + +conf = HierarchicalCanvasGraphConf( + nodes=nodes, + edges=edges, + events_handlers={ + "toggle_node": Command( + "ToggleNode", + "Handle node toggle", + on_toggle + ) + } +) +``` + +### State Persistence + +The graph automatically persists: + +**Collapsed nodes:** +```python +# Get current collapsed state +collapsed = graph.get_state().collapsed # List of node IDs + +# Programmatically set collapsed nodes +graph.set_collapsed({"node1", "node2"}) + +# Toggle a specific node +graph.toggle_node("node1") +``` + +**View transform (zoom/pan):** +```python +# Get current transform +transform = graph.get_state().transform +# {"x": 100, "y": 50, "scale": 1.5} +``` + +**Layout mode:** +```python +# Get current layout mode +mode = graph.get_state().layout_mode # "horizontal" or "vertical" +``` + +**Current selection:** +```python +# Get selected node ID +selected = graph.get_selected_id() # Returns node ID or None +``` + +### Layout Modes + +The graph supports two layout orientations: + +- **horizontal**: Nodes flow left-to-right (default) +- **vertical**: Nodes flow top-to-bottom + +Users can toggle between modes using the UI controls. The mode is persisted in state. + +## Examples + +### Example 1: Simple Class Hierarchy + +Basic inheritance tree visualization: + +```python +from fasthtml.common import * +from myfasthtml.controls.HierarchicalCanvasGraph import ( + HierarchicalCanvasGraph, + HierarchicalCanvasGraphConf +) +from myfasthtml.core.instances import RootInstance +from myfasthtml.myfastapp import create_app + +app, rt = create_app() + +@rt("/") +def index(session): + root = RootInstance(session) + + nodes = [ + {"id": "object", "label": "Object", "description": "Base class for all objects", "type": "BaseClass", "kind": "builtin"}, + {"id": "animal", "label": "Animal", "description": "Abstract animal class", "type": "Class", "kind": "abstract"}, + {"id": "dog", "label": "Dog", "description": "Canine implementation", "type": "Class", "kind": "concrete"}, + {"id": "cat", "label": "Cat", "description": "Feline implementation", "type": "Class", "kind": "concrete"}, + ] + + edges = [ + {"from": "object", "to": "animal"}, + {"from": "animal", "to": "dog"}, + {"from": "animal", "to": "cat"}, + ] + + conf = HierarchicalCanvasGraphConf(nodes=nodes, edges=edges) + graph = HierarchicalCanvasGraph(root, conf) + + return Titled("Class Hierarchy", graph) + +serve() +``` + +### Example 2: Graph with Event Handlers + +Handling node selection and expansion: + +```python +from fasthtml.common import * +from myfasthtml.controls.HierarchicalCanvasGraph import ( + HierarchicalCanvasGraph, + HierarchicalCanvasGraphConf +) +from myfasthtml.core.instances import RootInstance +from myfasthtml.core.commands import Command +from myfasthtml.myfastapp import create_app + +app, rt = create_app() + +@rt("/") +def index(session): + root = RootInstance(session) + + # Event handlers + def on_select(node_id=None): + return Div( + f"Selected: {node_id}", + id="selection-info", + cls="alert alert-info" + ) + + def on_toggle(node_id=None): + return Div( + f"Toggled: {node_id}", + id="toggle-info", + cls="alert alert-success" + ) + + # Create commands + select_cmd = Command("SelectNode", "Handle selection", on_select) + toggle_cmd = Command("ToggleNode", "Handle toggle", on_toggle) + + nodes = [ + {"id": "root", "label": "Root Module", "description": "Main package entry point", "type": "Module", "kind": "package"}, + {"id": "sub1", "label": "Submodule A", "description": "Feature A implementation", "type": "Module", "kind": "module"}, + {"id": "sub2", "label": "Submodule B", "description": "Feature B implementation", "type": "Module", "kind": "module"}, + {"id": "class1", "label": "ClassA", "description": "Handler for feature A", "type": "Class", "kind": "class"}, + {"id": "class2", "label": "ClassB", "description": "Handler for feature B", "type": "Class", "kind": "class"}, + ] + + edges = [ + {"from": "root", "to": "sub1"}, + {"from": "root", "to": "sub2"}, + {"from": "sub1", "to": "class1"}, + {"from": "sub2", "to": "class2"}, + ] + + conf = HierarchicalCanvasGraphConf( + nodes=nodes, + edges=edges, + events_handlers={ + "select_node": select_cmd, + "toggle_node": toggle_cmd + } + ) + + graph = HierarchicalCanvasGraph(root, conf) + + return Titled("Interactive Graph", + graph, + Div(id="selection-info"), + Div(id="toggle-info") + ) + +serve() +``` + +### Example 3: Filtered Graph with Type/Kind Badges + +Graph with multiple node types for filtering: + +```python +from fasthtml.common import * +from myfasthtml.controls.HierarchicalCanvasGraph import ( + HierarchicalCanvasGraph, + HierarchicalCanvasGraphConf +) +from myfasthtml.core.instances import RootInstance +from myfasthtml.myfastapp import create_app + +app, rt = create_app() + +@rt("/") +def index(session): + root = RootInstance(session) + + nodes = [ + # Controllers + {"id": "ctrl1", "label": "UserController", "description": "Handles user requests", "type": "Controller", "kind": "web"}, + {"id": "ctrl2", "label": "AdminController", "description": "Admin panel endpoints", "type": "Controller", "kind": "web"}, + + # Services + {"id": "svc1", "label": "AuthService", "description": "Authentication logic", "type": "Service", "kind": "business"}, + {"id": "svc2", "label": "EmailService", "description": "Email notifications", "type": "Service", "kind": "infrastructure"}, + + # Repositories + {"id": "repo1", "label": "UserRepo", "description": "User data access", "type": "Repository", "kind": "data"}, + {"id": "repo2", "label": "LogRepo", "description": "Logging data access", "type": "Repository", "kind": "data"}, + ] + + edges = [ + {"from": "ctrl1", "to": "svc1"}, + {"from": "ctrl2", "to": "svc1"}, + {"from": "svc1", "to": "repo1"}, + {"from": "svc2", "to": "repo2"}, + ] + + conf = HierarchicalCanvasGraphConf(nodes=nodes, edges=edges) + graph = HierarchicalCanvasGraph(root, conf) + + return Titled("Dependency Graph", + Div( + P("Click on type badges (Controller, Service, Repository) to filter by type"), + P("Click on node borders to filter by kind (web, business, infrastructure, data)"), + P("Use search bar to filter by text"), + cls="mb-4" + ), + graph + ) + +serve() +``` + +### Example 4: Programmatic State Control + +Controlling collapsed state and getting selection: + +```python +from fasthtml.common import * +from myfasthtml.controls.HierarchicalCanvasGraph import ( + HierarchicalCanvasGraph, + HierarchicalCanvasGraphConf +) +from myfasthtml.controls.helpers import mk +from myfasthtml.core.instances import RootInstance +from myfasthtml.core.commands import Command +from myfasthtml.myfastapp import create_app + +app, rt = create_app() + +@rt("/") +def index(session): + root = RootInstance(session) + + nodes = [ + {"id": "root", "label": "Project", "description": "Root directory", "type": "Folder", "kind": "root"}, + {"id": "src", "label": "src/", "description": "Source code", "type": "Folder", "kind": "folder"}, + {"id": "tests", "label": "tests/", "description": "Test suite", "type": "Folder", "kind": "folder"}, + {"id": "main", "label": "main.py", "description": "Application entry point", "type": "File", "kind": "python"}, + {"id": "utils", "label": "utils.py", "description": "Utility functions", "type": "File", "kind": "python"}, + {"id": "test1", "label": "test_main.py", "description": "Main module tests", "type": "File", "kind": "test"}, + ] + + edges = [ + {"from": "root", "to": "src"}, + {"from": "root", "to": "tests"}, + {"from": "src", "to": "main"}, + {"from": "src", "to": "utils"}, + {"from": "tests", "to": "test1"}, + ] + + conf = HierarchicalCanvasGraphConf(nodes=nodes, edges=edges) + graph = HierarchicalCanvasGraph(root, conf, _id="file-tree") + + # Commands to control graph + def collapse_all(): + graph.set_collapsed({"root", "src", "tests"}) + return graph + + def expand_all(): + graph.set_collapsed(set()) + return graph + + def show_selection(): + selected = graph.get_selected_id() + if selected: + return Div(f"Selected: {selected}", id="info", cls="alert alert-info") + return Div("No selection", id="info", cls="alert alert-warning") + + collapse_cmd = Command("CollapseAll", "Collapse all nodes", collapse_all) + expand_cmd = Command("ExpandAll", "Expand all nodes", expand_all) + selection_cmd = Command("ShowSelection", "Show selection", show_selection) + + return Titled("File Tree", + Div( + mk.button("Collapse All", command=collapse_cmd.htmx(target="#file-tree")), + mk.button("Expand All", command=expand_cmd.htmx(target="#file-tree")), + mk.button("Show Selection", command=selection_cmd.htmx(target="#info")), + cls="flex gap-2 mb-4" + ), + Div(id="info"), + graph + ) + +serve() +``` + +--- + +## Developer Reference + +This section contains technical details for developers working on the HierarchicalCanvasGraph component itself. + +### State + +The component uses `HierarchicalCanvasGraphState` (inherits from `DbObject`) for persistence. + +| Name | Type | Description | Default | Persisted | +|-----------------|-------------------|--------------------------------------------------|--------------|-----------| +| `collapsed` | `list[str]` | List of collapsed node IDs | `[]` | Yes | +| `transform` | `dict` | Zoom/pan transform: `{x, y, scale}` | See below | Yes | +| `layout_mode` | `str` | Layout orientation: "horizontal" or "vertical" | `"horizontal"` | Yes | +| `filter_text` | `Optional[str]` | Text search filter | `None` | Yes | +| `filter_type` | `Optional[str]` | Type filter (from badge click) | `None` | Yes | +| `filter_kind` | `Optional[str]` | Kind filter (from border click) | `None` | Yes | +| `ns_selected_id` | `Optional[str]` | Currently selected node ID (ephemeral) | `None` | No | + +**Default transform:** +```python +{"x": 0, "y": 0, "scale": 1} +``` + +### Commands + +Internal commands (managed by `Commands` class inheriting from `BaseCommands`): + +| Name | Description | Internal | +|----------------------|--------------------------------------------------|----------| +| `update_view_state()` | Update view transform and layout mode | Yes | +| `apply_filter()` | Apply current filter and update graph display | Yes | + +### Public Methods + +| Method | Description | Returns | +|--------------------------------|------------------------------------------|----------------------------| +| `get_state()` | Get the persistent state object | `HierarchicalCanvasGraphState` | +| `get_selected_id()` | Get currently selected node ID | `Optional[str]` | +| `set_collapsed(node_ids: set)` | Set collapsed state of nodes | `None` | +| `toggle_node(node_id: str)` | Toggle collapsed state of a node | `self` | +| `render()` | Render the complete component | `Div` | + +### Constructor Parameters + +| Parameter | Type | Description | Default | +|------------|-----------------------------------|------------------------------------|---------| +| `parent` | Instance | Parent instance (required) | - | +| `conf` | `HierarchicalCanvasGraphConf` | Configuration object | - | +| `_id` | `Optional[str]` | Custom ID (auto-generated if None) | `None` | + +### High Level Hierarchical Structure + +``` +Div(id="{id}", cls="mf-hierarchical-canvas-graph") +├── Query(id="{id}-query") +│ └── [Filter input and controls] +├── Div(id="{id}_container", cls="mf-hcg-container") +│ └── [Canvas element - created by JS] +└── Script + └── initHierarchicalCanvasGraph('{id}_container', options) +``` + +### Element IDs + +| Name | Description | +|----------------------|------------------------------------------| +| `{id}` | Root container div | +| `{id}-query` | Query filter component | +| `{id}_container` | Canvas container (sized by JS) | + +**Note:** `{id}` is the instance ID (auto-generated or custom `_id`). + +### CSS Classes + +| Class | Element | +|------------------------------------|-----------------------------------| +| `mf-hierarchical-canvas-graph` | Root container | +| `mf-hcg-container` | Canvas container div | + +### Internal Methods + +| Method | Description | Returns | +|-------------------------------------|--------------------------------------------------|-------------------| +| `_handle_update_view_state()` | Update view state from client | `str` (empty) | +| `_handle_apply_filter()` | Apply filter and re-render graph | `self` | +| `_calculate_filtered_nodes()` | Calculate which nodes match current filter | `Optional[list[str]]` | +| `_prepare_options()` | Prepare JavaScript options object | `dict` | + +### JavaScript Interface + +The component calls `initHierarchicalCanvasGraph(containerId, options)` where `options` is: + +```javascript +{ + nodes: [...], // Array of node objects {id, label, description?, type?, kind?} + edges: [...], // Array of edge objects {from, to} + collapsed: [...], // Array of collapsed node IDs + transform: {x, y, scale}, // Current view transform + layout_mode: "horizontal", // Layout orientation + filtered_nodes: [...], // Array of visible node IDs (null if no filter) + events: { + _internal_update_state: {...}, // HTMX options for state updates + _internal_filter_by_type: {...}, // HTMX options for type filter + _internal_filter_by_kind: {...}, // HTMX options for kind filter + select_node: {...}, // User event handler (optional) + toggle_node: {...} // User event handler (optional) + } +} +``` + +**Node object structure:** +- `id` (required): Unique identifier +- `label` (required): Main display text +- `description` (optional): Secondary text shown below label in smaller gray font +- `type` (optional): Type badge shown on node +- `kind` (optional): Category affecting border color (root|single|unique|multiple) + +### Event Flow + +**Node selection:** +1. User clicks node (not toggle button) +2. JS calls HTMX with `node_id` parameter +3. `select_node` handler executes (if defined) +4. Result updates target element + +**Node toggle:** +1. User clicks expand/collapse button +2. JS updates local collapsed state +3. JS calls `_internal_update_state` HTMX endpoint +4. State persisted in `HierarchicalCanvasGraphState.collapsed` +5. `toggle_node` handler executes (if defined) + +**Filter changes:** +1. User types in search / clicks badge / clicks border +2. Query component or JS triggers `apply_filter()` command +3. `_handle_apply_filter()` updates state filters +4. Component re-renders with filtered nodes +5. JS dims non-matching nodes + +### Dependencies + +**Python:** +- `fasthtml.components.Div` +- `fasthtml.xtend.Script` +- `myfasthtml.controls.Query` - Filter search bar +- `myfasthtml.core.instances.MultipleInstance` - Instance management +- `myfasthtml.core.dbmanager.DbObject` - State persistence + +**JavaScript:** +- `initHierarchicalCanvasGraph()` - Must be loaded via MyFastHtml assets +- Canvas API - For rendering +- HTMX - For server communication