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

internal/modules/graph.go

4bda28df3ab3dbfcc789dcbbb184cabc8cac9398 · 5.7 KB · 214 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 Ofs, PageSize int
26 HasPrev, HasNext bool
27 PrevOfs, NextOfs int
28 }
29
30 type Row struct {
31 Commit gitx.Commit
32 Lane int
33 Refs []gitx.Ref // branch/tag tips pointing here
34 // Through are lanes that pass this row untouched (draw a full-height
35 // vertical). In are lanes merging into this commit (diagonal from the
36 // top edge to the node). Out are lanes this commit forks into
37 // (diagonal from the node to the bottom edge).
38 Through []int
39 In []int
40 Out []int
41
42 // Rail is the row's slice of the graph, drawn as SVG. Geometry lives
43 // here because it IS the data; colour does not -- every stroke refers
44 // to a CSS custom property, so the theme still owns the palette.
45 Rail template.HTML
46 }
47
48 func init() { core.Register("graph", graphMod) }
49
50 // graph: the DAG across every ref. {{ mod "graph" "120" }} -- default
51 // core.GraphPageSize. Paginated via the ?ofs= query param on the graph
52 // route (see buildCtx), which core.GraphPageSize must match.
53 //
54 // Lane assignment below is a single forward pass that carries state
55 // (`active`) across every row, so a page can't be computed by slicing the
56 // git fetch with --skip -- that would reset `active` at the page boundary
57 // and corrupt lane continuity. Instead every request walks the full range
58 // from the top of history through the end of the requested page, and only
59 // the rendered rows are sliced down to the page afterward, once Lanes and
60 // each row's Rail SVG have already been computed against the full range.
61 func graphMod(c *core.Ctx, args ...string) (any, error) {
62 n := core.GraphPageSize
63 if len(args) > 0 {
64 if v, err := strconv.Atoi(args[0]); err == nil && v > 0 {
65 n = v
66 }
67 }
68 ofs, _ := strconv.Atoi(c.Var("ofs"))
69 if ofs < 0 {
70 ofs = 0
71 }
72 commits, err := c.Repo.LogAll(ofs + n + 1) // from the top; no --skip, see above
73 if err != nil {
74 return nil, err
75 }
76
77 tips := map[string][]gitx.Ref{}
78 if refs, err := c.Repo.Refs(); err == nil {
79 for _, r := range refs {
80 tips[r.Target] = append(tips[r.Target], r)
81 }
82 }
83
84 var g Graph
85 var active []string // lane -> SHA that lane is waiting for ("" = free)
86
87 for _, cm := range commits {
88 row := Row{Commit: cm, Refs: tips[cm.SHA]}
89
90 // Every lane already expecting this commit is an incoming edge.
91 mine := -1
92 for i, want := range active {
93 if want == cm.SHA {
94 if mine == -1 {
95 mine = i
96 } else {
97 row.In = append(row.In, i)
98 active[i] = "" // that lane terminates here
99 }
100 }
101 }
102 if mine == -1 { // a tip: no child has claimed it
103 mine = free(&active)
104 }
105 row.Lane = mine
106
107 // Lanes untouched by this row pass straight through.
108 for i, want := range active {
109 if i != mine && want != "" && !contains(row.In, i) {
110 row.Through = append(row.Through, i)
111 }
112 }
113
114 if len(cm.Parents) == 0 {
115 active[mine] = "" // root commit closes its lane
116 } else {
117 active[mine] = cm.Parents[0]
118 for _, p := range cm.Parents[1:] {
119 k := free(&active)
120 active[k] = p
121 row.Out = append(row.Out, k)
122 }
123 }
124
125 if len(active) > g.Lanes {
126 g.Lanes = len(active)
127 }
128 g.Rows = append(g.Rows, row)
129 }
130 for i := range g.Rows {
131 g.Rows[i].Rail = rail(g.Rows[i], g.Lanes)
132 }
133 if g.Lanes == 0 {
134 g.Lanes = 1
135 }
136
137 total := len(g.Rows)
138 end := ofs + n
139 hasNext := total > end
140 if end > total {
141 end = total
142 }
143 start := ofs
144 if start > total {
145 start = total
146 }
147 g.Rows = g.Rows[start:end]
148 g.Ofs, g.PageSize = ofs, n
149 g.HasPrev, g.PrevOfs = ofs > 0, ofs-n
150 if g.PrevOfs < 0 {
151 g.PrevOfs = 0
152 }
153 g.HasNext, g.NextOfs = hasNext, end
154 return g, nil
155 }
156
157 // Row geometry, in px. Height must match the table row height in CSS.
158 const (
159 laneW = 15.0
160 rowH = 30.0
161 radius = 3.6
162 )
163
164 func lane(i int) string { return fmt.Sprintf("var(--lane-%d)", i%6) }
165
166 func x(i int) float64 { return float64(i)*laneW + laneW/2 }
167
168 func rail(r Row, lanes int) template.HTML {
169 var b strings.Builder
170 w := float64(lanes) * laneW
171 fmt.Fprintf(&b, `<svg class="rail-svg" width="%.0f" height="%.0f" viewBox="0 0 %.0f %.0f" aria-hidden="true">`, w, rowH, w, rowH)
172
173 line := func(x1, y1, x2, y2 float64, c string) {
174 fmt.Fprintf(&b, `<path d="M%.1f %.1f C%.1f %.1f %.1f %.1f %.1f %.1f" stroke="%s" fill="none" stroke-width="1.6"/>`,
175 x1, y1, x1, (y1+y2)/2, x2, (y1+y2)/2, x2, y2, c)
176 }
177
178 for _, l := range r.Through { // uninvolved lanes pass straight down
179 line(x(l), 0, x(l), rowH, lane(l))
180 }
181 for _, l := range r.In { // merged-in lanes end at this node
182 line(x(l), 0, x(r.Lane), rowH/2, lane(l))
183 }
184 for _, l := range r.Out { // extra parents fork away below
185 line(x(r.Lane), rowH/2, x(l), rowH, lane(l))
186 }
187 // the commit's own lane: top half only if a child pointed here
188 line(x(r.Lane), 0, x(r.Lane), rowH/2, lane(r.Lane))
189 line(x(r.Lane), rowH/2, x(r.Lane), rowH, lane(r.Lane))
190
191 fmt.Fprintf(&b, `<circle cx="%.1f" cy="%.1f" r="%.1f" fill="var(--bg)" stroke="%s" stroke-width="2"/>`,
192 x(r.Lane), rowH/2, radius, lane(r.Lane))
193 b.WriteString(`</svg>`)
194 return template.HTML(b.String())
195 }
196
197 func free(active *[]string) int {
198 for i, v := range *active {
199 if v == "" {
200 return i
201 }
202 }
203 *active = append(*active, "")
204 return len(*active) - 1
205 }
206
207 func contains(xs []int, n int) bool {
208 for _, x := range xs {
209 if x == n {
210 return true
211 }
212 }
213 return false
214 }