From c394fbbc157d4032f23c59d79269dadb3440ff71 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 28 May 2026 10:58:55 +0300 Subject: refactor(showcase): split rank history svg generator (fq) --- internal/showcase/rank_history_svg.go | 484 +++++++++++++++-------------- 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 = `` + +const rankHistorySVGScriptTemplate = `` + // 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(``) } + 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(``) - // Embedded CSS – kept compact but readable. - svg.WriteString(``) + 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, ``, string(projectsJSON), svgViewWidth, svgViewHeight) + fmt.Fprintf(&svg, rankHistorySVGScriptTemplate, string(projectsJSON), svgViewWidth, svgViewHeight) svg.WriteString(``) 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) + } +} -- cgit v1.2.3