From 2a3d457cbfccdf9d6446c5f9367780ae8ac678d4 Mon Sep 17 00:00:00 2001 From: dhamsey3 <73405591+dhamsey3@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:53:33 +0200 Subject: [PATCH] fix: keep status page charts legible on narrow screens On a 280px-wide view the price plot shrank to 98px: the x labels ran into each other, and the hover read-out ran off the chart. Each chart now sizes its chrome by its own width (container queries). Below 480px the end labels give their room to the plot; the legend and read-out still name every line. Below 300px every other x label is hidden, leaving 00:00, 12:00 and 24:00. The read-out sits on whichever side of the crosshair fits, never leaves the chart, and truncates series names rather than values. Checked at 280, 320 and 375px. --- src/voltcast/page.py | 38 +++++++++++++++++++++++++++++--------- tests/test_status.py | 2 ++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/voltcast/page.py b/src/voltcast/page.py index b787169..c69e658 100644 --- a/src/voltcast/page.py +++ b/src/voltcast/page.py @@ -146,8 +146,12 @@ def left(i: float) -> str: svg.append(f'') y_labels = "".join(f'{_num(t, 0)}' for t in ticks) + # Every other inner tick is "minor": a narrow chart hides those (CSS) so + # the remaining labels never overlap. x_labels_html = "".join( - f'{_e(label)}' for i, label in x_ticks + f'{_e(label)}' + for k, (i, label) in enumerate(x_ticks) ) # Direct labels at the line ends, unless two would collide: then the @@ -569,7 +573,7 @@ def render(status: dict) -> str: .status-good path, .status-critical path { stroke: #fff; } .status-pending path { stroke: var(--pending); } .meter { margin-top: 10px; height: 6px; border-radius: 3px; background: var(--meter-track); overflow: hidden; } .meter span { display: block; height: 100%; min-width: 3px; border-radius: 3px; background: var(--meter); } -.chart { margin: 0; } +.chart { margin: 0; container-type: inline-size; } .chart figcaption { margin-bottom: 8px; } .legend { list-style: none; display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0 0 8px; padding: 0; font-size: 0.875rem; color: var(--ink-2); } @@ -594,11 +598,11 @@ def render(status: dict) -> str: .crosshair { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--axis); pointer-events: none; } .tip { position: absolute; top: 0; z-index: 2; min-width: 150px; padding: 8px 10px; border-radius: 8px; background: var(--surface); border: 1px solid var(--border); box-shadow: 0 4px 16px rgba(0,0,0,0.12); - font-size: 0.8125rem; pointer-events: none; } + font-size: 0.8125rem; pointer-events: none; max-width: 100cqw; } .tip-head { color: var(--ink-2); margin-bottom: 4px; } -.tip-row { display: flex; align-items: center; gap: 8px; } +.tip-row { display: flex; align-items: center; gap: 8px; white-space: nowrap; } .tip-row strong { font-variant-numeric: tabular-nums; } -.tip-name { color: var(--ink-2); } +.tip-name { color: var(--ink-2); min-width: 0; overflow: hidden; text-overflow: ellipsis; } .s1 { stroke: var(--s1); } .s2 { stroke: var(--s2); } .s3 { stroke: var(--s3); } .s4 { stroke: var(--s4); } .s5 { stroke: var(--s5); } .s6 { stroke: var(--s6); } .s7 { stroke: var(--s7); } .s8 { stroke: var(--s8); } .key.s1 { background: var(--s1); } .key.s2 { background: var(--s2); } .key.s3 { background: var(--s3); } @@ -627,9 +631,18 @@ def render(status: dict) -> str: footer p { margin: 4px 0; } @media (max-width: 560px) { section { padding: 16px; } - .frame { padding-right: 72px; } .facts div { grid-template-columns: 1fr; gap: 0; } } +/* Sized by each chart's own width, not the screen's. On phones the end + labels go (the legend and read-out still name every line) and the plot + takes their room; on very narrow charts every other x label goes too. */ +@container (max-width: 480px) { + .frame { padding-right: 12px; } + .end { display: none; } +} +@container (max-width: 300px) { + .xlab.minor { display: none; } +} @media (forced-colors: active) { .plot .line, .plot .dot { stroke: CanvasText; } } @@ -641,6 +654,7 @@ def render(status: dict) -> str: document.querySelectorAll("figure.chart").forEach((fig) => { const data = JSON.parse(fig.querySelector(".chart-data").textContent); const plot = fig.querySelector(".plot"); + const frame = fig.querySelector(".frame"); const line = fig.querySelector(".crosshair"); const tip = fig.querySelector(".tip"); const n = data.x.length; @@ -671,9 +685,15 @@ def render(status: dict) -> str: return row; }); tip.replaceChildren(head, ...rows); - const flip = x > 55; - tip.style.left = flip ? "" : "calc(" + x + "% + 12px)"; - tip.style.right = flip ? "calc(" + (100 - x) + "% + 12px)" : ""; + // Beside the crosshair, on whichever side fits, and never past the + // chart's frame: on a phone the read-out can be wider than the plot. + const pr = plot.getBoundingClientRect(); + const fr = frame.getBoundingClientRect(); + const at = (x / 100) * pr.width; + const w = tip.offsetWidth; + let left = at + 12 + w <= fr.right - pr.left ? at + 12 : at - 12 - w; + left = Math.max(fr.left - pr.left, Math.min(left, fr.right - pr.left - w)); + tip.style.left = left + "px"; } function hide() { line.hidden = true; diff --git a/tests/test_status.py b/tests/test_status.py index 9446d6a..40065d0 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -79,6 +79,8 @@ def test_page_renders_charts_tables_and_escapes_text(live_store): page = render(status) assert page.count('
]*>([^<]+)<', page)[:2] == ["06:00", "18:00"] assert "" not in page assert "<script>alert(1)</script>" in page # One table row per quarter-hour behind the price charts, so values never depend on hover.