feat(medications): refine the injection-site body map and add a marker legend

Redraw the inline body-map silhouette to the proportions the iOS client
calibrated against: an oval head on a 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.
The figure is composed as a torso-plus-legs outline with two separate arm
paths so there is a clean underarm gap and the arms read as arms rather
than a single blob. The eight site anchors keep their calibrated
coordinates on the 100x200 viewBox, so every tappable region and its
nearest-centre hit routing is preserved — the dots still land on real
anatomy (upper arms, the four abdomen quadrants, the thighs) and stay
selectable, theme-aware, and keyboard reachable.

Add a compact marker legend to the injection-site dialog: a dashed primary
ring marks the recommended next site and an amber ring marks the last-used
site. The last-used marker on the picker switches to the same amber tone so
the legend swatches mirror the body map exactly.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 21:02:22 +02:00
1 parent 86b2774567
commit 121a68255d
8 files changed
+213 -67

No files matched your search

+3
View File
@@ -1035,6 +1035,9 @@
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
"injectionSiteRecommendedAriaLabel": "{site} – empfohlene nächste Stelle",
"injectionSiteLegendAriaLabel": "Legende der Markierungen",
"injectionSiteLegendRecommended": "Empfohlene nächste Stelle",
"injectionSiteLegendLastUsed": "Zuletzt verwendete Stelle",
"siteAbdomenLeft": "Bauch, unten links",
"siteAbdomenRight": "Bauch, unten rechts",
"siteAbdomenUpperLeft": "Bauch, oben links",
+3
View File
@@ -1035,6 +1035,9 @@
"injectionSiteBodyOutlineAriaLabel": "Body outline",
"injectionSitePickerAriaLabel": "Injection site picker",
"injectionSiteRecommendedAriaLabel": "{site} — recommended next site",
"injectionSiteLegendAriaLabel": "Marker legend",
"injectionSiteLegendRecommended": "Recommended next site",
"injectionSiteLegendLastUsed": "Last used site",
"siteAbdomenLeft": "Abdomen, lower left",
"siteAbdomenRight": "Abdomen, lower right",
"siteAbdomenUpperLeft": "Abdomen, upper left",
+3
View File
@@ -1035,6 +1035,9 @@
"injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo",
"injectionSitePickerAriaLabel": "Selector de sitio de inyección",
"injectionSiteRecommendedAriaLabel": "{site}: sitio siguiente recomendado",
"injectionSiteLegendAriaLabel": "Leyenda de marcadores",
"injectionSiteLegendRecommended": "Próximo sitio recomendado",
"injectionSiteLegendLastUsed": "Último sitio usado",
"siteAbdomenLeft": "Abdomen, inferior izquierdo",
"siteAbdomenRight": "Abdomen, inferior derecho",
"siteAbdomenUpperLeft": "Abdomen, superior izquierdo",
+3
View File
@@ -1035,6 +1035,9 @@
"injectionSiteBodyOutlineAriaLabel": "Silhouette du corps",
"injectionSitePickerAriaLabel": "Sélecteur de site d'injection",
"injectionSiteRecommendedAriaLabel": "{site} — prochain site recommandé",
"injectionSiteLegendAriaLabel": "Légende des repères",
"injectionSiteLegendRecommended": "Prochain site recommandé",
"injectionSiteLegendLastUsed": "Dernier site utilisé",
"siteAbdomenLeft": "Abdomen, en bas à gauche",
"siteAbdomenRight": "Abdomen, en bas à droite",
"siteAbdomenUpperLeft": "Abdomen, en haut à gauche",
+3
View File
@@ -1035,6 +1035,9 @@
"injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo",
"injectionSitePickerAriaLabel": "Selettore del sito di iniezione",
"injectionSiteRecommendedAriaLabel": "{site} — prossimo sito consigliato",
"injectionSiteLegendAriaLabel": "Legenda dei segnaposto",
"injectionSiteLegendRecommended": "Prossimo sito consigliato",
"injectionSiteLegendLastUsed": "Ultimo sito utilizzato",
"siteAbdomenLeft": "Addome, basso a sinistra",
"siteAbdomenRight": "Addome, basso a destra",
"siteAbdomenUpperLeft": "Addome, alto a sinistra",
+3
View File
@@ -1035,6 +1035,9 @@
"injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała",
"injectionSitePickerAriaLabel": "Wybór miejsca iniekcji",
"injectionSiteRecommendedAriaLabel": "{site} — zalecane następne miejsce",
"injectionSiteLegendAriaLabel": "Legenda oznaczeń",
"injectionSiteLegendRecommended": "Zalecane następne miejsce",
"injectionSiteLegendLastUsed": "Ostatnio użyte miejsce",
"siteAbdomenLeft": "Brzuch, dolny lewy",
"siteAbdomenRight": "Brzuch, dolny prawy",
"siteAbdomenUpperLeft": "Brzuch, górny lewy",
@@ -19,9 +19,12 @@ import {
* 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.
* 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.
*
* `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
@@ -142,61 +145,139 @@ export function InjectionSitePicker({
</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 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"
{/* 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>
{/* Centre + waist references — barely-there guides that help the
eye read the abdomen quadrants. Kept low-alpha so they never
@@ -211,9 +292,9 @@ export function InjectionSitePicker({
strokeOpacity="0.35"
/>
<line
x1="29"
x1="40"
y1="88"
x2="71"
x2="60"
y2="88"
stroke="currentColor"
strokeWidth="0.4"
@@ -248,18 +329,20 @@ export function InjectionSitePicker({
strokeWidth="1.8"
/>
)}
{/* Last-used marker — a soft solid ring so the user can see
{/* Last-used marker — a solid amber 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"). */}
from the dashed primary recommendation ring. The amber tone
matches the dialog legend and reads in light + dark.
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"
className="stroke-amber-500"
strokeWidth="1.2"
/>
)}
{isRecommended && !isActive && !disabled && (
@@ -109,12 +109,57 @@ export function LogInjectionSiteDialog({
{t("medications.logInjectionSiteNoneAvailable")}
</p>
) : (
<InjectionSitePicker
value={selected}
history={history}
allowed={allowed}
onChange={setSelected}
/>
<>
<InjectionSitePicker
value={selected}
history={history}
allowed={allowed}
onChange={setSelected}
/>
{/* Marker legend — explains the two body-map annotations so the
dashed primary ring and the amber ring read at a glance. The
swatches mirror the picker's own marker strokes exactly. */}
<ul
className="text-muted-foreground flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs"
aria-label={t("medications.injectionSiteLegendAriaLabel")}
>
<li className="flex items-center gap-1.5">
<svg
viewBox="0 0 16 16"
className="h-3.5 w-3.5 shrink-0"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6"
fill="none"
className="stroke-primary"
strokeWidth="1.6"
strokeDasharray="3 3"
/>
</svg>
{t("medications.injectionSiteLegendRecommended")}
</li>
<li className="flex items-center gap-1.5">
<svg
viewBox="0 0 16 16"
className="h-3.5 w-3.5 shrink-0"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6"
fill="none"
className="stroke-amber-500"
strokeWidth="1.8"
/>
</svg>
{t("medications.injectionSiteLegendLastUsed")}
</li>
</ul>
</>
)}
<DialogFooter className="gap-2 sm:gap-2">