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,
``)
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
}
}