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.