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}
end

Add 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
end

If 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
end

Use 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)
end

Render:

<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)}
end

Use 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)}
end

This 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)}
end

Store 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)
end

Render 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
end

Do 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}
end

If 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_connections is not exceeded;
  • the command was not rejected by application policy;
  • the graph extension is configured on the canvas;
  • canvas_runtime/1 receives 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.