package modules
import (
"fmt"
"html/template"
"strconv"
"strings"
"gsg/internal/core"
"gsg/internal/gitx"
)
// Graph is the branch/commit DAG laid out in lanes, one row per commit.
//
// Layout runs in a single pass over --topo-order output, which guarantees a
// child is emitted before its parents. `active` holds, per lane, the SHA that
// lane is currently waiting to draw. A commit claims the lane that was
// expecting it (or a fresh one), hands that lane to its first parent, and
// opens a new lane for each additional parent -- which is exactly what makes
// a merge fork visually.
type Graph struct {
Rows []Row
Lanes int
}
type Row struct {
Commit gitx.Commit
Lane int
Refs []gitx.Ref // branch/tag tips pointing here
// Through are lanes that pass this row untouched (draw a full-height
// vertical). In are lanes merging into this commit (diagonal from the
// top edge to the node). Out are lanes this commit forks into
// (diagonal from the node to the bottom edge).
Through []int
In []int
Out []int
// Rail is the row's slice of the graph, drawn as SVG. Geometry lives
// here because it IS the data; colour does not -- every stroke refers
// to a CSS custom property, so the theme still owns the palette.
Rail template.HTML
}
func init() { core.Register("graph", graphMod) }
// graph: the DAG across every ref. {{ mod "graph" "120" }}
func graphMod(c *core.Ctx, args ...string) (any, error) {
n := 150
if len(args) > 0 {
if v, err := strconv.Atoi(args[0]); err == nil && v > 0 {
n = v
}
}
commits, err := c.Repo.LogAll(n)
if err != nil {
return nil, err
}
tips := map[string][]gitx.Ref{}
if refs, err := c.Repo.Refs(); err == nil {
for _, r := range refs {
tips[r.Target] = append(tips[r.Target], r)
}
}
var g Graph
var active []string // lane -> SHA that lane is waiting for ("" = free)
for _, cm := range commits {
row := Row{Commit: cm, Refs: tips[cm.SHA]}
// Every lane already expecting this commit is an incoming edge.
mine := -1
for i, want := range active {
if want == cm.SHA {
if mine == -1 {
mine = i
} else {
row.In = append(row.In, i)
active[i] = "" // that lane terminates here
}
}
}
if mine == -1 { // a tip: no child has claimed it
mine = free(&active)
}
row.Lane = mine
// Lanes untouched by this row pass straight through.
for i, want := range active {
if i != mine && want != "" && !contains(row.In, i) {
row.Through = append(row.Through, i)
}
}
if len(cm.Parents) == 0 {
active[mine] = "" // root commit closes its lane
} else {
active[mine] = cm.Parents[0]
for _, p := range cm.Parents[1:] {
k := free(&active)
active[k] = p
row.Out = append(row.Out, k)
}
}
if len(active) > g.Lanes {
g.Lanes = len(active)
}
g.Rows = append(g.Rows, row)
}
for i := range g.Rows {
g.Rows[i].Rail = rail(g.Rows[i], g.Lanes)
}
if g.Lanes == 0 {
g.Lanes = 1
}
return g, nil
}
// Row geometry, in px. Height must match the table row height in CSS.
const (
laneW = 15.0
rowH = 30.0
radius = 3.6
)
func lane(i int) string { return fmt.Sprintf("var(--lane-%d)", i%6) }
func x(i int) float64 { return float64(i)*laneW + laneW/2 }
func rail(r Row, lanes int) template.HTML {
var b strings.Builder
w := float64(lanes) * laneW
fmt.Fprintf(&b, ``)
return template.HTML(b.String())
}
func free(active *[]string) int {
for i, v := range *active {
if v == "" {
return i
}
}
*active = append(*active, "")
return len(*active) - 1
}
func contains(xs []int, n int) bool {
for _, x := range xs {
if x == n {
return true
}
}
return false
}