mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(charts): pin the compliance heatmap tooltip on tap and float the cell
v1.4.27 MB7 / CF-10 — touch-pin tooltip via pointer events runs in parallel with the existing mouse hover, so a tap on a cell holds the day-level breakdown open until the user taps outside or on a different cell. Pen + mouse keep the hover affordance unchanged. The heatmap now clamps the stretch-branch cell size to a 14 px floor and wraps the non-stretch render in overflow-x-auto so narrow viewports horizontal scroll instead of compressing the calendar below the touch target.
This commit is contained in:
1 parent
246c1def21
commit
9b6dbbf6ae
1 file changed
+107
-40
@@ -20,6 +20,10 @@ interface ComplianceHeatmapProps {
|
||||
|
||||
const CELL_SIZE = 18;
|
||||
const GAP = 3;
|
||||
// v1.4.27 MB7 / CF-10 — cell-floor so the static heatmap never paints
|
||||
// below the touch-friendly 14 px square on narrow viewports. The
|
||||
// stretch branch computes an adaptive size; both branches clamp here.
|
||||
const CELL_FLOOR_PX = 14;
|
||||
|
||||
function getColor(data: DailyData): string {
|
||||
if (data.expected === 0) return "var(--secondary)";
|
||||
@@ -78,8 +82,36 @@ export function ComplianceHeatmap({
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
/**
|
||||
* v1.4.27 MB7 / CF-10 — when `pinned` the tooltip stays mounted
|
||||
* across `onPointerLeave` so a touch user sees the per-cell
|
||||
* breakdown after lifting their finger. Mouse + pen users get the
|
||||
* existing hover-only experience because their interactions never
|
||||
* set `pinned`. A second tap on a different cell repositions the
|
||||
* tooltip; a tap outside any cell clears it (wired below).
|
||||
*/
|
||||
pinned?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// v1.4.27 MB7 / CF-10 — outside-click dismisses a pinned tooltip so a
|
||||
// touch user can clear the per-cell detail without scrolling the
|
||||
// pinned label off-screen. The listener is gated on `tooltip?.pinned`
|
||||
// so the non-touch hover flow never pays the indirection cost.
|
||||
useEffect(() => {
|
||||
if (!tooltip?.pinned) return;
|
||||
const handlePointer = (event: PointerEvent) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (!container.contains(event.target as Node)) {
|
||||
setTooltip(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointer, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointer, true);
|
||||
};
|
||||
}, [tooltip?.pinned]);
|
||||
|
||||
const WEEKDAY_LABELS = [
|
||||
t("charts.weekdays.mon"),
|
||||
"",
|
||||
@@ -188,10 +220,13 @@ export function ComplianceHeatmap({
|
||||
|
||||
const labelWidth = stretch ? 0 : 76;
|
||||
const headerHeight = 18;
|
||||
// v1.4.27 MB7 / CF-10 — clamp the stretch-branch adaptive cell to the
|
||||
// 14 px floor so the heatmap stays tap-friendly when the container
|
||||
// narrows. Static (non-stretch) instances use the canonical 18 px.
|
||||
const cellSize =
|
||||
stretch && containerWidth > 0
|
||||
? Math.max(
|
||||
8,
|
||||
CELL_FLOOR_PX,
|
||||
(containerWidth - labelWidth - Math.max(0, weeks - 1) * GAP) /
|
||||
Math.max(weeks, 1),
|
||||
)
|
||||
@@ -202,12 +237,20 @@ export function ComplianceHeatmap({
|
||||
|
||||
return (
|
||||
<div className={`relative ${stretch ? "w-full" : ""}`} ref={containerRef}>
|
||||
<div className={stretch ? "w-full" : "overflow-x-auto"}>
|
||||
{/* v1.4.27 MB7 / CF-10 — `overflow-x-auto` on `<sm` so the heatmap
|
||||
horizontal-scrolls inside its card instead of compressing the
|
||||
cells below the touch floor. The stretch branch already
|
||||
paints to container width on `>=sm`, so the scroll branch
|
||||
only kicks in for the non-stretch (static 18 px cell) case
|
||||
when the parent column is narrower than `weeks * 21 px`. */}
|
||||
<div
|
||||
className={stretch ? "w-full sm:w-full overflow-x-auto sm:overflow-visible" : "overflow-x-auto"}
|
||||
>
|
||||
<svg
|
||||
width={svgWidth}
|
||||
height={svgHeight}
|
||||
className={stretch ? "block w-full" : "block"}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
onMouseLeave={() => setTooltip((prev) => (prev?.pinned ? prev : null))}
|
||||
>
|
||||
{/* Month labels */}
|
||||
{monthMarkers.map((m, i) => (
|
||||
@@ -240,43 +283,67 @@ export function ComplianceHeatmap({
|
||||
),
|
||||
)}
|
||||
|
||||
{/* Cells */}
|
||||
{cells.map((cell) => (
|
||||
<rect
|
||||
key={cell.dateKey}
|
||||
x={labelWidth + cell.col * step}
|
||||
y={headerHeight + cell.row * step}
|
||||
width={cellSize}
|
||||
height={cellSize}
|
||||
rx={2}
|
||||
fill={cell.color}
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={(e) => {
|
||||
const rate =
|
||||
cell.data.expected > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
(cell.data.taken / cell.data.expected) * 100,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
const hasTimingData =
|
||||
cell.data.onTime !== undefined ||
|
||||
cell.data.late !== undefined ||
|
||||
cell.data.veryLate !== undefined;
|
||||
const timingInfo = hasTimingData
|
||||
? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}`
|
||||
: "";
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: `${formatDateDE(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`,
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
/>
|
||||
))}
|
||||
{/* Cells — v1.4.27 MB7 / CF-10: tap-to-pin tooltip in
|
||||
parallel with the existing hover affordance. Mouse + pen
|
||||
users keep `onPointerEnter` / `onPointerLeave`; touch
|
||||
users tap the cell to pin and tap outside (or another
|
||||
cell) to move/clear. `pointerType === "touch"` discriminates
|
||||
so a hover dismiss never wipes a pinned tooltip. */}
|
||||
{cells.map((cell) => {
|
||||
const buildText = (): string => {
|
||||
const rate =
|
||||
cell.data.expected > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round((cell.data.taken / cell.data.expected) * 100),
|
||||
)
|
||||
: 0;
|
||||
const hasTimingData =
|
||||
cell.data.onTime !== undefined ||
|
||||
cell.data.late !== undefined ||
|
||||
cell.data.veryLate !== undefined;
|
||||
const timingInfo = hasTimingData
|
||||
? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}`
|
||||
: "";
|
||||
return `${formatDateDE(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`;
|
||||
};
|
||||
return (
|
||||
<rect
|
||||
key={cell.dateKey}
|
||||
x={labelWidth + cell.col * step}
|
||||
y={headerHeight + cell.row * step}
|
||||
width={cellSize}
|
||||
height={cellSize}
|
||||
rx={2}
|
||||
fill={cell.color}
|
||||
className="cursor-pointer"
|
||||
onPointerEnter={(e) => {
|
||||
// Touch enter fires synthetically immediately before
|
||||
// `pointerdown`; skip it so the pinned tooltip below
|
||||
// takes precedence with its real coordinates.
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: buildText(),
|
||||
});
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null));
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.pointerType !== "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: buildText(),
|
||||
pinned: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user