diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-28 10:58:55 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-28 10:58:55 +0300 |
| commit | c394fbbc157d4032f23c59d79269dadb3440ff71 (patch) | |
| tree | d75af5751359d5587c729da816f79e5cc0345aa3 | |
| parent | 3b8be6195108a602c11761ee89c95cd6dc908703 (diff) | |
refactor(showcase): split rank history svg generator (fq)
| -rw-r--r-- | internal/showcase/rank_history_svg.go | 484 | ||||
| -rw-r--r-- | internal/showcase/rank_history_svg_test.go | 100 |
2 files changed, 355 insertions, 229 deletions
diff --git a/internal/showcase/rank_history_svg.go b/internal/showcase/rank_history_svg.go index aae4dc1..f0f1953 100644 --- a/internal/showcase/rank_history_svg.go +++ b/internal/showcase/rank_history_svg.go @@ -28,6 +28,184 @@ const ( svgViewWidth = svgMarginLeft + svgPlotWidth + svgMarginRight // 1280 px ) +const rankHistorySVGStyle = `<style> +svg{background:#1a1a2e;font-family:monospace} +.gl{stroke:#2a2a5a;stroke-width:.6;stroke-dasharray:4,3} +.al{fill:#888;font-size:11px} +.title{fill:#ddd;font-size:14px;font-weight:bold} +.sub{fill:#666;font-size:10px} +.pg{cursor:pointer;opacity:.55;transition:opacity .15s} +.pl{stroke-width:1;fill:none} +.pd circle{transition:r .15s} +.lg{cursor:pointer;opacity:1;transition:opacity .15s} +.ltx{fill:#aaa;font-size:9px} +.lhd{fill:#555;font-size:9px} +#tt{pointer-events:none;display:none} +#ttbg{fill:#1a1a40;stroke:#4444aa;stroke-width:1} +#tttl{fill:#fff;font-size:12px;font-weight:bold;font-family:monospace} +.ttrow{fill:#bbb;font-size:10px;font-family:monospace} +</style>` + +const rankHistorySVGScriptTemplate = `<script><![CDATA[ +var PROJECTS=%s; +var CHART_W=%d, CHART_H=%d; +var svgEl=document.querySelector('svg'); +var chartEl=document.getElementById('chart'); +var tt=document.getElementById('tt'); +var ttbg=document.getElementById('ttbg'); +var tttl=document.getElementById('tttl'); +var ttbd=document.getElementById('ttbd'); +// allPG = plot line groups; allLG = legend entry groups (same count, same order). +// Query inside chartEl so the IDs are scoped to the chart group. +var allPG=chartEl.querySelectorAll('.pg'); +var allLG=chartEl.querySelectorAll('.lg'); +var activeIdx=-1; + +// rescale stretches the chart to fill the full browser window in both axes. +// Sets explicit pixel width/height (bypassing the body-margin trap that makes +// percentage-relative sizes fall short in standalone SVG files) and a matching +// viewBox, then applies independent x/y scales so the chart always occupies +// every pixel — width tracks window width, height tracks window height. +// chartEl.getScreenCTM().inverse() accounts for both scale factors, keeping +// tooltip hit-testing correct regardless of the window aspect ratio. +function rescale(){ + var W=window.innerWidth||document.documentElement.clientWidth; + var H=window.innerHeight||document.documentElement.clientHeight; + svgEl.setAttribute('width',W); + svgEl.setAttribute('height',H); + svgEl.setAttribute('viewBox','0 0 '+W+' '+H); + var sx=W/CHART_W, sy=H/CHART_H; + chartEl.setAttribute('transform','scale('+sx+','+sy+')'); +} +window.addEventListener('resize',rescale); +rescale(); + +// Initialise inactive projects: grey plot line, dimmed legend entry. +// This runs once after DOM and rescale() are ready. +for(var i=0;i<allPG.length;i++){ + if(PROJECTS[i].inactive){ + allPG[i].style.opacity='0.3'; + allPG[i].querySelector('.pl').style.stroke='#555'; + } +} +for(var i=0;i<allLG.length;i++){ + if(PROJECTS[i].inactive) allLG[i].style.opacity='0.35'; +} + +// defaultPGOpacity returns the resting opacity for a plot group. +function defaultPGOpacity(i){return PROJECTS[i].inactive?'0.3':'0.55';} +// defaultLGOpacity returns the resting opacity for a legend entry. +function defaultLGOpacity(i){return PROJECTS[i].inactive?'0.35':'1';} + +// onEnter is called when the cursor enters a project group or legend entry. +// It dims/greys all other groups, shows the tooltip, and marks the project active. +function onEnter(idx,evt){ + activeIdx=idx; + var p=PROJECTS[idx]; + tttl.textContent=p.name; + + // Clear old tooltip rows. + while(ttbd.firstChild)ttbd.removeChild(ttbd.firstChild); + + // Score row just below the title. + var scoreRow=document.createElementNS('http://www.w3.org/2000/svg','text'); + scoreRow.setAttribute('x','10'); + scoreRow.setAttribute('y','30'); + scoreRow.setAttribute('class','ttrow'); + scoreRow.setAttribute('fill','#888'); + scoreRow.textContent='score: '+p.score.toFixed(1); + ttbd.appendChild(scoreRow); + + // Build one row per snapshot, newest first (points array is oldest-first). + // Start below the score row; y=44 keeps clear of title (y=18) and score (y=30). + var y=44; + for(var i=p.points.length-1;i>=0;i--){ + var pt=p.points[i]; + if(pt.spot<=0)continue; + var label=pt.label==='now'?'now':pt.label+' ago'; + var line='#'+pt.spot+' '+label+(pt.date?' ('+pt.date+')':''); + var t=document.createElementNS('http://www.w3.org/2000/svg','text'); + t.setAttribute('x','10'); + t.setAttribute('y',String(y)); + t.setAttribute('class','ttrow'); + t.textContent=line; + ttbd.appendChild(t); + y+=13; + } + + // Resize tooltip background: width adapts to the project name, height to rows. + var nameW=p.name.length*7.5+20; + var w=Math.max(160,Math.min(300,nameW)); + var h=Math.max(36,y+8); + ttbg.setAttribute('width',w); + ttbg.setAttribute('height',h); + + // Apply per-project opacity for plot lines: + // - hovered project → full opacity + project colour (restores inactive grey) + // - active others → dimmed to 0.08 + // - inactive others → stay at their grey resting state (not dimmed further) + for(var i=0;i<allPG.length;i++){ + var pl=allPG[i].querySelector('.pl'); + if(i===idx){ + allPG[i].style.opacity='1'; + pl.style.stroke=PROJECTS[i].color; // restore colour for inactive projects + pl.style.strokeWidth='3'; + } else if(PROJECTS[i].inactive){ + // Keep inactive lines at their grey resting state so they do not compete. + allPG[i].style.opacity='0.3'; + pl.style.stroke='#555'; + } else { + allPG[i].style.opacity='0.08'; + } + } + + // Highlight hovered legend entry; dim all others uniformly. + for(var i=0;i<allLG.length;i++){ + allLG[i].style.opacity=(i===idx)?'1':'0.2'; + } + + moveTT(evt); + tt.style.display='block'; +} + +// onLeave restores all groups to their per-project resting state. +function onLeave(){ + tt.style.display='none'; + for(var i=0;i<allPG.length;i++){ + var pl=allPG[i].querySelector('.pl'); + allPG[i].style.opacity=defaultPGOpacity(i); + pl.style.strokeWidth=''; + // Restore grey stroke for inactive projects; clear override for active ones. + pl.style.stroke=PROJECTS[i].inactive?'#555':''; + } + for(var i=0;i<allLG.length;i++){ + allLG[i].style.opacity=defaultLGOpacity(i); + } + activeIdx=-1; +} + +// Follow the cursor while hovering. +svgEl.addEventListener('mousemove',function(evt){ + if(activeIdx>=0)moveTT(evt); +}); + +// moveTT repositions the tooltip near the cursor, keeping it inside the chart +// coordinate space (CHART_W × CHART_H). chartEl.getScreenCTM() accounts for +// the rescale() transform, so the returned point is already in chart coords. +function moveTT(evt){ + var pt=svgEl.createSVGPoint(); + pt.x=evt.clientX; pt.y=evt.clientY; + var sp=pt.matrixTransform(chartEl.getScreenCTM().inverse()); + var w=parseFloat(ttbg.getAttribute('width')); + var h=parseFloat(ttbg.getAttribute('height')); + var tx=sp.x+14, ty=sp.y-10; + if(tx+w>CHART_W-5)tx=sp.x-w-14; + if(ty+h>CHART_H-5)ty=CHART_H-h-5; + if(ty<5)ty=5; + tt.setAttribute('transform','translate('+tx+','+ty+')'); +} +]]></script>` + // svgTimePoint is one weekly data snapshot for a project, embedded in the SVG // for JavaScript tooltip rendering. type svgTimePoint struct { @@ -157,18 +335,7 @@ func buildLegendSVG(allProjects []svgProjectData, legendX, plotH int) string { return buf.String() } -// GenerateRankHistorySVG creates an interactive inline SVG that shows a -// Google-Trends-style rank history graph for all projects. -// -// Layout: -// - Plot area: left = oldest snapshot, right = "now"; rank 1 at top. -// - Legend panel: 3-column grid to the right of the plot; hovering a -// legend entry highlights the corresponding plot line. -// - The SVG uses width/height="100%" so it fills the browser window. -func GenerateRankHistorySVG(summaries []ProjectSummary) string { - numPoints := rankHistoryPoints // up to 32 weekly snapshots - - // Collect per-project data, reversing the history so oldest is on the left. +func buildProjectData(summaries []ProjectSummary, numPoints int) ([]svgProjectData, int) { allProjects := make([]svgProjectData, 0, len(summaries)) maxRank := 1 colorIdx := 0 @@ -232,9 +399,12 @@ func GenerateRankHistorySVG(summaries []ProjectSummary) string { colorIdx++ } - // Trim leading all-zero columns so the graph starts at the oldest week - // that has real data for any project (not at week 32 if history only goes - // back 5 weeks). The rightmost column is always "now" (index numPoints-1). + return allProjects, maxRank +} + +// trimLeadingEmptyColumns removes leading columns where every project has no +// data so the leftmost rendered column is the oldest available data point. +func trimLeadingEmptyColumns(allProjects []svgProjectData, numPoints int) int { firstDataCol := numPoints - 1 // pessimistic: show at least "now" outer: for col := 0; col < numPoints; col++ { @@ -245,11 +415,15 @@ outer: } } } + for i := range allProjects { allProjects[i].Points = allProjects[i].Points[firstDataCol:] } - displayPoints := numPoints - firstDataCol // actual columns to render + return numPoints - firstDataCol +} + +func buildXLabels(displayPoints int) []string { // Human-readable X-axis labels (left = oldest visible, right = "now"). // Position i is (displayPoints-1-i) weeks ago; position displayPoints-1 is "now". xLabels := make([]string, displayPoints) @@ -261,36 +435,15 @@ outer: xLabels[i] = fmt.Sprintf("%dw ago", weeksAgo) } } + return xLabels +} - // --- Layout helpers --- - plotW := svgViewWidth - svgMarginLeft - svgMarginRight // = svgPlotWidth = 900 - plotH := svgViewHeight - svgMarginTop - svgMarginBottom - - xPos := func(i int) float64 { - if displayPoints <= 1 { - return float64(svgMarginLeft) + float64(plotW)/2 - } - return float64(svgMarginLeft) + float64(i)*float64(plotW)/float64(displayPoints-1) - } - - // rank 1 → top of plot, maxRank → bottom of plot. - yPos := func(rank int) float64 { - if rank <= 0 { - return -999 // off-screen sentinel; caller should skip - } - if maxRank <= 1 { - return float64(svgMarginTop) + float64(plotH)/2 - } - ratio := float64(rank-1) / float64(maxRank-1) - return float64(svgMarginTop) + ratio*float64(plotH) - } - - // Embed project data as JSON for the JS tooltip layer. - projectsJSON, _ := json.Marshal(allProjects) - - // --- Build SVG sub-sections --- - - // Horizontal grid lines and Y-axis labels. +func renderAxes( + maxRank, displayPoints, plotW, plotH int, + xPos func(int) float64, + yPos func(int) float64, + xLabels []string, +) (string, string) { var gridBuf strings.Builder yStep := gridStep(maxRank) plotRight := float64(svgMarginLeft + plotW) @@ -304,9 +457,6 @@ outer: float64(svgMarginLeft)-6, y+4, r) } - // Vertical grid lines and X-axis labels. - // When there are many columns (long history), only label every Nth column - // so the axis stays readable; "now" (rightmost) is always labelled. var xAxisBuf strings.Builder plotBottom := float64(svgMarginTop + plotH) labelStep := xLabelStep(displayPoints) @@ -322,8 +472,12 @@ outer: } } - // Project lines and dot groups. + return gridBuf.String(), xAxisBuf.String() +} + +func renderLines(allProjects []svgProjectData, xPos func(int) float64, yPos func(int) float64) string { var linesBuf strings.Builder + for i, proj := range allProjects { pathD := buildSVGPath(proj.Points, xPos, yPos) if pathD == "" { @@ -352,6 +506,53 @@ outer: linesBuf.WriteString(`</g>`) } + return linesBuf.String() +} + +// GenerateRankHistorySVG creates an interactive inline SVG that shows a +// Google-Trends-style rank history graph for all projects. +// +// Layout: +// - Plot area: left = oldest snapshot, right = "now"; rank 1 at top. +// - Legend panel: 3-column grid to the right of the plot; hovering a +// legend entry highlights the corresponding plot line. +// - The SVG uses width/height="100%" so it fills the browser window. +func GenerateRankHistorySVG(summaries []ProjectSummary) string { + numPoints := rankHistoryPoints // up to 32 weekly snapshots + + allProjects, maxRank := buildProjectData(summaries, numPoints) + displayPoints := trimLeadingEmptyColumns(allProjects, numPoints) + xLabels := buildXLabels(displayPoints) + + // --- Layout helpers --- + plotW := svgViewWidth - svgMarginLeft - svgMarginRight // = svgPlotWidth = 900 + plotH := svgViewHeight - svgMarginTop - svgMarginBottom + + xPos := func(i int) float64 { + if displayPoints <= 1 { + return float64(svgMarginLeft) + float64(plotW)/2 + } + return float64(svgMarginLeft) + float64(i)*float64(plotW)/float64(displayPoints-1) + } + + // rank 1 → top of plot, maxRank → bottom of plot. + yPos := func(rank int) float64 { + if rank <= 0 { + return -999 // off-screen sentinel; caller should skip + } + if maxRank <= 1 { + return float64(svgMarginTop) + float64(plotH)/2 + } + ratio := float64(rank-1) / float64(maxRank-1) + return float64(svgMarginTop) + ratio*float64(plotH) + } + + // Embed project data as JSON for the JS tooltip layer. + projectsJSON, _ := json.Marshal(allProjects) + + gridSVG, xAxisSVG := renderAxes(maxRank, displayPoints, plotW, plotH, xPos, yPos, xLabels) + linesSVG := renderLines(allProjects, xPos, yPos) + // --- Assemble the full SVG --- var svg strings.Builder @@ -362,24 +563,7 @@ outer: // fills the window without letterboxing. svg.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" style="position:fixed;top:0;left:0;display:block">`) - // Embedded CSS – kept compact but readable. - svg.WriteString(`<style> -svg{background:#1a1a2e;font-family:monospace} -.gl{stroke:#2a2a5a;stroke-width:.6;stroke-dasharray:4,3} -.al{fill:#888;font-size:11px} -.title{fill:#ddd;font-size:14px;font-weight:bold} -.sub{fill:#666;font-size:10px} -.pg{cursor:pointer;opacity:.55;transition:opacity .15s} -.pl{stroke-width:1;fill:none} -.pd circle{transition:r .15s} -.lg{cursor:pointer;opacity:1;transition:opacity .15s} -.ltx{fill:#aaa;font-size:9px} -.lhd{fill:#555;font-size:9px} -#tt{pointer-events:none;display:none} -#ttbg{fill:#1a1a40;stroke:#4444aa;stroke-width:1} -#tttl{fill:#fff;font-size:12px;font-weight:bold;font-family:monospace} -.ttrow{fill:#bbb;font-size:10px;font-family:monospace} -</style>`) + svg.WriteString(rankHistorySVGStyle) // Solid background rect covers the whole viewport regardless of how the // chart group is transformed. #chart is opened next so that rescale() @@ -405,9 +589,9 @@ svg{background:#1a1a2e;font-family:monospace} cx, cy, cx, cy) // Grid, axes, and project lines. - svg.WriteString(gridBuf.String()) - svg.WriteString(xAxisBuf.String()) - svg.WriteString(linesBuf.String()) + svg.WriteString(gridSVG) + svg.WriteString(xAxisSVG) + svg.WriteString(linesSVG) // Legend panel to the right of the plot area. legendX := svgMarginLeft + plotW + svgLegendGap @@ -429,165 +613,7 @@ svg{background:#1a1a2e;font-family:monospace} // PROJECTS is the JSON array; each entry has name, color, and points[]. // CHART_W / CHART_H are the fixed viewBox coordinates used when designing // the chart; rescale() maps them to the actual window size at runtime. - fmt.Fprintf(&svg, `<script><![CDATA[ -var PROJECTS=%s; -var CHART_W=%d, CHART_H=%d; -var svgEl=document.querySelector('svg'); -var chartEl=document.getElementById('chart'); -var tt=document.getElementById('tt'); -var ttbg=document.getElementById('ttbg'); -var tttl=document.getElementById('tttl'); -var ttbd=document.getElementById('ttbd'); -// allPG = plot line groups; allLG = legend entry groups (same count, same order). -// Query inside chartEl so the IDs are scoped to the chart group. -var allPG=chartEl.querySelectorAll('.pg'); -var allLG=chartEl.querySelectorAll('.lg'); -var activeIdx=-1; - -// rescale stretches the chart to fill the full browser window in both axes. -// Sets explicit pixel width/height (bypassing the body-margin trap that makes -// percentage-relative sizes fall short in standalone SVG files) and a matching -// viewBox, then applies independent x/y scales so the chart always occupies -// every pixel — width tracks window width, height tracks window height. -// chartEl.getScreenCTM().inverse() accounts for both scale factors, keeping -// tooltip hit-testing correct regardless of the window aspect ratio. -function rescale(){ - var W=window.innerWidth||document.documentElement.clientWidth; - var H=window.innerHeight||document.documentElement.clientHeight; - svgEl.setAttribute('width',W); - svgEl.setAttribute('height',H); - svgEl.setAttribute('viewBox','0 0 '+W+' '+H); - var sx=W/CHART_W, sy=H/CHART_H; - chartEl.setAttribute('transform','scale('+sx+','+sy+')'); -} -window.addEventListener('resize',rescale); -rescale(); - -// Initialise inactive projects: grey plot line, dimmed legend entry. -// This runs once after DOM and rescale() are ready. -for(var i=0;i<allPG.length;i++){ - if(PROJECTS[i].inactive){ - allPG[i].style.opacity='0.3'; - allPG[i].querySelector('.pl').style.stroke='#555'; - } -} -for(var i=0;i<allLG.length;i++){ - if(PROJECTS[i].inactive) allLG[i].style.opacity='0.35'; -} - -// defaultPGOpacity returns the resting opacity for a plot group. -function defaultPGOpacity(i){return PROJECTS[i].inactive?'0.3':'0.55';} -// defaultLGOpacity returns the resting opacity for a legend entry. -function defaultLGOpacity(i){return PROJECTS[i].inactive?'0.35':'1';} - -// onEnter is called when the cursor enters a project group or legend entry. -// It dims/greys all other groups, shows the tooltip, and marks the project active. -function onEnter(idx,evt){ - activeIdx=idx; - var p=PROJECTS[idx]; - tttl.textContent=p.name; - - // Clear old tooltip rows. - while(ttbd.firstChild)ttbd.removeChild(ttbd.firstChild); - - // Score row just below the title. - var scoreRow=document.createElementNS('http://www.w3.org/2000/svg','text'); - scoreRow.setAttribute('x','10'); - scoreRow.setAttribute('y','30'); - scoreRow.setAttribute('class','ttrow'); - scoreRow.setAttribute('fill','#888'); - scoreRow.textContent='score: '+p.score.toFixed(1); - ttbd.appendChild(scoreRow); - - // Build one row per snapshot, newest first (points array is oldest-first). - // Start below the score row; y=44 keeps clear of title (y=18) and score (y=30). - var y=44; - for(var i=p.points.length-1;i>=0;i--){ - var pt=p.points[i]; - if(pt.spot<=0)continue; - var label=pt.label==='now'?'now':pt.label+' ago'; - var line='#'+pt.spot+' '+label+(pt.date?' ('+pt.date+')':''); - var t=document.createElementNS('http://www.w3.org/2000/svg','text'); - t.setAttribute('x','10'); - t.setAttribute('y',String(y)); - t.setAttribute('class','ttrow'); - t.textContent=line; - ttbd.appendChild(t); - y+=13; - } - - // Resize tooltip background: width adapts to the project name, height to rows. - var nameW=p.name.length*7.5+20; - var w=Math.max(160,Math.min(300,nameW)); - var h=Math.max(36,y+8); - ttbg.setAttribute('width',w); - ttbg.setAttribute('height',h); - - // Apply per-project opacity for plot lines: - // - hovered project → full opacity + project colour (restores inactive grey) - // - active others → dimmed to 0.08 - // - inactive others → stay at their grey resting state (not dimmed further) - for(var i=0;i<allPG.length;i++){ - var pl=allPG[i].querySelector('.pl'); - if(i===idx){ - allPG[i].style.opacity='1'; - pl.style.stroke=PROJECTS[i].color; // restore colour for inactive projects - pl.style.strokeWidth='3'; - } else if(PROJECTS[i].inactive){ - // Keep inactive lines at their grey resting state so they do not compete. - allPG[i].style.opacity='0.3'; - pl.style.stroke='#555'; - } else { - allPG[i].style.opacity='0.08'; - } - } - - // Highlight hovered legend entry; dim all others uniformly. - for(var i=0;i<allLG.length;i++){ - allLG[i].style.opacity=(i===idx)?'1':'0.2'; - } - - moveTT(evt); - tt.style.display='block'; -} - -// onLeave restores all groups to their per-project resting state. -function onLeave(){ - tt.style.display='none'; - for(var i=0;i<allPG.length;i++){ - var pl=allPG[i].querySelector('.pl'); - allPG[i].style.opacity=defaultPGOpacity(i); - pl.style.strokeWidth=''; - // Restore grey stroke for inactive projects; clear override for active ones. - pl.style.stroke=PROJECTS[i].inactive?'#555':''; - } - for(var i=0;i<allLG.length;i++){ - allLG[i].style.opacity=defaultLGOpacity(i); - } - activeIdx=-1; -} - -// Follow the cursor while hovering. -svgEl.addEventListener('mousemove',function(evt){ - if(activeIdx>=0)moveTT(evt); -}); - -// moveTT repositions the tooltip near the cursor, keeping it inside the chart -// coordinate space (CHART_W × CHART_H). chartEl.getScreenCTM() accounts for -// the rescale() transform, so the returned point is already in chart coords. -function moveTT(evt){ - var pt=svgEl.createSVGPoint(); - pt.x=evt.clientX; pt.y=evt.clientY; - var sp=pt.matrixTransform(chartEl.getScreenCTM().inverse()); - var w=parseFloat(ttbg.getAttribute('width')); - var h=parseFloat(ttbg.getAttribute('height')); - var tx=sp.x+14, ty=sp.y-10; - if(tx+w>CHART_W-5)tx=sp.x-w-14; - if(ty+h>CHART_H-5)ty=CHART_H-h-5; - if(ty<5)ty=5; - tt.setAttribute('transform','translate('+tx+','+ty+')'); -} -]]></script>`, string(projectsJSON), svgViewWidth, svgViewHeight) + fmt.Fprintf(&svg, rankHistorySVGScriptTemplate, string(projectsJSON), svgViewWidth, svgViewHeight) svg.WriteString(`</svg>`) return svg.String() diff --git a/internal/showcase/rank_history_svg_test.go b/internal/showcase/rank_history_svg_test.go index d086a7d..88484c8 100644 --- a/internal/showcase/rank_history_svg_test.go +++ b/internal/showcase/rank_history_svg_test.go @@ -290,3 +290,103 @@ func TestGridStep_ReturnsSensibleSteps(t *testing.T) { } } } + +func TestBuildProjectData_MapsHistoryAndMetadata(t *testing.T) { + t.Parallel() + + summaries := []ProjectSummary{ + { + Name: "alpha", + Metadata: &RepoMetadata{ + Score: 42.5, + }, + RankHistory: []RepoRankHistory{ + {Spot: 1, Anchor: "now", SnapshotDate: "2026-05-27"}, + {Spot: 3, Anchor: "1w", SnapshotDate: "2026-05-20"}, + }, + }, + { + Name: "dormant", + Metadata: &RepoMetadata{ + AvgCommitAge: 1200, + Score: 1.5, + }, + RankHistory: []RepoRankHistory{ + {Spot: 4, Anchor: "now", SnapshotDate: "2026-05-27"}, + }, + }, + { + Name: "ghost", + RankHistory: []RepoRankHistory{{Spot: 0}, {Spot: 0}}, + }, + } + + projects, maxRank := buildProjectData(summaries, 5) + + if maxRank != 4 { + t.Fatalf("maxRank = %d, want 4", maxRank) + } + if len(projects) != 2 { + t.Fatalf("len(projects) = %d, want 2 (ghost should be skipped)", len(projects)) + } + + alpha := projects[0] + if alpha.Name != "alpha" { + t.Fatalf("first project name = %q, want alpha", alpha.Name) + } + if alpha.Score != 42.5 { + t.Fatalf("alpha score = %.1f, want 42.5", alpha.Score) + } + if alpha.Inactive { + t.Fatal("alpha should be active") + } + if alpha.Points[4].Label != "now" || alpha.Points[4].Spot != 1 { + t.Fatalf("alpha now point = %+v, want label=now spot=1", alpha.Points[4]) + } + if alpha.Points[3].Label != "1w" || alpha.Points[3].Spot != 3 { + t.Fatalf("alpha 1w point = %+v, want label=1w spot=3", alpha.Points[3]) + } + + dormant := projects[1] + if !dormant.Inactive { + t.Fatal("dormant should be inactive") + } +} + +func TestTrimLeadingEmptyColumns_EdgeCases(t *testing.T) { + t.Parallel() + + noProjects := []svgProjectData{} + if got := trimLeadingEmptyColumns(noProjects, 5); got != 1 { + t.Fatalf("displayPoints for empty projects = %d, want 1", got) + } + + projects := []svgProjectData{ + { + Name: "alpha", + Points: []svgTimePoint{ + {Spot: 0}, {Spot: 0}, {Spot: 0}, {Spot: 2}, {Spot: 1}, + }, + }, + } + + displayPoints := trimLeadingEmptyColumns(projects, 5) + if displayPoints != 2 { + t.Fatalf("displayPoints = %d, want 2", displayPoints) + } + if len(projects[0].Points) != 2 { + t.Fatalf("trimmed point count = %d, want 2", len(projects[0].Points)) + } + if projects[0].Points[0].Spot != 2 || projects[0].Points[1].Spot != 1 { + t.Fatalf("trimmed points = %+v, want spots [2 1]", projects[0].Points) + } +} + +func TestBuildXLabels_SinglePoint(t *testing.T) { + t.Parallel() + + labels := buildXLabels(1) + if len(labels) != 1 || labels[0] != "now" { + t.Fatalf("buildXLabels(1) = %#v, want [\"now\"]", labels) + } +} |
