summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-28 10:58:55 +0300
committerPaul Buetow <paul@buetow.org>2026-05-28 10:58:55 +0300
commitc394fbbc157d4032f23c59d79269dadb3440ff71 (patch)
treed75af5751359d5587c729da816f79e5cc0345aa3
parent3b8be6195108a602c11761ee89c95cd6dc908703 (diff)
refactor(showcase): split rank history svg generator (fq)
-rw-r--r--internal/showcase/rank_history_svg.go484
-rw-r--r--internal/showcase/rank_history_svg_test.go100
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)
+ }
+}