mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user