// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT package diagram import ( "fmt" "slices" "sort" "strings" ) // Layout constants, in SVG units. const ( margin = 32 rankGap = 64 laneGap = 48 nodeMinWidth = 96 textPad = 28 ) type laidNode struct { x, y, w, h int // centre and size in the TB orientation } type flowLayout struct { laid []laidNode // parallel to d.nodes rank []int // parallel to d.nodes width int height int } func renderFlowchart(lines []string, dir string) ([]byte, error) { d, err := parseFlowchart(lines, dir) if err != nil { return nil, err } lay := layoutFlowchart(d) return writeFlowchart(d, lay), nil } // layoutFlowchart assigns ranks by longest path, orders each rank by // barycentre sweeps with the order of appearance as the tie breaker, and // packs the rows. Every step walks nodes and edges by index, so the // layout never depends on map iteration. func layoutFlowchart(d *flowDiagram) *flowLayout { n := len(d.nodes) lay := &flowLayout{laid: make([]laidNode, n), rank: make([]int, n)} for i, node := range d.nodes { lay.laid[i].w, lay.laid[i].h = nodeSize(node) } // Ranks: relax along the edges, in index order, one pass per node at // most. A cycle cannot push the ranks past that bound. for pass := 0; pass <= len(d.nodes); pass++ { changed := false for _, e := range d.edges { if lay.rank[e.to] < lay.rank[e.from]+1 { lay.rank[e.to] = lay.rank[e.from] + 1 changed = true } } if !changed { break } } maxRank := 0 for _, r := range lay.rank { if r > maxRank { maxRank = r } } rows := make([][]int, maxRank+1) for i, r := range lay.rank { rows[r] = append(rows[r], i) } // Four alternating barycentre sweeps. for pass := range 4 { down := pass%2 == 0 ranks := make([]int, 0, len(rows)) if down { for r := range rows { ranks = append(ranks, r) } } else { for r := range slices.Backward(rows) { ranks = append(ranks, r) } } for _, r := range ranks { if down && r == 0 || !down && r == len(rows)-1 { continue } neighbour := r - 1 if !down { neighbour = r + 1 } position := map[int]int{} for p, i := range rows[neighbour] { position[i] = p } bary := map[int]int{} sum := map[int]int{} for _, e := range d.edges { from, to := e.from, e.to if !down { from, to = to, from } if lay.rank[from] == neighbour && lay.rank[to] == r { if p, ok := position[from]; ok { sum[to] += p bary[to]++ } } } sort.SliceStable(rows[r], func(a, b int) bool { ia, ib := rows[r][a], rows[r][b] switch { case bary[ia] == 0 && bary[ib] == 0: return ia < ib case bary[ia] == 0: return false case bary[ib] == 0: return true } ma := sum[ia] / bary[ia] mb := sum[ib] / bary[ib] if ma != mb { return ma < mb } return ia < ib }) } } // Rank heights, row widths and positions. rowHeight := make([]int, len(rows)) rowWidth := make([]int, len(rows)) for r, row := range rows { for _, i := range row { if lay.laid[i].h > rowHeight[r] { rowHeight[r] = lay.laid[i].h } rowWidth[r] += lay.laid[i].w + laneGap } rowWidth[r] -= laneGap } maxWidth := 0 y := margin rowY := make([]int, len(rows)) for r := range rows { rowY[r] = y + rowHeight[r]/2 y += rowHeight[r] + rankGap if rowWidth[r] > maxWidth { maxWidth = rowWidth[r] } } for r, row := range rows { x := margin + (maxWidth-rowWidth[r])/2 + laneGap/2 for _, i := range row { x += lay.laid[i].w / 2 lay.laid[i].x = x lay.laid[i].y = rowY[r] x += lay.laid[i].w/2 + laneGap/2 } } lay.width = maxWidth + 2*margin lay.height = y - rankGap + margin // A subgraph with its own direction lays its members out inside the // box they occupy, in that direction. for _, sg := range d.subgraphs { if sg.dir == "" || sg.dir == d.dir || len(sg.nodes) < 2 { continue } reLayoutSubgraph(d, lay, sg) } return lay } func reLayoutSubgraph(d *flowDiagram, lay *flowLayout, sg *flowSubgraph) { minX, minY, maxX, maxY := boundingBox(lay, sg.nodes) horizontal := sg.dir == "LR" || sg.dir == "RL" members := map[int]bool{} for _, i := range sg.nodes { members[i] = true } rank := map[int]int{} for pass := 0; pass <= len(sg.nodes); pass++ { changed := false for _, e := range d.edges { if members[e.from] && members[e.to] && rank[e.to] < rank[e.from]+1 { rank[e.to] = rank[e.from] + 1 changed = true } } if !changed { break } } maxRank := 0 for _, i := range sg.nodes { if rank[i] > maxRank { maxRank = rank[i] } } rows := make([][]int, maxRank+1) for _, i := range sg.nodes { rows[rank[i]] = append(rows[rank[i]], i) } centreS := (minX + maxX) / 2 centreP := (minY + maxY) / 2 if horizontal { centreS, centreP = centreP, centreS } spanP := (maxY - minY) - laneGap if horizontal { spanP = (maxX - minX) - laneGap } for r, row := range rows { p := centreP - spanP/2 + (spanP*(2*r+1))/(2*(maxRank+1)) var sSize int for _, i := range row { if horizontal { sSize += lay.laid[i].h } else { sSize += lay.laid[i].w } } sSize += laneGap / 2 * (len(row) - 1) s := centreS - sSize/2 for _, i := range row { if horizontal { s += lay.laid[i].h / 2 lay.laid[i].x = p lay.laid[i].y = s s += lay.laid[i].h/2 + laneGap/2 } else { s += lay.laid[i].w / 2 lay.laid[i].y = p lay.laid[i].x = s s += lay.laid[i].w/2 + laneGap/2 } } } // Keep every member inside the box. for _, i := range sg.nodes { l := &lay.laid[i] l.x = clamp(l.x, minX+l.w/2, maxX-l.w/2) l.y = clamp(l.y, minY+l.h/2, maxY-l.h/2) } } func clamp(v, low, high int) int { if v < low { return low } if v > high { return high } return v } func boundingBox(lay *flowLayout, nodes []int) (int, int, int, int) { minX, minY := 1<<30, 1<<30 maxX, maxY := -1<<30, -1<<30 for _, i := range nodes { l := lay.laid[i] minX = min(minX, l.x-l.w/2) maxX = max(maxX, l.x+l.w/2) minY = min(minY, l.y-l.h/2) maxY = max(maxY, l.y+l.h/2) } return minX, minY, maxX, maxY } func nodeSize(node *flowNode) (int, int) { lines := labelLines(node.label) width := 0 for _, l := range lines { width = max(width, textWidth(l, 14)) } h := 26 + 18*len(lines) w := max(width+textPad, nodeMinWidth) switch node.shape { case "diamond": w = max(width*2+textPad*2, 150) h = max(30+26*len(lines), w/2) case "circle": d := max(max(width+40, h), 68) w, h = d, d } return w, h } // mapPoint maps a TB-space point into the final orientation. func mapPoint(dir string, p point, width, height int) point { switch dir { case "BT": return point{p.x, height - p.y} case "LR": return point{p.y, p.x} case "RL": return point{height - p.y, p.x} } return p } // edgeStroke gives the path attributes for an edge kind, with the styles // of the linkStyle declarations appended. func edgeStroke(kind string, styles []stylePair) string { attrs := ` fill="none" stroke="#555" stroke-width="2"` switch { case strings.HasPrefix(kind, "thick"): attrs = ` fill="none" stroke="#555" stroke-width="3.5"` case strings.HasPrefix(kind, "dotted"): attrs = ` fill="none" stroke="#555" stroke-width="2" stroke-dasharray="6 5"` } return attrs + styleString(styles) } // nodeStyles gathers the inline styles and the class declarations of a // node, declaration order preserved. func (d *flowDiagram) nodeStyles(n *flowNode) []stylePair { pairs := append([]stylePair{}, n.styles...) for _, class := range n.classes { pairs = append(pairs, d.classes[class]...) } return pairs } func writeFlowchart(d *flowDiagram, lay *flowLayout) []byte { width, height := lay.width, lay.height if d.dir == "LR" || d.dir == "RL" { width, height = height, width } svg := newSVGBuilder(width, height) // Subgraph boxes, outer before inner, so the parents frame their // children. for _, sg := range slices.Backward(d.subgraphs) { if len(sg.nodes) == 0 { continue } minX, minY, maxX, maxY := boundingBox(lay, sg.nodes) a := mapPoint(d.dir, point{minX - 20, minY - 40}, width, height) b := mapPoint(d.dir, point{maxX + 20, maxY + 18}, width, height) x0, y0 := min(a.x, b.x), min(a.y, b.y) x1, y1 := max(a.x, b.x), max(a.y, b.y) svg.rect(x0, y0, x1-x0, y1-y0, 8, ` fill="#f5f5f5" fill-opacity="0.7" stroke="#999"`) svg.text(point{x0 + 10, y0 + 18}, sg.title, "start", ` font-size="14" font-weight="bold"`) } // Edges. type drawn struct { p0, c1, c2, p3 point arrow bool label string attrs string } var drawnEdges []drawn for i, e := range d.edges { a, b := lay.laid[e.from], lay.laid[e.to] var p0, p3, c1, c2 point switch { case e.from == e.to: p0 = point{a.x + a.w/2, a.y - 8} p3 = point{a.x + a.w/2, a.y + 8} c1 = point{a.x + a.w/2 + 46, a.y - 28} c2 = point{a.x + a.w/2 + 46, a.y + 28} case lay.rank[e.to] > lay.rank[e.from]: p0 = point{a.x, a.y + a.h/2} p3 = point{b.x, b.y - b.h/2} mid := max((p3.y-p0.y)/2, 24) c1 = point{p0.x, p0.y + mid} c2 = point{p3.x, p3.y - mid} case lay.rank[e.to] < lay.rank[e.from]: p0 = point{a.x, a.y - a.h/2} p3 = point{b.x, b.y + b.h/2} mid := max((p0.y-p3.y)/2, 24) c1 = point{p0.x, p0.y - mid} c2 = point{p3.x, p3.y + mid} default: if b.x >= a.x { p0 = point{a.x + a.w/2, a.y} p3 = point{b.x - b.w/2, b.y} } else { p0 = point{a.x - a.w/2, a.y} p3 = point{b.x + b.w/2, b.y} } c1 = point{p0.x + 42, p0.y} c2 = point{p3.x - 42, p3.y} } styles := append([]stylePair{}, d.linkDefault...) styles = append(styles, d.linkByIndex[i]...) drawnEdges = append(drawnEdges, drawn{ p0: mapPoint(d.dir, p0, width, height), c1: mapPoint(d.dir, c1, width, height), c2: mapPoint(d.dir, c2, width, height), p3: mapPoint(d.dir, p3, width, height), arrow: strings.HasSuffix(e.kind, "-arrow"), label: e.label, attrs: edgeStroke(e.kind, styles), }) } for _, e := range drawnEdges { svg.path(fmt.Sprintf("M %d %d C %d %d, %d %d, %d %d", e.p0.x, e.p0.y, e.c1.x, e.c1.y, e.c2.x, e.c2.y, e.p3.x, e.p3.y), e.attrs) } for _, e := range drawnEdges { if !e.arrow { continue } svg.polygon(arrowHead(e.p3, e.c2), ` fill="#555"`) } // Nodes. for i, node := range d.nodes { l := lay.laid[i] c := mapPoint(d.dir, point{l.x, l.y}, width, height) w, h := l.w, l.h if d.dir == "LR" || d.dir == "RL" { w, h = h, w } attrs := ` fill="#ffffff" stroke="#333" stroke-width="1.5"` + styleString(d.nodeStyles(node)) switch node.shape { case "round": svg.rect(c.x-w/2, c.y-h/2, w, h, 10, attrs) case "stadium": svg.rect(c.x-w/2, c.y-h/2, w, h, min(w, h)/2, attrs) case "circle": svg.rect(c.x-w/2, c.y-h/2, w, h, w/2, attrs) case "diamond": svg.polygon([]point{{c.x, c.y - h/2}, {c.x + w/2, c.y}, {c.x, c.y + h/2}, {c.x - w/2, c.y}}, attrs) case "hex": cut := min(20, w/4) svg.polygon([]point{{c.x - w/2 + cut, c.y - h/2}, {c.x + w/2 - cut, c.y - h/2}, {c.x + w/2, c.y}, {c.x + w/2 - cut, c.y + h/2}, {c.x - w/2 + cut, c.y + h/2}, {c.x - w/2, c.y}}, attrs) case "asym": svg.polygon([]point{{c.x - w/2, c.y - h/2}, {c.x + w/2 - 18, c.y - h/2}, {c.x + w/2, c.y}, {c.x + w/2 - 18, c.y + h/2}, {c.x - w/2, c.y + h/2}}, attrs) case "sub": svg.rect(c.x-w/2, c.y-h/2, w, h, 0, attrs) if d.dir == "LR" || d.dir == "RL" { svg.line(c.x, c.y-h/2+5, c.x, c.y+h/2-5, ` stroke="#333" stroke-width="1.5"`) svg.line(c.x, c.y-h/2+10, c.x, c.y+h/2-10, ` stroke="#333" stroke-width="1.5"`) } else { svg.line(c.x-w/2+5, c.y, c.x+w/2-5, c.y, ` stroke="#333" stroke-width="1.5"`) } default: svg.rect(c.x-w/2, c.y-h/2, w, h, 0, attrs) } lines := labelLines(node.label) for k, ln := range lines { y := c.y + 5 + (k-(len(lines)-1)/2)*18 if len(lines)%2 == 0 { y = c.y - 4 + k*18 } svg.text(point{c.x, y}, ln, "middle", ` font-size="14"`) } } // Edge labels on top. for _, e := range drawnEdges { if e.label == "" { continue } mid := point{(e.p0.x + e.p3.x) / 2, (e.p0.y+e.p3.y)/2 - 7} svg.text(mid, e.label, "middle", ` font-size="12"`) } return svg.finish() } // arrowHead builds a filled triangle at tip pointing from the control // point towards the tip. func arrowHead(tip, ctrl point) []point { dx, dy := tip.x-ctrl.x, tip.y-ctrl.y n := max(abs(dx)+abs(dy), 1) ux, uy := dx*1000/n, dy*1000/n base := point{tip.x - ux*11/1000, tip.y - uy*11/1000} return []point{ tip, {base.x - uy*5/1000, base.y + ux*5/1000}, {base.x + uy*5/1000, base.y - ux*5/1000}, } } func abs(v int) int { if v < 0 { return -v } return v }