Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

internal/modules/graph.go

3a50036d96eb14462e96598fd169d1c79264ac4b · 4.6 KB · 178 lines raw

1 package modules
2
3 import (
4 "fmt"
5 "html/template"
6 "strconv"
7 "strings"
8
9 "gsg/internal/core"
10 "gsg/internal/gitx"
11 )
12
13 // Graph is the branch/commit DAG laid out in lanes, one row per commit.
14 //
15 // Layout runs in a single pass over --topo-order output, which guarantees a
16 // child is emitted before its parents. `active` holds, per lane, the SHA that
17 // lane is currently waiting to draw. A commit claims the lane that was
18 // expecting it (or a fresh one), hands that lane to its first parent, and
19 // opens a new lane for each additional parent -- which is exactly what makes
20 // a merge fork visually.
21 type Graph struct {
22 Rows []Row
23 Lanes int
24 }
25
26 type Row struct {
27 Commit gitx.Commit
28 Lane int
29 Refs []gitx.Ref // branch/tag tips pointing here
30 // Through are lanes that pass this row untouched (draw a full-height
31 // vertical). In are lanes merging into this commit (diagonal from the
32 // top edge to the node). Out are lanes this commit forks into
33 // (diagonal from the node to the bottom edge).
34 Through []int
35 In []int
36 Out []int
37
38 // Rail is the row's slice of the graph, drawn as SVG. Geometry lives
39 // here because it IS the data; colour does not -- every stroke refers
40 // to a CSS custom property, so the theme still owns the palette.
41 Rail template.HTML
42 }
43
44 func init() { core.Register("graph", graphMod) }
45
46 // graph: the DAG across every ref. {{ mod "graph" "120" }}
47 func graphMod(c *core.Ctx, args ...string) (any, error) {
48 n := 150
49 if len(args) > 0 {
50 if v, err := strconv.Atoi(args[0]); err == nil && v > 0 {
51 n = v
52 }
53 }
54 commits, err := c.Repo.LogAll(n)
55 if err != nil {
56 return nil, err
57 }
58
59 tips := map[string][]gitx.Ref{}
60 if refs, err := c.Repo.Refs(); err == nil {
61 for _, r := range refs {
62 tips[r.Target] = append(tips[r.Target], r)
63 }
64 }
65
66 var g Graph
67 var active []string // lane -> SHA that lane is waiting for ("" = free)
68
69 for _, cm := range commits {
70 row := Row{Commit: cm, Refs: tips[cm.SHA]}
71
72 // Every lane already expecting this commit is an incoming edge.
73 mine := -1
74 for i, want := range active {
75 if want == cm.SHA {
76 if mine == -1 {
77 mine = i
78 } else {
79 row.In = append(row.In, i)
80 active[i] = "" // that lane terminates here
81 }
82 }
83 }
84 if mine == -1 { // a tip: no child has claimed it
85 mine = free(&active)
86 }
87 row.Lane = mine
88
89 // Lanes untouched by this row pass straight through.
90 for i, want := range active {
91 if i != mine && want != "" && !contains(row.In, i) {
92 row.Through = append(row.Through, i)
93 }
94 }
95
96 if len(cm.Parents) == 0 {
97 active[mine] = "" // root commit closes its lane
98 } else {
99 active[mine] = cm.Parents[0]
100 for _, p := range cm.Parents[1:] {
101 k := free(&active)
102 active[k] = p
103 row.Out = append(row.Out, k)
104 }
105 }
106
107 if len(active) > g.Lanes {
108 g.Lanes = len(active)
109 }
110 g.Rows = append(g.Rows, row)
111 }
112 for i := range g.Rows {
113 g.Rows[i].Rail = rail(g.Rows[i], g.Lanes)
114 }
115 if g.Lanes == 0 {
116 g.Lanes = 1
117 }
118 return g, nil
119 }
120
121 // Row geometry, in px. Height must match the table row height in CSS.
122 const (
123 laneW = 15.0
124 rowH = 30.0
125 radius = 3.6
126 )
127
128 func lane(i int) string { return fmt.Sprintf("var(--lane-%d)", i%6) }
129
130 func x(i int) float64 { return float64(i)*laneW + laneW/2 }
131
132 func rail(r Row, lanes int) template.HTML {
133 var b strings.Builder
134 w := float64(lanes) * laneW
135 fmt.Fprintf(&b, `<svg class="rail-svg" width="%.0f" height="%.0f" viewBox="0 0 %.0f %.0f" aria-hidden="true">`, w, rowH, w, rowH)
136
137 line := func(x1, y1, x2, y2 float64, c string) {
138 fmt.Fprintf(&b, `<path d="M%.1f %.1f C%.1f %.1f %.1f %.1f %.1f %.1f" stroke="%s" fill="none" stroke-width="1.6"/>`,
139 x1, y1, x1, (y1+y2)/2, x2, (y1+y2)/2, x2, y2, c)
140 }
141
142 for _, l := range r.Through { // uninvolved lanes pass straight down
143 line(x(l), 0, x(l), rowH, lane(l))
144 }
145 for _, l := range r.In { // merged-in lanes end at this node
146 line(x(l), 0, x(r.Lane), rowH/2, lane(l))
147 }
148 for _, l := range r.Out { // extra parents fork away below
149 line(x(r.Lane), rowH/2, x(l), rowH, lane(l))
150 }
151 // the commit's own lane: top half only if a child pointed here
152 line(x(r.Lane), 0, x(r.Lane), rowH/2, lane(r.Lane))
153 line(x(r.Lane), rowH/2, x(r.Lane), rowH, lane(r.Lane))
154
155 fmt.Fprintf(&b, `<circle cx="%.1f" cy="%.1f" r="%.1f" fill="var(--bg)" stroke="%s" stroke-width="2"/>`,
156 x(r.Lane), rowH/2, radius, lane(r.Lane))
157 b.WriteString(`</svg>`)
158 return template.HTML(b.String())
159 }
160
161 func free(active *[]string) int {
162 for i, v := range *active {
163 if v == "" {
164 return i
165 }
166 }
167 *active = append(*active, "")
168 return len(*active) - 1
169 }
170
171 func contains(xs []int, n int) bool {
172 for _, x := range xs {
173 if x == n {
174 return true
175 }
176 }
177 return false
178 }