feat(medications): replace the injection-site body map with a refined illustration

Swap the hand-authored body silhouette in the injection-site picker for a
clean, gender-neutral medical line-art figure with correct proportions
(arms to mid-thigh, head-to-body ≈1:7.5, separated legs, feet). The figure
ships as a small transparent raster (~8 KB) and is painted theme-aware: its
alpha drives an SVG mask over a currentColor rect, so the strokes take the
picker's text colour and tint correctly in light and dark.

Keeping the figure inside the SVG (rather than a CSS-masked sibling) means
it shares the dots' 100×200 viewBox, so alignment stays responsive by
construction. Recalibrate SITE_COORDS so the upper-arm, abdomen-quadrant
and thigh anchors land on the new figure's anatomy; the pointer overlay,
keyboard targets, recommendation ring, last-used marker and disabled-set
logic are unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 21:34:10 +02:00
1 parent 121a68255d
commit 33ca595405
3 files changed
+75 -177

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

@@ -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 `<rect>` 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<InjectionSiteKey | null>(null);
// Stable, instance-unique ids for the SVG paint servers so two pickers
// on one page can't collide their <defs>.
// Stable, instance-unique id for the SVG mask so two pickers on one
// page can't collide their <defs>.
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")}
>
<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>
{/* 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 `<rect fill=
currentColor>` 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. */}
<mask
id={bodyMaskId}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="100"
height="200"
style={{ maskType: "alpha" }}
>
<image
href="/injection-body-front.png"
x="0"
y="0"
width="100"
height="200"
preserveAspectRatio="xMidYMid meet"
/>
</mask>
</defs>
{/* 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. */}
<g
fill={`url(#${bodyFillId})`}
stroke="currentColor"
strokeWidth="0.9"
strokeLinejoin="round"
strokeLinecap="round"
>
{/* Head + neck — a clean oval head with small ears, a short
tapering neck blending into the trapezius line. */}
<path
d="M 50 4.5
C 46 4.5 42.8 8 42.8 13
C 42.8 16.8 44.2 20 46.4 22
C 46.4 24.6 45.8 26.4 44.2 27.4
C 45.8 28 47.8 28.4 50 28.4
C 52.2 28.4 54.2 28 55.8 27.4
C 54.2 26.4 53.6 24.6 53.6 22
C 55.8 20 57.2 16.8 57.2 13
C 57.2 8 54 4.5 50 4.5 Z"
/>
{/* Ears */}
<path d="M 42.9 14.4 C 41.6 13.8 40.8 15 41.2 16.4 C 41.5 17.5 42.4 18 43 17.6 Z" />
<path d="M 57.1 14.4 C 58.4 13.8 59.2 15 58.8 16.4 C 58.5 17.5 57.6 18 57 17.6 Z" />
{/* 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. */}
<path
d="M 50 27
C 47.6 27 45.6 27.4 43.6 28
C 40.6 28.8 37.4 30 34.6 31.6
C 36.4 33.4 37.6 36.2 38.2 39.6
C 38.6 41.8 38 43.6 36.6 44.6
C 38.2 47.6 39.4 51.8 39.8 56.4
C 40.4 63 40.2 71 39.4 78
C 38.8 83 38 86.8 38 90
C 38 93.4 38.8 96.6 40 99
C 39.2 102 38 105.4 37 108.8
C 37 116 36.8 124 36.6 132
C 36.4 144 36.8 157 37.8 169
C 38.4 176.4 39 183 39.6 187.6
C 40 190.6 40.4 192.6 41.4 193.4
C 42.4 194.2 44 194 44.8 192.8
C 45.4 191.8 45.8 189.8 46.2 186.8
C 47 180 47.8 169.6 48.2 159
C 48.6 149.4 48.8 139.8 49 132
C 49.1 127.4 49.4 124 50 121.6
C 50.6 124 50.9 127.4 51 132
C 51.2 139.8 51.4 149.4 51.8 159
C 52.2 169.6 53 180 53.8 186.8
C 54.2 189.8 54.6 191.8 55.2 192.8
C 56 194 57.6 194.2 58.6 193.4
C 59.6 192.6 60 190.6 60.4 187.6
C 61 183 61.6 176.4 62.2 169
C 63.2 157 63.6 144 63.4 132
C 63.2 124 62.6 116 61.8 108.8
C 63 105.4 61.8 102 61 99
C 62.2 96.6 62 93.4 62 90
C 62 86.8 61.2 83 60.6 78
C 59.8 71 59.6 63 60.2 56.4
C 60.6 51.8 61.8 47.6 63.4 44.6
C 62 43.6 61.4 41.8 61.8 39.6
C 62.4 36.2 63.6 33.4 65.4 31.6
C 62.6 30 59.4 28.8 56.4 28
C 54.4 27.4 52.4 27 50 27 Z"
/>
{/* 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. */}
<path
d="M 34.2 31.6
C 32.2 32.6 30.8 34.4 29.8 37.2
C 28.6 40.4 27.6 44.6 26.8 49.4
C 26 54.2 25.4 59.6 24.8 64.8
C 24.2 70 23.6 75.2 23 80
C 22.4 84.8 21.8 89.2 21.4 92.8
C 21 96 20.8 98.6 21 100.8
C 21.2 102.6 20.8 104 20.4 105.2
C 20 106.4 20 107.6 20.8 108.4
C 21.6 109.2 22.8 109.2 23.8 108.6
C 24.8 108 25.4 106.8 25.8 105.2
C 26.2 103.6 26.6 101.4 27 98.8
C 27.6 94.8 28.2 89.8 28.8 84.8
C 29.4 79.6 30 74 30.6 68.8
C 31.2 63.6 31.8 58.6 32.4 54
C 33 49.4 33.6 45.2 34.4 41.8
C 35 39 35.8 36.8 36.8 35.4
C 37.6 34.2 37.2 32.6 35.8 31.8
C 35.2 31.4 34.6 31.4 34.2 31.6 Z"
/>
{/* Right arm — mirror of the left. */}
<path
d="M 65.8 31.6
C 67.8 32.6 69.2 34.4 70.2 37.2
C 71.4 40.4 72.4 44.6 73.2 49.4
C 74 54.2 74.6 59.6 75.2 64.8
C 75.8 70 76.4 75.2 77 80
C 77.6 84.8 78.2 89.2 78.6 92.8
C 79 96 79.2 98.6 79 100.8
C 78.8 102.6 79.2 104 79.6 105.2
C 80 106.4 80 107.6 79.2 108.4
C 78.4 109.2 77.2 109.2 76.2 108.6
C 75.2 108 74.6 106.8 74.2 105.2
C 73.8 103.6 73.4 101.4 73 98.8
C 72.4 94.8 71.8 89.8 71.2 84.8
C 70.6 79.6 70 74 69.4 68.8
C 68.8 63.6 68.2 58.6 67.6 54
C 67 49.4 66.4 45.2 65.6 41.8
C 65 39 64.2 36.8 63.2 35.4
C 62.4 34.2 62.8 32.6 64.2 31.8
C 64.8 31.4 65.4 31.4 65.8 31.6 Z"
/>
{/* Feet — small forward-pointing shapes anchoring each leg so the
figure reads as a full body rather than truncated calves. */}
<path d="M 41.4 191.6 C 40 193.4 39.6 195.4 40.2 196.6 C 40.8 197.6 42.6 197.8 44.8 197.4 C 46.8 197 47.8 196.2 47.4 195 C 47 193.4 46.6 191.6 46.2 189.6 Z" />
<path d="M 58.6 191.6 C 60 193.4 60.4 195.4 59.8 196.6 C 59.2 197.6 57.4 197.8 55.2 197.4 C 53.2 197 52.2 196.2 52.6 195 C 53 193.4 53.4 191.6 53.8 189.6 Z" />
</g>
{/* 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. */}
<rect
x="0"
y="0"
width="100"
height="200"
fill="currentColor"
mask={`url(#${bodyMaskId})`}
/>
{/* Centre + waist references — barely-there guides that help the
eye read the abdomen quadrants. Kept low-alpha so they never
+14 -21
View File
@@ -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<InjectionSiteKey, { x: number; y: number }> = {
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 },
};
/**