Files
projax/internal/graph/layout.go
mAi 3901a1888e feat(phase 3f graph): visual /graph view, server-rendered SVG, layered DAG
- internal/graph package: pure-Go layered top-down DAG layout
  - LayerByLongestPath (multi-parent sits at max(parent-layer)+1)
  - OrderInLayer (slug-sort, deterministic)
  - Compute returns positions + edges + canvas size
  - cycle-safe (depth-cap)
- web/graph.go handler: filter chips reused from tree_filter
  - dim mode default (opacity 0.15 on non-matches)
  - ?isolate=1 hides non-matches + prunes orphaned edges
  - ?download=svg serves raw SVG attachment
- graph_svg.tmpl renders inline SVG: border colour by management
  (mai blue / self green / external orange / mixed dashed purple),
  opacity by status, tag pills, ×N multi-parent badge, click-navigate
- nav adds "graph" link; design.md §"Graph view" documents the surface
- 4 integration tests cover render, dim, isolate, SVG download
- 6 layout unit tests cover layering, ordering, cycle-guard
2026-05-15 19:06:57 +02:00

256 lines
6.6 KiB
Go

// Package graph computes layered top-down DAG layouts for the /graph view.
// It is intentionally minimal — pure Go, no external deps — and tuned for
// m's scale (≤ a few hundred items, single render per request).
//
// Algorithm: longest-path-from-root layering, then deterministic in-layer
// ordering by slug. Positions are assigned on a fixed grid with configurable
// node size and gaps. Multi-parent items resolve to the *maximum* layer
// of any of their parents + 1, so an item that surfaces under both `work`
// and `dev` sits below whichever lineage is longer.
package graph
import (
"fmt"
"sort"
)
// Node is the minimal item shape the layout needs. The web handler builds
// these from store.Item; this package never reaches into store/web so it
// stays independently testable.
type Node struct {
ID string
Slug string
ParentIDs []string
}
// Pos is a node's computed position in the rendered SVG. Coordinates are in
// the SVG's user-units (pixels at 1:1 viewBox).
type Pos struct {
X, Y float64
Layer int
Order int // 0-based position within the layer
}
// Edge is a parent→child edge with the four points needed for a smooth
// cubic Bézier: (Px,Py) source center-bottom, (Cx,Cy) target center-top,
// plus two control points laid out by the renderer.
type Edge struct {
ParentID string
ChildID string
SourceX, SourceY float64
TargetX, TargetY float64
}
// Opts shapes the layout grid.
type Opts struct {
NodeWidth float64 // default 120
NodeHeight float64 // default 40
HGap float64 // horizontal gap between sibling nodes (default 24)
VGap float64 // vertical gap between layers (default 80)
MarginX float64 // left/right canvas margin (default 24)
MarginY float64 // top/bottom canvas margin (default 24)
}
// applyDefaults fills zeroed Opts fields.
func (o *Opts) applyDefaults() {
if o.NodeWidth == 0 {
o.NodeWidth = 120
}
if o.NodeHeight == 0 {
o.NodeHeight = 40
}
if o.HGap == 0 {
o.HGap = 24
}
if o.VGap == 0 {
o.VGap = 80
}
if o.MarginX == 0 {
o.MarginX = 24
}
if o.MarginY == 0 {
o.MarginY = 24
}
}
// Layout is the rendered output: per-node positions + a flat list of edges
// + the total canvas size.
type Layout struct {
Positions map[string]Pos
Edges []Edge
CanvasWidth float64
CanvasHeight float64
Layers [][]string // node IDs per layer, top-down
}
// LayerByLongestPath assigns each node a layer index. Roots (no parents) sit
// at layer 0; every other node sits at `max(layer(parent) for parent in
// parents) + 1`. Cycle-safe by depth-cap (if a cycle exists the trigger
// guard already rejected it on write, but be paranoid in tests too).
func LayerByLongestPath(nodes []Node) (map[string]int, error) {
byID := make(map[string]Node, len(nodes))
for _, n := range nodes {
byID[n.ID] = n
}
layer := make(map[string]int, len(nodes))
const maxDepth = 64
var visit func(id string, depth int) (int, error)
visit = func(id string, depth int) (int, error) {
if depth > maxDepth {
return 0, fmt.Errorf("graph: depth exceeds %d at %s (cycle?)", maxDepth, id)
}
if v, ok := layer[id]; ok {
return v, nil
}
n, ok := byID[id]
if !ok {
// Parent referenced but not in input — treat as a synthetic root.
layer[id] = 0
return 0, nil
}
if len(n.ParentIDs) == 0 {
layer[id] = 0
return 0, nil
}
best := -1
for _, pid := range n.ParentIDs {
pl, err := visit(pid, depth+1)
if err != nil {
return 0, err
}
if pl+1 > best {
best = pl + 1
}
}
layer[id] = best
return best, nil
}
for _, n := range nodes {
if _, err := visit(n.ID, 0); err != nil {
return nil, err
}
}
return layer, nil
}
// OrderInLayer groups nodes by layer index and sorts each layer
// alphabetically by slug for stable rendering.
func OrderInLayer(nodes []Node, layers map[string]int) [][]string {
if len(layers) == 0 {
return nil
}
max := 0
for _, l := range layers {
if l > max {
max = l
}
}
byID := make(map[string]Node, len(nodes))
for _, n := range nodes {
byID[n.ID] = n
}
out := make([][]string, max+1)
for id, l := range layers {
out[l] = append(out[l], id)
}
for i := range out {
sort.Slice(out[i], func(a, b int) bool {
return byID[out[i][a]].Slug < byID[out[i][b]].Slug
})
}
return out
}
// Compute produces a full Layout from raw nodes. Pure function — callers can
// reuse it from handler tests or scripts without HTTP plumbing.
func Compute(nodes []Node, opts Opts) (*Layout, error) {
opts.applyDefaults()
if len(nodes) == 0 {
return &Layout{
Positions: map[string]Pos{},
CanvasWidth: opts.NodeWidth + 2*opts.MarginX,
CanvasHeight: opts.NodeHeight + 2*opts.MarginY,
}, nil
}
layers, err := LayerByLongestPath(nodes)
if err != nil {
return nil, err
}
ordered := OrderInLayer(nodes, layers)
widest := 0
for _, layer := range ordered {
if len(layer) > widest {
widest = len(layer)
}
}
canvasW := opts.MarginX*2 + float64(widest)*opts.NodeWidth + float64(widest-1)*opts.HGap
if widest <= 1 {
canvasW = opts.MarginX*2 + opts.NodeWidth
}
positions := make(map[string]Pos, len(nodes))
byID := make(map[string]Node, len(nodes))
for _, n := range nodes {
byID[n.ID] = n
}
for li, layer := range ordered {
rowW := float64(len(layer))*opts.NodeWidth + float64(len(layer)-1)*opts.HGap
if len(layer) <= 1 {
rowW = opts.NodeWidth
}
startX := (canvasW - rowW) / 2
for i, id := range layer {
x := startX + float64(i)*(opts.NodeWidth+opts.HGap)
y := opts.MarginY + float64(li)*(opts.NodeHeight+opts.VGap)
positions[id] = Pos{X: x, Y: y, Layer: li, Order: i}
}
}
canvasH := opts.MarginY*2 + float64(len(ordered))*opts.NodeHeight + float64(len(ordered)-1)*opts.VGap
if len(ordered) <= 1 {
canvasH = opts.MarginY*2 + opts.NodeHeight
}
// Edges: one per (parent, child) pair, source = parent center-bottom,
// target = child center-top. Multi-parent items emit one edge per parent.
var edges []Edge
for _, n := range nodes {
cp, ok := positions[n.ID]
if !ok {
continue
}
for _, pid := range n.ParentIDs {
pp, ok := positions[pid]
if !ok {
continue
}
edges = append(edges, Edge{
ParentID: pid,
ChildID: n.ID,
SourceX: pp.X + opts.NodeWidth/2,
SourceY: pp.Y + opts.NodeHeight,
TargetX: cp.X + opts.NodeWidth/2,
TargetY: cp.Y,
})
}
}
// Stable edge order for deterministic rendering.
sort.Slice(edges, func(i, j int) bool {
if edges[i].ParentID != edges[j].ParentID {
return edges[i].ParentID < edges[j].ParentID
}
return edges[i].ChildID < edges[j].ChildID
})
return &Layout{
Positions: positions,
Edges: edges,
CanvasWidth: canvasW,
CanvasHeight: canvasH,
Layers: ordered,
}, nil
}