From 573dce2ed0a85165970060b034c9bc3ec2317ba9 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 27 May 2026 21:41:26 +0300 Subject: showcase: extend rank history to 32 weeks, trim leading empty columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the graph was fixed at 5 weekly data points. Now: - rankHistoryPoints raised from 5 → 32 so the store accumulates up to 32 weekly snapshots and each project carries 32 history entries. - GenerateRankHistorySVG trims all leading all-zero columns before layout, so the graph only spans from the oldest week that any project actually has data — no empty space on the left when history is short. - displayPoints (trimmed width) drives all layout math: xPos(), xLabels, and the X-axis loop; numPoints is only used for the reversal placement. - xLabelStep() thins X-axis text labels when many columns are visible (step 1 for ≤6, 2 for ≤12, 4 for ≤24, 8 for >24 columns) while still drawing a grid line at every column and always labelling 'now'. - Test: give alpha a Spot at '4w' so trimming keeps that column and the time-axis label test still finds '4w ago' in the output. - New TestXLabelStep_ReturnsSensibleSteps covers the new helper. Co-Authored-By: Claude Sonnet 4.6 --- internal/showcase/rank_history.go | 2 +- internal/showcase/rank_history_svg.go | 63 ++++++++++++++++++++++++------ internal/showcase/rank_history_svg_test.go | 28 ++++++++++++- 3 files changed, 79 insertions(+), 14 deletions(-) (limited to 'internal/showcase') diff --git a/internal/showcase/rank_history.go b/internal/showcase/rank_history.go index 559237f..ba8594d 100644 --- a/internal/showcase/rank_history.go +++ b/internal/showcase/rank_history.go @@ -11,7 +11,7 @@ import ( const ( rankHistoryFilename = ".gitsyncer-showcase-rank-history.json" - rankHistoryPoints = 5 + rankHistoryPoints = 32 // up to 32 weekly snapshots kept in history rankHistoryVersion = 1 ) diff --git a/internal/showcase/rank_history_svg.go b/internal/showcase/rank_history_svg.go index fd77c83..d932edc 100644 --- a/internal/showcase/rank_history_svg.go +++ b/internal/showcase/rank_history_svg.go @@ -159,7 +159,7 @@ func buildLegendSVG(allProjects []svgProjectData, legendX, plotH int) string { // 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 // 5 weekly snapshots + numPoints := rankHistoryPoints // up to 32 weekly snapshots // Collect per-project data, reversing the history so oldest is on the left. allProjects := make([]svgProjectData, 0, len(summaries)) @@ -209,11 +209,29 @@ func GenerateRankHistorySVG(summaries []ProjectSummary) string { colorIdx++ } - // Human-readable X-axis labels (left = oldest, right = "now"). - // Position i is (numPoints-1-i) weeks ago; position numPoints-1 is "now". - xLabels := make([]string, numPoints) - for i := 0; i < numPoints; i++ { - weeksAgo := numPoints - 1 - i + // 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). + firstDataCol := numPoints - 1 // pessimistic: show at least "now" +outer: + for col := 0; col < numPoints; col++ { + for _, proj := range allProjects { + if proj.Points[col].Spot > 0 { + firstDataCol = col + break outer + } + } + } + for i := range allProjects { + allProjects[i].Points = allProjects[i].Points[firstDataCol:] + } + displayPoints := numPoints - firstDataCol // actual columns to render + + // 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) + for i := 0; i < displayPoints; i++ { + weeksAgo := displayPoints - 1 - i if weeksAgo == 0 { xLabels[i] = "now" } else { @@ -226,10 +244,10 @@ func GenerateRankHistorySVG(summaries []ProjectSummary) string { plotH := svgViewHeight - svgMarginTop - svgMarginBottom xPos := func(i int) float64 { - if numPoints <= 1 { + if displayPoints <= 1 { return float64(svgMarginLeft) + float64(plotW)/2 } - return float64(svgMarginLeft) + float64(i)*float64(plotW)/float64(numPoints-1) + return float64(svgMarginLeft) + float64(i)*float64(plotW)/float64(displayPoints-1) } // rank 1 → top of plot, maxRank → bottom of plot. @@ -264,16 +282,21 @@ func GenerateRankHistorySVG(summaries []ProjectSummary) string { } // 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) - for i := 0; i < numPoints; i++ { + labelStep := xLabelStep(displayPoints) + for i := 0; i < displayPoints; i++ { x := xPos(i) fmt.Fprintf(&xAxisBuf, ``, x, svgMarginTop, x, plotBottom) - fmt.Fprintf(&xAxisBuf, - `%s`, - x, plotBottom+16, xLabels[i]) + if i%labelStep == 0 || i == displayPoints-1 { + fmt.Fprintf(&xAxisBuf, + `%s`, + x, plotBottom+16, xLabels[i]) + } } // Project lines and dot groups. @@ -545,3 +568,19 @@ func gridStep(maxRank int) int { return 1 } } + +// xLabelStep returns how many X-axis columns to skip between printed labels so +// the time axis stays readable when many weeks of history are displayed. +// Grid lines are always drawn at every column; only labels are thinned. +func xLabelStep(displayPoints int) int { + switch { + case displayPoints > 24: + return 8 + case displayPoints > 12: + return 4 + case displayPoints > 6: + return 2 + default: + return 1 + } +} diff --git a/internal/showcase/rank_history_svg_test.go b/internal/showcase/rank_history_svg_test.go index 06aab4c..d086a7d 100644 --- a/internal/showcase/rank_history_svg_test.go +++ b/internal/showcase/rank_history_svg_test.go @@ -16,7 +16,7 @@ func makeTestSummaries() []ProjectSummary { {Spot: 2, Anchor: "1w", SnapshotDate: "2026-05-20"}, {Spot: 2, Anchor: "2w", SnapshotDate: "2026-05-13"}, {Spot: 3, Anchor: "3w", SnapshotDate: "2026-05-06"}, - {Spot: 0, Anchor: "4w"}, // missing snapshot week + {Spot: 4, Anchor: "4w", SnapshotDate: "2026-04-29"}, // oldest data point }, }, { @@ -239,6 +239,32 @@ func TestGenerateRankHistorySVG_PartialRankHistory(t *testing.T) { require(strings.Contains(svg, `"name":"single-entry"`), "SVG should include 'single-entry' project") } +// TestXLabelStep_ReturnsSensibleSteps verifies the X-axis label density +// function thins out labels as the number of display columns grows. +func TestXLabelStep_ReturnsSensibleSteps(t *testing.T) { + t.Parallel() + + tests := []struct { + displayPoints int + wantMax int // step must be ≤ wantMax + }{ + {5, 1}, + {10, 2}, + {20, 4}, + {32, 8}, + } + + for _, tc := range tests { + got := xLabelStep(tc.displayPoints) + if got > tc.wantMax { + t.Errorf("xLabelStep(%d) = %d, want ≤ %d", tc.displayPoints, got, tc.wantMax) + } + if got <= 0 { + t.Errorf("xLabelStep(%d) = %d, want > 0", tc.displayPoints, got) + } + } +} + // TestGridStep_ReturnsSensibleSteps ensures the Y-axis step function keeps // the graph legible across various project counts. func TestGridStep_ReturnsSensibleSteps(t *testing.T) { -- cgit v1.2.3