diff --git a/public/injection-body-front.png b/public/injection-body-front.png new file mode 100644 index 000000000..2464532b2 Binary files /dev/null and b/public/injection-body-front.png differ diff --git a/src/components/medications/injection-site-picker.tsx b/src/components/medications/injection-site-picker.tsx index 2f7d90364..1c6acdc00 100644 --- a/src/components/medications/injection-site-picker.tsx +++ b/src/components/medications/injection-site-picker.tsx @@ -13,22 +13,24 @@ import { } from "@/lib/medications/injection-sites"; /** - * Injection-site picker — anatomical front-of-body silhouette with 8 + * Injection-site picker — anatomical front-of-body figure with 8 * tappable sites. * - * 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 hand-authored to the proportions the iOS client - * calibrated against (see `.planning/ios-coord/v0.12-…-injection-bodymap-coords.md`): - * an oval head + short neck, sloped shoulders, a torso that tapers to the - * waist and flares to the hips, arms held slightly out to mid-thigh with - * simple hands, and separated legs ending in feet — so dots land on the - * same anatomy on both platforms. + * The body is a clean, gender-neutral medical line-art figure shipped as + * a small transparent raster (`/injection-body-front.png`, ~8 KB). It is + * painted THEME-AWARE without baking in a colour: the figure's alpha is + * used as an SVG mask (`mask-type="alpha"`) over a `` filled with + * `currentColor`, so the strokes take the picker's text colour and tint + * correctly in light/dark and across the zinc/Dracula palettes. Keeping + * the figure inside the SVG (rather than a CSS-masked sibling element) + * means it shares the exact `viewBox` coordinate space as the dots, so + * alignment is responsive by construction. * - * `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. + * `viewBox="0 0 100 200"` (aspect ≈0.5). The figure is drawn height-fit + * and horizontally centred on that box; `SITE_COORDS` is expressed on the + * same viewBox and measured to land each anchor on the figure's anatomy. + * 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. @@ -91,10 +93,10 @@ export function InjectionSitePicker({ // map (WCAG 2.4.7). const [focusedSite, setFocusedSite] = useState(null); - // Stable, instance-unique ids for the SVG paint servers so two pickers - // on one page can't collide their . + // Stable, instance-unique id for the SVG mask so two pickers on one + // page can't collide their . const uid = useId().replace(/[:]/g, ""); - const bodyFillId = `inj-body-${uid}`; + const bodyMaskId = `inj-body-mask-${uid}`; // The overlay maps a pointer event to viewBox space through the live // CTM, then resolves the nearest allowed site centre and selects it. @@ -136,148 +138,51 @@ export function InjectionSitePicker({ aria-label={t("medications.injectionSiteBodyOutlineAriaLabel")} > - {/* 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. */} - - - - + {/* The body figure's alpha drives an SVG mask. `mask-type="alpha"` + keys on the PNG's transparency (strokes opaque, surround + transparent) rather than luminance, so a `` painted through it renders the figure in the + picker's text colour — theme-aware in light/dark and across + the zinc/Dracula palettes with no baked-in tint. The image is + height-fit + horizontally centred on the 100×200 box so it + shares the dots' coordinate space exactly. */} + + + - {/* Anatomical front-of-body silhouette — traced to the iOS - reference proportions: a plain oval head on a short neck, - naturally sloped shoulders, a torso that narrows to the waist - and flares to the hips, arms held slightly away from the body - reaching to mid-thigh, and separated legs ending in feet. The - figure is composed as two mirror halves about x=50 so it stays - perfectly symmetric, with the upper-arm mass sitting under the - calibrated arm anchors (≈x26/74 @ y72) and the thigh mass under - the thigh anchors (≈x43/57 @ y140) — the dots land on real - anatomy on both platforms. Filled with the soft wash + a - hairline stroke so it reads as a calm medical illustration. */} - - {/* Head + neck — a clean oval head with small ears, a short - tapering neck blending into the trapezius line. */} - - {/* Ears */} - - - {/* Torso + legs — one outline. From the neck the shoulders slope - out to the top of the arms, dive into the armpit notch, run - down the torso side (narrowing to the waist, flaring to the - hips), split at the crotch into two legs ending in feet, and - mirror back up the right side. The arms are drawn separately - (below) so there is a clean underarm gap and the arm anchors - sit on real arm mass, not on the torso edge. */} - - {/* Left arm — held slightly out, tapering from the shoulder to a - rounded hand around hip height, with a clear underarm gap from - the torso. The arm anchor (≈x26 @ y72) sits squarely on the - upper-arm mass: at that height the arm spans ≈x21–31, so the - dot is centred on the muscle. */} - - {/* Right arm — mirror of the left. */} - - {/* Feet — small forward-pointing shapes anchoring each leg so the - figure reads as a full body rather than truncated calves. */} - - - + {/* Anatomical front-of-body figure — a clean, gender-neutral + medical line-art body painted in `currentColor` through the + alpha mask above. The figure is calibrated so the upper-arm + anchors (x23/77 @ y78) sit on the arm mass clear of the torso, + the abdomen quadrants (x43.5/56.5 @ y81/95) sit on the torso + either side of the midline, and the thigh anchors (x40/60 @ + y140) sit on the centre of each thigh — the dots land on real + anatomy. Rendered at a soft opacity so it reads as a calm + backdrop the dots sit on rather than competing with them. */} + {/* Centre + waist references — barely-there guides that help the eye read the abdomen quadrants. Kept low-alpha so they never diff --git a/src/lib/medications/injection-sites.ts b/src/lib/medications/injection-sites.ts index 2b2a06a89..3cbdcd5ad 100644 --- a/src/lib/medications/injection-sites.ts +++ b/src/lib/medications/injection-sites.ts @@ -22,38 +22,31 @@ export const INJECTION_SITE_KEYS = [ export type InjectionSiteKey = (typeof INJECTION_SITE_KEYS)[number]; /** - * SVG position for each site on the anatomical front-of-body silhouette + * SVG position for each site on the anatomical front-of-body figure * (`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". * - * 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 + * Calibrated against the body figure the picker masks onto the viewBox + * (`/injection-body-front.png`, rendered height-fit + horizontally + * centred on the 100×200 box): the anchors are measured to land on the + * figure's anatomy — the abdomen quadrants on the torso either side of + * the midline, the thigh anchors on the centre of each thigh, and the + * arm anchors on the upper-arm mass clear of the torso edge. Screen-left + * (`*_LEFT`) sits at the viewer's left, matching the long-standing + * "links == screen-left" convention. The iOS client carries its own + * coordinate set; these are the web picker's only consumer. */ export const SITE_COORDS: Record = { 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 }, + THIGH_LEFT: { x: 40, y: 140 }, + THIGH_RIGHT: { x: 60, y: 140 }, + UPPER_ARM_LEFT: { x: 23, y: 78 }, + UPPER_ARM_RIGHT: { x: 77, y: 78 }, }; /**