Graph visualization — Cytoscape network with click handlers
Render a node-edge graph in a panel using ui.Graph + on_node_click for drill-down
ui.Graph renders an interactive Cytoscape.js network graph inside any panel slot. It accepts flat node/edge dicts or the full Cytoscape {"data": {...}} wrapper — the SDK normalises both formats automatically. Use on_node_click to wire a drill-down: when the user selects a node, the panel host calls the target function with node_id injected as a param. The pattern fits any relationship-graph extension — knowledge-base entity links, project task dependencies, social/contact networks, or CRM account hierarchies.
from __future__ import annotations
from imperal_sdk import Extension, ChatExtension, ActionResult, ui
ext = Extension(
"my-ext",
display_name="My Extension",
description="An extension demonstrating graph visualization with node drill-down.",
actions_explicit=True,
)
chat = ChatExtension(
ext=ext,
tool_name="tool_my_ext_chat",
description="My Extension — browse entity relationships in a graph.",
)
# ── Stub graph data — replace with ctx.store or API fetch ─────────────────
#
# In production, fetch entities + relationships from your own backend
# (or ctx.store / an external API) and map them to this flat dict format.
_NODES = [
{"id": "person-1", "label": "Alice", "type": "person"},
{"id": "person-2", "label": "Bob", "type": "person"},
{"id": "company-1", "label": "Acme Corp", "type": "company"},
{"id": "company-2", "label": "Globex Inc", "type": "company"},
{"id": "event-1", "label": "Contract A", "type": "event"},
]
_EDGES = [
{"id": "e1", "source": "person-1", "target": "company-1", "label": "works_at"},
{"id": "e2", "source": "person-2", "target": "company-1", "label": "works_at"},
{"id": "e3", "source": "person-1", "target": "company-2", "label": "director"},
{"id": "e4", "source": "company-1", "target": "event-1", "label": "party_to"},
{"id": "e5", "source": "company-2", "target": "event-1", "label": "party_to"},
]
_ENTITY_DETAIL: dict[str, dict] = {
"person-1": {"name": "Alice", "type": "person", "role": "Director"},
"person-2": {"name": "Bob", "type": "person", "role": "Employee"},
"company-1": {"name": "Acme Corp", "type": "company", "reg": "GB12345678"},
"company-2": {"name": "Globex Inc", "type": "company", "reg": "GB87654321"},
"event-1": {"name": "Contract A", "type": "event", "date": "2026-01-15"},
}
# ── Left sidebar — graph overview ─────────────────────────────────────────
@ext.panel(
"graph",
slot="left",
title="Relationships",
icon="🌐",
default_width=400,
min_width=320,
max_width=720,
)
async def graph_panel(ctx: object, **kwargs: object) -> object:
# ui.Graph accepts flat dicts: {"id", "label", "type"} for nodes,
# {"id", "source", "target", "label"} for edges.
# The SDK also accepts Cytoscape {"data": {...}} wrappers — both are normalised.
#
# color_by="type" assigns distinct colors per unique value of the "type" field.
# on_node_click fires when the user selects a node; the node's "id" is
# injected as the "node_id" kwarg in the target function call.
return ui.Stack(
gap=2,
children=[
ui.Header(
"Entity network",
level=2,
subtitle=f"{len(_NODES)} entities · {len(_EDGES)} connections",
),
ui.Graph(
nodes=_NODES,
edges=_EDGES,
layout="cose-bilkent",
height=520,
color_by="type",
edge_label_visible=True,
on_node_click=ui.Call("__panel__entity_detail"),
),
],
)
# ── Center panel — entity drill-down ──────────────────────────────────────
@ext.panel(
"entity_detail",
slot="center",
center_overlay=True,
title="Entity detail",
icon="ℹ️",
)
async def entity_detail(ctx: object, node_id: str = "", **kwargs: object) -> object:
# on_node_click injects the selected node's "id" as "node_id".
# Return ui.Empty() at discovery (node_id is "").
if not node_id:
return ui.Empty(
message="Select a node in the graph to view details",
icon="🖱️",
)
entity = _ENTITY_DETAIL.get(node_id)
if entity is None:
return ui.Error(message=f"Entity '{node_id}' not found.")
# Build a KeyValue card with all fields from the entity dict.
kv_items = [{"key": k.capitalize(), "value": str(v)} for k, v in entity.items()]
return ui.Stack(
gap=4,
children=[
ui.Header(entity["name"], level=2, subtitle=entity["type"].capitalize()),
ui.KeyValue(items=kv_items, columns=2),
],
)
# ── chat.function — fetch the graph via chat ──────────────────────────────
@chat.function(
"show_entity_graph",
description="Display the entity relationship graph in the panel.",
action_type="read",
)
async def show_entity_graph(ctx: object) -> ActionResult:
return ActionResult(
status="ok",
summary=f"Graph loaded — {len(_NODES)} entities, {len(_EDGES)} connections.",
refresh_panels=["graph"],
)Walk-through
Flat dict vs Cytoscape format.
ui.Graph accepts two node/edge shapes. Flat dicts — {"id", "label", "type"} for nodes, {"id", "source", "target", "label"} for edges — are the simplest form and work directly with most database query results. The SDK also accepts the full Cytoscape {"data": {...}} wrapper (returned by Cytoscape JSON exports and some graph APIs). Both paths produce the same rendered output; the SDK normalises via an internal _unwrap step. Unknown extra fields are passed through and available as node data in the Cytoscape instance.
color_by="type".
The graph renderer dispatches a distinct color per unique value of the color_by field. The default is "type", which means nodes with "type": "person" get one color, "type": "company" another, and so on. Change color_by to any field present on all nodes — for example "status" for a workflow graph or "risk" for a forensic graph. Nodes missing the field fall back to the default palette color.
on_node_click and node_id injection.
on_node_click=ui.Call("__panel__entity_detail") wires the graph to the center panel. When the user clicks a node, the panel host reads the node's id field and injects it as node_id in the call params. The center handler receives node_id as a kwarg. Always default node_id="" and return ui.Empty() for the empty case — the center panel is also called at batch discovery with no params.
layout selection.
"cose-bilkent" (the default) is a force-directed algorithm — the best choice for general entity/relationship graphs because it separates clusters naturally. Use "breadthfirst" for hierarchy-shaped data (parent/child trees), "circle" for a fixed ring (useful for small graphs where you want consistent position across refreshes), and "concentric" for importance-ranked radial layouts. All layouts run client-side in the browser via Cytoscape.js.
Performance bound.
ui.Graph is designed for up to approximately 5,000 nodes. For larger datasets, filter upstream — aggregate by entity type, limit to first-degree connections from a seed node, or paginate into a sub-graph. A common pattern is to show only the direct connections of the currently-selected entity rather than the full graph.
edge_label_visible=False default.
Edge labels are hidden by default because they create visual noise on dense graphs. Set edge_label_visible=True only when the edge label carries essential semantics (as in the minimal example above, where the relationship type is the key insight). For graphs with more than ~50 edges, leave labels off and surface the label in the drill-down detail instead.
Cross-links
- ui primitives reference — Graph — full prop table, layout enum, Cytoscape format notes, and performance bounds.
- Panel layouts guide — composition patterns including left + center for drill-down.
- Master-detail panel recipe — the same
on_click → center panelpattern applied to a list.