mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(medications): route injection-site taps to the nearest quadrant centre
The two stacked abdomen quadrants sit 14 viewBox units apart, yet each per-dot hit-target was an r=14 transparent circle (Ø=28u), so the targets overlapped by ~14 units. SVG hit-testing resolves to the topmost-painted element rather than the nearest centre, so a tap in the overlap band logged the wrong quadrant — defeating the rotation feature. Replace the overlapping per-dot hit-circles with a single transparent overlay that projects the pointer into viewBox space and resolves it to the nearest site centre (new `nearestSiteAt` helper, constrained to the effective allowed set). Every pixel now maps to its closest site, so full-area routing holds without sub-target dead zones, and the abdomen quadrants can no longer mis-route. Keyboard and assistive-tech users keep a per-site focusable r=6 circle (non-overlapping at this spacing) with the Space/Enter handler and aria-label. Add tests asserting a tap in the former overlap band maps to the nearer quadrant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit a07988f139b0067e82ccc5ad7e5890e0517fe869)
This commit is contained in:
1 parent
107a111545
commit
bfdf0a12a0
3 files changed
+146
-14
No files matched your search
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
INJECTION_SITE_KEYS,
|
||||
SITE_COORDS,
|
||||
describeInjectionSite,
|
||||
nearestSiteAt,
|
||||
nextInjectionSite,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
@@ -31,11 +32,19 @@ import {
|
||||
* Existing intake flows keep working without it (the row's
|
||||
* `injectionSite` stays NULL).
|
||||
*
|
||||
* Touch: each visible dot is wrapped in an invisible hit-target circle.
|
||||
* The SVG renders at ≈320px tall over a 200-unit viewBox (≈1.6× scale),
|
||||
* so the r=14 transparent circle gives ≈45 CSS px — clearing the WCAG
|
||||
* 2.5.8 / 2.5.5 44px floor without the abdomen pair (Δx=13 units ≈21px)
|
||||
* colliding visually, since only the painted r=4.5 dot is opaque.
|
||||
* Pointer routing: a single transparent overlay rectangle spans the
|
||||
* whole SVG and resolves every tap to the NEAREST site centre via
|
||||
* {@link nearestSiteAt} (the overlay projects the client point into
|
||||
* viewBox space through the live `getScreenCTM()`). This replaces the
|
||||
* old per-dot r=14 hit-circles, whose Ø=28u targets overlapped the
|
||||
* Δy=14u-apart abdomen quadrants by ~14u — SVG hit-testing resolves to
|
||||
* the topmost-painted element, not the nearest centre, so taps in that
|
||||
* band logged the WRONG quadrant. The overlay makes "nearest-centre
|
||||
* wins" actually true and keeps full-area (≥44px-equivalent) routing
|
||||
* since every pixel maps to its closest site; the visible dots stay
|
||||
* small (r=4.5). Keyboard / assistive-tech users get a per-site
|
||||
* focusable r=6 circle (non-overlapping at this spacing) carrying the
|
||||
* `role="button"` + `aria-label` + Space/Enter handler.
|
||||
*/
|
||||
|
||||
interface InjectionSitePickerProps {
|
||||
@@ -84,6 +93,22 @@ export function InjectionSitePicker({
|
||||
const uid = useId().replace(/[:]/g, "");
|
||||
const bodyFillId = `inj-body-${uid}`;
|
||||
|
||||
// The overlay maps a pointer event to viewBox space through the live
|
||||
// CTM, then resolves the nearest allowed site centre and selects it.
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const handleOverlayPick = (clientX: number, clientY: number) => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) return;
|
||||
const point = svg.createSVGPoint();
|
||||
point.x = clientX;
|
||||
point.y = clientY;
|
||||
const local = point.matrixTransform(ctm.inverse());
|
||||
const site = nearestSiteAt(local.x, local.y, allowed);
|
||||
if (site) onChange(site);
|
||||
};
|
||||
|
||||
// Accessible name: fold the rotation recommendation into the label so a
|
||||
// screen-reader user gets the "recommended next site" cue that is
|
||||
// otherwise conveyed only by the dashed ring.
|
||||
@@ -101,6 +126,7 @@ export function InjectionSitePicker({
|
||||
aria-label={t("medications.injectionSitePickerAriaLabel")}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox="0 0 100 200"
|
||||
className="text-foreground/45 h-[320px] w-auto max-w-full"
|
||||
role="img"
|
||||
@@ -260,23 +286,22 @@ export function InjectionSitePicker({
|
||||
}
|
||||
strokeWidth="1.1"
|
||||
/>
|
||||
{/* Invisible hit-target for touch. r=14 on the 200-unit
|
||||
viewBox rendered at ≈320px tall (≈1.6× scale) gives
|
||||
≈45 CSS px diameter — clearing the WCAG 2.5.8 / 2.5.5
|
||||
44px floor. The abdomen quadrants sit Δx=13 / Δy=14
|
||||
units apart; the transparent targets touch but the
|
||||
pointer always resolves to the nearest centre. */}
|
||||
{/* Per-site focusable target for keyboard + assistive tech.
|
||||
r=6 (Ø=12u) keeps it well inside the Δy=14u abdomen
|
||||
spacing so adjacent targets never overlap — pointer
|
||||
routing is owned by the single nearest-centre overlay
|
||||
below, so this circle no longer needs to be a 44px tap
|
||||
target. Keyboard activation (Space/Enter) stays here. */}
|
||||
<circle
|
||||
cx={coord.x}
|
||||
cy={coord.y}
|
||||
r="14"
|
||||
r="6"
|
||||
fill="transparent"
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-pressed={isActive}
|
||||
aria-disabled={disabled}
|
||||
aria-label={siteLabel(site)}
|
||||
onClick={disabled ? undefined : () => onChange(site)}
|
||||
onFocus={disabled ? undefined : () => setFocusedSite(site)}
|
||||
onBlur={
|
||||
disabled
|
||||
@@ -305,6 +330,26 @@ export function InjectionSitePicker({
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Single transparent pointer overlay — topmost in paint order so
|
||||
it catches every tap/click, projects it into viewBox space via
|
||||
the live CTM, and routes to the NEAREST site centre. This is
|
||||
the correctness fix for the overlapping abdomen quadrants: a
|
||||
tap in the former Δy=14u overlap band now deterministically
|
||||
picks the nearer quadrant rather than the last-painted dot.
|
||||
Full-area routing also means there are no sub-44px dead zones.
|
||||
`aria-hidden` keeps it out of the AT tree — the per-site
|
||||
focusable circles above own keyboard + screen-reader access. */}
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="100"
|
||||
height="200"
|
||||
fill="transparent"
|
||||
aria-hidden="true"
|
||||
className="cursor-pointer focus:outline-none"
|
||||
onClick={(e) => handleOverlayPick(e.clientX, e.clientY)}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="text-center text-xs">
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
describeInjectionSite,
|
||||
effectiveAllowedSites,
|
||||
isSiteAllowed,
|
||||
nearestSiteAt,
|
||||
nextInjectionSite,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
|
||||
@@ -142,6 +143,54 @@ describe("injection-sites", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("nearestSiteAt() — pointer overlay nearest-centre routing", () => {
|
||||
it("resolves a tap exactly on a site centre to that site", () => {
|
||||
for (const site of INJECTION_SITE_KEYS) {
|
||||
const { x, y } = SITE_COORDS[site];
|
||||
expect(nearestSiteAt(x, y)).toBe(site);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps a tap in the former abdomen overlap band to the NEARER quadrant", () => {
|
||||
// ABDOMEN_UPPER_LEFT y=81, ABDOMEN_LEFT (lower) y=95 → midpoint 88.
|
||||
// The old r=14 hit-circles overlapped ~14u here and mis-routed to
|
||||
// the last-painted dot (the lower quadrant). Nearest-centre must
|
||||
// route a tap above the midpoint to the upper quadrant and below
|
||||
// it to the lower one.
|
||||
expect(nearestSiteAt(43.5, 84)).toBe("ABDOMEN_UPPER_LEFT");
|
||||
expect(nearestSiteAt(43.5, 92)).toBe("ABDOMEN_LEFT");
|
||||
// Same on the right column.
|
||||
expect(nearestSiteAt(56.5, 84)).toBe("ABDOMEN_UPPER_RIGHT");
|
||||
expect(nearestSiteAt(56.5, 92)).toBe("ABDOMEN_RIGHT");
|
||||
});
|
||||
|
||||
it("routes a tap left of centre to a *_LEFT site (screen-left == links)", () => {
|
||||
// A point in the abdomen row but left of the body midline resolves
|
||||
// to a left-column quadrant, never a right one.
|
||||
const site = nearestSiteAt(40, 88);
|
||||
expect(site).toMatch(/_LEFT$/);
|
||||
});
|
||||
|
||||
it("constrains routing to the allowed set (skips disabled sites)", () => {
|
||||
// A tap right on the upper-left abdomen centre, but that quadrant is
|
||||
// not allowed → the nearest ALLOWED site wins instead.
|
||||
const allowed = ["ABDOMEN_LEFT", "THIGH_LEFT"] as const;
|
||||
const { x, y } = SITE_COORDS.ABDOMEN_UPPER_LEFT;
|
||||
expect(nearestSiteAt(x, y, allowed)).toBe("ABDOMEN_LEFT");
|
||||
});
|
||||
|
||||
it("returns null when the allowed set is empty", () => {
|
||||
expect(nearestSiteAt(50, 88, [])).toBeNull();
|
||||
});
|
||||
|
||||
it("breaks ties toward the earlier site in canonical order", () => {
|
||||
// Equidistant between the two upper-abdomen quadrants (x midline,
|
||||
// y=81) → ABDOMEN_UPPER_LEFT precedes ABDOMEN_UPPER_RIGHT in
|
||||
// INJECTION_SITE_KEYS, so it wins the tie.
|
||||
expect(nearestSiteAt(50, 81)).toBe("ABDOMEN_UPPER_LEFT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeInjectionSite()", () => {
|
||||
it("returns the right i18n key per site", () => {
|
||||
expect(describeInjectionSite("ABDOMEN_LEFT")).toBe(
|
||||
|
||||
@@ -56,6 +56,44 @@ export const SITE_COORDS: Record<InjectionSiteKey, { x: number; y: number }> = {
|
||||
UPPER_ARM_RIGHT: { x: 73.8, y: 72 },
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.9.0 — resolve the injection site whose `SITE_COORDS` centre is
|
||||
* nearest to a point `(x, y)` expressed in the picker's `100 x 200`
|
||||
* viewBox space. This is the deterministic "nearest-centre wins" rule
|
||||
* the picker overlay relies on: a single transparent overlay catches
|
||||
* the pointer, projects the tap into viewBox space, and resolves it to
|
||||
* the closest site — so two visually-stacked dots (the abdomen
|
||||
* quadrants sit only Δy=14u apart) can never mis-route to the wrong
|
||||
* quadrant the way overlapping per-dot hit-circles did.
|
||||
*
|
||||
* `allowed` constrains the candidate pool exactly like
|
||||
* {@link nextInjectionSite}: `undefined` considers all eight sites; a
|
||||
* non-empty array restricts to those sites; an explicitly-empty array
|
||||
* yields `null` (nothing selectable). Ties (equidistant centres) break
|
||||
* toward the earlier site in canonical `INJECTION_SITE_KEYS` order.
|
||||
*/
|
||||
export function nearestSiteAt(
|
||||
x: number,
|
||||
y: number,
|
||||
allowed?: ReadonlyArray<InjectionSiteKey>,
|
||||
): InjectionSiteKey | null {
|
||||
const allowedSet = allowed ? new Set(allowed) : null;
|
||||
let best: InjectionSiteKey | null = null;
|
||||
let bestDistSq = Infinity;
|
||||
for (const site of INJECTION_SITE_KEYS) {
|
||||
if (allowedSet !== null && !allowedSet.has(site)) continue;
|
||||
const coord = SITE_COORDS[site];
|
||||
const dx = coord.x - x;
|
||||
const dy = coord.y - y;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
if (distSq < bestDistSq) {
|
||||
bestDistSq = distSq;
|
||||
best = site;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.5 — resolve the effective allowed set for a medication given the
|
||||
* per-medication preference and the user-level global exclusion. The
|
||||
|
||||
Reference in new issue
Block a user