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
Ofs, PageSize int
HasPrev, HasNext bool
PrevOfs, NextOfs 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" }} -- default
// core.GraphPageSize. Paginated via the ?ofs= query param on the graph
// route (see buildCtx), which core.GraphPageSize must match.
//
// Lane assignment below is a single forward pass that carries state
// (`active`) across every row, so a page can't be computed by slicing the
// git fetch with --skip -- that would reset `active` at the page boundary
// and corrupt lane continuity. Instead every request walks the full range
// from the top of history through the end of the requested page, and only
// the rendered rows are sliced down to the page afterward, once Lanes and
// each row's Rail SVG have already been computed against the full range.
func graphMod(c *core.Ctx, args ...string) (any, error) {
n := core.GraphPageSize
if len(args) > 0 {
if v, err := strconv.Atoi(args[0]); err == nil && v > 0 {
n = v
}
}
ofs, _ := strconv.Atoi(c.Var("ofs"))
if ofs < 0 {
ofs = 0
}
commits, err := c.Repo.LogAll(ofs + n + 1) // from the top; no --skip, see above
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
}
total := len(g.Rows)
end := ofs + n
hasNext := total > end
if end > total {
end = total
}
start := ofs
if start > total {
start = total
}
g.Rows = g.Rows[start:end]
g.Ofs, g.PageSize = ofs, n
g.HasPrev, g.PrevOfs = ofs > 0, ofs-n
if g.PrevOfs < 0 {
g.PrevOfs = 0
}
g.HasNext, g.NextOfs = hasNext, end
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
}