package showcase import ( "encoding/json" "fmt" "math" "strings" ) // SVG canvas and margin constants (pixels in viewBox coordinates). // The legend panel is placed to the right of the plot area; svgMarginRight // is wide enough to contain it so the plot width stays exactly svgPlotWidth. const ( svgViewHeight = 560 svgMarginLeft = 55 svgMarginTop = 70 svgMarginBottom = 50 // Legend panel sits to the right of the plot area. svgLegendGap = 25 // gap between plot right edge and legend svgLegendCols = 3 // columns in the legend grid svgLegendColW = 95 // width per legend column (px) svgLegendWidth = svgLegendCols * svgLegendColW // 285 px total legend width // Plot area: explicitly sized so svgViewWidth is simply additive. svgPlotWidth = 900 svgMarginRight = svgLegendGap + svgLegendWidth + 15 // 325 px svgViewWidth = svgMarginLeft + svgPlotWidth + svgMarginRight // 1280 px ) // svgTimePoint is one weekly data snapshot for a project, embedded in the SVG // for JavaScript tooltip rendering. type svgTimePoint struct { Label string `json:"label"` // "now", "1w", "2w", … Spot int `json:"spot"` // 0 means no data for this week Date string `json:"date,omitempty"` } // svgProjectData carries per-project metadata used by the interactive JS layer. type svgProjectData struct { Name string `json:"name"` Color string `json:"color"` Points []svgTimePoint `json:"points"` } // projectColor returns a visually distinct CSS hex color for project index i. // It uses golden-ratio hue spacing so successive projects never look similar. func projectColor(i int) string { const golden = 0.618033988749895 hue := math.Mod(float64(i)*golden*360, 360) return hslToRGBHex(hue, 0.75, 0.62) } // hslToRGBHex converts an HSL color (h in [0,360), s and l in [0,1]) to a // CSS hex string like "#rrggbb". func hslToRGBHex(h, s, l float64) string { c := (1 - math.Abs(2*l-1)) * s x := c * (1 - math.Abs(math.Mod(h/60, 2)-1)) m := l - c/2 var r, g, b float64 switch { case h < 60: r, g, b = c, x, 0 case h < 120: r, g, b = x, c, 0 case h < 180: r, g, b = 0, c, x case h < 240: r, g, b = 0, x, c case h < 300: r, g, b = x, 0, c default: r, g, b = c, 0, x } ri := int(math.Round((r + m) * 255)) gi := int(math.Round((g + m) * 255)) bi := int(math.Round((b + m) * 255)) return fmt.Sprintf("#%02x%02x%02x", ri, gi, bi) } // truncateName shortens s to at most maxRunes runes, appending "…" if cut. func truncateName(s string, maxRunes int) string { runes := []rune(s) if len(runes) <= maxRunes { return s } return string(runes[:maxRunes-1]) + "…" } // xmlEscape replaces the characters that are special in SVG/XML text content. func xmlEscape(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") return s } // buildLegendSVG returns SVG markup for the 3-column project legend panel. // Each entry calls onEnter/onLeave to synchronise with the plot lines. // legendX is the left edge of the first legend column. func buildLegendSVG(allProjects []svgProjectData, legendX, plotH int) string { if len(allProjects) == 0 { return "" } var buf strings.Builder // Faint vertical separator between plot and legend. fmt.Fprintf(&buf, ``, legendX-12, svgMarginTop, legendX-12, svgMarginTop+plotH) // Legend header. fmt.Fprintf(&buf, `PROJECTS (hover to highlight)`, legendX, svgMarginTop-8) // Distribute entries across columns: first fill column 0, then 1, then 2. rowsPerCol := (len(allProjects) + svgLegendCols - 1) / svgLegendCols const rowH = 13 // vertical stride per legend entry (px) const sqSize = 8 // colored square side length const maxChars = 12 // max display characters before truncation for i, proj := range allProjects { col := i / rowsPerCol row := i % rowsPerCol colX := legendX + col*svgLegendColW // rowY is the text baseline; the square is centered on it. rowY := svgMarginTop + row*rowH + rowH name := xmlEscape(truncateName(proj.Name, maxChars)) // Each legend entry reuses onEnter/onLeave so hovering a legend item // highlights the corresponding plot line and opens the same tooltip. fmt.Fprintf(&buf, ``, i, i) fmt.Fprintf(&buf, ``, colX, rowY-sqSize+1, sqSize, sqSize, proj.Color) fmt.Fprintf(&buf, `%s`, colX+sqSize+3, rowY, name) buf.WriteString(``) } 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 // 5 weekly snapshots // Collect per-project data, reversing the history so oldest is on the left. allProjects := make([]svgProjectData, 0, len(summaries)) maxRank := 1 colorIdx := 0 for _, s := range summaries { if len(s.RankHistory) == 0 { continue } pts := make([]svgTimePoint, numPoints) hasData := false // RankHistory is newest-first (index 0 = "now", index len-1 = oldest). // We want pts to be oldest-first so the left side of the graph is // the most distant point. Place each source entry at its destination // index (numPoints-1-j) so that "now" lands at pts[numPoints-1] and // older entries land to the left. Entries for weeks with no snapshot // stay as zero-value svgTimePoint (Spot=0 → no data for that column). // Using j as the source index (not a reversed index based on numPoints) // prevents an out-of-bounds panic when len(s.RankHistory) < numPoints. for j := 0; j < len(s.RankHistory) && j < numPoints; j++ { dstIdx := numPoints - 1 - j // "now" (j=0) maps to rightmost slot h := s.RankHistory[j] pts[dstIdx] = svgTimePoint{ Label: h.Anchor, Spot: h.Spot, Date: h.SnapshotDate, } if h.Spot > maxRank { maxRank = h.Spot } if h.Spot > 0 { hasData = true } } if !hasData { continue // skip projects that have never appeared in any snapshot } allProjects = append(allProjects, svgProjectData{ Name: s.Name, Color: projectColor(colorIdx), Points: pts, }) 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 if weeksAgo == 0 { xLabels[i] = "now" } else { xLabels[i] = fmt.Sprintf("%dw ago", weeksAgo) } } // --- Layout helpers --- plotW := svgViewWidth - svgMarginLeft - svgMarginRight // = svgPlotWidth = 900 plotH := svgViewHeight - svgMarginTop - svgMarginBottom xPos := func(i int) float64 { if numPoints <= 1 { return float64(svgMarginLeft) + float64(plotW)/2 } return float64(svgMarginLeft) + float64(i)*float64(plotW)/float64(numPoints-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. var gridBuf strings.Builder yStep := gridStep(maxRank) plotRight := float64(svgMarginLeft + plotW) for r := 1; r <= maxRank; r += yStep { y := yPos(r) fmt.Fprintf(&gridBuf, ``, float64(svgMarginLeft), y, plotRight, y) fmt.Fprintf(&gridBuf, `%d`, float64(svgMarginLeft)-6, y+4, r) } // Vertical grid lines and X-axis labels. var xAxisBuf strings.Builder plotBottom := float64(svgMarginTop + plotH) for i := 0; i < numPoints; i++ { x := xPos(i) fmt.Fprintf(&xAxisBuf, ``, x, svgMarginTop, x, plotBottom) fmt.Fprintf(&xAxisBuf, `%s`, x, plotBottom+16, xLabels[i]) } // Project lines and dot groups. var linesBuf strings.Builder for i, proj := range allProjects { pathD := buildSVGPath(proj.Points, xPos, yPos) if pathD == "" { continue } // One circle per valid data point so the tooltip hit-area is larger. var circleBuf strings.Builder for j, pt := range proj.Points { if pt.Spot <= 0 { continue } x := xPos(j) y := yPos(pt.Spot) if y < 0 { continue } fmt.Fprintf(&circleBuf, ``, x, y) } fmt.Fprintf(&linesBuf, ``, i, i) fmt.Fprintf(&linesBuf, ``, pathD, proj.Color) fmt.Fprintf(&linesBuf, `%s`, proj.Color, circleBuf.String()) linesBuf.WriteString(``) } // --- Assemble the full SVG --- var svg strings.Builder // width/height="100%" makes the SVG fill the browser window while the // viewBox preserves internal coordinate space and aspect ratio. fmt.Fprintf(&svg, ``, svgViewWidth, svgViewHeight) // Embedded CSS – kept compact but readable. svg.WriteString(``) // Solid background rect (belt-and-suspenders for SVG viewers that ignore // the CSS background property). fmt.Fprintf(&svg, ``, svgViewWidth, svgViewHeight) // Title + subtitle. fmt.Fprintf(&svg, `Project Rank History`, svgViewWidth/2) fmt.Fprintf(&svg, `rank 1 = highest score · hover a line or legend entry to highlight · %d projects tracked`, svgViewWidth/2, len(allProjects)) // Rotated Y-axis label. cx := float64(svgMarginLeft) - 40 cy := float64(svgMarginTop) + float64(plotH)/2 fmt.Fprintf(&svg, `Rank`, cx, cy, cx, cy) // Grid, axes, and project lines. svg.WriteString(gridBuf.String()) svg.WriteString(xAxisBuf.String()) svg.WriteString(linesBuf.String()) // Legend panel to the right of the plot area. legendX := svgMarginLeft + plotW + svgLegendGap svg.WriteString(buildLegendSVG(allProjects, legendX, plotH)) // Tooltip overlay (hidden until a project is hovered). // #tttl is the project-name title; #ttbd holds the per-snapshot rows below it. svg.WriteString(` `) // Inline JavaScript for interactivity. // PROJECTS is the JSON array; each entry has name, color, and points[]. fmt.Fprintf(&svg, ``, string(projectsJSON)) svg.WriteString(``) return svg.String() } // buildSVGPath converts a slice of time points into an SVG path string using // M (moveto) at the start of each run of valid points and L (lineto) within. // Gaps (Spot == 0) cause the pen to be lifted so the line breaks cleanly. func buildSVGPath(points []svgTimePoint, xPos func(int) float64, yPos func(int) float64) string { var parts []string prevValid := false for i, pt := range points { if pt.Spot <= 0 { prevValid = false continue } x := xPos(i) y := yPos(pt.Spot) if y < 0 { prevValid = false continue } cmd := "L" if !prevValid { cmd = "M" } parts = append(parts, fmt.Sprintf("%s%.1f %.1f", cmd, x, y)) prevValid = true } return strings.Join(parts, " ") } // gridStep returns how many rank positions to skip between Y-axis grid lines, // keeping the graph legible when there are many projects. func gridStep(maxRank int) int { switch { case maxRank > 30: return 5 case maxRank > 15: return 3 case maxRank > 8: return 2 default: return 1 } }