// 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 {
// Landing decorates a merge with the proposal it settled: what was
// landed, and who approved it. A merge is a real DAG node with
// rails, so it is decorated rather than collapsed into the landing.
Landing string
LandingTitle string
LandingApprover string
LandingNumber int // the landing's !N, on a merge row and a landing event row
Kind string // ci | w | t
Time string
Hash string
Short string
URL string // the event's page
User string
Title string // comment first line (flat kinds; drawn inside a link)
Lead template.HTML // check-in comment first line, links live
Excerpt string // technotes only: the note's opening prose
Paras []template.HTML // 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: twelve, 22 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).
// Neighbouring slots also alternate lightness (branchLightness), so two
// lines a slot apart differ in two ways and never read as one colour,
// as `ticket-list` and `fossil-caps` once did. Nine slots ran out on a
// busy page; twelve leaves room for a day of landings.
var branchSlots = []int{40, 62, 84, 106, 128, 150, 172, 240, 262, 284, 306, 328}
// branchLightness is a slot's lightness: alternating, so neighbouring
// hues are told apart by value as well as hue.
func branchLightness(slot int) int {
if slot%2 == 0 {
return 45
}
return 56
}
// 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"
}
h := p.hueOf(branch)
return fmt.Sprintf("hsl(%d, 55%%, %d%%)", h, branchLightness(indexOfSlot(h)))
}
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
// lane marks a cherry-pick's lane: a column held from the check-in
// that picked to the oldest check-in it picked on this page, so the
// dashed lane and its ticks have room, as fossil gives them a rail.
// It is never drawn as a rail and never wanted by a parent.
lane 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
}
// A chunk of a paged timeline (?before=) is laid out on its own, so
// it never sees the rows above it. A check-in that has a child (on
// any branch: not "not a leaf", since a closed tip is not a leaf
// either) and that no row here names as a parent has its child above
// the chunk: a landing that merged its branch, or the next commit of
// its line.
// Its line comes in from the chunk's top edge, as it would on one
// long page. Without this, every branch a landing merged in an
// earlier chunk drew as a dead end.
// Counted, not just seen: trunk's fork point has its next trunk
// check-in above the chunk and a branch inside it, and a yes/no
// "named here" stopped trunk at the fork.
named := map[string]int{}
for _, e := range entries {
for _, p := range e.Parents {
named[p]++
}
}
for _, e := range entries {
if e.Kind != "ci" || !e.HasChild || named[e.Hash] >= max(e.Children, 1) {
continue
}
alloc(e.Hash, e.Branch)
}
// 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 && !rails[i].lane {
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
}
onPage := func(hash string) bool { _, ok := branchOf[hash]; return ok }
var days []tlDay
// refs records where every row landed, for the cherry-pick pass
// that runs once the whole page is laid out.
var refs []rowRef
// below[i] is how many check-in rows follow entry i on this page:
// near the page's end a stub would free a column nothing can use.
below := make([]int, len(entries))
for i, n := len(entries)-1, 0; i >= 0; i-- {
below[i] = n
if !flatKind(entries[i].Kind) {
n++
}
}
// lastPick[i] is the lowest row entry i cherry-picked on this page,
// where its lane ends; laneEnd releases a lane's column after that row.
rowOf := map[string]int{}
for i, e := range entries {
if !flatKind(e.Kind) {
rowOf[e.Hash] = i
}
}
lastPick := make([]int, len(entries))
for i, e := range entries {
lastPick[i] = -1
for _, q := range e.Cherrypicks {
if j, ok := rowOf[q]; ok && j > i && j > lastPick[i] {
lastPick[i] = j
}
}
}
laneEnd := map[int]int{}
for ei, 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 flatKind(e.Kind) {
// A flat event row: rails pass through, no node.
c, w := carry()
url := eventPath(repo, e)
day := &days[len(days)-1]
// "Technote: " prefixes the stored comment so a mixed
// timeline says what a row IS. The note's own card carries
// that in the badge beside it, so the prefix would be the
// word twice.
title := e.Comment
if e.Kind == "e" {
title = strings.TrimPrefix(title, "Technote: ")
}
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: title, Excerpt: e.Excerpt, Tail: c, Width: w,
LandingNumber: e.LandingNumber,
})
refs = append(refs, rowRef{d: len(days) - 1, r: len(day.Rows) - 1, lane: -1})
continue
}
// Which rails were expecting this check-in?
joins := find(e.Hash)
var nodeRail int
// A line above this node exists only when a rail was ALREADY
// heading here. A freshly allocated rail is not one: alloc sets
// want to this very hash, so reading it back draws a line above
// every branch tip — a leaf claiming something follows it.
hadAbove := false
switch {
case mainline && e.Branch == defaultBranch && (contains(joins, 0) || !rails[0].active):
// The mainline 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 — the shape a
// reader expects: branches hang off trunk, trunk does not
// hop into a branch's column.
//
// Only when rail 0 is FREE to take it, though. A merge whose
// two parents are both on the mainline puts two chains in
// front of one column, and rail 0 can carry one: claiming it
// anyway both stranded the line already heading elsewhere
// (rail 0 stopped being drawn above this row — the gap) and
// overwrote the commit it was still travelling to.
nodeRail = 0
hadAbove = contains(joins, 0)
rails[0].active = true
rails[0].reserved = true
case len(joins) > 0:
nodeRail = joins[0]
hadAbove = true
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.
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 && !rails[i].lane && 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
}
// A line whose next check-in is not on this page ends in a stub
// instead of running to the bottom of the page (fossil's
// descender): a short solid drop, a few dots, and the column is
// free for the next row. Holding the column to the page's end
// for a parent hundreds of rows away is what made wide pages
// wide. The mainline's column is the exception: it is reserved,
// so stubbing it frees nothing, and the main line would seem to
// end halfway down the page. It runs on to the bottom. So does
// every line near the page's end, as fossil draws a descender
// within 150px of the bottom in full: with fewer than
// stubRunway rows left, a freed column is one nothing would use,
// and the line only looks cut.
// A check-in that cherry-picked from lower on the page takes a
// lane for the picks: the lowest free column, held until the
// oldest pick's row, so nothing else lands where the lane runs.
lane := -1
if lastPick[ei] > ei {
lane = alloc("", "")
rails[lane].lane = true
laneEnd[lane] = lastPick[ei]
}
var stubs []seg
nearEnd := below[ei] < stubRunway
if hasBelow && !nearEnd && !rails[nodeRail].reserved && !onPage(rails[nodeRail].want) {
stubs = append(stubs, seg{idx: nodeRail, branch: e.Branch})
}
for _, m := range mergeOut {
if !nearEnd && !rails[m.idx].reserved && !onPage(rails[m.idx].want) {
stubs = append(stubs, m)
}
}
stubbed := func(i int) bool {
for _, s := range stubs {
if s.idx == i {
return true
}
}
return false
}
// 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).
d := joinPath(x(j), nx, cy)
fmt.Fprintf(&hd, `<path class="tl-halo" d="%s"/><path d="%s" fill="none" stroke="%s" stroke-width="2.5"/>`,
d, d, pal.stroke(joinBranch[j]))
}
for _, m := range mergeOut {
d := mergeOutPath(nx, x(m.idx), cy)
fmt.Fprintf(&hd, `<path class="tl-halo" d="%s"/><path d="%s" fill="none" stroke="%s" stroke-width="2.5"/>`,
d, d, 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.
// The mark's shape, measured in the rail's own weight: the
// line (2.5), the ring's inner gap and the centre dot are
// all one width, so the node reads as the logo rather than
// as a squashed version of it.
// dot r 1.25 + gap 2.5 + half the ring stroke 1.1 = 4.85
fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="4.85" style="fill:var(--bg)" stroke="%s" stroke-width="2.2"/>`, nx, cy, stroke)
fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="1.25" fill="%s"/>`, nx, cy, stroke)
} else {
// The same DISC as the tip, filled. Matching the ring's r
// (4.85) is what looks wrong: the ring is stroked 2.2, so
// it paints out to 4.85 + 1.1, and a 4.8 disc beside it
// reads a fifth smaller. The radius that matches is the
// ring's outer edge.
fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="5.95" fill="%s"/>`, nx, cy, stroke)
if e.Closed && !e.HasChild {
// A closed tip nothing builds on keeps its branch's
// colour and has an X cut through it, fossil's mark for
// "nothing more will be built here". A landed branch's
// tip is closed too, but its merge line already says
// where it went, so it stays a plain node.
fmt.Fprintf(&hd, `<path class="tl-closed" d="M %.1f %.1f L %.1f %.1f M %.1f %.1f L %.1f %.1f"/>`,
float64(nx)-2.8, float64(cy)-2.8, float64(nx)+2.8, float64(cy)+2.8,
float64(nx)-2.8, float64(cy)+2.8, float64(nx)+2.8, float64(cy)-2.8)
}
}
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 && !stubbed(nodeRail) {
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 {
if stubbed(m.idx) {
continue
}
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>`)
for _, s := range stubs {
// A strip of dots, not the stretching tail's SVG: the dots
// keep their spacing whatever height the card gives the row,
// and the strip is capped at the row's own height, so the
// stub never reaches the row below and its column is free
// for the very next row.
fmt.Fprintf(&tl, `<span class="tl-stub" style="left:%dpx;--sc:%s"></span>`, x(s.idx), pal.stroke(s.branch))
rails[s.idx].active = false
}
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: eventPath(repo, e),
User: e.User, Lead: commentHTML(repo, title),
Paras: paragraphs(repo, body),
Branch: e.Branch, Tint: pal.tint(e.Branch),
IsLeaf: e.IsLeaf, IsMerge: e.IsMerge, CI: ciBadge(e.CI),
Landing: e.Landing, LandingTitle: e.LandingTitle, LandingApprover: e.LandingApprover,
LandingNumber: e.LandingNumber,
Head: template.HTML(hd.String()), Tail: template.HTML(tl.String()), Width: width,
})
lx := -1
if lane >= 0 {
lx = x(lane)
}
refs = append(refs, rowRef{d: len(days) - 1, r: len(day.Rows) - 1, ci: true,
hash: e.Hash, nx: nx, picks: e.Cherrypicks, lane: lx})
for l, end := range laneEnd {
if end == ei {
rails[l] = rail{}
delete(laneEnd, l)
}
}
}
drawCherrypicks(pal, branchOf, days, refs)
return days
}
// rowRef is where one row of the page landed: its day and its index in
// that day, and for a check-in, its node's x and what it cherry-picked.
type rowRef struct {
d, r int
ci bool
hash string
nx int
picks []string
// lane is the x of the column this check-in's cherry-picks run in,
// or -1 when it picked nothing lower on the page.
lane int
}
// flatKind says an entry is a flat event row (wiki, ticket, technote,
// forum, landing): rails pass through it and it takes no node.
func flatKind(k string) bool {
return k == "w" || k == "t" || k == "e" || k == "f" || k == "l" || k == "lc"
}
// stubRunway is how many check-in rows must follow a line for it to
// stub out rather than run to the bottom of the page.
const stubRunway = 3
// drawCherrypicks draws each check-in's cherry-picks the way fossil
// does, in a lane of their own: the lane is the column the check-in
// took at layout, from its row down to its oldest pick on this page.
// Each picked check-in sends a dashed tick into the lane with an
// arrowhead; the oldest turns into it through the fixed corner, since
// the lane starts there; and the lane turns into the check-in that
// picked, ending in an arrowhead at its node. A merge brings a whole
// branch across and stays solid; a pick takes one change, so it is
// dashed, and every pick is its own mark.
func drawCherrypicks(pal *palette, branchOf map[string]string, days []tlDay, refs []rowRef) {
at := map[string]int{}
for i, r := range refs {
if r.ci {
at[r.hash] = i
}
}
for i, p := range refs {
if p.lane < 0 {
continue
}
picked, last := map[int]bool{}, -1
for _, q := range p.picks {
if j, ok := at[q]; ok && j > i {
picked[j] = true
last = max(last, j)
}
}
if last < 0 {
continue
}
x := p.lane
stroke := pal.stroke(branchOf[refs[last].hash])
row := &days[p.d].Rows[p.r]
row.Head = beforeNode(row.Head, pickInto(p.nx, x, stroke))
row.Tail += pickStrip(x, stroke, false)
for k := i + 1; k < last; k++ {
r := &days[refs[k].d].Rows[refs[k].r]
r.Tail += pickStrip(x, stroke, true)
if picked[k] {
r.Head = beforeNode(r.Head, pickTick(refs[k].nx, x, stroke))
}
}
for d := p.d + 1; d <= refs[last].d; d++ {
days[d].Carry += pickStrip(x, stroke, true)
}
qr := &days[refs[last].d].Rows[refs[last].r]
qr.Head = beforeNode(qr.Head, pickStart(refs[last].nx, x, stroke))
}
}
// pickNodeR is a node's drawn radius, the edge an arrowhead touches;
// pickAW and pickAH are an arrowhead's length and half its width.
const pickNodeR, pickAW, pickAH = 5.95, 4.5, 3.0
// pickLine is one stretch of a cherry-pick's dashed line over its gap
// stroke, drawn in a head band.
func pickLine(d, stroke string) string {
return fmt.Sprintf(`<path class="tl-pickhalo" d="%s"/><path class="tl-pickline" d="%s" stroke="%s"/>`, d, d, stroke)
}
// pickArrow is an arrowhead lying on the node's row whose tip is at tip
// and which points toward the node or lane on side dir (+1 right, -1
// left of the base).
func pickArrow(tip float64, dir float64, stroke string) string {
cy, base := float64(nodeCY), tip-dir*pickAW
return fmt.Sprintf(`<path d="M %.2f %.2f L %.2f %.2f L %.2f %.2f Z" fill="%s"/>`, base, cy-pickAH, base, cy+pickAH, tip, cy, stroke)
}
// pickCorner is the fixed corner's radius for a turn whose horizontal
// run is span: the merge corner's, or less when the run is shorter.
func pickCorner(span float64) float64 {
return max(2, min(float64(cornerR), span))
}
// pickInto draws the lane's end in the picking row: up from the band's
// bottom at x, through the corner, into the node at nx, where an
// arrowhead touches its edge.
func pickInto(nx, x int, stroke string) string {
cy, fx, fnx := float64(nodeCY), float64(x), float64(nx)
if x > nx {
edge := fnx + pickNodeR
r := pickCorner(fx - edge)
return pickLine(fmt.Sprintf("M %d %d V %.2f A %.2f %.2f 0 0 0 %.2f %.2f H %.2f", x, headH, cy+r, r, r, fx-r, cy, edge+pickAW-0.5), stroke) +
pickArrow(edge, -1, stroke)
}
edge := fnx - pickNodeR
r := pickCorner(edge - fx)
return pickLine(fmt.Sprintf("M %d %d V %.2f A %.2f %.2f 0 0 1 %.2f %.2f H %.2f", x, headH, cy+r, r, r, fx+r, cy, edge-pickAW+0.5), stroke) +
pickArrow(edge, 1, stroke)
}
// pickTick is a picked check-in's mark: a dashed tick from its node's
// edge across to the lane at x, ending in an arrowhead on the lane.
func pickTick(nx, x int, stroke string) string {
cy, fx, fnx := float64(nodeCY), float64(x), float64(nx)
if x < nx {
return pickLine(fmt.Sprintf("M %.2f %.2f H %.2f", fnx-pickNodeR, cy, fx+pickAW-0.5), stroke) + pickArrow(fx, -1, stroke)
}
return pickLine(fmt.Sprintf("M %.2f %.2f H %.2f", fnx+pickNodeR, cy, fx-pickAW+0.5), stroke) + pickArrow(fx, 1, stroke)
}
// pickStart is the lane's start at the oldest pick: from that node,
// through the corner, and up the lane to the band's top. No arrowhead:
// the lane begins here.
func pickStart(nx, x int, stroke string) string {
cy, fx, fnx := float64(nodeCY), float64(x), float64(nx)
if x < nx {
r := pickCorner(fnx - pickNodeR - fx)
return pickLine(fmt.Sprintf("M %.2f %.2f H %.2f A %.2f %.2f 0 0 1 %.2f %.2f V 0", fnx, cy, fx+r, r, r, fx, cy-r), stroke)
}
r := pickCorner(fx - fnx - pickNodeR)
return pickLine(fmt.Sprintf("M %.2f %.2f H %.2f A %.2f %.2f 0 0 0 %.2f %.2f V 0", fnx, cy, fx-r, r, r, fx, cy-r), stroke)
}
// pickStrip is a cherry-pick's line through a stretching band: an HTML
// strip, not SVG, because the band's SVG is scaled to the row's height
// and would stretch the dashes with it. full covers the whole row;
// otherwise it starts below the head band, where the head draws it.
func pickStrip(x int, stroke string, full bool) template.HTML {
cls := "tl-pick"
if full {
cls += " full"
}
return template.HTML(fmt.Sprintf(`<span class="%s" style="left:%dpx;--pc:%s"></span>`, cls, x, stroke))
}
// beforeNode inserts marks into a head band ahead of its node, so the
// node is drawn over them and they tuck under it.
func beforeNode(head template.HTML, marks string) template.HTML {
h := string(head)
i := strings.Index(h, `r="4.85"`)
if i < 0 {
i = strings.Index(h, `r="5.95"`)
}
if i >= 0 {
if j := strings.LastIndex(h[:i], "<circle"); j >= 0 {
return template.HTML(h[:j] + marks + h[j:])
}
}
if j := strings.LastIndex(h, "</svg>"); j >= 0 {
return template.HTML(h[:j] + marks + h[j:])
}
return head
}
// 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(repo, body string) []template.HTML {
var out []template.HTML
for _, p := range strings.Split(body, "\n\n") {
p = strings.Join(strings.Fields(p), " ")
if p != "" {
out = append(out, commentHTML(repo, p))
}
}
return out
}
func contains(xs []int, v int) bool {
for _, x := range xs {
if x == v {
return true
}
}
return false
}
// eventPath is where a timeline event lives: the one answer the
// timeline page and its Atom feed both use, so a reader who follows a
// feed item lands where a click on the row would have taken them.
func eventPath(repo string, e timelineEntry) string {
switch e.Kind {
case "w": // a wiki row is one specific edit — link that revision
return "/r/" + repo + "/wiki/" + neturl.PathEscape(e.Ref) + "?rev=" + e.Hash[:min(12, len(e.Hash))]
case "t":
return "/r/" + repo + "/ticket/" + e.Ref
case "e":
return "/r/" + repo + "/technote/" + e.Ref[:min(16, len(e.Ref))]
case "f":
return "/r/" + repo + "/forum/" + e.Ref[:min(16, len(e.Ref))]
case "l", "lc":
// Every post in a landing links to the landing, not to the
// post: the record is the page, and a reader who clicked a
// verdict wants the decision it settled.
return "/r/" + repo + "/landing/" + e.Ref[:min(16, len(e.Ref))]
}
return "/r/" + repo + "/checkin/" + e.Hash
}
// cornerR is the one bend every merge and join line takes, however far
// it travels: a straight run, then this quarter circle into the rail.
// It is the head band below the node, so a merge-out lands exactly on
// the band's bottom edge. A span narrower than the radius bends tighter.
// Each line is drawn over a tl-halo copy of itself in the page colour,
// which cuts a small gap in every rail it crosses.
const cornerR = headH - nodeCY
// mergeOutPath runs from the node at (nx, cy) across to the rail at mx,
// then turns down into it at the bottom of the head band.
func mergeOutPath(nx, mx, cy int) string {
dx := mx - nx
r := min(cornerR, max(dx, -dx))
if dx >= 0 {
return fmt.Sprintf("M %d %d H %d A %d %d 0 0 1 %d %d V %d", nx, cy, mx-r, r, r, mx, cy+r, headH)
}
return fmt.Sprintf("M %d %d H %d A %d %d 0 0 0 %d %d V %d", nx, cy, mx+r, r, r, mx, cy+r, headH)
}
// joinPath comes down the rail at jx from the top of the head band,
// then turns across into the node at (nx, cy).
func joinPath(jx, nx, cy int) string {
dx := nx - jx
r := min(cornerR, max(dx, -dx))
if dx < 0 {
return fmt.Sprintf("M %d 0 V %d A %d %d 0 0 1 %d %d H %d", jx, cy-r, r, r, jx-r, cy, nx)
}
return fmt.Sprintf("M %d 0 V %d A %d %d 0 0 0 %d %d H %d", jx, cy-r, r, r, jx+r, cy, nx)
}