From 027bfd08e367ce520acfccf1b452687fb4bfdabb Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 27 May 2026 10:00:04 +0300 Subject: feat(showcase/svg): add legend panel, fix tooltip overlap, fill browser window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3-column legend panel to the right of the plot; each colored square is labelled with the project name (truncated at 12 chars); hovering a legend entry highlights the corresponding line and opens the same tooltip as hovering the line directly - Fix tooltip title/body overlap: body rows now start at y=32 (below the title baseline at y=18) so project name and rank rows never collide - Tooltip width adapts to the project name length (160–300 px) - SVG uses width/height='100%' + preserveAspectRatio='xMidYMid meet' so Firefox fills the browser window while keeping the aspect ratio - Add truncateName and xmlEscape helpers for safe legend text rendering - allLG (legend groups) dimmed/restored in onEnter/onLeave alongside allPG Co-Authored-By: Claude Sonnet 4.6 --- internal/showcase/rank_history_svg.go | 137 +++++++++++++++++++++++++++++----- 1 file changed, 120 insertions(+), 17 deletions(-) (limited to 'internal') diff --git a/internal/showcase/rank_history_svg.go b/internal/showcase/rank_history_svg.go index 43b2a66..b3f6c3a 100644 --- a/internal/showcase/rank_history_svg.go +++ b/internal/showcase/rank_history_svg.go @@ -8,13 +8,24 @@ import ( ) // 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 ( - svgViewWidth = 1000 svgViewHeight = 560 svgMarginLeft = 55 - svgMarginRight = 20 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 @@ -69,15 +80,84 @@ func hslToRGBHex(h, s, l float64) string { 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: -// - X-axis: left = oldest snapshot, right = "now". -// - Y-axis: rank 1 at top (best), max rank at bottom. -// - Each project is one colored polyline; gaps occur when a week has no data. -// - Hovering a line highlights it, dims others, and shows a tooltip with the -// project name and rank at each recorded snapshot. +// - 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 @@ -142,7 +222,7 @@ func GenerateRankHistorySVG(summaries []ProjectSummary) string { } // --- Layout helpers --- - plotW := svgViewWidth - svgMarginLeft - svgMarginRight + plotW := svgViewWidth - svgMarginLeft - svgMarginRight // = svgPlotWidth = 900 plotH := svgViewHeight - svgMarginTop - svgMarginBottom xPos := func(i int) float64 { @@ -229,9 +309,11 @@ func GenerateRankHistorySVG(summaries []ProjectSummary) string { // --- 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, svgViewWidth, svgViewHeight) + ``, + svgViewWidth, svgViewHeight) // Embedded CSS – kept compact but readable. svg.WriteString(`