Skip to content
quarry Artifact [c88e75bc0b]
Login

Artifact [c88e75bc0b]

Artifact c88e75bc0bb37edc83e54e6b3988addbe0042e60dbc663844d31ceefaba5c9ef:


// 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"
	"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
}

// branchHue is the cleanroom analogue of fossil's hash_color: a stable
// hue from the branch name, so every page renders a branch identically.
func branchHue(branch string) int {
	h := fnv.New32a()
	h.Write([]byte(branch))
	return int(h.Sum32() % 360)
}

func branchStroke(branch string) string {
	switch branch {
	case "trunk":
		return "#7086a8"
	case "":
		// 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"
	}
	return fmt.Sprintf("hsl(%d, 55%%, 45%%)", branchHue(branch))
}

func branchTint(branch string) template.CSS {
	if branch == "trunk" || branch == "" {
		return ""
	}
	return template.CSS(fmt.Sprintf("background: hsla(%d, 55%%, 50%%, 0.14)", 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
}

// 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 string, entries []timelineEntry) []tlDay {
	rails := []rail{}
	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 {
		for i := range rails {
			if !rails[i].active {
				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
				fmt.Fprintf(&b, `<line x1="%d" y1="0" x2="%d" y2="1" stroke="%s" stroke-width="2.5"/>`,
					x, x, branchStroke(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
		if len(joins) > 0 {
			nodeRail = joins[0]
		} else {
			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 before the rail is rebranded.
		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.
		extraJoins := []int{}
		if len(joins) > 1 {
			extraJoins = joins[1:]
		}
		for _, j := range extraJoins {
			rails[j].active = false
		}
		hasAbove := len(joins) > 0
		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 := branchStroke(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 {
			fmt.Fprintf(&hd, `<line x1="%d" y1="0" x2="%d" y2="%d" stroke="%s" stroke-width="2.5"/>`,
				x(s.idx), x(s.idx), headH, branchStroke(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, branchStroke(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, branchStroke(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, branchStroke(m.branch))
		}
		if e.IsLeaf {
			fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="4" style="fill:var(--bg)" stroke="%s" stroke-width="2.5"/>`, 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 {
			fmt.Fprintf(&tl, `<line x1="%d" y1="0" x2="%d" y2="1" stroke="%s" stroke-width="2.5"/>`,
				x(s.idx), x(s.idx), branchStroke(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), branchStroke(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: branchTint(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
}