These recipes show common graph-app patterns. They are intentionally app-level patterns: LivePanels owns runtime graph behavior, and your app owns product meaning.
Add A Node And Select It
def handle_event("add-step", _params, socket) do
id = "step-#{System.unique_integer([:positive])}"
socket =
socket
|> LivePanels.Canvas.Item.add!(:step,
id: id,
x: 120,
y: 80,
w: 280,
h: 160,
props: %{title: "New step"}
)
|> LivePanels.Canvas.Selection.select!(id)
{:noreply, socket}
endAdd A Node Between Two Nodes
def insert_between(socket, from_id, to_id) do
new_id = "step-#{System.unique_integer([:positive])}"
socket =
LivePanels.Canvas.transaction!(socket, fn tx ->
tx
|> LivePanels.Canvas.Item.add!(:step, id: new_id, x: 360, y: 120)
|> LivePanels.Graph.connect!(from: {from_id, "out"}, to: {new_id, "in"})
|> LivePanels.Graph.connect!(from: {new_id, "out"}, to: {to_id, "in"})
|> LivePanels.Canvas.Selection.select!(new_id)
end)
socket
endIf an existing connector should be removed as part of the insert, disconnect it in the same transaction.
Prevent Cycles In An App Event
Use LivePanels.Graph.Query.creates_cycle?/3 before submitting a graph command:
def handle_event("connect", %{"from" => from_id, "to" => to_id}, socket) do
state = LivePanels.Canvas.Read.state(socket)
if LivePanels.Graph.Query.creates_cycle?(state, from_id, to_id) do
{:noreply, put_flash(socket, :error, "That connection would create a cycle.")}
else
socket =
LivePanels.Graph.connect!(socket,
from: {from_id, "out"},
to: {to_id, "in"}
)
{:noreply, socket}
end
endUse application policy when the rule must apply to every command source. Use an application event check when the rule belongs to one product workflow.
Show Incoming And Outgoing Counts
defp assign_graph_counts(socket) do
state = LivePanels.Canvas.Read.state(socket)
by_item = LivePanels.Graph.Query.connectors_by_item(state)
assign(socket, :connector_counts, by_item)
endRender:
<span class="node-count">
{length(Map.get(@connector_counts, context.item.id, []))}
</span>Select Downstream Nodes
def handle_event("select-downstream", %{"id" => item_id}, socket) do
state = LivePanels.Canvas.Read.state(socket)
selection =
LivePanels.Graph.Query.navigation(state, {:item, item_id}, :outgoing,
mode: :all
).selection
{:noreply, LivePanels.Canvas.Selection.select!(socket, selection)}
endUse this pattern for "show dependencies", "select consumers", or "trace lineage" controls.
Fit A Selected Subgraph
def handle_event("focus-selection", _params, socket) do
{:noreply, LivePanels.Graph.fit(socket, :selection)}
endThis changes the current actor's viewport. It does not move nodes for other participants.
Toggle Connector Route
def handle_event("graph-route", %{"route" => route}, socket) do
route =
case route do
"bezier" -> :bezier
"orthogonal" -> :orthogonal
"straight" -> :straight
_ -> :bezier
end
{:noreply, assign(socket, :connector_route, route)}
endStore shared or persistent route choice on connector items:
socket =
LivePanels.Graph.edit!(socket, :all,
route: route,
direction: :horizontal
)Route choice can stay in application UI state only when it is a local display preference. Store it on connector items when it should persist, stream, or be shared.
Render A Graph Inspector
defp assign_graph_inspector(socket) do
state = LivePanels.Canvas.Read.state(socket)
selection = LivePanels.Canvas.Read.selection(socket)
selected_item_ids = LivePanels.Canvas.Selection.selected_item_ids(selection)
selected_connectors =
Enum.filter(LivePanels.Graph.connectors(state), fn connector ->
MapSet.member?(selected_item_ids, connector.id)
end)
assign(socket, selected_connectors: selected_connectors)
endRender connector controls:
<aside :if={@selected_connectors != []} class="graph-inspector">
<section :for={connector <- @selected_connectors}>
<p>{connector.id}</p>
<button {disconnect_attrs(connector)}>Remove connector</button>
</section>
</aside>Mirror Canvas Connectors To Domain Records
For domain-backed graphs, keep the domain write explicit:
def handle_event("connect-dependency", %{"from" => from_id, "to" => to_id}, socket) do
with {:ok, dependency} <- MyApp.Dependencies.create(from_id, to_id) do
socket =
LivePanels.Graph.connect!(socket,
from: {dependency.from_id, "out"},
to: {dependency.to_id, "in"}
)
{:noreply, socket}
else
{:error, changeset} ->
{:noreply, assign(socket, :dependency_error, changeset)}
end
endDo not hide the domain write inside LivePanels. LivePanels should only own the canvas mutation.
Restore A Domain-Backed Graph
Build source-authored items and connector commands from domain records:
def mount(%{"workflow_id" => workflow_id}, _session, socket) do
workflow = MyApp.Workflows.get!(workflow_id)
socket =
socket
|> assign(:workflow, workflow)
|> assign(:graph_items, graph_items(workflow))
|> assign(:graph_connectors, graph_connectors(workflow))
{:ok, socket}
endIf you also use LivePanels layout persistence, make sure domain records exist before the layout restores. Stable item ids are the bridge between app data and canvas state.
Debug A Missing Connector
Check these in order:
- both endpoint items exist;
- both endpoint ports are declared on the item attrs;
- directions are output-to-input;
- port types are compatible;
- target
max_connectionsis not exceeded; - the command was not rejected by application policy;
- the graph extension is configured on the canvas;
canvas_runtime/1receives current@livepanels.items;- route presentation on the connector item is valid.
Use LivePanels.Graph.connect/2, disconnect/2, or
reconnect/3 when server code needs the rejection reason immediately.
Browser graph gestures also record their result on
LivePanels.Canvas.Read.last_command/1.