Render Nodes And Ports

Copy Markdown View Source

LivePanels graph nodes are app-rendered item shells with runtime port attrs.

The application decides what a node looks like. LivePanels needs enough attrs to know which DOM element represents each declared port and which command to send when the user starts or completes a connection.

Render A Node Shell

Use the :item slot:

<.canvas_runtime {canvas_runtime_attrs(assigns)}>
  <:item :let={context}>
    <article class={["node-card", context.selected? && "is-selected"]}>
      <header {drag_handle_attrs(class: "node-card__title")}>
        {context.item.label}
      </header>

      <section class="node-card__body">
        {context.content}
      </section>

      <.resize_handles :if={context.view.resizable?} />
    </article>
  </:item>
</.canvas_runtime>

Graph nodes still need ordinary item behavior: drag, focus, selection, resize, visibility, z-order, and item content.

Render Port Controls

context.ports contains render-ready ports for the current node:

<button
  :for={port <- context.ports}
  class={"node-port node-port--#{port.direction}"}
  {graph_port_anchor_attrs(port)}
  {connection_attrs(context.item, port)}
>
  {port.label}
</button>

Then choose the command attrs from direction:

defp connection_attrs(item, %{direction: :output} = port), do: begin_connector_attrs(item, port)
defp connection_attrs(item, %{direction: :input} = port), do: complete_connector_attrs(item, port)
defp connection_attrs(_item, _port), do: %{}

graph_port_anchor_attrs/2 identifies the port for geometry and browser interaction. begin_connector_attrs/3 and complete_connector_attrs/3 emit graph commands.

Position Ports From Their Anchor

Use the same anchor declaration to style ports:

defp port_style(%{anchor: %{edge: edge, offset: offset}}) do
  case edge do
    :left -> "left:-0.5rem; top:#{offset}px; transform:translateY(-50%);"
    :right -> "right:-0.5rem; top:#{offset}px; transform:translateY(-50%);"
    :top -> "top:-0.5rem; left:#{offset}px; transform:translateX(-50%);"
    :bottom -> "bottom:-0.5rem; left:#{offset}px; transform:translateX(-50%);"
  end
end

That keeps visible handles aligned with the endpoint coordinates the server will use for connector projection.

Render Row-Based Ports

For table, form, or workflow nodes, derive port rows from item props:

<div :for={entry <- @column_port_entries} class="column-row">
  <span>{entry.column.name}</span>

  <button
    class="column-port"
    style={port_style(entry.port)}
    title={port_title(entry)}
    {graph_port_anchor_attrs(entry.port)}
    {connection_attrs(@item, entry.port)}
  />
</div>

Build the entries by matching declared ports to domain rows:

defp column_port_entries(columns, ports) do
  port_lookup = Map.new(ports, &{&1.name, &1})

  columns
  |> Enum.flat_map(fn column ->
    [
      %{column: column, port: Map.get(port_lookup, "col_#{column.slot}_in")},
      %{column: column, port: Map.get(port_lookup, "col_#{column.slot}_out")}
    ]
  end)
  |> Enum.reject(&is_nil(&1.port))
end

The row is product UI. The port is runtime metadata.

Show Connection Counts

Use LivePanels.Graph.Query to show connected ports:

defp connector_count(connectors, item_id, endpoint, port_name) do
  connectors
  |> Enum.count(fn connector ->
    case {endpoint, connector} do
      {:from, %{from: %{item_id: ^item_id, anchor: anchor}}} ->
        LivePanels.Item.Connector.port_name(anchor) == port_name

      {:to, %{to: %{item_id: ^item_id, anchor: anchor}}} ->
        LivePanels.Item.Connector.port_name(anchor) == port_name

      _ ->
        false
    end
  end)
end

Or use query helpers from the canvas LiveView:

state = LivePanels.Canvas.Read.state(socket)
connectors = LivePanels.Graph.connectors(state)
connected = LivePanels.Graph.Query.connectors_for_port(connectors, "table-1", "col_2_out")

Render counts as application UI:

<span :if={entry.outgoing_count > 0} class="port-count">
  {entry.outgoing_count}
</span>

Pending Connector State

The runtime projects the current actor's connector draft as @livepanels.connector_draft. Pass it to node components when the node should show compatible targets or reconnect state:

<.node context={context} pending_connector={@livepanels.connector_draft} />

Use pending state for visual hints only. Validation still happens on the server when the command completes.

Accessibility

Ports are real controls. Give them names:

<button
  aria-label={"Connect #{context.item.label} #{port.label}"}
  title={port.label}
  {graph_port_anchor_attrs(port)}
  {connection_attrs(context.item, port)}
/>

If a visible port is decorative and another labelled control starts the connection, put graph attrs on the labelled control instead.

Application Responsibilities

The application owns:

  • node layout and visual hierarchy;
  • port shape, label, hover state, count badges, and disabled hints;
  • node inspectors and property editors;
  • how domain data appears inside the node.

LivePanels owns:

  • port metadata projection;
  • graph command attrs;
  • endpoint validation;
  • connector state and connector projection.