Make the by-hour heatmap blocks tappable
The count behind a block was only ever reachable through its <title> tooltip,
which needs a pointer — on a phone there was no way to find out whether a dark
block meant two meals or five. Each cell now carries a transparent hit rect
that names it in a caption under the chart ("3 meals between 07:00 and
08:00"), the same string the tooltip shows, so hover and tap agree. The
focused cell takes an accent ring and tapping it again clears it.
The hit rect claims the 1px spacing between cells and half the gap to the
neighbouring rows, which takes the target from ~13x31 to ~13x38 CSS px on a
360px-wide screen; the rows still tile without overlapping and stay clear of
the hour labels. Width is capped by fitting 24 hours across the chart, so a
mis-tap lands on a neighbouring hour — the caption names the range it hit,
which makes that self-correcting.
The focused cell is held outside the render so a background sync can't wipe
what is being read; its count is recomputed each pass, so the caption stays
current. The caption is aria-live, which also gives screen readers a route to
the numbers that role="img" on the svg otherwise closes off.
This commit is contained in:
+46
-8
@@ -1349,18 +1349,25 @@
|
||||
setChartSVG(svg, parts); // wires the .bar[data-day] click → select that day
|
||||
}
|
||||
|
||||
// Which heatmap block is focused, remembered across re-renders so a background
|
||||
// sync doesn't wipe the block the user just tapped. The counts behind it are
|
||||
// recomputed every render, so the readout stays current.
|
||||
let hourCellSel = null; // `${type}-${hour}`, or null for nothing focused
|
||||
const HOUR_CELL_HINT = "Tap a block to see how many it counts.";
|
||||
|
||||
// Hour-of-day heatmap: one row per event type, 24 cells shaded by how often
|
||||
// that event lands in each hour across the window. Reveals daily rhythm that
|
||||
// the median gap can't show (e.g. "always poos ~7am and ~6pm").
|
||||
function renderHourHeatmap(events) {
|
||||
const svg = document.getElementById("chart-hour-heatmap");
|
||||
if (!svg) return;
|
||||
const info = document.getElementById("hour-heatmap-info");
|
||||
const from = startOfDay(new Date(Date.now() - (chartDays() - 1) * 86_400_000)).getTime();
|
||||
|
||||
const series = [
|
||||
{ type: "pee", label: "Pees", cls: "hm-pee" },
|
||||
{ type: "poo", label: "Poos", cls: "hm-poo" },
|
||||
{ type: "eat", label: "Meals", cls: "hm-eat" },
|
||||
{ type: "pee", label: "Pees", cls: "hm-pee", one: "pee", many: "pees" },
|
||||
{ type: "poo", label: "Poos", cls: "hm-poo", one: "poo", many: "poos" },
|
||||
{ type: "eat", label: "Meals", cls: "hm-eat", one: "meal", many: "meals" },
|
||||
];
|
||||
const counts = {};
|
||||
for (const s of series) counts[s.type] = new Array(24).fill(0);
|
||||
@@ -1376,7 +1383,15 @@
|
||||
const rowH = (H - MT - MB - (series.length - 1) * rowGap) / series.length;
|
||||
const cellW = innerW / 24;
|
||||
|
||||
// "3 meals between 07:00 and 08:00" — self-describing, so the same string
|
||||
// serves as the pointer tooltip and as the tap readout under the chart.
|
||||
const cellText = (s, h, c) =>
|
||||
`${c === 0 ? "No" : c} ${c === 1 ? s.one : s.many} between ` +
|
||||
`${pad2(h)}:00 and ${pad2((h + 1) % 24)}:00`;
|
||||
|
||||
const parts = [];
|
||||
const hits = []; // appended last so they sit above every row
|
||||
const details = {}; // cell key → readout text
|
||||
series.forEach((s, r) => {
|
||||
const y = MT + r * (rowH + rowGap);
|
||||
const max = Math.max(1, ...counts[s.type]);
|
||||
@@ -1384,11 +1399,18 @@
|
||||
const c = counts[s.type][h];
|
||||
const op = c === 0 ? 0.06 : 0.2 + 0.8 * (c / max);
|
||||
const x = ML + h * cellW;
|
||||
const range = `${pad2(h)}:00–${pad2((h + 1) % 24)}:00`;
|
||||
const key = `${s.type}-${h}`;
|
||||
details[key] = cellText(s, h, c);
|
||||
parts.push(
|
||||
`<rect class="hm-cell ${s.cls}" x="${x.toFixed(1)}" y="${y.toFixed(1)}" ` +
|
||||
`width="${(cellW - 1).toFixed(1)}" height="${rowH.toFixed(1)}" rx="1.5" fill-opacity="${op.toFixed(2)}">` +
|
||||
`<title>${escapeText(`${s.label} · ${range}: ${c}`)}</title></rect>`
|
||||
`<rect class="hm-cell ${s.cls}" data-cell="${key}" x="${x.toFixed(1)}" y="${y.toFixed(1)}" ` +
|
||||
`width="${(cellW - 1).toFixed(1)}" height="${rowH.toFixed(1)}" rx="1.5" fill-opacity="${op.toFixed(2)}"/>`
|
||||
);
|
||||
// A cell is only ~12px wide on a phone, so the tap target claims the
|
||||
// spacing between cells and half the gap to the neighbouring rows.
|
||||
hits.push(
|
||||
`<rect class="hm-hit" data-cell="${key}" x="${x.toFixed(1)}" y="${(y - rowGap / 2).toFixed(1)}" ` +
|
||||
`width="${cellW.toFixed(1)}" height="${(rowH + rowGap).toFixed(1)}">` +
|
||||
`<title>${escapeText(details[key])}</title></rect>`
|
||||
);
|
||||
}
|
||||
parts.push(`<text x="${ML - 6}" y="${(y + rowH / 2 + 3).toFixed(1)}" text-anchor="end">${s.label}</text>`);
|
||||
@@ -1401,7 +1423,23 @@
|
||||
}
|
||||
parts.push(`<text x="${(ML + innerW).toFixed(1)}" y="${yAxis}" text-anchor="end">24h</text>`);
|
||||
|
||||
svg.innerHTML = parts.join("");
|
||||
svg.innerHTML = parts.concat(hits).join("");
|
||||
|
||||
// <title> tooltips only ever show on a pointer, which left phones with no
|
||||
// way to read a block's count. Tap/hover names the block under the chart;
|
||||
// tapping the focused block again clears it.
|
||||
const cells = svg.querySelectorAll(".hm-cell[data-cell]");
|
||||
const focusCell = (key) => {
|
||||
hourCellSel = details[key] ? key : null;
|
||||
cells.forEach(c => c.classList.toggle("hm-active", c.dataset.cell === hourCellSel));
|
||||
if (info) info.textContent = hourCellSel ? details[hourCellSel] : HOUR_CELL_HINT;
|
||||
};
|
||||
svg.querySelectorAll(".hm-hit").forEach(hit => {
|
||||
const key = hit.dataset.cell;
|
||||
hit.addEventListener("click", () => focusCell(hourCellSel === key ? null : key));
|
||||
hit.addEventListener("mouseenter", () => focusCell(key));
|
||||
});
|
||||
focusCell(hourCellSel);
|
||||
}
|
||||
|
||||
// ---------- sleep trend ----------
|
||||
|
||||
Reference in New Issue
Block a user