fix widen charts on mobile by reclaiming axis gutters

On phone-width viewports the axis titles and secondary right-hand axes consumed so much horizontal space that the plot area collapsed into a thin strip (left-axis tick labels even overlapped the axis title). Apply the compact-viewport treatment already used by the weather page to every chart. - Add shared viewport.js (single source for the <576px breakpoint) - Insights: drop axis titles and collapse secondary right-hand axes on phones for the DO&ORP, pH-DO, stability, derivatives and diurnal charts; reapply scales on reload so orientation changes take effect - Probe charts (home/probes/graph pages): fewer, unrotated x-ticks and smaller tick fonts on phones, in both create and reload paths - Weather page: adopt the shared viewport helper instead of an inline query - Add JS unit tests for the new scale builders and the viewport helper

Commit
472c003aec68333300dcd291dc06c416eeee1b04
Author
gpt-5.6-terra medium <kiro-ai@amazon.com>
Author date
Committer
gpt-5.6-terra medium <kiro-ai@amazon.com>
Committer date
Changed files
roles/dashboard/public/js/dashboard/charts.js
index 51da91ec..4b6a2cd7 100644..100644
@@ -8,6 +8,7 @@
8 8 } from "./constants.js";
9 9 import { ageMs, formatAge, formatChartTime, formatDateTime, replaceChildren } from "./format.js";
10 10 import { timeframeRangeLabel } from "./timeframe.js";
11 Added: import { isCompactViewport } from "./viewport.js";
11 12
12 13 function chartElement(probe) {
13 14 return document.getElementById(`chart-${probe}`);
@@ -163,6 +164,8 @@
163 164 function createChart(canvas, series, trendLabel, trendUnit, yBounds, timeframe) {
164 165 registerTopLeftTooltipPositioner();
165 166
167 Added: const compact = isCompactViewport();
168 Added:
166 169 return new Chart(canvas, {
167 170 type: "line",
168 171 data: {
@@ -214,8 +217,17 @@
214 217 maintainAspectRatio: false,
215 218 animation: false,
216 219 scales: {
217 Removed: x: { ticks: { maxTicksLimit: TIMEFRAMES[timeframe].ticks } },
218 Removed: y: { beginAtZero: false, ...yBounds }
220 Added: x: {
221 Added: ticks: {
222 Added: maxTicksLimit: compact
223 Added: ? Math.min(6, TIMEFRAMES[timeframe].ticks)
224 Added: : TIMEFRAMES[timeframe].ticks,
225 Added: maxRotation: compact ? 0 : 45,
226 Added: minRotation: 0,
227 Added: font: { size: 10 }
228 Added: }
229 Added: },
230 Added: y: { beginAtZero: false, ticks: { font: { size: 10 } }, ...yBounds }
219 231 },
220 232 plugins: {
221 233 legend: { display: true, labels: { filter: item => item.datasetIndex !== 0 } },
@@ -289,7 +301,12 @@
289 301 chart.data.datasets[2].label = trendLabel;
290 302 chart.data.datasets[2].tooltipUnit = unit;
291 303 chart.data.datasets[3].data = series.gapFill;
292 Removed: chart.options.scales.x.ticks.maxTicksLimit = TIMEFRAMES[timeframe.selected()].ticks;
304 Added: const reloadCompact = isCompactViewport();
305 Added: const fullTicks = TIMEFRAMES[timeframe.selected()].ticks;
306 Added: chart.options.scales.x.ticks.maxTicksLimit = reloadCompact
307 Added: ? Math.min(6, fullTicks)
308 Added: : fullTicks;
309 Added: chart.options.scales.x.ticks.maxRotation = reloadCompact ? 0 : 45;
293 310 chart.options.scales.y.min = yBounds.min;
294 311 chart.options.scales.y.max = yBounds.max;
295 312 chart.update();
roles/dashboard/public/js/dashboard/insights.js
index 669b9401..69ae3854 100644..100644
@@ -13,6 +13,7 @@
13 13 timeframeToHours
14 14 } from "./timeframe.js";
15 15 import { initialiseGraphControls } from "./graph-controls.js";
16 Added: import { isCompactViewport } from "./viewport.js";
16 17
17 18 const PROBE_COLORS = {
18 19 ph: { border: "#8b5cf6", background: "rgba(139, 92, 246, 0.12)" },
@@ -41,6 +42,61 @@
41 42 // Probes shown on the rate-of-change chart (EC excluded per farmer request)
42 43 const DERIVATIVE_PROBES = ["ph", "do", "orp"];
43 44
45 Added: // Shared x-axis config: fewer, unrotated ticks on phones.
46 Added: function insightsXScale(compact) {
47 Added: return {
48 Added: ticks: {
49 Added: maxTicksLimit: compact ? 6 : 8,
50 Added: maxRotation: compact ? 0 : 45,
51 Added: minRotation: 0,
52 Added: font: { size: 10 }
53 Added: }
54 Added: };
55 Added: }
56 Added:
57 Added: // Single left-axis charts (stability, derivatives, diurnal): drop the axis
58 Added: // title on phones so the tick labels alone define the (narrow) left gutter.
59 Added: export function insightsSingleAxisScales(compact, { title, x = {}, y = {} } = {}) {
60 Added: return {
61 Added: x: { display: true, ...insightsXScale(compact), ...x },
62 Added: y: {
63 Added: title: { display: !compact, text: title },
64 Added: ticks: { font: { size: 10 } },
65 Added: ...y
66 Added: }
67 Added: };
68 Added: }
69 Added:
70 Added: // Dual/triple-axis overlay charts (DO & ORP, pH & DO): on phones hide axis
71 Added: // titles and collapse the secondary right-hand axis/axes entirely, handing
72 Added: // their horizontal space back to the plot area. Series stay identifiable via
73 Added: // colored ticks on the primary axis and the legend.
74 Added: export function insightsOverlayScales(compact, primary, secondaries = []) {
75 Added: const scales = {
76 Added: x: insightsXScale(compact),
77 Added: [primary.id]: {
78 Added: type: "linear",
79 Added: position: "left",
80 Added: title: { display: !compact, text: primary.text, color: primary.color },
81 Added: ticks: { color: primary.color, font: { size: 10 } }
82 Added: }
83 Added: };
84 Added:
85 Added: for (const axis of secondaries) {
86 Added: scales[axis.id] = {
87 Added: type: "linear",
88 Added: position: "right",
89 Added: display: !compact,
90 Added: title: { display: true, text: axis.text, color: axis.color },
91 Added: ticks: { color: axis.color, font: { size: 10 } },
92 Added: grid: { drawOnChartArea: false },
93 Added: ...(axis.extra || {})
94 Added: };
95 Added: }
96 Added:
97 Added: return scales;
98 Added: }
99 Added:
44 100 let charts = {};
45 101
46 102 // ══════════════════════════════════════════════════════════════════
@@ -55,6 +111,11 @@
55 111 const data = await fetchInsightStability(hours, signal);
56 112 const labels = data.points.map(p => formatDateTime(p.timestamp));
57 113 const values = data.points.map(p => p.score);
114 Added: const compact = isCompactViewport();
115 Added: const scales = insightsSingleAxisScales(compact, {
116 Added: title: "Volatility (σ)",
117 Added: y: { beginAtZero: true }
118 Added: });
58 119
59 120 if (!charts.stability) {
60 121 charts.stability = new Chart(canvas, {
@@ -76,10 +137,7 @@
76 137 responsive: true,
77 138 maintainAspectRatio: false,
78 139 animation: false,
79 Removed: scales: {
80 Removed: x: { display: true, ticks: { maxTicksLimit: 8, font: { size: 10 } } },
81 Removed: y: { beginAtZero: true, title: { display: true, text: "Volatility (σ)" } }
82 Removed: },
140 Added: scales,
83 141 plugins: {
84 142 legend: { display: false },
85 143 tooltip: tooltipOptions({ title: () => "" })
@@ -89,6 +147,7 @@
89 147 } else {
90 148 charts.stability.data.labels = labels;
91 149 charts.stability.data.datasets[0].data = values;
150 Added: charts.stability.options.scales = scales;
92 151 charts.stability.update();
93 152 }
94 153 }
@@ -99,6 +158,7 @@
99 158
100 159 const hours = timeframeToHours(timeframe);
101 160 const data = await fetchInsightDerivatives(hours, signal);
161 Added: const compact = isCompactViewport();
102 162
103 163 // Only use the probes we want (no EC)
104 164 const refProbe = DERIVATIVE_PROBES.find(p => data.probes[p]?.length > 0);
@@ -129,16 +189,7 @@
129 189 responsive: true,
130 190 maintainAspectRatio: false,
131 191 animation: false,
132 Removed: scales: {
133 Removed: x: { display: true, ticks: { maxTicksLimit: 8, font: { size: 10 } } },
134 Removed: y: {
135 Removed: title: { display: true, text: "% of optimal range / hour" },
136 Removed: grid: { color: ctx => ctx.tick.value === 0 ? "#94a3b8" : "rgba(0,0,0,0.05)" },
137 Removed: ticks: {
138 Removed: callback: value => `${value > 0 ? "+" : ""}${value.toFixed(1)}%`
139 Removed: }
140 Removed: }
141 Removed: },
192 Added: scales: derivativesScales(compact),
142 193 plugins: {
143 194 legend: { display: true },
144 195 tooltip: tooltipOptions({
@@ -151,10 +202,24 @@
151 202 } else {
152 203 charts.derivatives.data.labels = labels;
153 204 charts.derivatives.data.datasets = datasets;
205 Added: charts.derivatives.options.scales = derivativesScales(compact);
154 206 charts.derivatives.update();
155 207 }
156 208 }
157 209
210 Added: function derivativesScales(compact) {
211 Added: return insightsSingleAxisScales(compact, {
212 Added: title: "% of optimal range / hour",
213 Added: y: {
214 Added: grid: { color: ctx => ctx.tick.value === 0 ? "#94a3b8" : "rgba(0,0,0,0.05)" },
215 Added: ticks: {
216 Added: font: { size: 10 },
217 Added: callback: value => `${value > 0 ? "+" : ""}${value.toFixed(1)}%`
218 Added: }
219 Added: }
220 Added: });
221 Added: }
222 Added:
158 223 async function loadDiurnal(timeframe, signal) {
159 224 const canvas = document.getElementById("chart-diurnal");
160 225 if (!canvas) return;
@@ -165,6 +230,7 @@
165 230
166 231 const data = await fetchInsightDiurnal("do", days, signal);
167 232 const labels = data.hours.map(h => `${String(h).padStart(2, "0")}:00`);
233 Added: const compact = isCompactViewport();
168 234
169 235 const datasets = data.traces.map((trace, i) => ({
170 236 label: trace.date,
@@ -196,10 +262,7 @@
196 262 responsive: true,
197 263 maintainAspectRatio: false,
198 264 animation: false,
199 Removed: scales: {
200 Removed: x: { title: { display: true, text: "Hour of day (UTC)" } },
201 Removed: y: { title: { display: true, text: "DO (mg/L)" } }
202 Removed: },
265 Added: scales: diurnalScales(compact),
203 266 plugins: {
204 267 legend: { display: true, labels: { font: { size: 10 } } },
205 268 tooltip: tooltipOptions({ title: () => "" })
@@ -209,10 +272,18 @@
209 272 } else {
210 273 charts.diurnal.data.labels = labels;
211 274 charts.diurnal.data.datasets = datasets;
275 Added: charts.diurnal.options.scales = diurnalScales(compact);
212 276 charts.diurnal.update();
213 277 }
214 278 }
215 279
280 Added: function diurnalScales(compact) {
281 Added: return insightsSingleAxisScales(compact, {
282 Added: title: "DO (mg/L)",
283 Added: x: { title: { display: !compact, text: "Hour of day (UTC)" } }
284 Added: });
285 Added: }
286 Added:
216 287 // ══════════════════════════════════════════════════════════════════
217 288 // EXPLORE — Same page-level timeframe
218 289 // ══════════════════════════════════════════════════════════════════
@@ -246,6 +317,7 @@
246 317 const labels = doData.points.map(p => formatChartTime(p.timestamp, tf));
247 318 const doValues = doData.points.map(p => p.value);
248 319 const orpValues = orpData.points.map(p => p.value);
320 Added: const compact = isCompactViewport();
249 321
250 322 // Align weather data to the same label timestamps
251 323 const weatherByTime = Object.fromEntries(
@@ -299,29 +371,7 @@
299 371 maintainAspectRatio: false,
300 372 animation: false,
301 373 interaction: { mode: "index", intersect: false },
302 Removed: scales: {
303 Removed: x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
304 Removed: yDO: {
305 Removed: type: "linear",
306 Removed: position: "left",
307 Removed: title: { display: true, text: "DO (mg/L)", color: PROBE_COLORS.do.border },
308 Removed: ticks: { color: PROBE_COLORS.do.border }
309 Removed: },
310 Removed: yORP: {
311 Removed: type: "linear",
312 Removed: position: "right",
313 Removed: title: { display: true, text: "ORP (mV)", color: PROBE_COLORS.orp.border },
314 Removed: ticks: { color: PROBE_COLORS.orp.border },
315 Removed: grid: { drawOnChartArea: false }
316 Removed: },
317 Removed: yTemp: {
318 Removed: type: "linear",
319 Removed: position: "right",
320 Removed: title: { display: true, text: "°C", color: WEATHER_COLOR.border },
321 Removed: ticks: { color: WEATHER_COLOR.border },
322 Removed: grid: { drawOnChartArea: false }
323 Removed: }
324 Removed: },
374 Added: scales: doOrpScales(compact),
325 375 plugins: {
326 376 legend: { display: true },
327 377 tooltip: tooltipOptions({ title: () => "" })
@@ -331,10 +381,22 @@
331 381 } else {
332 382 charts.doOrp.data.labels = labels;
333 383 charts.doOrp.data.datasets = datasets;
384 Added: charts.doOrp.options.scales = doOrpScales(compact);
334 385 charts.doOrp.update();
335 386 }
336 387 }
337 388
389 Added: function doOrpScales(compact) {
390 Added: return insightsOverlayScales(
391 Added: compact,
392 Added: { id: "yDO", text: "DO (mg/L)", color: PROBE_COLORS.do.border },
393 Added: [
394 Added: { id: "yORP", text: "ORP (mV)", color: PROBE_COLORS.orp.border },
395 Added: { id: "yTemp", text: "°C", color: WEATHER_COLOR.border }
396 Added: ]
397 Added: );
398 Added: }
399 Added:
338 400 async function loadPhDoLag(timeframe, signal) {
339 401 const canvas = document.getElementById("chart-ph-do-lag");
340 402 if (!canvas) return;
@@ -349,6 +411,7 @@
349 411 const labels = phData.points.map(p => formatChartTime(p.timestamp, tf));
350 412 const phValues = phData.points.map(p => p.value);
351 413 const doValues = doData.points.map(p => p.value);
414 Added: const compact = isCompactViewport();
352 415
353 416 if (!charts.phDoLag) {
354 417 charts.phDoLag = new Chart(canvas, {
@@ -385,22 +448,7 @@
385 448 maintainAspectRatio: false,
386 449 animation: false,
387 450 interaction: { mode: "index", intersect: false },
388 Removed: scales: {
389 Removed: x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
390 Removed: yPH: {
391 Removed: type: "linear",
392 Removed: position: "left",
393 Removed: title: { display: true, text: "pH", color: PROBE_COLORS.ph.border },
394 Removed: ticks: { color: PROBE_COLORS.ph.border }
395 Removed: },
396 Removed: yDO: {
397 Removed: type: "linear",
398 Removed: position: "right",
399 Removed: title: { display: true, text: "DO (mg/L)", color: PROBE_COLORS.do.border },
400 Removed: ticks: { color: PROBE_COLORS.do.border },
401 Removed: grid: { drawOnChartArea: false }
402 Removed: }
403 Removed: },
451 Added: scales: phDoScales(compact),
404 452 plugins: {
405 453 legend: { display: true },
406 454 tooltip: tooltipOptions({ title: () => "" })
@@ -411,8 +459,17 @@
411 459 charts.phDoLag.data.labels = labels;
412 460 charts.phDoLag.data.datasets[0].data = phValues;
413 461 charts.phDoLag.data.datasets[1].data = doValues;
462 Added: charts.phDoLag.options.scales = phDoScales(compact);
414 463 charts.phDoLag.update();
415 464 }
465 Added: }
466 Added:
467 Added: function phDoScales(compact) {
468 Added: return insightsOverlayScales(
469 Added: compact,
470 Added: { id: "yPH", text: "pH", color: PROBE_COLORS.ph.border },
471 Added: [{ id: "yDO", text: "DO (mg/L)", color: PROBE_COLORS.do.border }]
472 Added: );
416 473 }
417 474
418 475 // ══════════════════════════════════════════════════════════════════
roles/dashboard/public/js/dashboard/viewport.js
index 00000000..81e7ca2a 000000..100644
@@ -0,0 +1,15 @@
1 Added: // Shared viewport helpers.
2 Added: //
3 Added: // On narrow (phone) viewports, chart axis titles and secondary right-hand
4 Added: // axes steal so much horizontal space that the plot area collapses into a
5 Added: // thin strip. Charts use this breakpoint to drop titles, collapse secondary
6 Added: // axes, and thin out x-axis ticks so the plot reclaims the width.
7 Added: //
8 Added: // Kept in sync with the Bootstrap `sm` breakpoint (<576px) used elsewhere.
9 Added: export const COMPACT_MEDIA_QUERY = "(max-width: 575.98px)";
10 Added:
11 Added: export function isCompactViewport() {
12 Added: return typeof window !== "undefined"
13 Added: && typeof window.matchMedia === "function"
14 Added: && window.matchMedia(COMPACT_MEDIA_QUERY).matches;
15 Added: }
roles/dashboard/public/js/dashboard/weather-page.js
index de7c8280..8aec8723 100644..100644
@@ -3,6 +3,7 @@
3 3 import { formatChartTime } from "./format.js";
4 4 import { createTimeframeState, timeframeToHours } from "./timeframe.js";
5 5 import { initialiseGraphControls } from "./graph-controls.js";
6 Added: import { isCompactViewport } from "./viewport.js";
6 7
7 8 let charts = {};
8 9
@@ -12,7 +13,7 @@
12 13 const labels = data.points.map(point =>
13 14 formatChartTime(point.timestamp, timeframe.selected())
14 15 );
15 Removed: const compact = window.matchMedia("(max-width: 575.98px)").matches;
16 Added: const compact = isCompactViewport();
16 17
17 18 loadCombined(data, labels, compact);
18 19 loadSolar(data, labels, compact);
roles/dashboard/t/js/frontend.test.cjs
index ba7ef8d7..1deeb197 100644..100644
@@ -147,3 +147,62 @@
147 147 assert.equal(desktop.yHumid.display, true);
148 148 assert.equal(desktop.yRain.display, true);
149 149 });
150 Added:
151 Added: test("compact single-axis insights scales drop the axis title", async () => {
152 Added: const module = await dashboardModule("insights.js");
153 Added: const { insightsSingleAxisScales } = module.namespace;
154 Added:
155 Added: const compact = insightsSingleAxisScales(true, {
156 Added: title: "Volatility (σ)",
157 Added: y: { beginAtZero: true }
158 Added: });
159 Added: const desktop = insightsSingleAxisScales(false, { title: "Volatility (σ)" });
160 Added:
161 Added: assert.equal(compact.y.title.display, false);
162 Added: assert.equal(compact.x.ticks.maxRotation, 0);
163 Added: assert.equal(compact.x.ticks.maxTicksLimit, 6);
164 Added: // Extra y options are preserved through the merge.
165 Added: assert.equal(compact.y.beginAtZero, true);
166 Added:
167 Added: assert.equal(desktop.y.title.display, true);
168 Added: assert.equal(desktop.y.title.text, "Volatility (σ)");
169 Added: assert.equal(desktop.x.ticks.maxRotation, 45);
170 Added: });
171 Added:
172 Added: test("compact overlay insights scales collapse secondary axes", async () => {
173 Added: const module = await dashboardModule("insights.js");
174 Added: const { insightsOverlayScales } = module.namespace;
175 Added:
176 Added: const primary = { id: "yDO", text: "DO (mg/L)", color: "#0ea5e9" };
177 Added: const secondaries = [
178 Added: { id: "yORP", text: "ORP (mV)", color: "#f59e0b" },
179 Added: { id: "yTemp", text: "°C", color: "#64748b" }
180 Added: ];
181 Added:
182 Added: const compact = insightsOverlayScales(true, primary, secondaries);
183 Added: const desktop = insightsOverlayScales(false, primary, secondaries);
184 Added:
185 Added: // Primary axis stays visible but loses its title on phones.
186 Added: assert.equal(compact.yDO.title.display, false);
187 Added: assert.equal(compact.yDO.position, "left");
188 Added: assert.equal(compact.yDO.ticks.color, "#0ea5e9");
189 Added:
190 Added: // Secondary right-hand axes are hidden entirely to reclaim width.
191 Added: assert.equal(compact.yORP.display, false);
192 Added: assert.equal(compact.yTemp.display, false);
193 Added:
194 Added: // Desktop keeps everything.
195 Added: assert.equal(desktop.yDO.title.display, true);
196 Added: assert.equal(desktop.yORP.display, true);
197 Added: assert.equal(desktop.yTemp.display, true);
198 Added: assert.equal(desktop.yTemp.title.text, "°C");
199 Added: });
200 Added:
201 Added: test("compact viewport helper degrades gracefully without matchMedia", async () => {
202 Added: const module = await dashboardModule("viewport.js");
203 Added: const { isCompactViewport, COMPACT_MEDIA_QUERY } = module.namespace;
204 Added:
205 Added: assert.equal(COMPACT_MEDIA_QUERY, "(max-width: 575.98px)");
206 Added: // No window in the test VM → must not throw, returns false.
207 Added: assert.equal(isCompactViewport(), false);
208 Added: });