From 701151009201f4f89a095078c7ef3573607cd899 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Thu, 7 May 2026 22:55:12 +0100 Subject: [PATCH] =?UTF-8?q?ui:=20chart=20polish=20=E2=80=94=20rotated=20y-?= =?UTF-8?q?axis=20labels,=20wider=20viewBox,=20single-day=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add rotated 'Size' (left) and 'Snapshots' (right) axis titles in the chart's outer margins so the two y-axes are self-describing. - Bump the chart viewBox from 600x220 to 640x220 and lift padL from 56 to 72 so the rotated labels and byte tick numbers don't crowd. - Dedupe the X-axis labels for short windows (1 or 2 days collapsed the start/mid/end indices onto each other, stacking 'May 7' three times); the 1-day case now centres a single label, 2-day uses start+end only. - Pin a lone data dot to the chart centre instead of the left edge when len(days)==1, so it sits under the centred date label. Goldens regenerated. --- internal/server/http/ui_repo.go | 2 +- internal/web/sparkline/sparkline.go | 47 ++++++++++++++++--- .../web/sparkline/testdata/chart_empty.svg | 2 +- .../sparkline/testdata/chart_two_series.svg | 2 +- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/internal/server/http/ui_repo.go b/internal/server/http/ui_repo.go index 4807a63..a630831 100644 --- a/internal/server/http/ui_repo.go +++ b/internal/server/http/ui_repo.go @@ -284,7 +284,7 @@ func (s *Server) buildRepoTrendView(ctx context.Context, hostID, rangeKey string chartSVG := sparkline.RenderChart([]sparkline.Series{ {Name: "size", Stroke: "#3b82f6", Axis: sparkline.AxisLeft, Format: sparkline.FormatBytes, Points: sizes}, {Name: "snapshots", Stroke: "#f59e0b", Axis: sparkline.AxisRight, Format: sparkline.FormatCount, Points: counts}, - }, dayList, sparkline.ChartOpts{Width: 600, Height: 220}) + }, dayList, sparkline.ChartOpts{Width: 640, Height: 220}) return repoTrendView{HostID: hostID, Range: rangeKey, ChartSVG: chartSVG} } diff --git a/internal/web/sparkline/sparkline.go b/internal/web/sparkline/sparkline.go index e57fb4b..2bbf444 100644 --- a/internal/web/sparkline/sparkline.go +++ b/internal/web/sparkline/sparkline.go @@ -156,7 +156,7 @@ func RenderChart(series []Series, days []time.Time, opts ChartOpts) template.HTM if opts.EmptyLabel == "" { opts.EmptyLabel = "no data yet" } - const padL, padR, padT, padB = 56, 56, 16, 28 + const padL, padR, padT, padB = 72, 56, 16, 28 w, h := opts.Width, opts.Height innerW := w - padL - padR innerH := h - padT - padB @@ -255,6 +255,11 @@ func RenderChart(series []Series, days []time.Time, opts ChartOpts) template.HTM continue } x := float64(padL) + stepX*float64(i) + if len(days) == 1 { + // Single-day: pin the lone dot to the chart centre so it + // sits under the centred date label. + x = float64(padL) + float64(innerW)/2 + } y := float64(padT) + float64(innerH) - (v-a.min)/(a.max-a.min)*float64(innerH) fmt.Fprintf(&seg, "%.2f,%.2f ", x, y) d := days[i] @@ -267,18 +272,48 @@ func RenderChart(series []Series, days []time.Time, opts ChartOpts) template.HTM if axArr[AxisLeft].has { writeAxisLabels(&b, padL-6, padT, innerH, axArr[AxisLeft].min, axArr[AxisLeft].max, FormatBytes, "end") + // Rotated axis title in the left margin. Position inset from + // the viewBox edge by ≈ font-size so the rotated glyph extents + // don't clip against the SVG boundary. + cy := padT + innerH/2 + fmt.Fprintf(&b, + `Size`, + cy, cy) } if axArr[AxisRight].has { writeAxisLabels(&b, w-padR+6, padT, innerH, axArr[AxisRight].min, axArr[AxisRight].max, FormatCount, "start") + cy := padT + innerH/2 + fmt.Fprintf(&b, + `Snapshots`, + w-14, cy, w-14, cy) } - xLabels := []int{0, len(days) / 2, len(days) - 1} - anchors := []string{"start", "middle", "end"} - for i, idx := range xLabels { - x := float64(padL) + stepX*float64(idx) + // X-axis labels at start / mid / end. With 1-2 days the indices + // collapse onto each other — dedupe so we don't stack overlapping + // "Jan 2" labels at the same x coordinate. + type xLabel struct { + idx int + anchor string + } + var xLabels []xLabel + switch { + case len(days) == 1: + xLabels = []xLabel{{0, "middle"}} + case len(days) == 2: + xLabels = []xLabel{{0, "start"}, {1, "end"}} + default: + xLabels = []xLabel{{0, "start"}, {len(days) / 2, "middle"}, {len(days) - 1, "end"}} + } + for _, l := range xLabels { + x := float64(padL) + stepX*float64(l.idx) + // With a single point, anchor "middle" centres on padL — push to + // the chart's centre line so the lone label sits over the dot. + if len(days) == 1 { + x = float64(padL) + float64(innerW)/2 + } fmt.Fprintf(&b, `%s`, - x, h-padB+16, anchors[i], days[idx].Format("Jan 2")) + x, h-padB+16, l.anchor, days[l.idx].Format("Jan 2")) } b.WriteString(``) diff --git a/internal/web/sparkline/testdata/chart_empty.svg b/internal/web/sparkline/testdata/chart_empty.svg index 52f8d64..4ffd92b 100644 --- a/internal/web/sparkline/testdata/chart_empty.svg +++ b/internal/web/sparkline/testdata/chart_empty.svg @@ -1 +1 @@ -no data yet +no data yet diff --git a/internal/web/sparkline/testdata/chart_two_series.svg b/internal/web/sparkline/testdata/chart_two_series.svg index f84855c..0f6b564 100644 --- a/internal/web/sparkline/testdata/chart_two_series.svg +++ b/internal/web/sparkline/testdata/chart_two_series.svg @@ -1 +1 @@ -2026-05-01 · size: 1.0 KiB2026-05-02 · size: 2.0 KiB2026-05-03 · size: 4.0 KiB2026-05-04 · size: 8.0 KiB2026-05-01 · snapshots: 12026-05-02 · snapshots: 22026-05-03 · snapshots: 32026-05-04 · snapshots: 48.0 KiB6.2 KiB4.5 KiB2.8 KiB1.0 KiB43221May 1May 3May 4 +2026-05-01 · size: 1.0 KiB2026-05-02 · size: 2.0 KiB2026-05-03 · size: 4.0 KiB2026-05-04 · size: 8.0 KiB2026-05-01 · snapshots: 12026-05-02 · snapshots: 22026-05-03 · snapshots: 32026-05-04 · snapshots: 48.0 KiB6.2 KiB4.5 KiB2.8 KiB1.0 KiBSize43221SnapshotsMay 1May 3May 4