How the Archify Lifecycle Renderer Handles State Transitions and Phase Columns

The Archify Lifecycle renderer maps states to phase columns using band-based geometry calculations and routes transitions through configurable path algorithms with automatic port spreading.

The archify/renderers/lifecycle/render-lifecycle.mjs module transforms JSON lifecycle definitions into SVG diagrams by measuring state positions across phase columns and computing transition paths. This article explains the rendering pipeline, from band mapping and column placement to transition routing and path assembly.

Mapping States to Bands and Phase Columns

Band Assignment with bandFor

Every state belongs to a lane, and each lane maps to a fixed band that determines its visual treatment. The bandFor function in archify/renderers/lifecycle/render-lifecycle.mjs performs this mapping:

// archify/renderers/lifecycle/render-lifecycle.mjs#L98-L105
function bandFor(lane) {
  if (lane === 'main') return 'phase';
  if (lane === 'terminal') return 'outcome';
  return 'event';
}
  • main lanes render as phase states—the primary workflow steps
  • terminal lanes render as outcome states—final success or failure states
  • All other lanes render as event states—intermediate or auxiliary states

Geometry Calculation in measureState

The measureState function computes each state's position using band-specific dimensions and column coordinates. States are centered on X-coordinates from layout.phaseXs, layout.eventXs, or layout.outcomeXs based on their band and col index:

// archify/renderers/lifecycle/render-lifecycle.mjs#L107-L128
function measureState(state) {
  const isPhase = bandFor(state.lane) === 'phase';
  const isOutcome = bandFor(state.lane) === 'outcome';
  const width = state.width || (isPhase ? layout.phaseW : isOutcome ? layout.outcomeW : layout.eventW);
  const height = state.height || (isPhase ? layout.phaseH : isOutcome ? layout.outcomeH : layout.eventH);
  const xs = isPhase ? layout.phaseXs : isOutcome ? layout.outcomeXs : layout.eventXs;
  const cx = xs[state.col] ?? xs[xs.length - 1];
  const y = (isPhase ? layout.phaseY : isOutcome ? layout.outcomeY : layout.eventY) + (state.yOffset || 0);
  return {
    ...state,
    width,
    height,
    x: cx - width / 2,
    y,
    cx,
    cy: y + height / 2
  };
}

Key behaviors:

  • Column validation: If state.col exceeds available columns, the last column is used (with validation errors raised separately around lines 158–170)
  • Vertical positioning: Each band has a base Y-coordinate (phaseY, outcomeY, eventY) with optional yOffset adjustment
  • Measured state storage: Results are cached in a Map named states for O(1) lookup during transition routing

Routing Transitions Between States

Side Selection with transitionSides

Transitions specify which edges of source and target states to connect. The renderer respects explicit fromSide/toSide values or computes defaults from relative positions:

// archify/renderers/lifecycle/render-lifecycle.mjs#L74-L80
function transitionSides(transition) {
  const from = states.get(transition.from);
  const to   = states.get(transition.to);
  return {
    fromSide: chosenSide(transition.fromSide, defaultFromSide(from, to)),
    toSide:   chosenSide(transition.toSide,   defaultToSide(from, to)),
  };
}

Automatic Port Spreading

To prevent overlapping arrows when multiple transitions share endpoints, automaticPortSpread pre-computes distributed attachment points:

// archify/renderers/lifecycle/render-lifecycle.mjs#L83-L86
const automaticPorts = automaticPortSpread(lifecycle.transitions, states, {
  sideFor: (transition, endpoint) => transitionSides(transition)[endpoint === 'source' ? 'fromSide' : 'toSide'],
});

These ports are used unless a transition defines its own via points.

Path Algorithms in routeVia

The core routing logic supports multiple strategies via the route property:

// archify/renderers/lifecycle/render-lifecycle.mjs#L29-L70
function routeVia(transition, from, to, start, end, fromSide, toSide) {
  if (transition.via) return transition.via;
  switch (transition.route || 'auto') {
    case 'straight':   return [];
    case 'drop':       return [[start[0], (start[1] + end[1]) / 2], [end[0], (start[1] + end[1]) / 2]];
    case 'bottom-channel':
      const yBot = transition.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 34;
      return [[start[0], yBot], [end[0], yBot]];
    case 'top-channel':
      const yTop = transition.channelY ?? Math.min(from.y, to.y) - 28;
      return [[start[0], yTop], [end[0], yTop]];
    case 'right-channel':
      const xR = transition.channelX ?? Math.max(from.x + from.width, to.x + to.width) + 36;
      return [[xR, start[1]], [xR, end[1]]];
    case 'left-channel':
      const xL = transition.channelX ?? Math.min(from.x, to.x) - 36;
      return [[xL, start[1]], [xL, end[1]]];
    case 'auto':
    default:
      if (start[0] === end[0] || start[1] === end[1]) return [];
      const fromVertical = fromSide === 'top' || fromSide === 'bottom';
      const toVertical   = toSide   === 'top' || toSide   === 'bottom';
      if (fromVertical !== toVertical) {
        return [fromVertical ? [start[0], end[1]] : [end[0], start[1]]];
      }
      if (fromVertical) {
        const y = transition.channelY ?? (start[1] + end[1]) / 2;
        return [[start[0], y], [end[0], y]];
      }
      const x = transition.channelX ?? (start[0] + end[0]) / 2;
      return [[x, start[1]], [x, end[1]]];
  }
}

Available route types:

Route Behavior
straight Direct line, no intermediate points
drop Horizontal midpoint with vertical drop
bottom-channel Routes below both states at channelY or auto-calculated offset
top-channel Routes above both states at channelY or auto-calculated offset
right-channel Routes to the right of both states at channelX or auto-calculated offset
left-channel Routes to the left of both states at channelX or auto-calculated offset
auto (default) Single bend for orthogonal sides; horizontal or vertical channel for parallel sides

Path Assembly in pathFor

The final SVG path combines anchored endpoints, computed via points, and rounded corners:

// archify/renderers/lifecycle/render-lifecycle.mjs#L87-L107
function pathFor(transition) {
  if (pathCache.has(transition)) return pathCache.get(transition);
  const from = states.get(transition.from);
  const to   = states.get(transition.to);
  const ports = automaticPorts.get(transition);
  const { fromSide, toSide } = transitionSides(transition);
  const start = ports?.from || anchor(from, fromSide);
  const end   = ports?.to   || anchor(to,   toSide);
  let via = routeVia(transition, from, to, start, end, fromSide, toSide);
  if (ports && !via.length && Math.abs(start[0] - end[0]) >= 4 && Math.abs(start[1] - end[1]) >= 4) {
    const midX = (start[0] + end[0]) / 2;
    via = [[midX, start[1]], [midX, end[1]]];
  }
  const points = [start, ...via, end];
  const routed = {
    d: roundedPath(points, transition.cornerRadius ?? 10),
    points,
  };
  pathCache.set(transition, routed);
  return routed;
}

The pathCache Map eliminates redundant computation for repeated transitions.

Phase Column Layout in Practice

The phase column system centers states on pre-computed X-coordinates. In archify/examples/agent-run.lifecycle.json, the "Executing" state uses col: 2 to position itself at layout.phaseXs[2] (402px in the default template). This columnar approach ensures consistent horizontal alignment across complex lifecycles without manual coordinate specification.

States in the main lane automatically participate in phase column layout, while terminal and custom lanes use separate coordinate arrays for outcome and event positioning.

Rendering a Complete Lifecycle Diagram

Consumer code invokes the renderer through a standardized pipeline:

import { loadDiagramWithBrandMarks } from '../shared/cli.mjs';
import { renderSvg } from './render-lifecycle.mjs';

// 1️⃣ Load the JSON definition (here we use the built‑in example)
const { diagram: lifecycle, template, outPath } = await loadDiagramWithBrandMarks({
  rendererDir: new URL(import.meta.url).pathname,
  diagramType: 'lifecycle',
  defaultExample: 'agent-run.lifecycle.json',
});

// 2️⃣ The renderer validates the layout, builds the SVG and writes the file
await writeDiagram({
  outPath,
  template,
  diagramType: 'lifecycle',
  meta: lifecycle.meta,
  svg: renderSvg(),
  cards: lifecycle.cards,
});

This mirrors the implementation in render-lifecycle.mjs lines 53–61, delegating all measurement, validation, and routing to internal functions.

Summary

  • Band mapping assigns lanes to phase/outcome/event categories via bandFor, determining which layout coordinates apply
  • Phase column placement uses measureState to center states on layout.phaseXs indices, with validation for out-of-range columns
  • Transition routing combines explicit side selection, automatic port spreading, and seven routing algorithms in routeVia
  • Path caching in pathFor improves performance for diagrams with repeated transition patterns
  • Configurable defaults allow per-transition override of channelX, channelY, cornerRadius, and via points

Frequently Asked Questions

How do I position a state in a specific phase column?

Set the col property in your state definition to the desired column index (0-based). The renderer uses layout.phaseXs[col] as the horizontal center. For example, "col": 2 positions the state at the third phase column coordinate.

What happens if a state's column index is out of range?

measureState falls back to the last available column (xs[xs.length - 1]) while a separate validation pass (around lines 158–170) raises an error to alert you to the mismatch between your lifecycle definition and layout template.

Can I control which side of a state a transition connects to?

Yes. Specify fromSide and/or toSide on the transition object with values like "top", "bottom", "left", or "right". If omitted, the renderer computes default sides based on the relative positions of the connected states.

How does the renderer prevent overlapping transition lines?

The automaticPortSpread function distributes attachment points along state edges when multiple transitions share the same source or target. Additionally, you can use channel routes (bottom-channel, right-channel, etc.) or explicit via points to separate overlapping paths.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →