Artifact 26d6044ea4b6f3f240ace958b97c493da38ebf08962bb2d504e62c0f796dec66:
- File
internal/web/timeline.go
— part of check-in
[a754d20b3c]
at
2026-08-23 00:28:43
on branch trunk
— timeline: branch colours cannot collide on a page
The hue was a raw hash mod 360, so two names could land a few degrees apart — ticket-list and fossil-caps rendered as one magenta. Hues now come from nine slots at least 30 degrees apart, with the mainline's blue and the accent's orange left out; a branch keeps its hashed slot unless another branch in view wants it, and assignment is by name so it never depends on commit order. (user: dmestas, size: 16513) [annotate] [blame] [check-ins using]
// timeline.go — the fossil-shaped timeline view model: day dividers, // per-branch colors, and the DAG rail graph rendered as server-side // inline SVG (no JavaScript; 0001 Principle 3). Rail assignment follows // the shape of fossil's graph.c — computed here, drawn here — walking // rows newest-first: a node lands on the rail that expects its hash, // rails continue toward primary parents, merges curve out to the rail // carrying the secondary parent. // // Rows have content-dependent heights, so each row's rails render as a // stretchable SVG (preserveAspectRatio="none": verticals stay straight, // curves stretch) filling the cell, with the node drawn as a separate // fixed-size dot overlay that cannot distort — that is what keeps the // rail visually continuous through tall cards, like fossil's graph. package web import ( "fmt" "hash/fnv" "html/template" neturl "net/url" "sort" "strings" ) const ( railW = 14 // px between rails headH = 36 // fixed head band: node + curves live here nodeCY = 24 // node center inside the head: card padding + half line maxRail = 14 // clamp; beyond this rails overlap rather than widen the page ) type tlDay struct { Date string Carry template.HTML // rails passing through the date-divider row Width int Rows []tlRow } type tlRow struct { Kind string // ci | w | t Time string Hash string Short string URL string // the event's page User string Title string // comment first line Paras []string // remaining comment paragraphs, newlines collapsed Branch string Tint template.CSS // per-branch card tint (empty for trunk) IsLeaf bool IsMerge bool CI template.HTML // CI verdict badge, empty when none was recorded Head template.HTML // fixed-height top layer: curves + node Tail template.HTML // stretching lower layer: rails only Width int // graph cell width } // branchSlots are the hues a branch line can take: nine, spaced at // least 30 degrees apart, with the two bands a reader already reads as // meaning something left out — the mainline's blue (~213) and the // accent orange (~18). Two lines can then never be the near-identical // magentas that made `ticket-list` and `fossil-caps` one colour. var branchSlots = []int{60, 90, 120, 150, 180, 240, 270, 300, 330} // branchHue is the cleanroom analogue of fossil's hash_color: a stable // hue from the branch name. It is the PREFERENCE, not the answer — // palette resolves collisions among the branches actually in view. func branchHue(branch string) int { h := fnv.New32a() h.Write([]byte(branch)) return branchSlots[int(h.Sum32())%len(branchSlots)] } // palette assigns each branch on one page a distinguishable hue. // // Distinctness is per PAGE, deliberately. A hash alone is globally // stable but collides — two names landing five degrees apart render as // one colour, and the reader cannot tell two lines apart, which is the // only thing the colour is for. So a branch keeps its hashed hue // unless another branch in view wants the same slot; then the later // one (by name, so the outcome never depends on commit order) moves to // the next free slot. type palette struct { defaultBranch string hue map[string]int } func newPalette(defaultBranch string, entries []timelineEntry) *palette { p := &palette{defaultBranch: defaultBranch, hue: map[string]int{}} seen := map[string]bool{} var names []string for _, e := range entries { b := e.Branch if b == "" || b == defaultBranch || b == "trunk" || seen[b] { continue } seen[b] = true names = append(names, b) } sort.Strings(names) taken := map[int]bool{} for _, b := range names { want := branchHue(b) slot := want for i := 0; i < len(branchSlots); i++ { cand := branchSlots[(indexOfSlot(want)+i)%len(branchSlots)] if !taken[cand] { slot = cand break } } taken[slot] = true p.hue[b] = slot } return p } func indexOfSlot(hue int) int { for i, h := range branchSlots { if h == hue { return i } } return 0 } func (p *palette) stroke(branch string) string { switch { case branch == "": // A rail whose branch is unknowable (its commit is beyond this // page) renders neutral — never a hashed hue, so no arbitrary // intermediate colors appear mid-line. return "#8a8f98" case branch == "trunk" || (p != nil && branch == p.defaultBranch): return "#7086a8" } return fmt.Sprintf("hsl(%d, 55%%, 45%%)", p.hueOf(branch)) } func (p *palette) tint(branch string) template.CSS { if branch == "" || branch == "trunk" || (p != nil && branch == p.defaultBranch) { return "" } // Same weight as --surface (10% alpha), so a branch card and a trunk // card read as one material that differs only in hue. return template.CSS(fmt.Sprintf("background: hsla(%d, 45%%, 50%%, 0.10)", p.hueOf(branch))) } func (p *palette) hueOf(branch string) int { if p != nil { if h, ok := p.hue[branch]; ok { return h } } return branchHue(branch) } // rail tracks one vertical line of the graph: the hash it expects to // reach next (a parent of something above) and the branch coloring it. type rail struct { want string branch string active bool // reserved marks the mainline's column. A reserved rail is never // handed to another branch, even while it is inactive, so trunk // keeps the leftmost line for the whole page instead of losing it // to whichever branch happens to hold the newest commit. reserved bool } // stroke emits one vertical rail segment. A rail whose commits are all // off-page is otherwise unidentifiable (the jimtcl question: a branch // line running the whole page with nothing naming it), so every segment // carries its branch name as a native hover title. func railLine(pal *palette, b *strings.Builder, x1, y1, x2, y2 int, branch string) { if branch == "" { fmt.Fprintf(b, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2.5"/>`, x1, y1, x2, y2, pal.stroke(branch)) return } fmt.Fprintf(b, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2.5"><title>%s</title></line>`, x1, y1, x2, y2, pal.stroke(branch), template.HTMLEscapeString(branch)) } // buildTimeline groups entries under day dividers and assigns graph // rails. Entries must be newest-first (the timeline order). Non-checkin // events (wiki, ticket) take no rail: the active rails pass straight // through their rows, like the date dividers. func buildTimeline(repo, defaultBranch string, entries []timelineEntry) []tlDay { // One palette per page: hues are resolved against the branches // actually in view, so two lines are never the same colour. pal := newPalette(defaultBranch, entries) rails := []rail{} // The mainline owns rail 0 whenever it appears on the page: a // reader scans left for it, and a branch that forked yesterday must // not take that column just because it committed most recently. If // the default branch is not in view at all, nothing is reserved and // the remaining lines close up as before. mainline := false if defaultBranch != "" { for _, e := range entries { if e.Kind == "ci" && e.Branch == defaultBranch { mainline = true break } } } if mainline { rails = append(rails, rail{branch: defaultBranch, reserved: true}) } find := func(hash string) []int { var idxs []int for i := range rails { if rails[i].active && rails[i].want == hash { idxs = append(idxs, i) } } return idxs } alloc := func(want, branch string) int { if mainline && branch == defaultBranch && !rails[0].active { rails[0] = rail{want: want, branch: branch, active: true, reserved: true} return 0 } for i := range rails { if rails[i].active || rails[i].reserved { continue } rails[i] = rail{want: want, branch: branch, active: true} return i } rails = append(rails, rail{want: want, branch: branch, active: true}) return len(rails) - 1 } // carry renders the rails active at a row boundary — the stretchable // layer a date-divider row shows so rails run unbroken through it. carry := func() (template.HTML, int) { width := (min(len(rails), maxRail))*railW + railW var b strings.Builder any := false fmt.Fprintf(&b, `<svg class="tl-tail" width="%d" preserveAspectRatio="none" viewBox="0 0 %d 1" aria-hidden="true">`, width, width) for i := range rails { if rails[i].active { any = true x := min(i, maxRail-1)*railW + railW/2 railLine(pal, &b, x, 0, x, 1, rails[i].branch) } } b.WriteString(`</svg>`) if !any { return "", width } return template.HTML(b.String()), width } // Look-ahead branch map: a rail heading toward a commit carries that // commit's branch color for its whole run — the branch line reads as // one color from fork to merge (fossil's rule). branchOf := map[string]string{} for _, e := range entries { branchOf[e.Hash] = e.Branch } var days []tlDay for _, e := range entries { date, clock, _ := strings.Cut(e.Date, "T") if len(clock) > 5 { clock = clock[:5] } if len(days) == 0 || days[len(days)-1].Date != date { c, w := carry() days = append(days, tlDay{Date: date, Carry: c, Width: w}) } if e.Kind == "w" || e.Kind == "t" || e.Kind == "e" || e.Kind == "f" { // A flat event row: rails pass through, no node. c, w := carry() var url string switch e.Kind { case "w": // a wiki row is one specific edit — link that revision url = "/r/" + repo + "/wiki/" + neturl.PathEscape(e.Ref) + "?rev=" + e.Hash[:min(12, len(e.Hash))] case "t": url = "/r/" + repo + "/ticket/" + e.Ref case "e": url = "/r/" + repo + "/technote/" + e.Ref[:min(16, len(e.Ref))] case "f": url = "/r/" + repo + "/forum/" + e.Ref[:min(16, len(e.Ref))] } day := &days[len(days)-1] day.Rows = append(day.Rows, tlRow{ Kind: e.Kind, Time: clock, Hash: e.Hash, URL: url, Short: e.Hash[:min(12, len(e.Hash))], User: e.User, Title: e.Comment, Tail: c, Width: w, }) continue } // Which rails were expecting this check-in? joins := find(e.Hash) var nodeRail int switch { case mainline && e.Branch == defaultBranch: // The mainline always draws on its own rail, even when a // branch line arrived here first by following its parents // into the fork point. Those lines converge INTO it — which // is the shape a reader expects: branches hang off trunk, // trunk does not hop into a branch's column. nodeRail = 0 rails[0].active = true rails[0].reserved = true case len(joins) > 0: nodeRail = joins[0] default: nodeRail = alloc(e.Hash, e.Branch) // a tip: nothing above points here } // The segment ABOVE this node belongs to the line that was // heading here (e.g. a branch line ending at its fork commit); // capture its color, and whether it existed at all, before the // rail is rebranded. hadAbove := rails[nodeRail].active && rails[nodeRail].want == e.Hash aboveBranch := rails[nodeRail].branch joinBranch := map[int]string{} for _, j := range joins { joinBranch[j] = rails[j].branch } rails[nodeRail].branch = e.Branch // Snapshot the rails passing through this row (before rewiring), // for the vertical strokes. type seg struct { idx int branch string } var through []seg for i := range rails { if rails[i].active && i != nodeRail && !contains(joins, i) { through = append(through, seg{idx: i, branch: rails[i].branch}) } } // Rewire: converging rails close; the node rail continues to the // primary parent; secondary parents curve out. // Every rail that was heading here other than the node's own // closes with a curve into it. extraJoins := []int{} for _, j := range joins { if j != nodeRail { extraJoins = append(extraJoins, j) } } for _, j := range extraJoins { rails[j].active = false } // A line above this node exists when the node's own rail was // already carrying one into this row. Reading it off `joins` // was equivalent only while nodeRail always came from joins. hasAbove := hadAbove hasBelow := false var mergeOut []seg if len(e.Parents) > 0 { rails[nodeRail].want = e.Parents[0] hasBelow = true for _, p := range e.Parents[1:] { var target int if t := find(p); len(t) > 0 { target = t[0] } else { // The merged branch's line: colored by the commit // it descends to (look-ahead), neutral if off-page. target = alloc(p, branchOf[p]) } mergeOut = append(mergeOut, seg{idx: target, branch: rails[target].branch}) } } else { rails[nodeRail].active = false // root: nothing below } // Render the two graph layers. The head is a fixed-height band // holding the node and every curve (top-anchored, like fossil — // the node sits beside the card's first line); the tail is a // stretching layer of plain verticals that fills whatever height // the card takes, keeping rails continuous. width := (min(len(rails), maxRail))*railW + railW x := func(i int) int { return min(i, maxRail-1)*railW + railW/2 } cy := nodeCY nx := x(nodeRail) stroke := pal.stroke(e.Branch) var hd strings.Builder fmt.Fprintf(&hd, `<svg class="tl-head" width="%d" height="%d" viewBox="0 0 %d %d" aria-hidden="true">`, width, headH, width, headH) for _, s := range through { railLine(pal, &hd, x(s.idx), 0, x(s.idx), headH, s.branch) } if hasAbove { fmt.Fprintf(&hd, `<line x1="%d" y1="0" x2="%d" y2="%d" stroke="%s" stroke-width="2.5"/>`, nx, nx, cy, pal.stroke(aboveBranch)) } if hasBelow { fmt.Fprintf(&hd, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2.5"/>`, nx, cy, nx, headH, stroke) } for _, j := range extraJoins { // A converging line ends here; it keeps ITS branch color to // the last pixel (a branch line meeting its fork point). fmt.Fprintf(&hd, `<path d="M %d 0 Q %d %d %d %d" fill="none" stroke="%s" stroke-width="2.5"/>`, x(j), x(j), cy, nx, cy, pal.stroke(joinBranch[j])) } for _, m := range mergeOut { fmt.Fprintf(&hd, `<path d="M %d %d Q %d %d %d %d" fill="none" stroke="%s" stroke-width="2.5"/>`, nx, cy, x(m.idx), cy, x(m.idx), headH, pal.stroke(m.branch)) } if e.IsLeaf { // A branch tip is drawn the way the mark is: an open node // with the check-in dot at its centre. The open ring alone // said "nothing follows"; the dot says "and this is where // the branch is now", which is what a tip actually means. fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="4" style="fill:var(--bg)" stroke="%s" stroke-width="2.5"/>`, nx, cy, stroke) fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="1.6" fill="%s"/>`, nx, cy, stroke) } else { fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="4.5" fill="%s"/>`, nx, cy, stroke) } hd.WriteString(`</svg>`) var tl strings.Builder fmt.Fprintf(&tl, `<svg class="tl-tail" width="%d" preserveAspectRatio="none" viewBox="0 0 %d 1" aria-hidden="true">`, width, width) for _, s := range through { railLine(pal, &tl, x(s.idx), 0, x(s.idx), 1, s.branch) } if hasBelow { fmt.Fprintf(&tl, `<line x1="%d" y1="0" x2="%d" y2="1" stroke="%s" stroke-width="2.5"/>`, nx, nx, stroke) } for _, m := range mergeOut { fmt.Fprintf(&tl, `<line x1="%d" y1="0" x2="%d" y2="1" stroke="%s" stroke-width="2.5"/>`, x(m.idx), x(m.idx), pal.stroke(m.branch)) } tl.WriteString(`</svg>`) title, body, _ := strings.Cut(strings.TrimSpace(e.Comment), "\n") day := &days[len(days)-1] day.Rows = append(day.Rows, tlRow{ Kind: "ci", Time: clock, Hash: e.Hash, Short: e.Hash[:min(12, len(e.Hash))], URL: "/r/" + repo + "/checkin/" + e.Hash, User: e.User, Title: title, Paras: paragraphs(body), Branch: e.Branch, Tint: pal.tint(e.Branch), IsLeaf: e.IsLeaf, IsMerge: e.IsMerge, CI: ciBadge(e.CI), Head: template.HTML(hd.String()), Tail: template.HTML(tl.String()), Width: width, }) } return days } // paragraphs splits a comment body on blank lines and collapses the // hard newlines within each paragraph — check-in comments carry 72-col // wraps that must reflow to the card width (fossil renders comments the // same way: newlines are not line breaks). func paragraphs(body string) []string { var out []string for _, p := range strings.Split(body, "\n\n") { p = strings.Join(strings.Fields(p), " ") if p != "" { out = append(out, p) } } return out } func contains(xs []int, v int) bool { for _, x := range xs { if x == v { return true } } return false }