mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(medications): redraw the injection-site picker as an anatomical silhouette
Replace the stick-figure outline with a traced front-of-body silhouette, still an inline SVG so it inherits the theme tokens (currentColor + a soft currentColor wash) and tints correctly across light/dark and the zinc/Dracula palettes with no raster asset. Recalibrate SITE_COORDS onto the new 100x200 viewBox (aspect ≈0.5) using the iOS client's hands-on fractional coordinates, so both apps land their dots on the same anatomy. The coordinates double as the Euclidean basis for the rotation recommender, so picker and recommendation stay in sync. Dot states: selected (filled), recommended-next (dashed primary ring), and a new last-used solid ring; hit targets are r=14 on the viewBox (≈45 CSS px at the rendered scale), clearing the 44px floor. Props, callbacks, the screen-left=="links" convention and the v1.8.5 site→enum bridge are unchanged — graphic and coordinates only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 46125c3bbec5573910f41a319ae717d37f39c228)
This commit is contained in:
1 parent
7857aa3dd2
commit
09af60303d
3 files changed
+174
-112
No files matched your search
@@ -247,13 +247,14 @@ describe("<InjectionSitePicker>", () => {
|
||||
});
|
||||
|
||||
it("uses the SVG body outline as the picker surface", () => {
|
||||
// Sanity check the body outline renders — head circle + torso
|
||||
// path are the load-bearing visual anchors for the click targets.
|
||||
// Sanity check the body outline renders — the silhouette path is the
|
||||
// load-bearing visual anchor for the click targets, drawn on the
|
||||
// 100x200 viewBox the iOS reference coords are calibrated against.
|
||||
const html = render(
|
||||
<InjectionSitePicker value={null} onChange={() => {}} />,
|
||||
);
|
||||
|
||||
expect(html).toMatch(/<svg[^>]*viewBox="0 0 120 300"/);
|
||||
expect(html).toMatch(/<svg[^>]*viewBox="0 0 100 200"/);
|
||||
expect(html).toContain('aria-label="Body outline"');
|
||||
// The outer group has the localised "Once weekly on …" picker
|
||||
// label so the wrapper announces its role properly.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
@@ -12,27 +12,38 @@ import {
|
||||
} from "@/lib/medications/injection-sites";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — injection-site picker.
|
||||
* Injection-site picker — anatomical front-of-body silhouette with 8
|
||||
* tappable sites.
|
||||
*
|
||||
* Stylized front-of-body SVG with 8 click-targets. The active site
|
||||
* highlights, the recommended next-site is ringed with a dashed outline
|
||||
* so the user can rotate sensibly without consulting a separate guide.
|
||||
* The silhouette is an INLINE SVG so it inherits the app's theme tokens
|
||||
* (`currentColor` + CSS variables) and tints correctly in light/dark and
|
||||
* across the zinc/Dracula palettes with no raster asset and no luminance
|
||||
* key. The body is traced to the proportions the iOS client calibrated
|
||||
* against (see `.planning/ios-coord/v0.12-…-injection-bodymap-coords.md`)
|
||||
* so dots land on the same anatomy on both platforms.
|
||||
*
|
||||
* Per Marc's "nichts brickt" directive the picker is opt-in — it's only
|
||||
* `viewBox="0 0 100 200"` (aspect ≈0.5). `SITE_COORDS` is expressed on
|
||||
* the same viewBox; the recommended-next ring, the last-used marker and
|
||||
* the selected fill all read directly from those coordinates.
|
||||
*
|
||||
* Per the long-standing "nichts brickt" directive the picker is opt-in —
|
||||
* mounted from the GLP-1 dashboard tile and the optional log dialog.
|
||||
* Existing medication intake flows continue to work without it (the
|
||||
* intake-event row's `injectionSite` field stays NULL).
|
||||
* Existing intake flows keep working without it (the row's
|
||||
* `injectionSite` stays NULL).
|
||||
*
|
||||
* Mobile-first: the picker is centred in a 320px column and remains
|
||||
* touch-friendly at Pixel-5 width. SVG buttons have 44px hit areas
|
||||
* (the visible circle is smaller but the <button> wrapper meets WCAG).
|
||||
* 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.
|
||||
*/
|
||||
|
||||
interface InjectionSitePickerProps {
|
||||
/** Current selection, if any. */
|
||||
value: InjectionSiteKey | null;
|
||||
/** Recent rotation history (most recent first). Powers the
|
||||
* "recommended next site" dashed-ring annotation. */
|
||||
* "recommended next site" dashed-ring annotation and the subtle
|
||||
* "last used" marker. */
|
||||
history?: ReadonlyArray<InjectionSiteKey>;
|
||||
/**
|
||||
* v1.8.5 — the effective allowed set (per-medication allowed sites
|
||||
@@ -58,6 +69,7 @@ export function InjectionSitePicker({
|
||||
}: InjectionSitePickerProps) {
|
||||
const { t } = useTranslations();
|
||||
const recommended = nextInjectionSite(history, 4, allowed);
|
||||
const lastUsed = history[0] ?? null;
|
||||
const allowedSet = allowed ? new Set(allowed) : null;
|
||||
const isAllowed = (site: InjectionSiteKey) =>
|
||||
allowedSet === null || allowedSet.has(site);
|
||||
@@ -67,6 +79,11 @@ export function InjectionSitePicker({
|
||||
// map (WCAG 2.4.7).
|
||||
const [focusedSite, setFocusedSite] = useState<InjectionSiteKey | null>(null);
|
||||
|
||||
// Stable, instance-unique ids for the SVG paint servers so two pickers
|
||||
// on one page can't collide their <defs>.
|
||||
const uid = useId().replace(/[:]/g, "");
|
||||
const bodyFillId = `inj-body-${uid}`;
|
||||
|
||||
// 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.
|
||||
@@ -84,90 +101,107 @@ export function InjectionSitePicker({
|
||||
aria-label={t("medications.injectionSitePickerAriaLabel")}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 120 300"
|
||||
className="text-foreground/40 h-[320px] w-auto max-w-full"
|
||||
viewBox="0 0 100 200"
|
||||
className="text-foreground/45 h-[320px] w-auto max-w-full"
|
||||
role="img"
|
||||
aria-label={t("medications.injectionSiteBodyOutlineAriaLabel")}
|
||||
>
|
||||
{/* Head */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="22"
|
||||
r="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{/* Neck */}
|
||||
<defs>
|
||||
{/* A whisper-soft vertical wash gives the flat silhouette a
|
||||
little depth without fighting the theme — both stops are the
|
||||
current text colour at low alpha, so it tints with the body. */}
|
||||
<linearGradient id={bodyFillId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="currentColor" stopOpacity="0.06" />
|
||||
<stop offset="1" stopColor="currentColor" stopOpacity="0.10" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Anatomical front-of-body silhouette — one continuous outline
|
||||
traced to the iOS reference proportions (upright, arms slightly
|
||||
out, plain oval head). Filled with the soft wash + a hairline
|
||||
stroke so it reads as a calm medical illustration. */}
|
||||
<path
|
||||
d="M 56 36 L 56 46 L 64 46 L 64 36"
|
||||
fill="none"
|
||||
d="M 50 4
|
||||
C 44.2 4 39.8 8.6 39.8 15
|
||||
C 39.8 19.6 41.4 23.4 44 25.6
|
||||
C 43.4 28.4 42 30.4 39.2 31.4
|
||||
C 35 32.9 30.4 34.2 27.4 36.6
|
||||
C 24.4 39 23 42.8 22.2 47.4
|
||||
C 21.2 53.2 20.2 60 18.2 67
|
||||
C 16.4 73.4 14 80.4 12.6 86.4
|
||||
C 11.8 89.8 12.4 92.2 14.2 92.8
|
||||
C 16 93.4 17.6 91.8 18.4 88.6
|
||||
C 19.8 83 21.8 76.6 23.6 71
|
||||
C 24.4 68.4 25.4 67.8 25.8 69
|
||||
C 26.2 70.2 26 74 25.6 79
|
||||
C 25.2 84.6 24.8 90.6 25.2 95.6
|
||||
C 25.6 100.4 26.8 104.4 28 107.8
|
||||
C 27.2 119 26.2 133 26.6 145
|
||||
C 27 156.6 28.6 170 30 182
|
||||
C 30.6 187.4 31.4 191.6 33 193.4
|
||||
C 34.4 195 37 195.2 39 194.6
|
||||
C 40.8 194 41.4 192.2 41.6 189.4
|
||||
C 42.2 181.4 43 169 44 158
|
||||
C 44.8 149.4 45.6 141.4 46.4 136
|
||||
C 47 131.8 48.2 129.6 50 129.6
|
||||
C 51.8 129.6 53 131.8 53.6 136
|
||||
C 54.4 141.4 55.2 149.4 56 158
|
||||
C 57 169 57.8 181.4 58.4 189.4
|
||||
C 58.6 192.2 59.2 194 61 194.6
|
||||
C 63 195.2 65.6 195 67 193.4
|
||||
C 68.6 191.6 69.4 187.4 70 182
|
||||
C 71.4 170 73 156.6 73.4 145
|
||||
C 73.8 133 72.8 119 72 107.8
|
||||
C 73.2 104.4 74.4 100.4 74.8 95.6
|
||||
C 75.2 90.6 74.8 84.6 74.4 79
|
||||
C 74 74 73.8 70.2 74.2 69
|
||||
C 74.6 67.8 75.6 68.4 76.4 71
|
||||
C 78.2 76.6 80.2 83 81.6 88.6
|
||||
C 82.4 91.8 84 93.4 85.8 92.8
|
||||
C 87.6 92.2 88.2 89.8 87.4 86.4
|
||||
C 86 80.4 83.6 73.4 81.8 67
|
||||
C 79.8 60 78.8 53.2 77.8 47.4
|
||||
C 77 42.8 75.6 39 72.6 36.6
|
||||
C 69.6 34.2 65 32.9 60.8 31.4
|
||||
C 58 30.4 56.6 28.4 56 25.6
|
||||
C 58.6 23.4 60.2 19.6 60.2 15
|
||||
C 60.2 8.6 55.8 4 50 4 Z"
|
||||
fill={`url(#${bodyFillId})`}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="0.9"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Torso */}
|
||||
<path
|
||||
d="M 40 50 Q 30 60 28 80 L 32 175 Q 40 185 60 185 Q 80 185 88 175 L 92 80 Q 90 60 80 50 Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{/* Arms */}
|
||||
<path
|
||||
d="M 32 60 L 16 110 L 14 150"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M 88 60 L 104 110 L 106 150"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{/* Thighs */}
|
||||
<path
|
||||
d="M 36 185 L 30 270"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M 50 185 L 50 270"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M 70 185 L 70 270"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M 84 185 L 90 270"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{/* Belt line */}
|
||||
|
||||
{/* Centre + waist references — barely-there guides that help the
|
||||
eye read the abdomen quadrants. Kept low-alpha so they never
|
||||
compete with the dots. */}
|
||||
<line
|
||||
x1="32"
|
||||
y1="155"
|
||||
x2="88"
|
||||
y2="155"
|
||||
x1="50"
|
||||
y1="34"
|
||||
x2="50"
|
||||
y2="104"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.8"
|
||||
strokeDasharray="2 3"
|
||||
strokeWidth="0.4"
|
||||
strokeOpacity="0.35"
|
||||
/>
|
||||
<line
|
||||
x1="29"
|
||||
y1="88"
|
||||
x2="71"
|
||||
y2="88"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.4"
|
||||
strokeOpacity="0.35"
|
||||
/>
|
||||
{/* Navel reference */}
|
||||
<circle cx="60" cy="130" r="1.5" fill="currentColor" />
|
||||
<circle cx="50" cy="88" r="0.9" fill="currentColor" fillOpacity="0.45" />
|
||||
|
||||
{/* Click targets */}
|
||||
{INJECTION_SITE_KEYS.map((site) => {
|
||||
const coord = SITE_COORDS[site];
|
||||
const isActive = site === value;
|
||||
const isRecommended = site === recommended;
|
||||
const isLastUsed = site === lastUsed && !isActive;
|
||||
// v1.8.5 — a site outside the effective allowed set renders
|
||||
// disabled (dimmed, non-interactive) so the user cannot pick a
|
||||
// site the server would reject.
|
||||
@@ -182,27 +216,41 @@ export function InjectionSitePicker({
|
||||
<circle
|
||||
cx={coord.x}
|
||||
cy={coord.y}
|
||||
r="10.5"
|
||||
r="8"
|
||||
fill="none"
|
||||
className="stroke-ring"
|
||||
strokeWidth="2.5"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
)}
|
||||
{/* Last-used marker — a soft solid ring so the user can see
|
||||
where they injected most recently at a glance, distinct
|
||||
from the dashed recommendation ring. Suppressed on the
|
||||
active selection (the filled dot already says "here"). */}
|
||||
{isLastUsed && !disabled && (
|
||||
<circle
|
||||
cx={coord.x}
|
||||
cy={coord.y}
|
||||
r="7"
|
||||
fill="none"
|
||||
className="stroke-foreground/35"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
)}
|
||||
{isRecommended && !isActive && !disabled && (
|
||||
<circle
|
||||
cx={coord.x}
|
||||
cy={coord.y}
|
||||
r="9"
|
||||
r="7"
|
||||
fill="none"
|
||||
className="stroke-primary"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="1.1"
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx={coord.x}
|
||||
cy={coord.y}
|
||||
r="6"
|
||||
r="4.5"
|
||||
className={
|
||||
disabled
|
||||
? "fill-muted/30 stroke-foreground/20"
|
||||
@@ -210,19 +258,18 @@ export function InjectionSitePicker({
|
||||
? "fill-primary stroke-primary-foreground"
|
||||
: "fill-muted stroke-foreground/60 hover:fill-accent"
|
||||
}
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="1.1"
|
||||
/>
|
||||
{/* Invisible 24-unit hit-target for touch. The SVG renders
|
||||
at ≈1.07× scale (320px box / 300-unit viewBox height),
|
||||
so r=12 gives ≈25.6 CSS px diameter — clearing the
|
||||
WCAG 2.5.8 Level AA 24×24 floor. Going larger would
|
||||
overlap the abdomen-left / abdomen-right pair at
|
||||
Δx=24 units; the picker stays interactive without
|
||||
spacing collisions. */}
|
||||
{/* 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. */}
|
||||
<circle
|
||||
cx={coord.x}
|
||||
cy={coord.y}
|
||||
r="12"
|
||||
r="14"
|
||||
fill="transparent"
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
|
||||
@@ -22,24 +22,38 @@ export const INJECTION_SITE_KEYS = [
|
||||
export type InjectionSiteKey = (typeof INJECTION_SITE_KEYS)[number];
|
||||
|
||||
/**
|
||||
* Stylized SVG position for each site (front-of-body view, ~120x300
|
||||
* viewBox). Used by `injection-site-picker.tsx` to render the clickable
|
||||
* dots; tuned to read clearly on a 320px mobile viewport.
|
||||
* SVG position for each site on the anatomical front-of-body silhouette
|
||||
* (`100 x 200` viewBox, aspect ≈0.5). Used by `injection-site-picker.tsx`
|
||||
* to render the clickable dots AND by {@link nextInjectionSite} as the
|
||||
* Euclidean-distance basis for the rotation recommendation, so the two
|
||||
* always agree on where a site "is".
|
||||
*
|
||||
* The body outline is a rounded humanoid: head (y≈20), shoulders
|
||||
* (y≈60), torso (y≈70-180), thighs (y≈180-260). Sites sit on the
|
||||
* dominant injection zones — abdomen quadrants centred on (60, 130),
|
||||
* thighs at (40, 215) / (80, 215), upper arms at (20, 80) / (100, 80).
|
||||
* Calibrated against the iOS client's hands-on tune (see
|
||||
* `.planning/ios-coord/v0.12-ios-to-server-injection-bodymap-coords.md`):
|
||||
* fractional coords (0–1, x across width / y down height) multiplied by
|
||||
* the viewBox `width=100` / `height=200`, so both apps land their dots on
|
||||
* the same anatomy. Screen-left (`*_LEFT`) sits at the viewer's left,
|
||||
* matching the long-standing "links == screen-left" convention.
|
||||
*
|
||||
* site (iOS naming) fraction (x, y) → viewBox (x, y)
|
||||
* armLeft 0.262, 0.360 26.2, 72
|
||||
* armRight 0.738, 0.360 73.8, 72
|
||||
* abdomenLeftUpper 0.435, 0.405 43.5, 81
|
||||
* abdomenRightUpper 0.565, 0.405 56.5, 81
|
||||
* abdomenLeftLower 0.435, 0.475 43.5, 95
|
||||
* abdomenRightLower 0.565, 0.475 56.5, 95
|
||||
* thighLeft 0.430, 0.700 43, 140
|
||||
* thighRight 0.570, 0.700 57, 140
|
||||
*/
|
||||
export const SITE_COORDS: Record<InjectionSiteKey, { x: number; y: number }> = {
|
||||
ABDOMEN_UPPER_LEFT: { x: 48, y: 110 },
|
||||
ABDOMEN_UPPER_RIGHT: { x: 72, y: 110 },
|
||||
ABDOMEN_LEFT: { x: 48, y: 140 },
|
||||
ABDOMEN_RIGHT: { x: 72, y: 140 },
|
||||
THIGH_LEFT: { x: 42, y: 215 },
|
||||
THIGH_RIGHT: { x: 78, y: 215 },
|
||||
UPPER_ARM_LEFT: { x: 18, y: 82 },
|
||||
UPPER_ARM_RIGHT: { x: 102, y: 82 },
|
||||
ABDOMEN_UPPER_LEFT: { x: 43.5, y: 81 },
|
||||
ABDOMEN_UPPER_RIGHT: { x: 56.5, y: 81 },
|
||||
ABDOMEN_LEFT: { x: 43.5, y: 95 },
|
||||
ABDOMEN_RIGHT: { x: 56.5, y: 95 },
|
||||
THIGH_LEFT: { x: 43, y: 140 },
|
||||
THIGH_RIGHT: { x: 57, y: 140 },
|
||||
UPPER_ARM_LEFT: { x: 26.2, y: 72 },
|
||||
UPPER_ARM_RIGHT: { x: 73.8, y: 72 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user