@broberg/bodymap 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/three.cjs +14 -4
- package/dist/three.cjs.map +1 -1
- package/dist/three.d.cts +4 -0
- package/dist/three.d.ts +4 -0
- package/dist/three.js +14 -4
- package/dist/three.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -112,6 +112,10 @@ import { BodyMap3D } from "@broberg/bodymap/three";
|
|
|
112
112
|
- **Same contract as 2D.** `onChange` emits the identical `PainReport`, so swapping
|
|
113
113
|
`<BodyMap>` for `<BodyMap3D>` needs zero change to your report handling. It honours
|
|
114
114
|
the per-app `config` too — a non-selectable region isn't pickable in 3D either.
|
|
115
|
+
- **Body type from a profile.** Pass `sex="male" | "female"` to run it fully
|
|
116
|
+
controlled (the parent owns it), and `showSexToggle={false}` to hide the picker —
|
|
117
|
+
common when the body type comes from the user's profile and a mid-flow toggle would
|
|
118
|
+
just be noise. Omit both to get the built-in toggle (uncontrolled).
|
|
115
119
|
- **No WebXR/VR.** This is a rotatable in-page 3D canvas, not an immersive session —
|
|
116
120
|
WebXR is intentionally out (a Capacitor webview can't host it reliably).
|
|
117
121
|
- Every control carries a `data-testid` (`bodymap3d-canvas`, `bodymap3d-sex-*`,
|
package/dist/three.cjs
CHANGED
|
@@ -255,6 +255,8 @@ function BodyMap3D(props) {
|
|
|
255
255
|
labels,
|
|
256
256
|
ui,
|
|
257
257
|
defaultSex = "male",
|
|
258
|
+
sex: sexProp,
|
|
259
|
+
showSexToggle = true,
|
|
258
260
|
onSexChange,
|
|
259
261
|
autoRotate = true,
|
|
260
262
|
className
|
|
@@ -266,7 +268,8 @@ function BodyMap3D(props) {
|
|
|
266
268
|
const loadedRef = react.useRef(null);
|
|
267
269
|
const [unsupported, setUnsupported] = react.useState(false);
|
|
268
270
|
const [ready, setReady] = react.useState(false);
|
|
269
|
-
const [
|
|
271
|
+
const [internalSex, setInternalSex] = react.useState(defaultSex);
|
|
272
|
+
const sex = sexProp ?? internalSex;
|
|
270
273
|
const [selected, setSelected] = react.useState(null);
|
|
271
274
|
const [internal, setInternal] = react.useState(defaultValue ?? []);
|
|
272
275
|
const report = value ?? internal;
|
|
@@ -284,6 +287,8 @@ function BodyMap3D(props) {
|
|
|
284
287
|
configRef.current = config;
|
|
285
288
|
const modelsRef = react.useRef(models);
|
|
286
289
|
modelsRef.current = models;
|
|
290
|
+
const sexRef = react.useRef(sex);
|
|
291
|
+
sexRef.current = sex;
|
|
287
292
|
const setSelectedRef = react.useRef(setSelected);
|
|
288
293
|
setSelectedRef.current = setSelected;
|
|
289
294
|
const setReadyRef = react.useRef(setReady);
|
|
@@ -429,7 +434,7 @@ function BodyMap3D(props) {
|
|
|
429
434
|
}
|
|
430
435
|
});
|
|
431
436
|
};
|
|
432
|
-
loadModel(
|
|
437
|
+
loadModel(sexRef.current);
|
|
433
438
|
apiRef.current = {
|
|
434
439
|
setSex: (s) => {
|
|
435
440
|
loadedRef.current?.removeAttribute("data-loaded");
|
|
@@ -521,7 +526,12 @@ function BodyMap3D(props) {
|
|
|
521
526
|
react.useEffect(() => {
|
|
522
527
|
apiRef.current?.refresh();
|
|
523
528
|
}, [selected, report, palette]);
|
|
529
|
+
const sexInited = react.useRef(false);
|
|
524
530
|
react.useEffect(() => {
|
|
531
|
+
if (!sexInited.current) {
|
|
532
|
+
sexInited.current = true;
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
525
535
|
apiRef.current?.setSex(sex);
|
|
526
536
|
}, [sex]);
|
|
527
537
|
const pointOf = (k) => report.find((p) => p.region === k);
|
|
@@ -533,13 +543,13 @@ function BodyMap3D(props) {
|
|
|
533
543
|
setSelected(null);
|
|
534
544
|
};
|
|
535
545
|
const changeSex = (s) => {
|
|
536
|
-
|
|
546
|
+
if (sexProp === void 0) setInternalSex(s);
|
|
537
547
|
onSexChange?.(s);
|
|
538
548
|
};
|
|
539
549
|
const region = selected ? REGIONS.find((r) => r.key === selected) : null;
|
|
540
550
|
const current = selected ? pointOf(selected) : void 0;
|
|
541
551
|
return /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-testid": "bodymap3d-root", className, style: { fontFamily: "system-ui, sans-serif", color: "#1e293b" }, children: [
|
|
542
|
-
/* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", gap: 16, flexWrap: "wrap", marginBottom: 12, fontSize: 13 }, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [
|
|
552
|
+
showSexToggle && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", gap: 16, flexWrap: "wrap", marginBottom: 12, fontSize: 13 }, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [
|
|
543
553
|
/* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": "bodymap3d-sex-male", onClick: () => changeSex("male"), style: seg(sex === "male"), children: UI.male }),
|
|
544
554
|
/* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": "bodymap3d-sex-female", onClick: () => changeSex("female"), style: seg(sex === "female"), children: UI.female })
|
|
545
555
|
] }) }),
|
package/dist/three.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/react.tsx","../src/three.tsx"],"names":["z","useRef","useState","useEffect","THREE","OrbitControls","GLTFLoader","jsxs","jsx"],"mappings":"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","file":"three.cjs","sourcesContent":["// @broberg/bodymap — headless core (F052.1).\n//\n// Framework-neutral: the region taxonomy + the PainReport data model (zod) + a\n// selection engine + per-app region config. NO React/Preact/DOM import — the 2D\n// (SVG) and 3D (Three.js) renderers, and all three FD apps, share this one\n// contract. Output is a structured PainReport, never a bare image.\n\nimport { z } from \"zod\";\n\nexport type Side = \"left\" | \"right\";\n\nexport interface BodyRegion {\n /** Stable, unique identifier (snake_case) — the key used in a PainReport. */\n key: string;\n /** Human label (Danish). */\n label: string;\n /** Clinical short code (unique). */\n code: string;\n /** Body side, when the region is paired. */\n side?: Side;\n}\n\n/** The canonical body regions — the AUTHORITATIVE fd-sundhed clinical taxonomy\n * (docs/BODYMAP-TAKSONOMI.md, broberg-ai/fd-sundhed @360842f): 18 SIDE-LESS\n * clinical codes + a separate `side` field (L/R on limbs, C=center on axis\n * regions). The `key` is a unique per-side identifier; `code` is the side-less\n * clinical code that goes on the bodymap/v1 wire. NOT an anatomical atlas —\n * ~30 named surface regions for a pain-map. The 2D front renderer draws the\n * front-visible subset; the 3D body (F052.6) drives them all. */\nexport const REGIONS: readonly BodyRegion[] = [\n // axis / centre-line (serialised side \"center\")\n { key: \"head\", label: \"Hoved\", code: \"HEAD\" },\n { key: \"neck\", label: \"Nakke\", code: \"NECK\" },\n { key: \"chest\", label: \"Bryst\", code: \"CHEST\" },\n { key: \"thora\", label: \"Øvre ryg (thorakal)\", code: \"THORA\" },\n { key: \"lumbar\", label: \"Lænd (lumbal)\", code: \"LUMBAR\" },\n { key: \"groin\", label: \"Lyske\", code: \"GROIN\" },\n // paired limbs / sides (L / R)\n { key: \"shoulder_left\", label: \"Skulder, venstre\", code: \"SHOULDER\", side: \"left\" },\n { key: \"shoulder_right\", label: \"Skulder, højre\", code: \"SHOULDER\", side: \"right\" },\n { key: \"uarm_left\", label: \"Overarm, venstre\", code: \"UARM\", side: \"left\" },\n { key: \"uarm_right\", label: \"Overarm, højre\", code: \"UARM\", side: \"right\" },\n { key: \"elbow_left\", label: \"Albue, venstre\", code: \"ELBOW\", side: \"left\" },\n { key: \"elbow_right\", label: \"Albue, højre\", code: \"ELBOW\", side: \"right\" },\n { key: \"farm_left\", label: \"Underarm, venstre\", code: \"FARM\", side: \"left\" },\n { key: \"farm_right\", label: \"Underarm, højre\", code: \"FARM\", side: \"right\" },\n { key: \"wrist_left\", label: \"Håndled, venstre\", code: \"WRIST\", side: \"left\" },\n { key: \"wrist_right\", label: \"Håndled, højre\", code: \"WRIST\", side: \"right\" },\n { key: \"hand_left\", label: \"Hånd, venstre\", code: \"HAND\", side: \"left\" },\n { key: \"hand_right\", label: \"Hånd, højre\", code: \"HAND\", side: \"right\" },\n { key: \"hip_left\", label: \"Hofte, venstre\", code: \"HIP\", side: \"left\" },\n { key: \"hip_right\", label: \"Hofte, højre\", code: \"HIP\", side: \"right\" },\n { key: \"thigh_left\", label: \"Lår, venstre\", code: \"THIGH\", side: \"left\" },\n { key: \"thigh_right\", label: \"Lår, højre\", code: \"THIGH\", side: \"right\" },\n { key: \"knee_left\", label: \"Knæ, venstre\", code: \"KNEE\", side: \"left\" },\n { key: \"knee_right\", label: \"Knæ, højre\", code: \"KNEE\", side: \"right\" },\n { key: \"lowleg_left\", label: \"Underben, venstre\", code: \"LOWLEG\", side: \"left\" },\n { key: \"lowleg_right\", label: \"Underben, højre\", code: \"LOWLEG\", side: \"right\" },\n { key: \"ankle_left\", label: \"Ankel, venstre\", code: \"ANKLE\", side: \"left\" },\n { key: \"ankle_right\", label: \"Ankel, højre\", code: \"ANKLE\", side: \"right\" },\n { key: \"foot_left\", label: \"Fod, venstre\", code: \"FOOT\", side: \"left\" },\n { key: \"foot_right\", label: \"Fod, højre\", code: \"FOOT\", side: \"right\" },\n];\n\nconst REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));\nexport const REGION_KEYS: readonly string[] = REGIONS.map((r) => r.key);\n\n/** Look up a region by its key. */\nexport function getRegion(key: string): BodyRegion | undefined {\n return REGIONS.find((r) => r.key === key);\n}\n\n// ---- PainReport model ---------------------------------------------------\n\nexport const PAIN_TYPES = [\"stikkende\", \"dump\", \"konstant\", \"jagende\"] as const;\nexport type PainType = (typeof PAIN_TYPES)[number];\n\n/** One marked pain point. `region` MUST be a known region key; `intensity` is a\n * 0-10 integer; `type` is optional but constrained; `timestamp` is an ISO string. */\nexport const painPointSchema = z.object({\n region: z.string().refine((k) => REGION_KEY_SET.has(k), { message: \"unknown region\" }),\n intensity: z.number().int().min(0).max(10),\n type: z.enum(PAIN_TYPES).optional(),\n timestamp: z.string(),\n});\nexport type PainPoint = z.infer<typeof painPointSchema>;\n\nexport const painReportSchema = z.array(painPointSchema);\nexport type PainReport = PainPoint[];\n\n// ---- Selection engine (framework-agnostic) ------------------------------\n\nexport interface PainSelection {\n /** Mark (or update) pain on a region. One point per region — latest wins. */\n set(region: string, intensity: number, type?: PainType): PainPoint;\n remove(region: string): boolean;\n get(region: string): PainPoint | undefined;\n has(region: string): boolean;\n clear(): void;\n /** The current, validated PainReport. */\n getReport(): PainReport;\n}\n\nexport interface PainSelectionOptions {\n /** Injectable clock (ISO string) — defaults to `new Date().toISOString()`. */\n now?: () => string;\n}\n\n/** Create a selection engine seeded with an optional report. Pure state — no\n * DOM, no framework, no network. One point per region. */\nexport function createPainSelection(\n initial: PainReport = [],\n opts: PainSelectionOptions = {},\n): PainSelection {\n const now = opts.now ?? (() => new Date().toISOString());\n const map = new Map<string, PainPoint>();\n for (const p of initial) {\n const v = painPointSchema.parse(p);\n map.set(v.region, v);\n }\n return {\n set(region, intensity, type) {\n const point = painPointSchema.parse({ region, intensity, type, timestamp: now() });\n map.set(point.region, point);\n return point;\n },\n remove: (region) => map.delete(region),\n get: (region) => map.get(region),\n has: (region) => map.has(region),\n clear: () => map.clear(),\n getReport: () => painReportSchema.parse(Array.from(map.values())),\n };\n}\n\n// ---- Per-app region config (the toggle) ---------------------------------\n\nexport interface RegionSetting {\n /** Render this region at all. Default true. */\n visible?: boolean;\n /** Allow marking pain on this region. Default true. */\n selectable?: boolean;\n}\n\n/** Per-app config keyed by region key. An absent key ⇒ visible + selectable. */\nexport type RegionConfig = Record<string, RegionSetting>;\n\n/** The regions an app should render, honouring `visible` (default true). */\nexport function resolveRegions(config: RegionConfig = {}): BodyRegion[] {\n return REGIONS.filter((r) => config[r.key]?.visible ?? true);\n}\n\n/** Whether a region may be marked. A hidden region is never selectable. */\nexport function isSelectable(key: string, config: RegionConfig = {}): boolean {\n const s = config[key];\n if (s?.visible === false) return false;\n return s?.selectable ?? true;\n}\n\n// ---- palette (consumer-defined colours — shared by the 2D + 3D renderers) ---\n\n/** Colour control for the body renderers. Consumers pass a palette to theme the\n * body base colour, the hover + selected highlights, the pain-heat colours, and\n * optional per-region base colours. All values are CSS/hex colour strings. */\nexport interface BodymapPalette {\n /** Base body colour (an unmarked region). */\n body: string;\n /** Region highlight on hover (before click). */\n hover: string;\n /** A region selected (clicked) but not yet given an intensity. */\n selected: string;\n /** Pain-intensity heat colours: low (0-3), mid (4-6), high (7-10). */\n heat: { low: string; mid: string; high: string };\n /** Optional per-region base-colour overrides (region key → colour). */\n regions?: Record<string, string>;\n}\n\n/** The fleet default palette. Override any field per consumer. */\nexport const defaultPalette: BodymapPalette = {\n body: \"#d2d7de\",\n hover: \"#8fd0cd\",\n selected: \"#5cc4b7\",\n heat: { low: \"#fcd34d\", mid: \"#fb923c\", high: \"#ef4444\" },\n};\n\n/** The heat colour for a pain intensity, honouring the palette. */\nexport function heatFor(intensity: number, palette: BodymapPalette = defaultPalette): string {\n return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;\n}\n\n/** The base colour for a region (a per-region override, else the body colour). */\nexport function baseColorFor(regionKey: string, palette: BodymapPalette = defaultPalette): string {\n return palette.regions?.[regionKey] ?? palette.body;\n}\n\n// ---- bodymap/v1 serialization (the shared cross-app / native wire format) ---\n//\n// The shape every consumer + the native mobile apps read (aligned with\n// fd-sundhed's bodymap/v1: region CODE + side + intensity + quality + view).\n// The internal PainReport keys on the region KEY; this maps key -> clinical CODE\n// so the report is portable and human-readable on the wire.\n\nexport type BodyView = \"front\" | \"back\" | \"left\" | \"right\";\n/** Side in the serialized report — a midline region (no side) becomes \"center\". */\nexport type SerializedSide = \"left\" | \"right\" | \"center\";\n\nexport interface SerializedPainPoint {\n /** Clinical region CODE (e.g. \"LUMB\"). */\n region: string;\n side: SerializedSide;\n intensity: number;\n quality?: PainType;\n}\n\nexport interface BodymapReportV1 {\n schema: \"bodymap/v1\";\n view: BodyView;\n points: SerializedPainPoint[];\n}\n\nexport const bodymapReportV1Schema = z.object({\n schema: z.literal(\"bodymap/v1\"),\n view: z.enum([\"front\", \"back\", \"left\", \"right\"]),\n points: z.array(\n z.object({\n region: z.string(),\n side: z.enum([\"left\", \"right\", \"center\"]),\n intensity: z.number().int().min(0).max(10),\n quality: z.enum(PAIN_TYPES).optional(),\n }),\n ),\n});\n\n/** Serialize a PainReport to the shared `bodymap/v1` wire format. */\nexport function serializeReport(\n report: PainReport,\n opts: { view?: BodyView } = {},\n): BodymapReportV1 {\n return {\n schema: \"bodymap/v1\",\n view: opts.view ?? \"front\",\n points: report.map((p) => {\n const r = getRegion(p.region);\n return {\n region: r?.code ?? p.region,\n side: (r?.side ?? \"center\") as SerializedSide,\n intensity: p.intensity,\n quality: p.type,\n };\n }),\n };\n}\n\n/** Parse a `bodymap/v1` report back into an internal PainReport. Region CODE →\n * key; a point whose code is unknown to this taxonomy is dropped. */\nexport function deserializeReport(\n env: unknown,\n now: () => string = () => new Date().toISOString(),\n): PainReport {\n const parsed = bodymapReportV1Schema.parse(env);\n // code is side-less, so a point is identified by code + side.\n const byCodeSide = new Map(\n REGIONS.map((r) => [`${r.code}|${r.side ?? \"center\"}`, r.key] as const),\n );\n const out: PainReport = [];\n for (const sp of parsed.points) {\n const key = byCodeSide.get(`${sp.region}|${sp.side}`);\n if (!key) continue;\n out.push(\n painPointSchema.parse({\n region: key,\n intensity: sp.intensity,\n type: sp.quality,\n timestamp: now(),\n }),\n );\n }\n return out;\n}\n","// @broberg/bodymap/react — the 2D SVG renderer (F052.2).\n//\n// <BodyMap> is the shippable MVP: a genderless, colour-blocked body a patient\n// clicks to mark where it hurts. Selecting a region opens a custom intensity\n// (0-10) + quality control (NO native inputs) that upserts a validated\n// PainReport and fires onChange. Honours the per-app RegionConfig. The 3D\n// <BodyMap3D> (F052.6) will render the SAME core on the SAME onChange contract.\n//\n// F052.8 touch-first + F052.11 a11y: the body is PRIMARILY a mobile surface, so\n// every region has a >=44px transparent hit-area, the body pinch-zooms + pans\n// (with a tap-vs-drag guard so a gesture never mis-marks), controls are\n// thumb-sized and the layout stacks on a narrow viewport. Everything is also\n// fully keyboard-operable with live-state ARIA labels + visible focus.\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n REGIONS,\n PAIN_TYPES,\n painPointSchema,\n resolveRegions,\n isSelectable,\n getRegion,\n heatFor,\n baseColorFor,\n type BodyRegion,\n type BodymapPalette,\n type PainReport,\n type PainType,\n type RegionConfig,\n} from \"./index.js\";\n\n// ---- geometry: each region → an SVG shape (a neutral colour-blockout) --------\n\ntype Shape =\n | { el: \"circle\"; cx: number; cy: number; r: number }\n | { el: \"ellipse\"; cx: number; cy: number; rx: number; ry: number }\n | { el: \"rect\"; x: number; y: number; width: number; height: number; rx: number };\n\n// Front-view geometry (viewBox 260×470). The 2D MVP renders the front-visible\n// regions; back-only regions (thora, lumbar) + hip are driven by the 3D body /\n// a later back view. \"left\"/\"right\" are placed self-view (viewer's left = key\n// left) for the MVP — the facing-the-patient mirror is a later config option.\nconst SHAPES: Record<string, Shape> = {\n head: { el: \"circle\", cx: 130, cy: 40, r: 26 },\n neck: { el: \"rect\", x: 120, y: 62, width: 20, height: 16, rx: 6 },\n chest: { el: \"rect\", x: 100, y: 84, width: 60, height: 104, rx: 16 },\n groin: { el: \"rect\", x: 106, y: 190, width: 48, height: 30, rx: 13 },\n // left arm (viewer-left)\n shoulder_left: { el: \"circle\", cx: 88, cy: 98, r: 14 },\n uarm_left: { el: \"rect\", x: 66, y: 104, width: 18, height: 54, rx: 9 },\n elbow_left: { el: \"circle\", cx: 73, cy: 164, r: 10 },\n farm_left: { el: \"rect\", x: 62, y: 174, width: 16, height: 50, rx: 8 },\n wrist_left: { el: \"circle\", cx: 68, cy: 230, r: 8 },\n hand_left: { el: \"ellipse\", cx: 67, cy: 250, rx: 11, ry: 13 },\n // right arm (viewer-right)\n shoulder_right: { el: \"circle\", cx: 172, cy: 98, r: 14 },\n uarm_right: { el: \"rect\", x: 176, y: 104, width: 18, height: 54, rx: 9 },\n elbow_right: { el: \"circle\", cx: 187, cy: 164, r: 10 },\n farm_right: { el: \"rect\", x: 182, y: 174, width: 16, height: 50, rx: 8 },\n wrist_right: { el: \"circle\", cx: 192, cy: 230, r: 8 },\n hand_right: { el: \"ellipse\", cx: 193, cy: 250, rx: 11, ry: 13 },\n // legs\n thigh_left: { el: \"rect\", x: 104, y: 224, width: 22, height: 80, rx: 11 },\n thigh_right: { el: \"rect\", x: 134, y: 224, width: 22, height: 80, rx: 11 },\n knee_left: { el: \"circle\", cx: 115, cy: 312, r: 13 },\n knee_right: { el: \"circle\", cx: 145, cy: 312, r: 13 },\n lowleg_left: { el: \"rect\", x: 106, y: 322, width: 18, height: 74, rx: 9 },\n lowleg_right: { el: \"rect\", x: 136, y: 322, width: 18, height: 74, rx: 9 },\n ankle_left: { el: \"circle\", cx: 115, cy: 404, r: 9 },\n ankle_right: { el: \"circle\", cx: 145, cy: 404, r: 9 },\n foot_left: { el: \"ellipse\", cx: 115, cy: 424, rx: 14, ry: 10 },\n foot_right: { el: \"ellipse\", cx: 145, cy: 424, rx: 14, ry: 10 },\n};\n\nconst FILL: Record<string, string> = {\n head: \"#cbb7ec\", neck: \"#b8a3e6\", chest: \"#e26d6d\", groin: \"#e88a52\",\n shoulder_left: \"#f6c667\", shoulder_right: \"#f6c667\",\n uarm_left: \"#7ececb\", uarm_right: \"#7ececb\",\n elbow_left: \"#86cfcc\", elbow_right: \"#86cfcc\",\n farm_left: \"#8fd0cd\", farm_right: \"#8fd0cd\",\n wrist_left: \"#9ad6d3\", wrist_right: \"#9ad6d3\",\n hand_left: \"#a7ddda\", hand_right: \"#a7ddda\",\n thigh_left: \"#7fb2e8\", thigh_right: \"#7fb2e8\",\n knee_left: \"#c79be0\", knee_right: \"#c79be0\",\n lowleg_left: \"#e39ec6\", lowleg_right: \"#e39ec6\",\n ankle_left: \"#eeaab9\", ankle_right: \"#eeaab9\",\n foot_left: \"#f0a5a5\", foot_right: \"#f0a5a5\",\n};\n\n// Back-view geometry (same viewBox 260×470 + same limb/head geometry as the\n// front, so a marked limb sits at the same spot in both views). The torso swaps\n// front regions (chest, groin) for the posterior ones — THORA (upper back),\n// LUMBAR (lower back) and HIP (buttocks, paired) — the pilot's blocker regions.\n// Same self-view L/R convention as the front (patient-left = viewer-left).\nconst SHAPES_BACK: Record<string, Shape> = {\n head: { el: \"circle\", cx: 130, cy: 40, r: 26 },\n neck: { el: \"rect\", x: 120, y: 62, width: 20, height: 16, rx: 6 },\n thora: { el: \"rect\", x: 100, y: 84, width: 60, height: 58, rx: 15 },\n lumbar: { el: \"rect\", x: 104, y: 144, width: 52, height: 44, rx: 13 },\n hip_left: { el: \"rect\", x: 100, y: 190, width: 27, height: 32, rx: 13 },\n hip_right: { el: \"rect\", x: 133, y: 190, width: 27, height: 32, rx: 13 },\n // arms — shared with the front (back of the arm sits at the same x/y)\n shoulder_left: { el: \"circle\", cx: 88, cy: 98, r: 14 },\n uarm_left: { el: \"rect\", x: 66, y: 104, width: 18, height: 54, rx: 9 },\n elbow_left: { el: \"circle\", cx: 73, cy: 164, r: 10 },\n farm_left: { el: \"rect\", x: 62, y: 174, width: 16, height: 50, rx: 8 },\n wrist_left: { el: \"circle\", cx: 68, cy: 230, r: 8 },\n hand_left: { el: \"ellipse\", cx: 67, cy: 250, rx: 11, ry: 13 },\n shoulder_right: { el: \"circle\", cx: 172, cy: 98, r: 14 },\n uarm_right: { el: \"rect\", x: 176, y: 104, width: 18, height: 54, rx: 9 },\n elbow_right: { el: \"circle\", cx: 187, cy: 164, r: 10 },\n farm_right: { el: \"rect\", x: 182, y: 174, width: 16, height: 50, rx: 8 },\n wrist_right: { el: \"circle\", cx: 192, cy: 230, r: 8 },\n hand_right: { el: \"ellipse\", cx: 193, cy: 250, rx: 11, ry: 13 },\n // legs — back (thigh→calf→heel, same x/y as the front)\n thigh_left: { el: \"rect\", x: 104, y: 224, width: 22, height: 80, rx: 11 },\n thigh_right: { el: \"rect\", x: 134, y: 224, width: 22, height: 80, rx: 11 },\n knee_left: { el: \"circle\", cx: 115, cy: 312, r: 13 },\n knee_right: { el: \"circle\", cx: 145, cy: 312, r: 13 },\n lowleg_left: { el: \"rect\", x: 106, y: 322, width: 18, height: 74, rx: 9 },\n lowleg_right: { el: \"rect\", x: 136, y: 322, width: 18, height: 74, rx: 9 },\n ankle_left: { el: \"circle\", cx: 115, cy: 404, r: 9 },\n ankle_right: { el: \"circle\", cx: 145, cy: 404, r: 9 },\n foot_left: { el: \"ellipse\", cx: 115, cy: 424, rx: 14, ry: 10 },\n foot_right: { el: \"ellipse\", cx: 145, cy: 424, rx: 14, ry: 10 },\n};\n\nconst FILL_BACK: Record<string, string> = {\n ...FILL,\n thora: \"#e2896d\",\n lumbar: \"#d97fa0\",\n hip_left: \"#c99bd6\", hip_right: \"#c99bd6\",\n};\n\nexport type BodyView2D = \"front\" | \"back\";\n\n// ---- i18n (F052.12) — every UI string is overridable; da + en ship ----------\n// Region CODE + keys + the bodymap/v1 wire are NEVER touched — labels are\n// display-only, so a report is byte-identical in any language.\n\nexport interface BodyMapLabels {\n /** Region key → display name. */\n regions: Record<string, string>;\n /** PainType → display word (e.g. \"stikkende\" → \"Stabbing\"). */\n qualities: Record<string, string>;\n intensity: string;\n quality: string;\n remove: string;\n front: string;\n back: string;\n empty: string;\n /** Compare view (F052.13). */\n before: string;\n after: string;\n noChange: string;\n change: { new: string; resolved: string; improved: string; worse: string };\n /** Accessible name for a marked region. */\n ariaMarked: (name: string, intensity: number, quality?: string) => string;\n /** Accessible name for an unmarked, selectable region. */\n ariaUnmarked: (name: string) => string;\n svgLabel: string;\n viewLabel: string;\n zoomLabel: string;\n zoomIn: string;\n zoomOut: string;\n zoomReset: string;\n}\n\nconst daRegions: Record<string, string> = Object.fromEntries(REGIONS.map((r) => [r.key, r.label]));\nconst EN_CODE: Record<string, string> = {\n HEAD: \"Head\", NECK: \"Neck\", CHEST: \"Chest\", THORA: \"Upper back\", LUMBAR: \"Lower back\", GROIN: \"Groin\",\n SHOULDER: \"Shoulder\", UARM: \"Upper arm\", ELBOW: \"Elbow\", FARM: \"Forearm\", WRIST: \"Wrist\", HAND: \"Hand\",\n HIP: \"Hip\", THIGH: \"Thigh\", KNEE: \"Knee\", LOWLEG: \"Lower leg\", ANKLE: \"Ankle\", FOOT: \"Foot\",\n};\nconst enRegions: Record<string, string> = Object.fromEntries(\n REGIONS.map((r) => [r.key, EN_CODE[r.code] + (r.side ? (r.side === \"left\" ? \", left\" : \", right\") : \"\")]),\n);\n\nexport const LABELS_DA: BodyMapLabels = {\n regions: daRegions,\n qualities: { stikkende: \"stikkende\", dump: \"dump\", konstant: \"konstant\", jagende: \"jagende\" },\n intensity: \"Intensitet (0-10)\",\n quality: \"Kvalitet\",\n remove: \"Fjern punkt\",\n front: \"Forfra\",\n back: \"Bagfra\",\n empty: \"Vælg en kropsdel for at markere smerte.\",\n before: \"Før\",\n after: \"Efter\",\n noChange: \"Ingen ændring\",\n change: { new: \"nyt\", resolved: \"forsvundet\", improved: \"bedre\", worse: \"værre\" },\n ariaMarked: (n, i, q) => `${n}, smerte ${i} af 10${q ? \", \" + q : \"\"}`,\n ariaUnmarked: (n) => `${n}, ikke markeret. Aktivér for at markere smerte.`,\n svgLabel: \"Kropskort — vælg hvor det gør ondt\",\n viewLabel: \"Visning\",\n zoomLabel: \"Zoom\",\n zoomIn: \"Zoom ind\",\n zoomOut: \"Zoom ud\",\n zoomReset: \"Nulstil zoom\",\n};\n\nexport const LABELS_EN: BodyMapLabels = {\n regions: enRegions,\n qualities: { stikkende: \"stabbing\", dump: \"dull\", konstant: \"constant\", jagende: \"shooting\" },\n intensity: \"Intensity (0-10)\",\n quality: \"Quality\",\n remove: \"Remove point\",\n front: \"Front\",\n back: \"Back\",\n empty: \"Pick a body part to mark pain.\",\n before: \"Before\",\n after: \"After\",\n noChange: \"No change\",\n change: { new: \"new\", resolved: \"resolved\", improved: \"improved\", worse: \"worse\" },\n ariaMarked: (n, i, q) => `${n}, pain ${i} of 10${q ? \", \" + q : \"\"}`,\n ariaUnmarked: (n) => `${n}, not marked. Activate to mark pain.`,\n svgLabel: \"Body map — pick where it hurts\",\n viewLabel: \"View\",\n zoomLabel: \"Zoom\",\n zoomIn: \"Zoom in\",\n zoomOut: \"Zoom out\",\n zoomReset: \"Reset zoom\",\n};\n\nexport type BodyMapLocale = \"da\" | \"en\";\n\nfunction resolveLabels(locale: BodyMapLocale, overrides?: Partial<BodyMapLabels>): BodyMapLabels {\n const base = locale === \"en\" ? LABELS_EN : LABELS_DA;\n if (!overrides) return base;\n return {\n ...base,\n ...overrides,\n regions: { ...base.regions, ...overrides.regions },\n qualities: { ...base.qualities, ...overrides.qualities },\n };\n}\n\nconst VBW = 260;\nconst VBH = 470;\n// The SVG renders ~320px wide → ~1.23 px per viewBox unit. A hit-area needs\n// ~36 units to clear the 44px touch minimum; small regions get an invisible\n// padded hit-shape at least this big (zoom handles genuinely dense clusters).\nconst MIN_HIT = 18; // half-extent (radius) in viewBox units\n\nfunction center(s: Shape): { x: number; y: number } {\n if (s.el === \"rect\") return { x: s.x + s.width / 2, y: s.y + s.height / 2 };\n return { x: s.cx, y: s.cy };\n}\n// An enlarged, invisible hit target so small regions clear the 44px touch min.\nfunction hitShape(s: Shape): Shape {\n if (s.el === \"circle\") return { el: \"circle\", cx: s.cx, cy: s.cy, r: Math.max(s.r, MIN_HIT) };\n if (s.el === \"ellipse\")\n return { el: \"ellipse\", cx: s.cx, cy: s.cy, rx: Math.max(s.rx, MIN_HIT), ry: Math.max(s.ry, MIN_HIT) };\n const c = center(s);\n const w = Math.max(s.width, MIN_HIT * 2);\n const h = Math.max(s.height, MIN_HIT * 2);\n return { el: \"rect\", x: c.x - w / 2, y: c.y - h / 2, width: w, height: h, rx: s.rx };\n}\nfunction heat(v: number): string {\n return v >= 7 ? \"#ef4444\" : v >= 4 ? \"#fb923c\" : \"#fcd34d\";\n}\nfunction heatTier(v: number): \"low\" | \"mid\" | \"high\" {\n return v >= 7 ? \"high\" : v >= 4 ? \"mid\" : \"low\";\n}\n// F052.10 palette: a `palette` prop wins (direct colour); with no prop, a CSS-var\n// chain (--bmap-region-<key> → --bmap-body) themes with the default rainbow as\n// the fallback. Marked regions likewise: palette.heat or --bmap-heat-<tier>.\nfunction baseFill(key: string, rainbow: string, palette?: BodymapPalette): string {\n return palette ? baseColorFor(key, palette) : `var(--bmap-region-${key}, var(--bmap-body, ${rainbow}))`;\n}\nfunction markedFill(v: number, palette?: BodymapPalette): string {\n return palette ? heatFor(v, palette) : `var(--bmap-heat-${heatTier(v)}, ${heat(v)})`;\n}\nfunction shapeEl(s: Shape, props: Record<string, unknown>) {\n if (s.el === \"circle\") return <circle cx={s.cx} cy={s.cy} r={s.r} {...props} />;\n if (s.el === \"ellipse\") return <ellipse cx={s.cx} cy={s.cy} rx={s.rx} ry={s.ry} {...props} />;\n return <rect x={s.x} y={s.y} width={s.width} height={s.height} rx={s.rx} {...props} />;\n}\n\n// ---- one-time style injection (no external CSS; theme via CSS variables) -----\n\nconst STYLE_ID = \"broberg-bodymap-styles\";\nconst STYLE = `\n.bmap{--bmap-accent:var(--primary,#0e8f8a);--bmap-line:#e2e8f0;--bmap-panel:#fff;--bmap-ink:#1e293b;--bmap-muted:#64748b;\n display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--bmap-ink)}\n.bmap__stage{display:flex;flex-direction:column;gap:10px;flex:0 1 340px;min-width:0;max-width:360px}\n.bmap__bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}\n.bmap__viewtoggle{display:inline-flex;background:#f1f5f9;border:1px solid var(--bmap-line);border-radius:10px;padding:3px;gap:2px}\n.bmap__vbtn{font:inherit;font-size:13px;font-weight:600;color:#556274;background:none;border:0;border-radius:7px;padding:8px 16px;cursor:pointer;transition:color .12s,background .12s,transform .1s}\n.bmap__vbtn:hover{color:var(--bmap-ink)}\n.bmap__vbtn:active{transform:scale(.97)}\n.bmap__vbtn--on{background:#fff;color:var(--bmap-ink);box-shadow:0 1px 2px rgba(15,23,42,.14)}\n.bmap__zoom{display:inline-flex;gap:4px;margin-left:auto}\n.bmap__zoom button{font:inherit;font-weight:700;font-size:16px;line-height:1;width:36px;height:36px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-ink);border-radius:8px;cursor:pointer;transition:border-color .12s,transform .1s;display:flex;align-items:center;justify-content:center}\n.bmap__zoom button:hover{border-color:var(--bmap-accent)}\n.bmap__zoom button:active{transform:scale(.9)}\n.bmap__zoom button:disabled{opacity:.4;cursor:default}\n.bmap__svg{width:100%;height:auto;display:block;touch-action:none;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;background:transparent;border-radius:14px}\n.bmap__vis{stroke:#fff;stroke-width:2.4;pointer-events:none;transition:filter .12s,stroke .12s,stroke-width .12s}\n.bmap__vis--sel{stroke:var(--bmap-accent);stroke-width:3.6}\n.bmap__hit{fill:transparent;cursor:pointer;outline:none}\n.bmap__hit--locked{cursor:default}\n.bmap__hit:hover + .bmap__vis{filter:brightness(1.08) saturate(1.15)}\n.bmap__hit:focus-visible + .bmap__vis{stroke:var(--bmap-accent);stroke-width:4;stroke-dasharray:4 2.5}\n.bmap__heatn{font:700 12px ui-sans-serif;fill:#fff;pointer-events:none}\n.bmap__panel{flex:1 1 240px;min-width:220px;border:1px solid var(--bmap-line);border-radius:14px;padding:16px 18px;background:var(--bmap-panel)}\n.bmap__panel--empty{color:var(--bmap-muted);font-size:13.5px}\n.bmap__ph{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px}\n.bmap__ph b{font-size:16px}\n.bmap__code{font:11px ui-monospace,monospace;color:var(--bmap-muted);background:#f1f5f9;border-radius:6px;padding:2px 7px}\n.bmap__lbl{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--bmap-muted);margin:0 0 7px}\n.bmap__scale{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}\n.bmap__i{font:inherit;font-size:14px;font-weight:600;width:38px;height:38px;border-radius:9px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-ink);cursor:pointer;transition:border-color .1s,background .1s,transform .1s}\n.bmap__i:hover{border-color:var(--bmap-accent)}\n.bmap__i:active{transform:scale(.9)}\n.bmap__i--on{background:var(--bmap-accent);border-color:var(--bmap-accent);color:#fff}\n.bmap__chips{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:14px}\n.bmap__chip{font:inherit;font-size:13px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-muted);border-radius:999px;padding:9px 15px;cursor:pointer;transition:.1s}\n.bmap__chip:hover{border-color:var(--bmap-accent);color:var(--bmap-accent)}\n.bmap__chip:active{transform:scale(.96)}\n.bmap__chip--on{background:var(--bmap-ink);border-color:var(--bmap-ink);color:#fff}\n.bmap__rm{font:inherit;font-size:14px;font-weight:600;border:1px solid #f6c9c9;background:#fff;color:#ef4444;border-radius:9px;padding:10px 15px;cursor:pointer;transition:.1s}\n.bmap__rm:hover{background:#fef2f2}\n.bmap__rm:active{transform:scale(.97)}\n.bmap__i:focus-visible,.bmap__chip:focus-visible,.bmap__vbtn:focus-visible,.bmap__rm:focus-visible,.bmap__zoom button:focus-visible{outline:2px solid var(--bmap-accent);outline-offset:2px}\n.bmapc{--bmap-accent:var(--primary,#0e8f8a);--bmap-line:#e2e8f0;--bmap-ink:#1e293b;--bmap-muted:#64748b;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--bmap-ink)}\n.bmapc__bodies{display:flex;gap:14px;flex-wrap:wrap}\n.bmapc__fig{flex:1 1 150px;min-width:130px;max-width:220px;margin:0;text-align:center}\n.bmapc__cap{font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#556274;margin-bottom:6px}\n.bmapc__svg{width:100%;height:auto;display:block}\n.bmapc__vis{stroke:#fff;stroke-width:2.4}\n.bmapc__n{font:700 12px ui-sans-serif;fill:#fff;pointer-events:none}\n.bmapc__delta{margin-top:16px;display:flex;flex-direction:column;gap:6px;max-width:460px}\n.bmapc__row{display:flex;align-items:center;gap:10px;font-size:13.5px;border:1px solid var(--bmap-line);border-radius:9px;padding:7px 11px}\n.bmapc__rname{font-weight:600;flex:1 1 auto}\n.bmapc__rval{font:12px ui-monospace,monospace;color:#556274;white-space:nowrap}\n.bmapc__badge{font-size:11px;font-weight:700;border-radius:6px;padding:2px 8px;white-space:nowrap}\n.bmapc__badge--improved{background:#dcfce7;color:#166534}\n.bmapc__badge--worse{background:#fee2e2;color:#991b1b}\n.bmapc__badge--new{background:#fef3c7;color:#92400e}\n.bmapc__badge--resolved{background:#e2e8f0;color:#475569}\n.bmapc__empty{color:#556274;font-size:13.5px;margin-top:14px}\n@media (max-width:560px){\n .bmap{flex-direction:column;gap:14px}\n .bmap__stage{max-width:100%;width:100%;flex-basis:auto}\n .bmap__panel{width:100%}\n .bmap__i{width:46px;height:46px;font-size:16px}\n .bmap__chip{padding:11px 17px;font-size:15px}\n .bmap__vbtn{padding:10px 18px;font-size:15px}\n}\n@media (prefers-reduced-motion:reduce){.bmap *{transition:none !important}}\n`;\n\nfunction ensureStyles(): void {\n if (typeof document === \"undefined\") return;\n if (document.getElementById(STYLE_ID)) return;\n const el = document.createElement(\"style\");\n el.id = STYLE_ID;\n el.textContent = STYLE;\n document.head.appendChild(el);\n}\n\n// ---- component ---------------------------------------------------------------\n\nexport interface BodyMapProps {\n /** Controlled report. Omit for an uncontrolled component (use defaultValue). */\n value?: PainReport;\n /** Initial report when uncontrolled. */\n defaultValue?: PainReport;\n /** Fired with the full validated PainReport on every change. */\n onChange?: (report: PainReport) => void;\n /** Per-app region config (visible / selectable). */\n config?: RegionConfig;\n /** Initial body view (front / back). Default \"front\". */\n defaultView?: BodyView2D;\n /** Fired when the user toggles the front/back view — pass it to\n * serializeReport({ view }) so the wire report carries the active view. */\n onViewChange?: (view: BodyView2D) => void;\n /** Colour theme (F052.10). Overrides the default rainbow: body/hover/selected/\n * heat + optional per-region base colours. Without it, the rainbow is used\n * (themeable via --bmap-body / --bmap-region-<key> / --bmap-heat-* CSS vars). */\n palette?: BodymapPalette;\n /** UI language (F052.12). \"da\" (default) or \"en\". */\n locale?: BodyMapLocale;\n /** Override individual UI strings (region names, headings, quality words, …).\n * Merged over the locale set — region CODE + wire format are never affected. */\n labels?: Partial<BodyMapLabels>;\n /** Display-only mode (F052.9): render a saved report coloured by intensity\n * with NO picker and no interactivity. For journals, PDFs, clinician views. */\n readOnly?: boolean;\n className?: string;\n}\n\nconst ZMIN = 1;\nconst ZMAX = 4;\nconst clampPan = (p: { x: number; y: number }, z: number) => ({\n x: Math.min(0, Math.max(VBW * (1 - z), p.x)),\n y: Math.min(0, Math.max(VBH * (1 - z), p.y)),\n});\n\n/** 2D SVG body pain-map. Click/tap a region → set intensity + quality → PainReport. */\nexport function BodyMap({\n value, defaultValue, onChange, config = {}, defaultView = \"front\", onViewChange,\n palette, locale = \"da\", labels, readOnly = false, className,\n}: BodyMapProps) {\n useEffect(ensureStyles, []);\n const [internal, setInternal] = useState<PainReport>(defaultValue ?? []);\n const [selected, setSelected] = useState<string | null>(null);\n const [view, setView] = useState<BodyView2D>(defaultView);\n const [zoom, setZoom] = useState(1);\n const [pan, setPan] = useState({ x: 0, y: 0 });\n const report = value ?? internal;\n const shapes = view === \"back\" ? SHAPES_BACK : SHAPES;\n const fills = view === \"back\" ? FILL_BACK : FILL;\n const L = resolveLabels(locale, labels);\n const nameOf = (key: string) => L.regions[key] ?? getRegion(key)?.label ?? key;\n\n const svgRef = useRef<SVGSVGElement>(null);\n const zoomRef = useRef(1);\n const panRef = useRef({ x: 0, y: 0 });\n const pointers = useRef(new Map<number, { x: number; y: number }>());\n const gesture = useRef({ startDist: 0, startZoom: 1, midVB: { x: 0, y: 0 }, start: { x: 0, y: 0 }, moved: false });\n const suppressClick = useRef(false);\n\n const setZP = (z: number, p: { x: number; y: number }) => {\n const cz = Math.max(ZMIN, Math.min(ZMAX, z));\n const cp = clampPan(p, cz);\n zoomRef.current = cz; panRef.current = cp;\n setZoom(cz); setPan(cp);\n };\n const screenToVB = (cx: number, cy: number) => {\n const el = svgRef.current;\n if (!el) return { x: VBW / 2, y: VBH / 2 };\n const r = el.getBoundingClientRect();\n if (!r.width || !r.height) return { x: VBW / 2, y: VBH / 2 };\n return { x: ((cx - r.left) / r.width) * VBW, y: ((cy - r.top) / r.height) * VBH };\n };\n // Zoom keeping the given viewBox focal point under the cursor/fingers.\n const zoomTo = (nz: number, focalVB: { x: number; y: number }) => {\n const z = Math.max(ZMIN, Math.min(ZMAX, nz));\n const p = panRef.current;\n setZP(z, { x: focalVB.x - (focalVB.x - p.x) * (z / zoomRef.current), y: focalVB.y - (focalVB.y - p.y) * (z / zoomRef.current) });\n };\n const zoomBtn = (factor: number) => zoomTo(zoomRef.current * factor, { x: VBW / 2, y: VBH / 2 });\n const resetZoom = () => setZP(1, { x: 0, y: 0 });\n\n const onPointerDown = (e: React.PointerEvent) => {\n pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });\n const pts = [...pointers.current.values()];\n if (pts.length === 2) {\n gesture.current.startDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);\n gesture.current.startZoom = zoomRef.current;\n gesture.current.midVB = screenToVB((pts[0].x + pts[1].x) / 2, (pts[0].y + pts[1].y) / 2);\n gesture.current.moved = true;\n suppressClick.current = true;\n } else {\n gesture.current.start = { x: e.clientX, y: e.clientY };\n gesture.current.moved = false;\n }\n };\n const onPointerMove = (e: React.PointerEvent) => {\n const prev = pointers.current.get(e.pointerId);\n if (!prev) return;\n pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });\n const pts = [...pointers.current.values()];\n if (pts.length >= 2) {\n const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);\n if (gesture.current.startDist > 0) zoomTo(gesture.current.startZoom * (dist / gesture.current.startDist), gesture.current.midVB);\n return;\n }\n const el = svgRef.current;\n const rect = el?.getBoundingClientRect();\n if (Math.hypot(e.clientX - gesture.current.start.x, e.clientY - gesture.current.start.y) > 6) {\n gesture.current.moved = true;\n suppressClick.current = true;\n }\n if (zoomRef.current > 1 && rect && rect.width) {\n const dxVB = ((e.clientX - prev.x) / rect.width) * VBW;\n const dyVB = ((e.clientY - prev.y) / rect.height) * VBH;\n setZP(zoomRef.current, { x: panRef.current.x + dxVB, y: panRef.current.y + dyVB });\n }\n };\n const onPointerUp = (e: React.PointerEvent) => {\n pointers.current.delete(e.pointerId);\n if (pointers.current.size === 0) window.setTimeout(() => { suppressClick.current = false; }, 60);\n };\n\n const changeView = (v: BodyView2D) => {\n if (v === view) return;\n setView(v);\n const sh = v === \"back\" ? SHAPES_BACK : SHAPES;\n if (selected && !sh[selected]) setSelected(null); // selected region absent in the new view\n onViewChange?.(v);\n };\n\n const commit = (next: PainReport) => {\n if (value === undefined) setInternal(next);\n onChange?.(next);\n };\n const pointOf = (key: string) => report.find((p) => p.region === key);\n const setPain = (key: string, intensity: number, type?: PainType) => {\n const point = painPointSchema.parse({ region: key, intensity, type, timestamp: new Date().toISOString() });\n commit([...report.filter((p) => p.region !== key), point]);\n };\n const removePain = (key: string) => {\n commit(report.filter((p) => p.region !== key));\n setSelected(null);\n };\n const pickRegion = (key: string) => {\n if (suppressClick.current) { suppressClick.current = false; return; } // a pan/pinch just ended — not a tap\n setSelected(key);\n };\n\n const regions = resolveRegions(config);\n const region: BodyRegion | undefined = selected ? REGIONS.find((r) => r.key === selected) : undefined;\n const current = selected ? pointOf(selected) : undefined;\n const rootCls = [\"bmap\", className].filter(Boolean).join(\" \");\n // palette.selected drives the focus/selected stroke via --bmap-accent.\n const rootStyle = palette ? ({ \"--bmap-accent\": palette.selected } as React.CSSProperties) : undefined;\n const ariaFor = (r: BodyRegion, marked?: PainReport[number], interactive = true): string => {\n if (marked) return L.ariaMarked(nameOf(r.key), marked.intensity, marked.type ? L.qualities[marked.type] : undefined);\n return interactive ? L.ariaUnmarked(nameOf(r.key)) : nameOf(r.key);\n };\n\n return (\n <div className={rootCls} data-testid=\"bodymap-root\" style={rootStyle}>\n <div className=\"bmap__stage\">\n <div className=\"bmap__bar\">\n <div className=\"bmap__viewtoggle\" role=\"group\" aria-label={L.viewLabel}>\n <button\n type=\"button\"\n className={\"bmap__vbtn\" + (view === \"front\" ? \" bmap__vbtn--on\" : \"\")}\n data-testid=\"bodymap-view-front\"\n aria-pressed={view === \"front\"}\n onClick={() => changeView(\"front\")}\n >\n {L.front}\n </button>\n <button\n type=\"button\"\n className={\"bmap__vbtn\" + (view === \"back\" ? \" bmap__vbtn--on\" : \"\")}\n data-testid=\"bodymap-view-back\"\n aria-pressed={view === \"back\"}\n onClick={() => changeView(\"back\")}\n >\n {L.back}\n </button>\n </div>\n <div className=\"bmap__zoom\" role=\"group\" aria-label={L.zoomLabel}>\n <button type=\"button\" data-testid=\"bodymap-zoom-out\" aria-label={L.zoomOut} disabled={zoom <= ZMIN} onClick={() => zoomBtn(1 / 1.4)}>−</button>\n <button type=\"button\" data-testid=\"bodymap-zoom-reset\" aria-label={L.zoomReset} disabled={zoom === 1 && pan.x === 0 && pan.y === 0} onClick={resetZoom}>⤢</button>\n <button type=\"button\" data-testid=\"bodymap-zoom-in\" aria-label={L.zoomIn} disabled={zoom >= ZMAX} onClick={() => zoomBtn(1.4)}>+</button>\n </div>\n </div>\n <svg\n ref={svgRef}\n className=\"bmap__svg\"\n viewBox=\"0 0 260 470\"\n role=\"group\"\n aria-label={L.svgLabel}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n onPointerCancel={onPointerUp}\n onWheel={(e) => zoomTo(zoomRef.current * (e.deltaY < 0 ? 1.12 : 1 / 1.12), screenToVB(e.clientX, e.clientY))}\n >\n <g transform={`translate(${pan.x} ${pan.y}) scale(${zoom})`}>\n {regions.map((r) => {\n const s = shapes[r.key];\n if (!s) return null;\n const marked = pointOf(r.key);\n const interactive = isSelectable(r.key, config) && !readOnly;\n const visCls = [\"bmap__vis\", selected === r.key && \"bmap__vis--sel\"].filter(Boolean).join(\" \");\n const fill = marked ? markedFill(marked.intensity, palette) : baseFill(r.key, fills[r.key] ?? \"#cbd5e1\", palette);\n const hs = hitShape(s);\n return (\n <g key={r.key}>\n {shapeEl(hs, {\n className: \"bmap__hit\" + (interactive ? \"\" : \" bmap__hit--locked\"),\n \"data-testid\": `bodymap-region-${r.key}`,\n role: interactive ? \"button\" : \"img\",\n tabIndex: interactive ? 0 : -1,\n \"aria-label\": ariaFor(r, marked, interactive),\n \"aria-pressed\": interactive ? selected === r.key : undefined,\n onClick: interactive ? () => pickRegion(r.key) : undefined,\n onKeyDown: interactive\n ? (e: React.KeyboardEvent) => { if (e.key === \"Enter\" || e.key === \" \") { e.preventDefault(); suppressClick.current = false; setSelected(r.key); } }\n : undefined,\n })}\n {shapeEl(s, { className: visCls, fill, opacity: !readOnly && !isSelectable(r.key, config) ? 0.4 : 1 })}\n </g>\n );\n })}\n {/* intensity numbers on marked regions visible in the current view */}\n {report.map((p) => {\n const s = shapes[p.region];\n if (!s || config[p.region]?.visible === false) return null;\n const c = center(s);\n return (\n <text key={p.region} x={c.x} y={c.y + 4} textAnchor=\"middle\" className=\"bmap__heatn\">\n {p.intensity}\n </text>\n );\n })}\n </g>\n </svg>\n </div>\n\n {/* picker panel — hidden entirely in readOnly (display-only) mode */}\n {readOnly ? null : region ? (\n <div className=\"bmap__panel\" data-testid=\"bodymap-panel\">\n <div className=\"bmap__ph\">\n <b>{nameOf(region.key)}</b>\n <span className=\"bmap__code\">{region.code}</span>\n </div>\n <p className=\"bmap__lbl\" id=\"bmap-intensity-lbl\">{L.intensity}</p>\n <div className=\"bmap__scale\" role=\"group\" aria-labelledby=\"bmap-intensity-lbl\">\n {Array.from({ length: 11 }, (_, n) => (\n <button\n key={n}\n type=\"button\"\n className={\"bmap__i\" + (current?.intensity === n ? \" bmap__i--on\" : \"\")}\n data-testid={`bodymap-intensity-${n}`}\n aria-pressed={current?.intensity === n}\n onClick={() => setPain(region.key, n, current?.type)}\n >\n {n}\n </button>\n ))}\n </div>\n <p className=\"bmap__lbl\" id=\"bmap-quality-lbl\">{L.quality}</p>\n <div className=\"bmap__chips\" role=\"group\" aria-labelledby=\"bmap-quality-lbl\">\n {PAIN_TYPES.map((t) => (\n <button\n key={t}\n type=\"button\"\n className={\"bmap__chip\" + (current?.type === t ? \" bmap__chip--on\" : \"\")}\n data-testid={`bodymap-type-${t}`}\n aria-pressed={current?.type === t}\n onClick={() => setPain(region.key, current?.intensity ?? 5, t)}\n >\n {L.qualities[t]}\n </button>\n ))}\n </div>\n {current && (\n <button type=\"button\" className=\"bmap__rm\" data-testid=\"bodymap-remove\" onClick={() => removePain(region.key)}>\n {L.remove}\n </button>\n )}\n </div>\n ) : (\n <div className=\"bmap__panel bmap__panel--empty\" data-testid=\"bodymap-panel\">\n {L.empty}\n </div>\n )}\n </div>\n );\n}\n\n// ---- <BodyMapCompare> — before/after progress (F052.13) ---------------------\n// Two read-only bodies side by side + a per-region change list (improved /\n// worse / new / resolved). Pure UI over two PainReports — no network. NB: the\n// \"nudge the user to update their report over time\" is the CONSUMER app's job\n// (fd-sundhed's mobile app via @broberg/webpush), NOT this component.\n\ntype RegionChange = \"new\" | \"resolved\" | \"improved\" | \"worse\" | \"same\";\nfunction changeOf(b?: number, a?: number): RegionChange {\n if (b == null && a != null) return \"new\";\n if (b != null && a == null) return \"resolved\";\n if (b == null || a == null) return \"same\";\n return a < b ? \"improved\" : a > b ? \"worse\" : \"same\";\n}\n\nfunction CompareFigure({ report, view, palette, caption, testid }: {\n report: PainReport; view: BodyView2D; palette?: BodymapPalette; caption: string; testid: string;\n}) {\n const shapes = view === \"back\" ? SHAPES_BACK : SHAPES;\n const fills = view === \"back\" ? FILL_BACK : FILL;\n return (\n <figure className=\"bmapc__fig\" data-testid={testid}>\n <figcaption className=\"bmapc__cap\">{caption}</figcaption>\n <svg className=\"bmapc__svg\" viewBox=\"0 0 260 470\" role=\"img\" aria-label={caption}>\n {REGIONS.map((r) => {\n const s = shapes[r.key];\n if (!s) return null;\n const m = report.find((p) => p.region === r.key);\n const fill = m ? markedFill(m.intensity, palette) : baseFill(r.key, fills[r.key] ?? \"#cbd5e1\", palette);\n return <g key={r.key}>{shapeEl(s, { className: \"bmapc__vis\", fill })}</g>;\n })}\n {report.map((p) => {\n const s = shapes[p.region];\n if (!s) return null;\n const c = center(s);\n return (\n <text key={p.region} x={c.x} y={c.y + 4} textAnchor=\"middle\" className=\"bmapc__n\">{p.intensity}</text>\n );\n })}\n </svg>\n </figure>\n );\n}\n\nexport interface BodyMapCompareProps {\n /** The earlier report. */\n before: PainReport;\n /** The later report. */\n after: PainReport;\n palette?: BodymapPalette;\n locale?: BodyMapLocale;\n labels?: Partial<BodyMapLabels>;\n defaultView?: BodyView2D;\n className?: string;\n}\n\n/** Read-only before/after view: two bodies side by side + a per-region change list. */\nexport function BodyMapCompare({\n before, after, palette, locale = \"da\", labels, defaultView = \"front\", className,\n}: BodyMapCompareProps) {\n useEffect(ensureStyles, []);\n const [view, setView] = useState<BodyView2D>(defaultView);\n const L = resolveLabels(locale, labels);\n const nameOf = (k: string) => L.regions[k] ?? getRegion(k)?.label ?? k;\n const rootStyle = palette ? ({ \"--bmap-accent\": palette.selected } as React.CSSProperties) : undefined;\n\n const keys = Array.from(new Set([...before, ...after].map((p) => p.region)));\n const changes = keys\n .map((k) => ({\n key: k,\n b: before.find((p) => p.region === k)?.intensity,\n a: after.find((p) => p.region === k)?.intensity,\n status: changeOf(before.find((p) => p.region === k)?.intensity, after.find((p) => p.region === k)?.intensity),\n }))\n .filter((c) => c.status !== \"same\")\n .sort((x, y) => (x.key < y.key ? -1 : 1));\n\n return (\n <div className={[\"bmapc\", className].filter(Boolean).join(\" \")} data-testid=\"bodymap-compare\" style={rootStyle}>\n <div className=\"bmap__viewtoggle\" role=\"group\" aria-label={L.viewLabel} style={{ marginBottom: 12 }}>\n <button type=\"button\" className={\"bmap__vbtn\" + (view === \"front\" ? \" bmap__vbtn--on\" : \"\")} data-testid=\"bodymap-compare-view-front\" aria-pressed={view === \"front\"} onClick={() => setView(\"front\")}>{L.front}</button>\n <button type=\"button\" className={\"bmap__vbtn\" + (view === \"back\" ? \" bmap__vbtn--on\" : \"\")} data-testid=\"bodymap-compare-view-back\" aria-pressed={view === \"back\"} onClick={() => setView(\"back\")}>{L.back}</button>\n </div>\n <div className=\"bmapc__bodies\">\n <CompareFigure report={before} view={view} palette={palette} caption={L.before} testid=\"bodymap-compare-before\" />\n <CompareFigure report={after} view={view} palette={palette} caption={L.after} testid=\"bodymap-compare-after\" />\n </div>\n {changes.length ? (\n <div className=\"bmapc__delta\" data-testid=\"bodymap-compare-delta\">\n {changes.map((c) => (\n <div key={c.key} className=\"bmapc__row\" data-testid={`bodymap-compare-delta-${c.key}`}>\n <span className=\"bmapc__rname\">{nameOf(c.key)}</span>\n <span className=\"bmapc__rval\">{c.b ?? \"–\"} → {c.a ?? \"–\"}</span>\n <span className={`bmapc__badge bmapc__badge--${c.status}`}>{L.change[c.status as Exclude<RegionChange, \"same\">]}</span>\n </div>\n ))}\n </div>\n ) : (\n <div className=\"bmapc__empty\" data-testid=\"bodymap-compare-delta\">{L.noChange}</div>\n )}\n </div>\n );\n}\n","/**\n * @broberg/bodymap/three — a rotatable 3D body pain-map on the SAME core.\n *\n * `<BodyMap3D>` wraps a VANILLA three.js scene (NOT react-three-fiber, so it\n * runs in React, Preact and a Capacitor webview alike): a realistic Blender\n * Studio human base mesh (CC0), rotatable (drag) + zoomable (scroll), with\n * hover-highlight and click-to-mark — a click colours the body part by pain\n * intensity and produces the same structured PainReport (bodymap/v1) the 2D\n * renderer does. Region hit-testing is anchor-based: every mesh vertex is\n * assigned to its nearest region anchor (true per-zone mesh segmentation is a\n * later refinement — the wire + interaction are identical either way).\n *\n * `three` is an OPTIONAL peer — only consumers importing this subpath pull it\n * in. The body models are NOT bundled: pass `models={{ male, female }}` as URLs\n * you host (the package ships reference GLBs under `@broberg/bodymap/models/`).\n */\nimport { useEffect, useRef, useState } from \"react\";\nimport * as THREE from \"three\";\nimport { GLTFLoader } from \"three/examples/jsm/loaders/GLTFLoader.js\";\nimport { OrbitControls } from \"three/examples/jsm/controls/OrbitControls.js\";\nimport {\n REGIONS,\n PAIN_TYPES,\n getRegion,\n isSelectable,\n serializeReport,\n heatFor,\n baseColorFor,\n defaultPalette,\n type BodymapPalette,\n type PainReport,\n type PainType,\n type RegionConfig,\n} from \"./index.js\";\nimport {\n LABELS_DA,\n LABELS_EN,\n type BodyMapLabels,\n type BodyMapLocale,\n} from \"./react.js\";\n\nexport type BodyMap3DSex = \"male\" | \"female\";\n\n/** URLs of the body GLB models the consumer hosts (reference GLBs ship under `@broberg/bodymap/models/`). */\nexport interface BodyMap3DModels {\n male: string;\n female: string;\n}\n\n/** The 3D-only control strings (the shared labels come from `locale`/`labels`). */\nexport interface BodyMap3DUiLabels {\n male: string;\n female: string;\n hoverHint: string;\n}\n\nconst UI_DA: BodyMap3DUiLabels = { male: \"Mand\", female: \"Kvinde\", hoverHint: \"Hover for at fremhæve · klik en kropsdel for at markere smerte.\" };\nconst UI_EN: BodyMap3DUiLabels = { male: \"Male\", female: \"Female\", hoverHint: \"Hover to highlight · tap a body part to mark pain.\" };\n\n// Region anchors in normalised body space (height ~1.9, feet y=0, front +z),\n// then x-flipped so the patient's own left maps to \"venstre\" (self-view).\nconst ANCHORS: Record<string, [number, number, number]> = {\n head: [0, 1.79, 0.02], neck: [0, 1.57, 0.0],\n chest: [0, 1.42, 0.11], thora: [0, 1.42, -0.12], lumbar: [0, 1.13, -0.13], groin: [0, 0.92, 0.09],\n shoulder_left: [-0.2, 1.5, 0], shoulder_right: [0.2, 1.5, 0],\n uarm_left: [-0.27, 1.3, 0.0], uarm_right: [0.27, 1.3, 0.0],\n elbow_left: [-0.31, 1.08, 0], elbow_right: [0.31, 1.08, 0],\n farm_left: [-0.34, 0.93, 0.02], farm_right: [0.34, 0.93, 0.02],\n wrist_left: [-0.36, 0.79, 0.02], wrist_right: [0.36, 0.79, 0.02],\n hand_left: [-0.37, 0.68, 0.03], hand_right: [0.37, 0.68, 0.03],\n hip_left: [-0.14, 1.02, -0.03], hip_right: [0.14, 1.02, -0.03],\n thigh_left: [-0.1, 0.68, 0.05], thigh_right: [0.1, 0.68, 0.05],\n knee_left: [-0.1, 0.4, 0.06], knee_right: [0.1, 0.4, 0.06],\n lowleg_left: [-0.1, 0.22, 0.04], lowleg_right: [0.1, 0.22, 0.04],\n ankle_left: [-0.1, 0.05, 0.02], ankle_right: [0.1, 0.05, 0.02],\n foot_left: [-0.1, 0.02, 0.11], foot_right: [0.1, 0.02, 0.11],\n};\nconst ANCHOR_KEYS = Object.keys(ANCHORS);\nfor (const k of ANCHOR_KEYS) ANCHORS[k][0] = -ANCHORS[k][0];\n\nfunction mergeLabels(locale: BodyMapLocale, overrides?: Partial<BodyMapLabels>): BodyMapLabels {\n const base = locale === \"en\" ? LABELS_EN : LABELS_DA;\n if (!overrides) return base;\n return {\n ...base,\n ...overrides,\n regions: { ...base.regions, ...overrides.regions },\n qualities: { ...base.qualities, ...overrides.qualities },\n };\n}\n\nfunction webglAvailable(): boolean {\n try {\n const c = document.createElement(\"canvas\");\n return !!(c.getContext(\"webgl\") || c.getContext(\"experimental-webgl\"));\n } catch {\n return false;\n }\n}\n\nexport interface BodyMap3DProps {\n /** URLs of the male/female body GLBs (you host them; reference GLBs ship under `@broberg/bodymap/models/`). */\n models: BodyMap3DModels;\n value?: PainReport;\n defaultValue?: PainReport;\n onChange?: (report: PainReport) => void;\n /** Per-app region config — a non-selectable region is not pickable in 3D either. */\n config?: RegionConfig;\n palette?: BodymapPalette;\n locale?: BodyMapLocale;\n labels?: Partial<BodyMapLabels>;\n /** Override the 3D-only control strings (male/female/hoverHint). */\n ui?: Partial<BodyMap3DUiLabels>;\n defaultSex?: BodyMap3DSex;\n onSexChange?: (sex: BodyMap3DSex) => void;\n /** Slowly auto-rotate until the user interacts (default true). */\n autoRotate?: boolean;\n className?: string;\n}\n\nconst btn: React.CSSProperties = { font: \"inherit\", cursor: \"pointer\", borderRadius: 8, border: \"1px solid #e2e8f0\", background: \"#fff\", padding: \"6px 9px\" };\nconst seg = (on: boolean): React.CSSProperties => ({ ...btn, background: on ? \"#0e8f8a\" : \"#fff\", color: on ? \"#fff\" : \"#1e293b\", fontWeight: 600 });\n\nexport function BodyMap3D(props: BodyMap3DProps) {\n const {\n models, value, defaultValue, onChange, config, palette = defaultPalette,\n locale = \"da\", labels, ui, defaultSex = \"male\", onSexChange,\n autoRotate = true, className,\n } = props;\n\n const L = mergeLabels(locale, labels);\n const UI = { ...(locale === \"en\" ? UI_EN : UI_DA), ...ui };\n const nameOf = (key: string) => L.regions[key] ?? getRegion(key)?.label ?? key;\n\n const mountRef = useRef<HTMLDivElement>(null);\n const loadedRef = useRef<HTMLSpanElement>(null);\n const [unsupported, setUnsupported] = useState(false);\n const [ready, setReady] = useState(false);\n const [sex, setSex] = useState<BodyMap3DSex>(defaultSex);\n const [selected, setSelected] = useState<string | null>(null);\n const [internal, setInternal] = useState<PainReport>(defaultValue ?? []);\n const report = value ?? internal;\n\n const commit = (next: PainReport) => {\n if (value === undefined) setInternal(next);\n onChange?.(next);\n };\n\n // Latest state visible to the once-mounted imperative scene.\n const reportRef = useRef(report); reportRef.current = report;\n const selectedRef = useRef(selected); selectedRef.current = selected;\n const paletteRef = useRef(palette); paletteRef.current = palette;\n const configRef = useRef(config); configRef.current = config;\n const modelsRef = useRef(models); modelsRef.current = models;\n const setSelectedRef = useRef(setSelected); setSelectedRef.current = setSelected;\n const setReadyRef = useRef(setReady); setReadyRef.current = setReady;\n const apiRef = useRef<{ setSex: (s: BodyMap3DSex) => void; refresh: () => void } | null>(null);\n\n useEffect(() => {\n if (!webglAvailable()) { setUnsupported(true); return; }\n const el = mountRef.current;\n if (!el) return;\n let W = el.clientWidth || 520, H = el.clientHeight || 600;\n\n const scene = new THREE.Scene();\n scene.background = new THREE.Color(0x0e1424);\n const camera = new THREE.PerspectiveCamera(32, W / H, 0.1, 100);\n camera.position.set(0, 1.05, 4.4);\n let renderer: THREE.WebGLRenderer;\n try {\n renderer = new THREE.WebGLRenderer({ antialias: true });\n } catch {\n setUnsupported(true);\n return;\n }\n renderer.setSize(W, H);\n renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n el.appendChild(renderer.domElement);\n scene.add(new THREE.HemisphereLight(0xffffff, 0x223044, 1.05));\n const kl = new THREE.DirectionalLight(0xffffff, 1.5); kl.position.set(3, 5, 4); scene.add(kl);\n const rl = new THREE.DirectionalLight(0x88aaff, 0.7); rl.position.set(-4, 2, -3); scene.add(rl);\n\n const controls = new OrbitControls(camera, renderer.domElement);\n controls.enableDamping = true; controls.dampingFactor = 0.08;\n controls.autoRotate = autoRotate; controls.autoRotateSpeed = 1.1;\n controls.minDistance = 2.2; controls.maxDistance = 8; controls.enablePan = false;\n controls.target.set(0, 0.95, 0);\n controls.addEventListener(\"start\", () => { controls.autoRotate = false; });\n\n // On-demand rendering: render only while auto-rotating or while damping is\n // still settling, then STOP — so the page goes idle. That saves battery on\n // mobile (bodymap is primarily a mobile surface) AND lets a headless\n // Lens/Playwright run actually land clicks instead of starving on a\n // never-quiet WebGL loop (fd-sundhed's F052.6-preview finding). A control\n // 'change' / interaction / state change kicks the pump back to life.\n let raf = 0;\n let pumping = false;\n const renderFrame = () => renderer.render(scene, camera);\n const pump = () => {\n const moved = controls.update();\n renderFrame();\n if (moved || controls.autoRotate) { raf = requestAnimationFrame(pump); }\n else { pumping = false; raf = 0; }\n };\n const kick = () => { if (!pumping && !document.hidden) { pumping = true; raf = requestAnimationFrame(pump); } };\n controls.addEventListener(\"change\", kick);\n\n let modelRoot: THREE.Object3D | null = null;\n let bodyMesh: THREE.Mesh | null = null;\n let vertexRegion: string[] = [];\n let colorAttr: THREE.BufferAttribute | null = null;\n let hovered: string | null = null;\n const loader = new GLTFLoader();\n const anchorVecs = ANCHOR_KEYS.map((k) => new THREE.Vector3(...ANCHORS[k]));\n const tmp = new THREE.Color();\n\n const restingHex = (key: string): string => {\n const pt = reportRef.current.find((p) => p.region === key);\n if (pt) return heatFor(pt.intensity, paletteRef.current);\n if (selectedRef.current === key) return paletteRef.current.selected;\n return baseColorFor(key, paletteRef.current);\n };\n const colorRegion = (key: string, hex: string) => {\n if (!colorAttr) return;\n tmp.set(hex);\n for (let i = 0; i < vertexRegion.length; i++) if (vertexRegion[i] === key) colorAttr.setXYZ(i, tmp.r, tmp.g, tmp.b);\n colorAttr.needsUpdate = true;\n };\n const refresh = () => { for (const k of ANCHOR_KEYS) colorRegion(k, hovered === k ? paletteRef.current.hover : restingHex(k)); renderFrame(); };\n\n const loadModel = (which: BodyMap3DSex) => {\n const url = which === \"female\" ? modelsRef.current.female : modelsRef.current.male;\n loader.load(url, (gltf) => {\n if (modelRoot) scene.remove(modelRoot);\n const model = gltf.scene;\n const box = new THREE.Box3().setFromObject(model);\n const size = new THREE.Vector3(); box.getSize(size);\n const center = new THREE.Vector3(); box.getCenter(center);\n const scale = 1.9 / size.y;\n model.scale.setScalar(scale);\n model.position.set(-center.x * scale, -box.min.y * scale, -center.z * scale);\n model.updateMatrixWorld(true);\n bodyMesh = null;\n model.traverse((o) => { const m = o as THREE.Mesh; if (m.isMesh && !bodyMesh) bodyMesh = m; });\n if (bodyMesh) {\n const geo = (bodyMesh as THREE.Mesh).geometry as THREE.BufferGeometry;\n geo.computeVertexNormals();\n const pos = geo.getAttribute(\"position\") as THREE.BufferAttribute;\n const n = pos.count;\n const cols = new Float32Array(n * 3);\n vertexRegion = new Array(n);\n const v = new THREE.Vector3();\n for (let i = 0; i < n; i++) {\n v.fromBufferAttribute(pos, i).applyMatrix4((bodyMesh as THREE.Mesh).matrixWorld);\n let best = 0, bd = Infinity;\n for (let a = 0; a < anchorVecs.length; a++) { const d = v.distanceToSquared(anchorVecs[a]); if (d < bd) { bd = d; best = a; } }\n vertexRegion[i] = ANCHOR_KEYS[best];\n }\n colorAttr = new THREE.BufferAttribute(cols, 3);\n geo.setAttribute(\"color\", colorAttr);\n (bodyMesh as THREE.Mesh).material = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.72, metalness: 0.04 });\n refresh();\n }\n modelRoot = model;\n scene.add(model);\n renderFrame();\n kick();\n setReadyRef.current(true);\n if (loadedRef.current) { loadedRef.current.setAttribute(\"data-loaded\", \"true\"); loadedRef.current.setAttribute(\"data-model\", which); }\n });\n };\n loadModel(defaultSex);\n apiRef.current = {\n setSex: (s) => { loadedRef.current?.removeAttribute(\"data-loaded\"); setReadyRef.current(false); loadModel(s); },\n refresh,\n };\n\n const raycaster = new THREE.Raycaster();\n const ndc = new THREE.Vector2();\n const pick = (clientX: number, clientY: number): string | null => {\n if (!bodyMesh) return null;\n const rect = renderer.domElement.getBoundingClientRect();\n ndc.set(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);\n raycaster.setFromCamera(ndc, camera);\n const hits = raycaster.intersectObject(bodyMesh, true);\n if (!hits.length) return null;\n const p = hits[0].point;\n // Nearest SELECTABLE anchor — a region hidden/locked by the per-app config\n // is skipped, so it never highlights or gets picked (2D-parity).\n let best: string | null = null, bd = Infinity;\n for (const k of ANCHOR_KEYS) {\n if (!isSelectable(k, configRef.current ?? {})) continue;\n const a = ANCHORS[k];\n const d = (p.x - a[0]) ** 2 + (p.y - a[1]) ** 2 + (p.z - a[2]) ** 2;\n if (d < bd) { bd = d; best = k; }\n }\n return best;\n };\n\n let downX = 0, downY = 0, downT = 0;\n const canvas = renderer.domElement;\n const onDown = (e: PointerEvent) => { downX = e.clientX; downY = e.clientY; downT = Date.now(); };\n const onMove = (e: PointerEvent) => {\n if ((e.buttons || 0) !== 0) return;\n const k = pick(e.clientX, e.clientY);\n if (k === hovered) return;\n const prev = hovered; hovered = k;\n if (prev) colorRegion(prev, restingHex(prev));\n if (k) colorRegion(k, paletteRef.current.hover);\n canvas.style.cursor = k ? \"pointer\" : \"default\";\n renderFrame();\n };\n const onUp = (e: PointerEvent) => {\n if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6 || Date.now() - downT > 450) return;\n const k = pick(e.clientX, e.clientY);\n if (k) setSelectedRef.current(k);\n };\n canvas.addEventListener(\"pointerdown\", onDown);\n canvas.addEventListener(\"pointermove\", onMove);\n canvas.addEventListener(\"pointerup\", onUp);\n\n renderFrame();\n kick(); // settle initial damping / start auto-rotate if enabled\n const onVis = () => { if (document.hidden) { cancelAnimationFrame(raf); pumping = false; raf = 0; } else kick(); };\n document.addEventListener(\"visibilitychange\", onVis);\n const onResize = () => { W = el.clientWidth || 520; H = el.clientHeight || 600; camera.aspect = W / H; camera.updateProjectionMatrix(); renderer.setSize(W, H); renderFrame(); };\n window.addEventListener(\"resize\", onResize);\n\n return () => {\n cancelAnimationFrame(raf);\n window.removeEventListener(\"resize\", onResize);\n document.removeEventListener(\"visibilitychange\", onVis);\n controls.removeEventListener(\"change\", kick);\n canvas.removeEventListener(\"pointerdown\", onDown);\n canvas.removeEventListener(\"pointermove\", onMove);\n canvas.removeEventListener(\"pointerup\", onUp);\n controls.dispose();\n renderer.dispose();\n if (canvas.parentNode) canvas.parentNode.removeChild(canvas);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // React → scene: recolour on selection / report / palette change; swap model on sex change.\n useEffect(() => { apiRef.current?.refresh(); }, [selected, report, palette]);\n useEffect(() => { apiRef.current?.setSex(sex); }, [sex]);\n\n const pointOf = (k: string) => report.find((p) => p.region === k);\n const setPain = (k: string, intensity: number, type?: PainType) => {\n commit([...report.filter((p) => p.region !== k), { region: k, intensity, type, timestamp: new Date().toISOString() }]);\n };\n const removePain = (k: string) => { commit(report.filter((p) => p.region !== k)); setSelected(null); };\n const changeSex = (s: BodyMap3DSex) => { setSex(s); onSexChange?.(s); };\n\n const region = selected ? REGIONS.find((r) => r.key === selected) : null;\n const current = selected ? pointOf(selected) : undefined;\n\n return (\n <div data-testid=\"bodymap3d-root\" className={className} style={{ fontFamily: \"system-ui, sans-serif\", color: \"#1e293b\" }}>\n <div style={{ display: \"flex\", gap: 16, flexWrap: \"wrap\", marginBottom: 12, fontSize: 13 }}>\n <div style={{ display: \"flex\", gap: 6, alignItems: \"center\" }}>\n <button data-testid=\"bodymap3d-sex-male\" onClick={() => changeSex(\"male\")} style={seg(sex === \"male\")}>{UI.male}</button>\n <button data-testid=\"bodymap3d-sex-female\" onClick={() => changeSex(\"female\")} style={seg(sex === \"female\")}>{UI.female}</button>\n </div>\n </div>\n <span ref={loadedRef} data-testid=\"bodymap3d-loaded\" style={{ display: \"none\" }} />\n {ready && <span data-testid=\"bodymap3d-ready\" style={{ position: \"absolute\", width: 1, height: 1, opacity: 0, pointerEvents: \"none\" }} />}\n <div style={{ display: \"flex\", gap: 18, alignItems: \"flex-start\", flexWrap: \"wrap\" }}>\n {unsupported ? (\n <div data-testid=\"bodymap3d-unsupported\" style={{ flex: \"1 1 520px\", minWidth: 320, height: \"60vh\", borderRadius: 16, background: \"#0e1424\", color: \"#cbd5e1\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\", padding: 24, textAlign: \"center\", fontSize: 14 }}>\n 3D kræver WebGL, som ikke er tilgængeligt her.\n </div>\n ) : (\n <div ref={mountRef} data-testid=\"bodymap3d-canvas\" style={{ flex: \"1 1 520px\", height: \"60vh\", minWidth: 320, borderRadius: 16, overflow: \"hidden\", background: \"#0e1424\", touchAction: \"none\" }} />\n )}\n <div style={{ flex: \"1 1 300px\", minWidth: 260 }}>\n {region ? (\n <div style={{ border: \"1px solid #e2e8f0\", borderRadius: 14, padding: 16, background: \"#fff\" }}>\n <div style={{ display: \"flex\", justifyContent: \"space-between\", alignItems: \"center\", marginBottom: 12 }}>\n <b style={{ fontSize: 16 }}>{nameOf(region.key)}</b>\n <span style={{ font: \"11px ui-monospace, monospace\", color: \"#64748b\", background: \"#f1f5f9\", borderRadius: 6, padding: \"2px 7px\" }}>{region.code}{region.side ? \" · \" + region.side : \"\"}</span>\n </div>\n <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: \".06em\", textTransform: \"uppercase\", color: \"#94a3b8\", marginBottom: 7 }}>{L.intensity}</div>\n <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 4, marginBottom: 12 }}>\n {Array.from({ length: 11 }, (_, i) => (\n <button key={i} data-testid={`bodymap3d-intensity-${i}`} onClick={() => setPain(region.key, i, current?.type)} style={{ ...btn, width: 30, height: 30, background: current?.intensity === i ? \"#0e8f8a\" : \"#fff\", color: current?.intensity === i ? \"#fff\" : \"#1e293b\" }}>{i}</button>\n ))}\n </div>\n <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: \".06em\", textTransform: \"uppercase\", color: \"#94a3b8\", marginBottom: 7 }}>{L.quality}</div>\n <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 6, marginBottom: 14 }}>\n {PAIN_TYPES.map((t) => (\n <button key={t} data-testid={`bodymap3d-type-${t}`} onClick={() => setPain(region.key, current?.intensity ?? 5, t)} style={{ ...btn, borderRadius: 999, padding: \"6px 12px\", background: current?.type === t ? \"#1e293b\" : \"#fff\", color: current?.type === t ? \"#fff\" : \"#64748b\" }}>{L.qualities[t] ?? t}</button>\n ))}\n </div>\n {current && <button data-testid=\"bodymap3d-remove\" onClick={() => removePain(region.key)} style={{ ...btn, color: \"#ef4444\", borderColor: \"#f6c9c9\" }}>{L.remove}</button>}\n </div>\n ) : (\n <div data-testid=\"bodymap3d-empty\" style={{ border: \"1px solid #e2e8f0\", borderRadius: 14, padding: 16, background: \"#fff\", color: \"#94a3b8\", fontSize: 13.5 }}>{UI.hoverHint}</div>\n )}\n </div>\n </div>\n </div>\n );\n}\n\n/** Re-export for convenience so a 3D-only consumer can serialize without a second import. */\nexport { serializeReport };\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/react.tsx","../src/three.tsx"],"names":["z","useRef","useState","useEffect","THREE","OrbitControls","GLTFLoader","jsxs","jsx"],"mappings":"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","file":"three.cjs","sourcesContent":["// @broberg/bodymap — headless core (F052.1).\n//\n// Framework-neutral: the region taxonomy + the PainReport data model (zod) + a\n// selection engine + per-app region config. NO React/Preact/DOM import — the 2D\n// (SVG) and 3D (Three.js) renderers, and all three FD apps, share this one\n// contract. Output is a structured PainReport, never a bare image.\n\nimport { z } from \"zod\";\n\nexport type Side = \"left\" | \"right\";\n\nexport interface BodyRegion {\n /** Stable, unique identifier (snake_case) — the key used in a PainReport. */\n key: string;\n /** Human label (Danish). */\n label: string;\n /** Clinical short code (unique). */\n code: string;\n /** Body side, when the region is paired. */\n side?: Side;\n}\n\n/** The canonical body regions — the AUTHORITATIVE fd-sundhed clinical taxonomy\n * (docs/BODYMAP-TAKSONOMI.md, broberg-ai/fd-sundhed @360842f): 18 SIDE-LESS\n * clinical codes + a separate `side` field (L/R on limbs, C=center on axis\n * regions). The `key` is a unique per-side identifier; `code` is the side-less\n * clinical code that goes on the bodymap/v1 wire. NOT an anatomical atlas —\n * ~30 named surface regions for a pain-map. The 2D front renderer draws the\n * front-visible subset; the 3D body (F052.6) drives them all. */\nexport const REGIONS: readonly BodyRegion[] = [\n // axis / centre-line (serialised side \"center\")\n { key: \"head\", label: \"Hoved\", code: \"HEAD\" },\n { key: \"neck\", label: \"Nakke\", code: \"NECK\" },\n { key: \"chest\", label: \"Bryst\", code: \"CHEST\" },\n { key: \"thora\", label: \"Øvre ryg (thorakal)\", code: \"THORA\" },\n { key: \"lumbar\", label: \"Lænd (lumbal)\", code: \"LUMBAR\" },\n { key: \"groin\", label: \"Lyske\", code: \"GROIN\" },\n // paired limbs / sides (L / R)\n { key: \"shoulder_left\", label: \"Skulder, venstre\", code: \"SHOULDER\", side: \"left\" },\n { key: \"shoulder_right\", label: \"Skulder, højre\", code: \"SHOULDER\", side: \"right\" },\n { key: \"uarm_left\", label: \"Overarm, venstre\", code: \"UARM\", side: \"left\" },\n { key: \"uarm_right\", label: \"Overarm, højre\", code: \"UARM\", side: \"right\" },\n { key: \"elbow_left\", label: \"Albue, venstre\", code: \"ELBOW\", side: \"left\" },\n { key: \"elbow_right\", label: \"Albue, højre\", code: \"ELBOW\", side: \"right\" },\n { key: \"farm_left\", label: \"Underarm, venstre\", code: \"FARM\", side: \"left\" },\n { key: \"farm_right\", label: \"Underarm, højre\", code: \"FARM\", side: \"right\" },\n { key: \"wrist_left\", label: \"Håndled, venstre\", code: \"WRIST\", side: \"left\" },\n { key: \"wrist_right\", label: \"Håndled, højre\", code: \"WRIST\", side: \"right\" },\n { key: \"hand_left\", label: \"Hånd, venstre\", code: \"HAND\", side: \"left\" },\n { key: \"hand_right\", label: \"Hånd, højre\", code: \"HAND\", side: \"right\" },\n { key: \"hip_left\", label: \"Hofte, venstre\", code: \"HIP\", side: \"left\" },\n { key: \"hip_right\", label: \"Hofte, højre\", code: \"HIP\", side: \"right\" },\n { key: \"thigh_left\", label: \"Lår, venstre\", code: \"THIGH\", side: \"left\" },\n { key: \"thigh_right\", label: \"Lår, højre\", code: \"THIGH\", side: \"right\" },\n { key: \"knee_left\", label: \"Knæ, venstre\", code: \"KNEE\", side: \"left\" },\n { key: \"knee_right\", label: \"Knæ, højre\", code: \"KNEE\", side: \"right\" },\n { key: \"lowleg_left\", label: \"Underben, venstre\", code: \"LOWLEG\", side: \"left\" },\n { key: \"lowleg_right\", label: \"Underben, højre\", code: \"LOWLEG\", side: \"right\" },\n { key: \"ankle_left\", label: \"Ankel, venstre\", code: \"ANKLE\", side: \"left\" },\n { key: \"ankle_right\", label: \"Ankel, højre\", code: \"ANKLE\", side: \"right\" },\n { key: \"foot_left\", label: \"Fod, venstre\", code: \"FOOT\", side: \"left\" },\n { key: \"foot_right\", label: \"Fod, højre\", code: \"FOOT\", side: \"right\" },\n];\n\nconst REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));\nexport const REGION_KEYS: readonly string[] = REGIONS.map((r) => r.key);\n\n/** Look up a region by its key. */\nexport function getRegion(key: string): BodyRegion | undefined {\n return REGIONS.find((r) => r.key === key);\n}\n\n// ---- PainReport model ---------------------------------------------------\n\nexport const PAIN_TYPES = [\"stikkende\", \"dump\", \"konstant\", \"jagende\"] as const;\nexport type PainType = (typeof PAIN_TYPES)[number];\n\n/** One marked pain point. `region` MUST be a known region key; `intensity` is a\n * 0-10 integer; `type` is optional but constrained; `timestamp` is an ISO string. */\nexport const painPointSchema = z.object({\n region: z.string().refine((k) => REGION_KEY_SET.has(k), { message: \"unknown region\" }),\n intensity: z.number().int().min(0).max(10),\n type: z.enum(PAIN_TYPES).optional(),\n timestamp: z.string(),\n});\nexport type PainPoint = z.infer<typeof painPointSchema>;\n\nexport const painReportSchema = z.array(painPointSchema);\nexport type PainReport = PainPoint[];\n\n// ---- Selection engine (framework-agnostic) ------------------------------\n\nexport interface PainSelection {\n /** Mark (or update) pain on a region. One point per region — latest wins. */\n set(region: string, intensity: number, type?: PainType): PainPoint;\n remove(region: string): boolean;\n get(region: string): PainPoint | undefined;\n has(region: string): boolean;\n clear(): void;\n /** The current, validated PainReport. */\n getReport(): PainReport;\n}\n\nexport interface PainSelectionOptions {\n /** Injectable clock (ISO string) — defaults to `new Date().toISOString()`. */\n now?: () => string;\n}\n\n/** Create a selection engine seeded with an optional report. Pure state — no\n * DOM, no framework, no network. One point per region. */\nexport function createPainSelection(\n initial: PainReport = [],\n opts: PainSelectionOptions = {},\n): PainSelection {\n const now = opts.now ?? (() => new Date().toISOString());\n const map = new Map<string, PainPoint>();\n for (const p of initial) {\n const v = painPointSchema.parse(p);\n map.set(v.region, v);\n }\n return {\n set(region, intensity, type) {\n const point = painPointSchema.parse({ region, intensity, type, timestamp: now() });\n map.set(point.region, point);\n return point;\n },\n remove: (region) => map.delete(region),\n get: (region) => map.get(region),\n has: (region) => map.has(region),\n clear: () => map.clear(),\n getReport: () => painReportSchema.parse(Array.from(map.values())),\n };\n}\n\n// ---- Per-app region config (the toggle) ---------------------------------\n\nexport interface RegionSetting {\n /** Render this region at all. Default true. */\n visible?: boolean;\n /** Allow marking pain on this region. Default true. */\n selectable?: boolean;\n}\n\n/** Per-app config keyed by region key. An absent key ⇒ visible + selectable. */\nexport type RegionConfig = Record<string, RegionSetting>;\n\n/** The regions an app should render, honouring `visible` (default true). */\nexport function resolveRegions(config: RegionConfig = {}): BodyRegion[] {\n return REGIONS.filter((r) => config[r.key]?.visible ?? true);\n}\n\n/** Whether a region may be marked. A hidden region is never selectable. */\nexport function isSelectable(key: string, config: RegionConfig = {}): boolean {\n const s = config[key];\n if (s?.visible === false) return false;\n return s?.selectable ?? true;\n}\n\n// ---- palette (consumer-defined colours — shared by the 2D + 3D renderers) ---\n\n/** Colour control for the body renderers. Consumers pass a palette to theme the\n * body base colour, the hover + selected highlights, the pain-heat colours, and\n * optional per-region base colours. All values are CSS/hex colour strings. */\nexport interface BodymapPalette {\n /** Base body colour (an unmarked region). */\n body: string;\n /** Region highlight on hover (before click). */\n hover: string;\n /** A region selected (clicked) but not yet given an intensity. */\n selected: string;\n /** Pain-intensity heat colours: low (0-3), mid (4-6), high (7-10). */\n heat: { low: string; mid: string; high: string };\n /** Optional per-region base-colour overrides (region key → colour). */\n regions?: Record<string, string>;\n}\n\n/** The fleet default palette. Override any field per consumer. */\nexport const defaultPalette: BodymapPalette = {\n body: \"#d2d7de\",\n hover: \"#8fd0cd\",\n selected: \"#5cc4b7\",\n heat: { low: \"#fcd34d\", mid: \"#fb923c\", high: \"#ef4444\" },\n};\n\n/** The heat colour for a pain intensity, honouring the palette. */\nexport function heatFor(intensity: number, palette: BodymapPalette = defaultPalette): string {\n return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;\n}\n\n/** The base colour for a region (a per-region override, else the body colour). */\nexport function baseColorFor(regionKey: string, palette: BodymapPalette = defaultPalette): string {\n return palette.regions?.[regionKey] ?? palette.body;\n}\n\n// ---- bodymap/v1 serialization (the shared cross-app / native wire format) ---\n//\n// The shape every consumer + the native mobile apps read (aligned with\n// fd-sundhed's bodymap/v1: region CODE + side + intensity + quality + view).\n// The internal PainReport keys on the region KEY; this maps key -> clinical CODE\n// so the report is portable and human-readable on the wire.\n\nexport type BodyView = \"front\" | \"back\" | \"left\" | \"right\";\n/** Side in the serialized report — a midline region (no side) becomes \"center\". */\nexport type SerializedSide = \"left\" | \"right\" | \"center\";\n\nexport interface SerializedPainPoint {\n /** Clinical region CODE (e.g. \"LUMB\"). */\n region: string;\n side: SerializedSide;\n intensity: number;\n quality?: PainType;\n}\n\nexport interface BodymapReportV1 {\n schema: \"bodymap/v1\";\n view: BodyView;\n points: SerializedPainPoint[];\n}\n\nexport const bodymapReportV1Schema = z.object({\n schema: z.literal(\"bodymap/v1\"),\n view: z.enum([\"front\", \"back\", \"left\", \"right\"]),\n points: z.array(\n z.object({\n region: z.string(),\n side: z.enum([\"left\", \"right\", \"center\"]),\n intensity: z.number().int().min(0).max(10),\n quality: z.enum(PAIN_TYPES).optional(),\n }),\n ),\n});\n\n/** Serialize a PainReport to the shared `bodymap/v1` wire format. */\nexport function serializeReport(\n report: PainReport,\n opts: { view?: BodyView } = {},\n): BodymapReportV1 {\n return {\n schema: \"bodymap/v1\",\n view: opts.view ?? \"front\",\n points: report.map((p) => {\n const r = getRegion(p.region);\n return {\n region: r?.code ?? p.region,\n side: (r?.side ?? \"center\") as SerializedSide,\n intensity: p.intensity,\n quality: p.type,\n };\n }),\n };\n}\n\n/** Parse a `bodymap/v1` report back into an internal PainReport. Region CODE →\n * key; a point whose code is unknown to this taxonomy is dropped. */\nexport function deserializeReport(\n env: unknown,\n now: () => string = () => new Date().toISOString(),\n): PainReport {\n const parsed = bodymapReportV1Schema.parse(env);\n // code is side-less, so a point is identified by code + side.\n const byCodeSide = new Map(\n REGIONS.map((r) => [`${r.code}|${r.side ?? \"center\"}`, r.key] as const),\n );\n const out: PainReport = [];\n for (const sp of parsed.points) {\n const key = byCodeSide.get(`${sp.region}|${sp.side}`);\n if (!key) continue;\n out.push(\n painPointSchema.parse({\n region: key,\n intensity: sp.intensity,\n type: sp.quality,\n timestamp: now(),\n }),\n );\n }\n return out;\n}\n","// @broberg/bodymap/react — the 2D SVG renderer (F052.2).\n//\n// <BodyMap> is the shippable MVP: a genderless, colour-blocked body a patient\n// clicks to mark where it hurts. Selecting a region opens a custom intensity\n// (0-10) + quality control (NO native inputs) that upserts a validated\n// PainReport and fires onChange. Honours the per-app RegionConfig. The 3D\n// <BodyMap3D> (F052.6) will render the SAME core on the SAME onChange contract.\n//\n// F052.8 touch-first + F052.11 a11y: the body is PRIMARILY a mobile surface, so\n// every region has a >=44px transparent hit-area, the body pinch-zooms + pans\n// (with a tap-vs-drag guard so a gesture never mis-marks), controls are\n// thumb-sized and the layout stacks on a narrow viewport. Everything is also\n// fully keyboard-operable with live-state ARIA labels + visible focus.\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n REGIONS,\n PAIN_TYPES,\n painPointSchema,\n resolveRegions,\n isSelectable,\n getRegion,\n heatFor,\n baseColorFor,\n type BodyRegion,\n type BodymapPalette,\n type PainReport,\n type PainType,\n type RegionConfig,\n} from \"./index.js\";\n\n// ---- geometry: each region → an SVG shape (a neutral colour-blockout) --------\n\ntype Shape =\n | { el: \"circle\"; cx: number; cy: number; r: number }\n | { el: \"ellipse\"; cx: number; cy: number; rx: number; ry: number }\n | { el: \"rect\"; x: number; y: number; width: number; height: number; rx: number };\n\n// Front-view geometry (viewBox 260×470). The 2D MVP renders the front-visible\n// regions; back-only regions (thora, lumbar) + hip are driven by the 3D body /\n// a later back view. \"left\"/\"right\" are placed self-view (viewer's left = key\n// left) for the MVP — the facing-the-patient mirror is a later config option.\nconst SHAPES: Record<string, Shape> = {\n head: { el: \"circle\", cx: 130, cy: 40, r: 26 },\n neck: { el: \"rect\", x: 120, y: 62, width: 20, height: 16, rx: 6 },\n chest: { el: \"rect\", x: 100, y: 84, width: 60, height: 104, rx: 16 },\n groin: { el: \"rect\", x: 106, y: 190, width: 48, height: 30, rx: 13 },\n // left arm (viewer-left)\n shoulder_left: { el: \"circle\", cx: 88, cy: 98, r: 14 },\n uarm_left: { el: \"rect\", x: 66, y: 104, width: 18, height: 54, rx: 9 },\n elbow_left: { el: \"circle\", cx: 73, cy: 164, r: 10 },\n farm_left: { el: \"rect\", x: 62, y: 174, width: 16, height: 50, rx: 8 },\n wrist_left: { el: \"circle\", cx: 68, cy: 230, r: 8 },\n hand_left: { el: \"ellipse\", cx: 67, cy: 250, rx: 11, ry: 13 },\n // right arm (viewer-right)\n shoulder_right: { el: \"circle\", cx: 172, cy: 98, r: 14 },\n uarm_right: { el: \"rect\", x: 176, y: 104, width: 18, height: 54, rx: 9 },\n elbow_right: { el: \"circle\", cx: 187, cy: 164, r: 10 },\n farm_right: { el: \"rect\", x: 182, y: 174, width: 16, height: 50, rx: 8 },\n wrist_right: { el: \"circle\", cx: 192, cy: 230, r: 8 },\n hand_right: { el: \"ellipse\", cx: 193, cy: 250, rx: 11, ry: 13 },\n // legs\n thigh_left: { el: \"rect\", x: 104, y: 224, width: 22, height: 80, rx: 11 },\n thigh_right: { el: \"rect\", x: 134, y: 224, width: 22, height: 80, rx: 11 },\n knee_left: { el: \"circle\", cx: 115, cy: 312, r: 13 },\n knee_right: { el: \"circle\", cx: 145, cy: 312, r: 13 },\n lowleg_left: { el: \"rect\", x: 106, y: 322, width: 18, height: 74, rx: 9 },\n lowleg_right: { el: \"rect\", x: 136, y: 322, width: 18, height: 74, rx: 9 },\n ankle_left: { el: \"circle\", cx: 115, cy: 404, r: 9 },\n ankle_right: { el: \"circle\", cx: 145, cy: 404, r: 9 },\n foot_left: { el: \"ellipse\", cx: 115, cy: 424, rx: 14, ry: 10 },\n foot_right: { el: \"ellipse\", cx: 145, cy: 424, rx: 14, ry: 10 },\n};\n\nconst FILL: Record<string, string> = {\n head: \"#cbb7ec\", neck: \"#b8a3e6\", chest: \"#e26d6d\", groin: \"#e88a52\",\n shoulder_left: \"#f6c667\", shoulder_right: \"#f6c667\",\n uarm_left: \"#7ececb\", uarm_right: \"#7ececb\",\n elbow_left: \"#86cfcc\", elbow_right: \"#86cfcc\",\n farm_left: \"#8fd0cd\", farm_right: \"#8fd0cd\",\n wrist_left: \"#9ad6d3\", wrist_right: \"#9ad6d3\",\n hand_left: \"#a7ddda\", hand_right: \"#a7ddda\",\n thigh_left: \"#7fb2e8\", thigh_right: \"#7fb2e8\",\n knee_left: \"#c79be0\", knee_right: \"#c79be0\",\n lowleg_left: \"#e39ec6\", lowleg_right: \"#e39ec6\",\n ankle_left: \"#eeaab9\", ankle_right: \"#eeaab9\",\n foot_left: \"#f0a5a5\", foot_right: \"#f0a5a5\",\n};\n\n// Back-view geometry (same viewBox 260×470 + same limb/head geometry as the\n// front, so a marked limb sits at the same spot in both views). The torso swaps\n// front regions (chest, groin) for the posterior ones — THORA (upper back),\n// LUMBAR (lower back) and HIP (buttocks, paired) — the pilot's blocker regions.\n// Same self-view L/R convention as the front (patient-left = viewer-left).\nconst SHAPES_BACK: Record<string, Shape> = {\n head: { el: \"circle\", cx: 130, cy: 40, r: 26 },\n neck: { el: \"rect\", x: 120, y: 62, width: 20, height: 16, rx: 6 },\n thora: { el: \"rect\", x: 100, y: 84, width: 60, height: 58, rx: 15 },\n lumbar: { el: \"rect\", x: 104, y: 144, width: 52, height: 44, rx: 13 },\n hip_left: { el: \"rect\", x: 100, y: 190, width: 27, height: 32, rx: 13 },\n hip_right: { el: \"rect\", x: 133, y: 190, width: 27, height: 32, rx: 13 },\n // arms — shared with the front (back of the arm sits at the same x/y)\n shoulder_left: { el: \"circle\", cx: 88, cy: 98, r: 14 },\n uarm_left: { el: \"rect\", x: 66, y: 104, width: 18, height: 54, rx: 9 },\n elbow_left: { el: \"circle\", cx: 73, cy: 164, r: 10 },\n farm_left: { el: \"rect\", x: 62, y: 174, width: 16, height: 50, rx: 8 },\n wrist_left: { el: \"circle\", cx: 68, cy: 230, r: 8 },\n hand_left: { el: \"ellipse\", cx: 67, cy: 250, rx: 11, ry: 13 },\n shoulder_right: { el: \"circle\", cx: 172, cy: 98, r: 14 },\n uarm_right: { el: \"rect\", x: 176, y: 104, width: 18, height: 54, rx: 9 },\n elbow_right: { el: \"circle\", cx: 187, cy: 164, r: 10 },\n farm_right: { el: \"rect\", x: 182, y: 174, width: 16, height: 50, rx: 8 },\n wrist_right: { el: \"circle\", cx: 192, cy: 230, r: 8 },\n hand_right: { el: \"ellipse\", cx: 193, cy: 250, rx: 11, ry: 13 },\n // legs — back (thigh→calf→heel, same x/y as the front)\n thigh_left: { el: \"rect\", x: 104, y: 224, width: 22, height: 80, rx: 11 },\n thigh_right: { el: \"rect\", x: 134, y: 224, width: 22, height: 80, rx: 11 },\n knee_left: { el: \"circle\", cx: 115, cy: 312, r: 13 },\n knee_right: { el: \"circle\", cx: 145, cy: 312, r: 13 },\n lowleg_left: { el: \"rect\", x: 106, y: 322, width: 18, height: 74, rx: 9 },\n lowleg_right: { el: \"rect\", x: 136, y: 322, width: 18, height: 74, rx: 9 },\n ankle_left: { el: \"circle\", cx: 115, cy: 404, r: 9 },\n ankle_right: { el: \"circle\", cx: 145, cy: 404, r: 9 },\n foot_left: { el: \"ellipse\", cx: 115, cy: 424, rx: 14, ry: 10 },\n foot_right: { el: \"ellipse\", cx: 145, cy: 424, rx: 14, ry: 10 },\n};\n\nconst FILL_BACK: Record<string, string> = {\n ...FILL,\n thora: \"#e2896d\",\n lumbar: \"#d97fa0\",\n hip_left: \"#c99bd6\", hip_right: \"#c99bd6\",\n};\n\nexport type BodyView2D = \"front\" | \"back\";\n\n// ---- i18n (F052.12) — every UI string is overridable; da + en ship ----------\n// Region CODE + keys + the bodymap/v1 wire are NEVER touched — labels are\n// display-only, so a report is byte-identical in any language.\n\nexport interface BodyMapLabels {\n /** Region key → display name. */\n regions: Record<string, string>;\n /** PainType → display word (e.g. \"stikkende\" → \"Stabbing\"). */\n qualities: Record<string, string>;\n intensity: string;\n quality: string;\n remove: string;\n front: string;\n back: string;\n empty: string;\n /** Compare view (F052.13). */\n before: string;\n after: string;\n noChange: string;\n change: { new: string; resolved: string; improved: string; worse: string };\n /** Accessible name for a marked region. */\n ariaMarked: (name: string, intensity: number, quality?: string) => string;\n /** Accessible name for an unmarked, selectable region. */\n ariaUnmarked: (name: string) => string;\n svgLabel: string;\n viewLabel: string;\n zoomLabel: string;\n zoomIn: string;\n zoomOut: string;\n zoomReset: string;\n}\n\nconst daRegions: Record<string, string> = Object.fromEntries(REGIONS.map((r) => [r.key, r.label]));\nconst EN_CODE: Record<string, string> = {\n HEAD: \"Head\", NECK: \"Neck\", CHEST: \"Chest\", THORA: \"Upper back\", LUMBAR: \"Lower back\", GROIN: \"Groin\",\n SHOULDER: \"Shoulder\", UARM: \"Upper arm\", ELBOW: \"Elbow\", FARM: \"Forearm\", WRIST: \"Wrist\", HAND: \"Hand\",\n HIP: \"Hip\", THIGH: \"Thigh\", KNEE: \"Knee\", LOWLEG: \"Lower leg\", ANKLE: \"Ankle\", FOOT: \"Foot\",\n};\nconst enRegions: Record<string, string> = Object.fromEntries(\n REGIONS.map((r) => [r.key, EN_CODE[r.code] + (r.side ? (r.side === \"left\" ? \", left\" : \", right\") : \"\")]),\n);\n\nexport const LABELS_DA: BodyMapLabels = {\n regions: daRegions,\n qualities: { stikkende: \"stikkende\", dump: \"dump\", konstant: \"konstant\", jagende: \"jagende\" },\n intensity: \"Intensitet (0-10)\",\n quality: \"Kvalitet\",\n remove: \"Fjern punkt\",\n front: \"Forfra\",\n back: \"Bagfra\",\n empty: \"Vælg en kropsdel for at markere smerte.\",\n before: \"Før\",\n after: \"Efter\",\n noChange: \"Ingen ændring\",\n change: { new: \"nyt\", resolved: \"forsvundet\", improved: \"bedre\", worse: \"værre\" },\n ariaMarked: (n, i, q) => `${n}, smerte ${i} af 10${q ? \", \" + q : \"\"}`,\n ariaUnmarked: (n) => `${n}, ikke markeret. Aktivér for at markere smerte.`,\n svgLabel: \"Kropskort — vælg hvor det gør ondt\",\n viewLabel: \"Visning\",\n zoomLabel: \"Zoom\",\n zoomIn: \"Zoom ind\",\n zoomOut: \"Zoom ud\",\n zoomReset: \"Nulstil zoom\",\n};\n\nexport const LABELS_EN: BodyMapLabels = {\n regions: enRegions,\n qualities: { stikkende: \"stabbing\", dump: \"dull\", konstant: \"constant\", jagende: \"shooting\" },\n intensity: \"Intensity (0-10)\",\n quality: \"Quality\",\n remove: \"Remove point\",\n front: \"Front\",\n back: \"Back\",\n empty: \"Pick a body part to mark pain.\",\n before: \"Before\",\n after: \"After\",\n noChange: \"No change\",\n change: { new: \"new\", resolved: \"resolved\", improved: \"improved\", worse: \"worse\" },\n ariaMarked: (n, i, q) => `${n}, pain ${i} of 10${q ? \", \" + q : \"\"}`,\n ariaUnmarked: (n) => `${n}, not marked. Activate to mark pain.`,\n svgLabel: \"Body map — pick where it hurts\",\n viewLabel: \"View\",\n zoomLabel: \"Zoom\",\n zoomIn: \"Zoom in\",\n zoomOut: \"Zoom out\",\n zoomReset: \"Reset zoom\",\n};\n\nexport type BodyMapLocale = \"da\" | \"en\";\n\nfunction resolveLabels(locale: BodyMapLocale, overrides?: Partial<BodyMapLabels>): BodyMapLabels {\n const base = locale === \"en\" ? LABELS_EN : LABELS_DA;\n if (!overrides) return base;\n return {\n ...base,\n ...overrides,\n regions: { ...base.regions, ...overrides.regions },\n qualities: { ...base.qualities, ...overrides.qualities },\n };\n}\n\nconst VBW = 260;\nconst VBH = 470;\n// The SVG renders ~320px wide → ~1.23 px per viewBox unit. A hit-area needs\n// ~36 units to clear the 44px touch minimum; small regions get an invisible\n// padded hit-shape at least this big (zoom handles genuinely dense clusters).\nconst MIN_HIT = 18; // half-extent (radius) in viewBox units\n\nfunction center(s: Shape): { x: number; y: number } {\n if (s.el === \"rect\") return { x: s.x + s.width / 2, y: s.y + s.height / 2 };\n return { x: s.cx, y: s.cy };\n}\n// An enlarged, invisible hit target so small regions clear the 44px touch min.\nfunction hitShape(s: Shape): Shape {\n if (s.el === \"circle\") return { el: \"circle\", cx: s.cx, cy: s.cy, r: Math.max(s.r, MIN_HIT) };\n if (s.el === \"ellipse\")\n return { el: \"ellipse\", cx: s.cx, cy: s.cy, rx: Math.max(s.rx, MIN_HIT), ry: Math.max(s.ry, MIN_HIT) };\n const c = center(s);\n const w = Math.max(s.width, MIN_HIT * 2);\n const h = Math.max(s.height, MIN_HIT * 2);\n return { el: \"rect\", x: c.x - w / 2, y: c.y - h / 2, width: w, height: h, rx: s.rx };\n}\nfunction heat(v: number): string {\n return v >= 7 ? \"#ef4444\" : v >= 4 ? \"#fb923c\" : \"#fcd34d\";\n}\nfunction heatTier(v: number): \"low\" | \"mid\" | \"high\" {\n return v >= 7 ? \"high\" : v >= 4 ? \"mid\" : \"low\";\n}\n// F052.10 palette: a `palette` prop wins (direct colour); with no prop, a CSS-var\n// chain (--bmap-region-<key> → --bmap-body) themes with the default rainbow as\n// the fallback. Marked regions likewise: palette.heat or --bmap-heat-<tier>.\nfunction baseFill(key: string, rainbow: string, palette?: BodymapPalette): string {\n return palette ? baseColorFor(key, palette) : `var(--bmap-region-${key}, var(--bmap-body, ${rainbow}))`;\n}\nfunction markedFill(v: number, palette?: BodymapPalette): string {\n return palette ? heatFor(v, palette) : `var(--bmap-heat-${heatTier(v)}, ${heat(v)})`;\n}\nfunction shapeEl(s: Shape, props: Record<string, unknown>) {\n if (s.el === \"circle\") return <circle cx={s.cx} cy={s.cy} r={s.r} {...props} />;\n if (s.el === \"ellipse\") return <ellipse cx={s.cx} cy={s.cy} rx={s.rx} ry={s.ry} {...props} />;\n return <rect x={s.x} y={s.y} width={s.width} height={s.height} rx={s.rx} {...props} />;\n}\n\n// ---- one-time style injection (no external CSS; theme via CSS variables) -----\n\nconst STYLE_ID = \"broberg-bodymap-styles\";\nconst STYLE = `\n.bmap{--bmap-accent:var(--primary,#0e8f8a);--bmap-line:#e2e8f0;--bmap-panel:#fff;--bmap-ink:#1e293b;--bmap-muted:#64748b;\n display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--bmap-ink)}\n.bmap__stage{display:flex;flex-direction:column;gap:10px;flex:0 1 340px;min-width:0;max-width:360px}\n.bmap__bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}\n.bmap__viewtoggle{display:inline-flex;background:#f1f5f9;border:1px solid var(--bmap-line);border-radius:10px;padding:3px;gap:2px}\n.bmap__vbtn{font:inherit;font-size:13px;font-weight:600;color:#556274;background:none;border:0;border-radius:7px;padding:8px 16px;cursor:pointer;transition:color .12s,background .12s,transform .1s}\n.bmap__vbtn:hover{color:var(--bmap-ink)}\n.bmap__vbtn:active{transform:scale(.97)}\n.bmap__vbtn--on{background:#fff;color:var(--bmap-ink);box-shadow:0 1px 2px rgba(15,23,42,.14)}\n.bmap__zoom{display:inline-flex;gap:4px;margin-left:auto}\n.bmap__zoom button{font:inherit;font-weight:700;font-size:16px;line-height:1;width:36px;height:36px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-ink);border-radius:8px;cursor:pointer;transition:border-color .12s,transform .1s;display:flex;align-items:center;justify-content:center}\n.bmap__zoom button:hover{border-color:var(--bmap-accent)}\n.bmap__zoom button:active{transform:scale(.9)}\n.bmap__zoom button:disabled{opacity:.4;cursor:default}\n.bmap__svg{width:100%;height:auto;display:block;touch-action:none;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;background:transparent;border-radius:14px}\n.bmap__vis{stroke:#fff;stroke-width:2.4;pointer-events:none;transition:filter .12s,stroke .12s,stroke-width .12s}\n.bmap__vis--sel{stroke:var(--bmap-accent);stroke-width:3.6}\n.bmap__hit{fill:transparent;cursor:pointer;outline:none}\n.bmap__hit--locked{cursor:default}\n.bmap__hit:hover + .bmap__vis{filter:brightness(1.08) saturate(1.15)}\n.bmap__hit:focus-visible + .bmap__vis{stroke:var(--bmap-accent);stroke-width:4;stroke-dasharray:4 2.5}\n.bmap__heatn{font:700 12px ui-sans-serif;fill:#fff;pointer-events:none}\n.bmap__panel{flex:1 1 240px;min-width:220px;border:1px solid var(--bmap-line);border-radius:14px;padding:16px 18px;background:var(--bmap-panel)}\n.bmap__panel--empty{color:var(--bmap-muted);font-size:13.5px}\n.bmap__ph{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px}\n.bmap__ph b{font-size:16px}\n.bmap__code{font:11px ui-monospace,monospace;color:var(--bmap-muted);background:#f1f5f9;border-radius:6px;padding:2px 7px}\n.bmap__lbl{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--bmap-muted);margin:0 0 7px}\n.bmap__scale{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}\n.bmap__i{font:inherit;font-size:14px;font-weight:600;width:38px;height:38px;border-radius:9px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-ink);cursor:pointer;transition:border-color .1s,background .1s,transform .1s}\n.bmap__i:hover{border-color:var(--bmap-accent)}\n.bmap__i:active{transform:scale(.9)}\n.bmap__i--on{background:var(--bmap-accent);border-color:var(--bmap-accent);color:#fff}\n.bmap__chips{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:14px}\n.bmap__chip{font:inherit;font-size:13px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-muted);border-radius:999px;padding:9px 15px;cursor:pointer;transition:.1s}\n.bmap__chip:hover{border-color:var(--bmap-accent);color:var(--bmap-accent)}\n.bmap__chip:active{transform:scale(.96)}\n.bmap__chip--on{background:var(--bmap-ink);border-color:var(--bmap-ink);color:#fff}\n.bmap__rm{font:inherit;font-size:14px;font-weight:600;border:1px solid #f6c9c9;background:#fff;color:#ef4444;border-radius:9px;padding:10px 15px;cursor:pointer;transition:.1s}\n.bmap__rm:hover{background:#fef2f2}\n.bmap__rm:active{transform:scale(.97)}\n.bmap__i:focus-visible,.bmap__chip:focus-visible,.bmap__vbtn:focus-visible,.bmap__rm:focus-visible,.bmap__zoom button:focus-visible{outline:2px solid var(--bmap-accent);outline-offset:2px}\n.bmapc{--bmap-accent:var(--primary,#0e8f8a);--bmap-line:#e2e8f0;--bmap-ink:#1e293b;--bmap-muted:#64748b;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--bmap-ink)}\n.bmapc__bodies{display:flex;gap:14px;flex-wrap:wrap}\n.bmapc__fig{flex:1 1 150px;min-width:130px;max-width:220px;margin:0;text-align:center}\n.bmapc__cap{font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#556274;margin-bottom:6px}\n.bmapc__svg{width:100%;height:auto;display:block}\n.bmapc__vis{stroke:#fff;stroke-width:2.4}\n.bmapc__n{font:700 12px ui-sans-serif;fill:#fff;pointer-events:none}\n.bmapc__delta{margin-top:16px;display:flex;flex-direction:column;gap:6px;max-width:460px}\n.bmapc__row{display:flex;align-items:center;gap:10px;font-size:13.5px;border:1px solid var(--bmap-line);border-radius:9px;padding:7px 11px}\n.bmapc__rname{font-weight:600;flex:1 1 auto}\n.bmapc__rval{font:12px ui-monospace,monospace;color:#556274;white-space:nowrap}\n.bmapc__badge{font-size:11px;font-weight:700;border-radius:6px;padding:2px 8px;white-space:nowrap}\n.bmapc__badge--improved{background:#dcfce7;color:#166534}\n.bmapc__badge--worse{background:#fee2e2;color:#991b1b}\n.bmapc__badge--new{background:#fef3c7;color:#92400e}\n.bmapc__badge--resolved{background:#e2e8f0;color:#475569}\n.bmapc__empty{color:#556274;font-size:13.5px;margin-top:14px}\n@media (max-width:560px){\n .bmap{flex-direction:column;gap:14px}\n .bmap__stage{max-width:100%;width:100%;flex-basis:auto}\n .bmap__panel{width:100%}\n .bmap__i{width:46px;height:46px;font-size:16px}\n .bmap__chip{padding:11px 17px;font-size:15px}\n .bmap__vbtn{padding:10px 18px;font-size:15px}\n}\n@media (prefers-reduced-motion:reduce){.bmap *{transition:none !important}}\n`;\n\nfunction ensureStyles(): void {\n if (typeof document === \"undefined\") return;\n if (document.getElementById(STYLE_ID)) return;\n const el = document.createElement(\"style\");\n el.id = STYLE_ID;\n el.textContent = STYLE;\n document.head.appendChild(el);\n}\n\n// ---- component ---------------------------------------------------------------\n\nexport interface BodyMapProps {\n /** Controlled report. Omit for an uncontrolled component (use defaultValue). */\n value?: PainReport;\n /** Initial report when uncontrolled. */\n defaultValue?: PainReport;\n /** Fired with the full validated PainReport on every change. */\n onChange?: (report: PainReport) => void;\n /** Per-app region config (visible / selectable). */\n config?: RegionConfig;\n /** Initial body view (front / back). Default \"front\". */\n defaultView?: BodyView2D;\n /** Fired when the user toggles the front/back view — pass it to\n * serializeReport({ view }) so the wire report carries the active view. */\n onViewChange?: (view: BodyView2D) => void;\n /** Colour theme (F052.10). Overrides the default rainbow: body/hover/selected/\n * heat + optional per-region base colours. Without it, the rainbow is used\n * (themeable via --bmap-body / --bmap-region-<key> / --bmap-heat-* CSS vars). */\n palette?: BodymapPalette;\n /** UI language (F052.12). \"da\" (default) or \"en\". */\n locale?: BodyMapLocale;\n /** Override individual UI strings (region names, headings, quality words, …).\n * Merged over the locale set — region CODE + wire format are never affected. */\n labels?: Partial<BodyMapLabels>;\n /** Display-only mode (F052.9): render a saved report coloured by intensity\n * with NO picker and no interactivity. For journals, PDFs, clinician views. */\n readOnly?: boolean;\n className?: string;\n}\n\nconst ZMIN = 1;\nconst ZMAX = 4;\nconst clampPan = (p: { x: number; y: number }, z: number) => ({\n x: Math.min(0, Math.max(VBW * (1 - z), p.x)),\n y: Math.min(0, Math.max(VBH * (1 - z), p.y)),\n});\n\n/** 2D SVG body pain-map. Click/tap a region → set intensity + quality → PainReport. */\nexport function BodyMap({\n value, defaultValue, onChange, config = {}, defaultView = \"front\", onViewChange,\n palette, locale = \"da\", labels, readOnly = false, className,\n}: BodyMapProps) {\n useEffect(ensureStyles, []);\n const [internal, setInternal] = useState<PainReport>(defaultValue ?? []);\n const [selected, setSelected] = useState<string | null>(null);\n const [view, setView] = useState<BodyView2D>(defaultView);\n const [zoom, setZoom] = useState(1);\n const [pan, setPan] = useState({ x: 0, y: 0 });\n const report = value ?? internal;\n const shapes = view === \"back\" ? SHAPES_BACK : SHAPES;\n const fills = view === \"back\" ? FILL_BACK : FILL;\n const L = resolveLabels(locale, labels);\n const nameOf = (key: string) => L.regions[key] ?? getRegion(key)?.label ?? key;\n\n const svgRef = useRef<SVGSVGElement>(null);\n const zoomRef = useRef(1);\n const panRef = useRef({ x: 0, y: 0 });\n const pointers = useRef(new Map<number, { x: number; y: number }>());\n const gesture = useRef({ startDist: 0, startZoom: 1, midVB: { x: 0, y: 0 }, start: { x: 0, y: 0 }, moved: false });\n const suppressClick = useRef(false);\n\n const setZP = (z: number, p: { x: number; y: number }) => {\n const cz = Math.max(ZMIN, Math.min(ZMAX, z));\n const cp = clampPan(p, cz);\n zoomRef.current = cz; panRef.current = cp;\n setZoom(cz); setPan(cp);\n };\n const screenToVB = (cx: number, cy: number) => {\n const el = svgRef.current;\n if (!el) return { x: VBW / 2, y: VBH / 2 };\n const r = el.getBoundingClientRect();\n if (!r.width || !r.height) return { x: VBW / 2, y: VBH / 2 };\n return { x: ((cx - r.left) / r.width) * VBW, y: ((cy - r.top) / r.height) * VBH };\n };\n // Zoom keeping the given viewBox focal point under the cursor/fingers.\n const zoomTo = (nz: number, focalVB: { x: number; y: number }) => {\n const z = Math.max(ZMIN, Math.min(ZMAX, nz));\n const p = panRef.current;\n setZP(z, { x: focalVB.x - (focalVB.x - p.x) * (z / zoomRef.current), y: focalVB.y - (focalVB.y - p.y) * (z / zoomRef.current) });\n };\n const zoomBtn = (factor: number) => zoomTo(zoomRef.current * factor, { x: VBW / 2, y: VBH / 2 });\n const resetZoom = () => setZP(1, { x: 0, y: 0 });\n\n const onPointerDown = (e: React.PointerEvent) => {\n pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });\n const pts = [...pointers.current.values()];\n if (pts.length === 2) {\n gesture.current.startDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);\n gesture.current.startZoom = zoomRef.current;\n gesture.current.midVB = screenToVB((pts[0].x + pts[1].x) / 2, (pts[0].y + pts[1].y) / 2);\n gesture.current.moved = true;\n suppressClick.current = true;\n } else {\n gesture.current.start = { x: e.clientX, y: e.clientY };\n gesture.current.moved = false;\n }\n };\n const onPointerMove = (e: React.PointerEvent) => {\n const prev = pointers.current.get(e.pointerId);\n if (!prev) return;\n pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });\n const pts = [...pointers.current.values()];\n if (pts.length >= 2) {\n const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);\n if (gesture.current.startDist > 0) zoomTo(gesture.current.startZoom * (dist / gesture.current.startDist), gesture.current.midVB);\n return;\n }\n const el = svgRef.current;\n const rect = el?.getBoundingClientRect();\n if (Math.hypot(e.clientX - gesture.current.start.x, e.clientY - gesture.current.start.y) > 6) {\n gesture.current.moved = true;\n suppressClick.current = true;\n }\n if (zoomRef.current > 1 && rect && rect.width) {\n const dxVB = ((e.clientX - prev.x) / rect.width) * VBW;\n const dyVB = ((e.clientY - prev.y) / rect.height) * VBH;\n setZP(zoomRef.current, { x: panRef.current.x + dxVB, y: panRef.current.y + dyVB });\n }\n };\n const onPointerUp = (e: React.PointerEvent) => {\n pointers.current.delete(e.pointerId);\n if (pointers.current.size === 0) window.setTimeout(() => { suppressClick.current = false; }, 60);\n };\n\n const changeView = (v: BodyView2D) => {\n if (v === view) return;\n setView(v);\n const sh = v === \"back\" ? SHAPES_BACK : SHAPES;\n if (selected && !sh[selected]) setSelected(null); // selected region absent in the new view\n onViewChange?.(v);\n };\n\n const commit = (next: PainReport) => {\n if (value === undefined) setInternal(next);\n onChange?.(next);\n };\n const pointOf = (key: string) => report.find((p) => p.region === key);\n const setPain = (key: string, intensity: number, type?: PainType) => {\n const point = painPointSchema.parse({ region: key, intensity, type, timestamp: new Date().toISOString() });\n commit([...report.filter((p) => p.region !== key), point]);\n };\n const removePain = (key: string) => {\n commit(report.filter((p) => p.region !== key));\n setSelected(null);\n };\n const pickRegion = (key: string) => {\n if (suppressClick.current) { suppressClick.current = false; return; } // a pan/pinch just ended — not a tap\n setSelected(key);\n };\n\n const regions = resolveRegions(config);\n const region: BodyRegion | undefined = selected ? REGIONS.find((r) => r.key === selected) : undefined;\n const current = selected ? pointOf(selected) : undefined;\n const rootCls = [\"bmap\", className].filter(Boolean).join(\" \");\n // palette.selected drives the focus/selected stroke via --bmap-accent.\n const rootStyle = palette ? ({ \"--bmap-accent\": palette.selected } as React.CSSProperties) : undefined;\n const ariaFor = (r: BodyRegion, marked?: PainReport[number], interactive = true): string => {\n if (marked) return L.ariaMarked(nameOf(r.key), marked.intensity, marked.type ? L.qualities[marked.type] : undefined);\n return interactive ? L.ariaUnmarked(nameOf(r.key)) : nameOf(r.key);\n };\n\n return (\n <div className={rootCls} data-testid=\"bodymap-root\" style={rootStyle}>\n <div className=\"bmap__stage\">\n <div className=\"bmap__bar\">\n <div className=\"bmap__viewtoggle\" role=\"group\" aria-label={L.viewLabel}>\n <button\n type=\"button\"\n className={\"bmap__vbtn\" + (view === \"front\" ? \" bmap__vbtn--on\" : \"\")}\n data-testid=\"bodymap-view-front\"\n aria-pressed={view === \"front\"}\n onClick={() => changeView(\"front\")}\n >\n {L.front}\n </button>\n <button\n type=\"button\"\n className={\"bmap__vbtn\" + (view === \"back\" ? \" bmap__vbtn--on\" : \"\")}\n data-testid=\"bodymap-view-back\"\n aria-pressed={view === \"back\"}\n onClick={() => changeView(\"back\")}\n >\n {L.back}\n </button>\n </div>\n <div className=\"bmap__zoom\" role=\"group\" aria-label={L.zoomLabel}>\n <button type=\"button\" data-testid=\"bodymap-zoom-out\" aria-label={L.zoomOut} disabled={zoom <= ZMIN} onClick={() => zoomBtn(1 / 1.4)}>−</button>\n <button type=\"button\" data-testid=\"bodymap-zoom-reset\" aria-label={L.zoomReset} disabled={zoom === 1 && pan.x === 0 && pan.y === 0} onClick={resetZoom}>⤢</button>\n <button type=\"button\" data-testid=\"bodymap-zoom-in\" aria-label={L.zoomIn} disabled={zoom >= ZMAX} onClick={() => zoomBtn(1.4)}>+</button>\n </div>\n </div>\n <svg\n ref={svgRef}\n className=\"bmap__svg\"\n viewBox=\"0 0 260 470\"\n role=\"group\"\n aria-label={L.svgLabel}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n onPointerCancel={onPointerUp}\n onWheel={(e) => zoomTo(zoomRef.current * (e.deltaY < 0 ? 1.12 : 1 / 1.12), screenToVB(e.clientX, e.clientY))}\n >\n <g transform={`translate(${pan.x} ${pan.y}) scale(${zoom})`}>\n {regions.map((r) => {\n const s = shapes[r.key];\n if (!s) return null;\n const marked = pointOf(r.key);\n const interactive = isSelectable(r.key, config) && !readOnly;\n const visCls = [\"bmap__vis\", selected === r.key && \"bmap__vis--sel\"].filter(Boolean).join(\" \");\n const fill = marked ? markedFill(marked.intensity, palette) : baseFill(r.key, fills[r.key] ?? \"#cbd5e1\", palette);\n const hs = hitShape(s);\n return (\n <g key={r.key}>\n {shapeEl(hs, {\n className: \"bmap__hit\" + (interactive ? \"\" : \" bmap__hit--locked\"),\n \"data-testid\": `bodymap-region-${r.key}`,\n role: interactive ? \"button\" : \"img\",\n tabIndex: interactive ? 0 : -1,\n \"aria-label\": ariaFor(r, marked, interactive),\n \"aria-pressed\": interactive ? selected === r.key : undefined,\n onClick: interactive ? () => pickRegion(r.key) : undefined,\n onKeyDown: interactive\n ? (e: React.KeyboardEvent) => { if (e.key === \"Enter\" || e.key === \" \") { e.preventDefault(); suppressClick.current = false; setSelected(r.key); } }\n : undefined,\n })}\n {shapeEl(s, { className: visCls, fill, opacity: !readOnly && !isSelectable(r.key, config) ? 0.4 : 1 })}\n </g>\n );\n })}\n {/* intensity numbers on marked regions visible in the current view */}\n {report.map((p) => {\n const s = shapes[p.region];\n if (!s || config[p.region]?.visible === false) return null;\n const c = center(s);\n return (\n <text key={p.region} x={c.x} y={c.y + 4} textAnchor=\"middle\" className=\"bmap__heatn\">\n {p.intensity}\n </text>\n );\n })}\n </g>\n </svg>\n </div>\n\n {/* picker panel — hidden entirely in readOnly (display-only) mode */}\n {readOnly ? null : region ? (\n <div className=\"bmap__panel\" data-testid=\"bodymap-panel\">\n <div className=\"bmap__ph\">\n <b>{nameOf(region.key)}</b>\n <span className=\"bmap__code\">{region.code}</span>\n </div>\n <p className=\"bmap__lbl\" id=\"bmap-intensity-lbl\">{L.intensity}</p>\n <div className=\"bmap__scale\" role=\"group\" aria-labelledby=\"bmap-intensity-lbl\">\n {Array.from({ length: 11 }, (_, n) => (\n <button\n key={n}\n type=\"button\"\n className={\"bmap__i\" + (current?.intensity === n ? \" bmap__i--on\" : \"\")}\n data-testid={`bodymap-intensity-${n}`}\n aria-pressed={current?.intensity === n}\n onClick={() => setPain(region.key, n, current?.type)}\n >\n {n}\n </button>\n ))}\n </div>\n <p className=\"bmap__lbl\" id=\"bmap-quality-lbl\">{L.quality}</p>\n <div className=\"bmap__chips\" role=\"group\" aria-labelledby=\"bmap-quality-lbl\">\n {PAIN_TYPES.map((t) => (\n <button\n key={t}\n type=\"button\"\n className={\"bmap__chip\" + (current?.type === t ? \" bmap__chip--on\" : \"\")}\n data-testid={`bodymap-type-${t}`}\n aria-pressed={current?.type === t}\n onClick={() => setPain(region.key, current?.intensity ?? 5, t)}\n >\n {L.qualities[t]}\n </button>\n ))}\n </div>\n {current && (\n <button type=\"button\" className=\"bmap__rm\" data-testid=\"bodymap-remove\" onClick={() => removePain(region.key)}>\n {L.remove}\n </button>\n )}\n </div>\n ) : (\n <div className=\"bmap__panel bmap__panel--empty\" data-testid=\"bodymap-panel\">\n {L.empty}\n </div>\n )}\n </div>\n );\n}\n\n// ---- <BodyMapCompare> — before/after progress (F052.13) ---------------------\n// Two read-only bodies side by side + a per-region change list (improved /\n// worse / new / resolved). Pure UI over two PainReports — no network. NB: the\n// \"nudge the user to update their report over time\" is the CONSUMER app's job\n// (fd-sundhed's mobile app via @broberg/webpush), NOT this component.\n\ntype RegionChange = \"new\" | \"resolved\" | \"improved\" | \"worse\" | \"same\";\nfunction changeOf(b?: number, a?: number): RegionChange {\n if (b == null && a != null) return \"new\";\n if (b != null && a == null) return \"resolved\";\n if (b == null || a == null) return \"same\";\n return a < b ? \"improved\" : a > b ? \"worse\" : \"same\";\n}\n\nfunction CompareFigure({ report, view, palette, caption, testid }: {\n report: PainReport; view: BodyView2D; palette?: BodymapPalette; caption: string; testid: string;\n}) {\n const shapes = view === \"back\" ? SHAPES_BACK : SHAPES;\n const fills = view === \"back\" ? FILL_BACK : FILL;\n return (\n <figure className=\"bmapc__fig\" data-testid={testid}>\n <figcaption className=\"bmapc__cap\">{caption}</figcaption>\n <svg className=\"bmapc__svg\" viewBox=\"0 0 260 470\" role=\"img\" aria-label={caption}>\n {REGIONS.map((r) => {\n const s = shapes[r.key];\n if (!s) return null;\n const m = report.find((p) => p.region === r.key);\n const fill = m ? markedFill(m.intensity, palette) : baseFill(r.key, fills[r.key] ?? \"#cbd5e1\", palette);\n return <g key={r.key}>{shapeEl(s, { className: \"bmapc__vis\", fill })}</g>;\n })}\n {report.map((p) => {\n const s = shapes[p.region];\n if (!s) return null;\n const c = center(s);\n return (\n <text key={p.region} x={c.x} y={c.y + 4} textAnchor=\"middle\" className=\"bmapc__n\">{p.intensity}</text>\n );\n })}\n </svg>\n </figure>\n );\n}\n\nexport interface BodyMapCompareProps {\n /** The earlier report. */\n before: PainReport;\n /** The later report. */\n after: PainReport;\n palette?: BodymapPalette;\n locale?: BodyMapLocale;\n labels?: Partial<BodyMapLabels>;\n defaultView?: BodyView2D;\n className?: string;\n}\n\n/** Read-only before/after view: two bodies side by side + a per-region change list. */\nexport function BodyMapCompare({\n before, after, palette, locale = \"da\", labels, defaultView = \"front\", className,\n}: BodyMapCompareProps) {\n useEffect(ensureStyles, []);\n const [view, setView] = useState<BodyView2D>(defaultView);\n const L = resolveLabels(locale, labels);\n const nameOf = (k: string) => L.regions[k] ?? getRegion(k)?.label ?? k;\n const rootStyle = palette ? ({ \"--bmap-accent\": palette.selected } as React.CSSProperties) : undefined;\n\n const keys = Array.from(new Set([...before, ...after].map((p) => p.region)));\n const changes = keys\n .map((k) => ({\n key: k,\n b: before.find((p) => p.region === k)?.intensity,\n a: after.find((p) => p.region === k)?.intensity,\n status: changeOf(before.find((p) => p.region === k)?.intensity, after.find((p) => p.region === k)?.intensity),\n }))\n .filter((c) => c.status !== \"same\")\n .sort((x, y) => (x.key < y.key ? -1 : 1));\n\n return (\n <div className={[\"bmapc\", className].filter(Boolean).join(\" \")} data-testid=\"bodymap-compare\" style={rootStyle}>\n <div className=\"bmap__viewtoggle\" role=\"group\" aria-label={L.viewLabel} style={{ marginBottom: 12 }}>\n <button type=\"button\" className={\"bmap__vbtn\" + (view === \"front\" ? \" bmap__vbtn--on\" : \"\")} data-testid=\"bodymap-compare-view-front\" aria-pressed={view === \"front\"} onClick={() => setView(\"front\")}>{L.front}</button>\n <button type=\"button\" className={\"bmap__vbtn\" + (view === \"back\" ? \" bmap__vbtn--on\" : \"\")} data-testid=\"bodymap-compare-view-back\" aria-pressed={view === \"back\"} onClick={() => setView(\"back\")}>{L.back}</button>\n </div>\n <div className=\"bmapc__bodies\">\n <CompareFigure report={before} view={view} palette={palette} caption={L.before} testid=\"bodymap-compare-before\" />\n <CompareFigure report={after} view={view} palette={palette} caption={L.after} testid=\"bodymap-compare-after\" />\n </div>\n {changes.length ? (\n <div className=\"bmapc__delta\" data-testid=\"bodymap-compare-delta\">\n {changes.map((c) => (\n <div key={c.key} className=\"bmapc__row\" data-testid={`bodymap-compare-delta-${c.key}`}>\n <span className=\"bmapc__rname\">{nameOf(c.key)}</span>\n <span className=\"bmapc__rval\">{c.b ?? \"–\"} → {c.a ?? \"–\"}</span>\n <span className={`bmapc__badge bmapc__badge--${c.status}`}>{L.change[c.status as Exclude<RegionChange, \"same\">]}</span>\n </div>\n ))}\n </div>\n ) : (\n <div className=\"bmapc__empty\" data-testid=\"bodymap-compare-delta\">{L.noChange}</div>\n )}\n </div>\n );\n}\n","/**\n * @broberg/bodymap/three — a rotatable 3D body pain-map on the SAME core.\n *\n * `<BodyMap3D>` wraps a VANILLA three.js scene (NOT react-three-fiber, so it\n * runs in React, Preact and a Capacitor webview alike): a realistic Blender\n * Studio human base mesh (CC0), rotatable (drag) + zoomable (scroll), with\n * hover-highlight and click-to-mark — a click colours the body part by pain\n * intensity and produces the same structured PainReport (bodymap/v1) the 2D\n * renderer does. Region hit-testing is anchor-based: every mesh vertex is\n * assigned to its nearest region anchor (true per-zone mesh segmentation is a\n * later refinement — the wire + interaction are identical either way).\n *\n * `three` is an OPTIONAL peer — only consumers importing this subpath pull it\n * in. The body models are NOT bundled: pass `models={{ male, female }}` as URLs\n * you host (the package ships reference GLBs under `@broberg/bodymap/models/`).\n */\nimport { useEffect, useRef, useState } from \"react\";\nimport * as THREE from \"three\";\nimport { GLTFLoader } from \"three/examples/jsm/loaders/GLTFLoader.js\";\nimport { OrbitControls } from \"three/examples/jsm/controls/OrbitControls.js\";\nimport {\n REGIONS,\n PAIN_TYPES,\n getRegion,\n isSelectable,\n serializeReport,\n heatFor,\n baseColorFor,\n defaultPalette,\n type BodymapPalette,\n type PainReport,\n type PainType,\n type RegionConfig,\n} from \"./index.js\";\nimport {\n LABELS_DA,\n LABELS_EN,\n type BodyMapLabels,\n type BodyMapLocale,\n} from \"./react.js\";\n\nexport type BodyMap3DSex = \"male\" | \"female\";\n\n/** URLs of the body GLB models the consumer hosts (reference GLBs ship under `@broberg/bodymap/models/`). */\nexport interface BodyMap3DModels {\n male: string;\n female: string;\n}\n\n/** The 3D-only control strings (the shared labels come from `locale`/`labels`). */\nexport interface BodyMap3DUiLabels {\n male: string;\n female: string;\n hoverHint: string;\n}\n\nconst UI_DA: BodyMap3DUiLabels = { male: \"Mand\", female: \"Kvinde\", hoverHint: \"Hover for at fremhæve · klik en kropsdel for at markere smerte.\" };\nconst UI_EN: BodyMap3DUiLabels = { male: \"Male\", female: \"Female\", hoverHint: \"Hover to highlight · tap a body part to mark pain.\" };\n\n// Region anchors in normalised body space (height ~1.9, feet y=0, front +z),\n// then x-flipped so the patient's own left maps to \"venstre\" (self-view).\nconst ANCHORS: Record<string, [number, number, number]> = {\n head: [0, 1.79, 0.02], neck: [0, 1.57, 0.0],\n chest: [0, 1.42, 0.11], thora: [0, 1.42, -0.12], lumbar: [0, 1.13, -0.13], groin: [0, 0.92, 0.09],\n shoulder_left: [-0.2, 1.5, 0], shoulder_right: [0.2, 1.5, 0],\n uarm_left: [-0.27, 1.3, 0.0], uarm_right: [0.27, 1.3, 0.0],\n elbow_left: [-0.31, 1.08, 0], elbow_right: [0.31, 1.08, 0],\n farm_left: [-0.34, 0.93, 0.02], farm_right: [0.34, 0.93, 0.02],\n wrist_left: [-0.36, 0.79, 0.02], wrist_right: [0.36, 0.79, 0.02],\n hand_left: [-0.37, 0.68, 0.03], hand_right: [0.37, 0.68, 0.03],\n hip_left: [-0.14, 1.02, -0.03], hip_right: [0.14, 1.02, -0.03],\n thigh_left: [-0.1, 0.68, 0.05], thigh_right: [0.1, 0.68, 0.05],\n knee_left: [-0.1, 0.4, 0.06], knee_right: [0.1, 0.4, 0.06],\n lowleg_left: [-0.1, 0.22, 0.04], lowleg_right: [0.1, 0.22, 0.04],\n ankle_left: [-0.1, 0.05, 0.02], ankle_right: [0.1, 0.05, 0.02],\n foot_left: [-0.1, 0.02, 0.11], foot_right: [0.1, 0.02, 0.11],\n};\nconst ANCHOR_KEYS = Object.keys(ANCHORS);\nfor (const k of ANCHOR_KEYS) ANCHORS[k][0] = -ANCHORS[k][0];\n\nfunction mergeLabels(locale: BodyMapLocale, overrides?: Partial<BodyMapLabels>): BodyMapLabels {\n const base = locale === \"en\" ? LABELS_EN : LABELS_DA;\n if (!overrides) return base;\n return {\n ...base,\n ...overrides,\n regions: { ...base.regions, ...overrides.regions },\n qualities: { ...base.qualities, ...overrides.qualities },\n };\n}\n\nfunction webglAvailable(): boolean {\n try {\n const c = document.createElement(\"canvas\");\n return !!(c.getContext(\"webgl\") || c.getContext(\"experimental-webgl\"));\n } catch {\n return false;\n }\n}\n\nexport interface BodyMap3DProps {\n /** URLs of the male/female body GLBs (you host them; reference GLBs ship under `@broberg/bodymap/models/`). */\n models: BodyMap3DModels;\n value?: PainReport;\n defaultValue?: PainReport;\n onChange?: (report: PainReport) => void;\n /** Per-app region config — a non-selectable region is not pickable in 3D either. */\n config?: RegionConfig;\n palette?: BodymapPalette;\n locale?: BodyMapLocale;\n labels?: Partial<BodyMapLabels>;\n /** Override the 3D-only control strings (male/female/hoverHint). */\n ui?: Partial<BodyMap3DUiLabels>;\n defaultSex?: BodyMap3DSex;\n onSexChange?: (sex: BodyMap3DSex) => void;\n /** Controlled body type — when set, the parent owns it (e.g. from the user's profile); `onSexChange` still fires. */\n sex?: BodyMap3DSex;\n /** Show the Male/Female toggle (default true). Set false when `sex` comes from a profile and the picker would just be noise. */\n showSexToggle?: boolean;\n /** Slowly auto-rotate until the user interacts (default true). */\n autoRotate?: boolean;\n className?: string;\n}\n\nconst btn: React.CSSProperties = { font: \"inherit\", cursor: \"pointer\", borderRadius: 8, border: \"1px solid #e2e8f0\", background: \"#fff\", padding: \"6px 9px\" };\nconst seg = (on: boolean): React.CSSProperties => ({ ...btn, background: on ? \"#0e8f8a\" : \"#fff\", color: on ? \"#fff\" : \"#1e293b\", fontWeight: 600 });\n\nexport function BodyMap3D(props: BodyMap3DProps) {\n const {\n models, value, defaultValue, onChange, config, palette = defaultPalette,\n locale = \"da\", labels, ui, defaultSex = \"male\", sex: sexProp, showSexToggle = true, onSexChange,\n autoRotate = true, className,\n } = props;\n\n const L = mergeLabels(locale, labels);\n const UI = { ...(locale === \"en\" ? UI_EN : UI_DA), ...ui };\n const nameOf = (key: string) => L.regions[key] ?? getRegion(key)?.label ?? key;\n\n const mountRef = useRef<HTMLDivElement>(null);\n const loadedRef = useRef<HTMLSpanElement>(null);\n const [unsupported, setUnsupported] = useState(false);\n const [ready, setReady] = useState(false);\n const [internalSex, setInternalSex] = useState<BodyMap3DSex>(defaultSex);\n const sex = sexProp ?? internalSex; // controlled when `sex` is passed, else internal\n const [selected, setSelected] = useState<string | null>(null);\n const [internal, setInternal] = useState<PainReport>(defaultValue ?? []);\n const report = value ?? internal;\n\n const commit = (next: PainReport) => {\n if (value === undefined) setInternal(next);\n onChange?.(next);\n };\n\n // Latest state visible to the once-mounted imperative scene.\n const reportRef = useRef(report); reportRef.current = report;\n const selectedRef = useRef(selected); selectedRef.current = selected;\n const paletteRef = useRef(palette); paletteRef.current = palette;\n const configRef = useRef(config); configRef.current = config;\n const modelsRef = useRef(models); modelsRef.current = models;\n const sexRef = useRef(sex); sexRef.current = sex;\n const setSelectedRef = useRef(setSelected); setSelectedRef.current = setSelected;\n const setReadyRef = useRef(setReady); setReadyRef.current = setReady;\n const apiRef = useRef<{ setSex: (s: BodyMap3DSex) => void; refresh: () => void } | null>(null);\n\n useEffect(() => {\n if (!webglAvailable()) { setUnsupported(true); return; }\n const el = mountRef.current;\n if (!el) return;\n let W = el.clientWidth || 520, H = el.clientHeight || 600;\n\n const scene = new THREE.Scene();\n scene.background = new THREE.Color(0x0e1424);\n const camera = new THREE.PerspectiveCamera(32, W / H, 0.1, 100);\n camera.position.set(0, 1.05, 4.4);\n let renderer: THREE.WebGLRenderer;\n try {\n renderer = new THREE.WebGLRenderer({ antialias: true });\n } catch {\n setUnsupported(true);\n return;\n }\n renderer.setSize(W, H);\n renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n el.appendChild(renderer.domElement);\n scene.add(new THREE.HemisphereLight(0xffffff, 0x223044, 1.05));\n const kl = new THREE.DirectionalLight(0xffffff, 1.5); kl.position.set(3, 5, 4); scene.add(kl);\n const rl = new THREE.DirectionalLight(0x88aaff, 0.7); rl.position.set(-4, 2, -3); scene.add(rl);\n\n const controls = new OrbitControls(camera, renderer.domElement);\n controls.enableDamping = true; controls.dampingFactor = 0.08;\n controls.autoRotate = autoRotate; controls.autoRotateSpeed = 1.1;\n controls.minDistance = 2.2; controls.maxDistance = 8; controls.enablePan = false;\n controls.target.set(0, 0.95, 0);\n controls.addEventListener(\"start\", () => { controls.autoRotate = false; });\n\n // On-demand rendering: render only while auto-rotating or while damping is\n // still settling, then STOP — so the page goes idle. That saves battery on\n // mobile (bodymap is primarily a mobile surface) AND lets a headless\n // Lens/Playwright run actually land clicks instead of starving on a\n // never-quiet WebGL loop (fd-sundhed's F052.6-preview finding). A control\n // 'change' / interaction / state change kicks the pump back to life.\n let raf = 0;\n let pumping = false;\n const renderFrame = () => renderer.render(scene, camera);\n const pump = () => {\n const moved = controls.update();\n renderFrame();\n if (moved || controls.autoRotate) { raf = requestAnimationFrame(pump); }\n else { pumping = false; raf = 0; }\n };\n const kick = () => { if (!pumping && !document.hidden) { pumping = true; raf = requestAnimationFrame(pump); } };\n controls.addEventListener(\"change\", kick);\n\n let modelRoot: THREE.Object3D | null = null;\n let bodyMesh: THREE.Mesh | null = null;\n let vertexRegion: string[] = [];\n let colorAttr: THREE.BufferAttribute | null = null;\n let hovered: string | null = null;\n const loader = new GLTFLoader();\n const anchorVecs = ANCHOR_KEYS.map((k) => new THREE.Vector3(...ANCHORS[k]));\n const tmp = new THREE.Color();\n\n const restingHex = (key: string): string => {\n const pt = reportRef.current.find((p) => p.region === key);\n if (pt) return heatFor(pt.intensity, paletteRef.current);\n if (selectedRef.current === key) return paletteRef.current.selected;\n return baseColorFor(key, paletteRef.current);\n };\n const colorRegion = (key: string, hex: string) => {\n if (!colorAttr) return;\n tmp.set(hex);\n for (let i = 0; i < vertexRegion.length; i++) if (vertexRegion[i] === key) colorAttr.setXYZ(i, tmp.r, tmp.g, tmp.b);\n colorAttr.needsUpdate = true;\n };\n const refresh = () => { for (const k of ANCHOR_KEYS) colorRegion(k, hovered === k ? paletteRef.current.hover : restingHex(k)); renderFrame(); };\n\n const loadModel = (which: BodyMap3DSex) => {\n const url = which === \"female\" ? modelsRef.current.female : modelsRef.current.male;\n loader.load(url, (gltf) => {\n if (modelRoot) scene.remove(modelRoot);\n const model = gltf.scene;\n const box = new THREE.Box3().setFromObject(model);\n const size = new THREE.Vector3(); box.getSize(size);\n const center = new THREE.Vector3(); box.getCenter(center);\n const scale = 1.9 / size.y;\n model.scale.setScalar(scale);\n model.position.set(-center.x * scale, -box.min.y * scale, -center.z * scale);\n model.updateMatrixWorld(true);\n bodyMesh = null;\n model.traverse((o) => { const m = o as THREE.Mesh; if (m.isMesh && !bodyMesh) bodyMesh = m; });\n if (bodyMesh) {\n const geo = (bodyMesh as THREE.Mesh).geometry as THREE.BufferGeometry;\n geo.computeVertexNormals();\n const pos = geo.getAttribute(\"position\") as THREE.BufferAttribute;\n const n = pos.count;\n const cols = new Float32Array(n * 3);\n vertexRegion = new Array(n);\n const v = new THREE.Vector3();\n for (let i = 0; i < n; i++) {\n v.fromBufferAttribute(pos, i).applyMatrix4((bodyMesh as THREE.Mesh).matrixWorld);\n let best = 0, bd = Infinity;\n for (let a = 0; a < anchorVecs.length; a++) { const d = v.distanceToSquared(anchorVecs[a]); if (d < bd) { bd = d; best = a; } }\n vertexRegion[i] = ANCHOR_KEYS[best];\n }\n colorAttr = new THREE.BufferAttribute(cols, 3);\n geo.setAttribute(\"color\", colorAttr);\n (bodyMesh as THREE.Mesh).material = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.72, metalness: 0.04 });\n refresh();\n }\n modelRoot = model;\n scene.add(model);\n renderFrame();\n kick();\n setReadyRef.current(true);\n if (loadedRef.current) { loadedRef.current.setAttribute(\"data-loaded\", \"true\"); loadedRef.current.setAttribute(\"data-model\", which); }\n });\n };\n loadModel(sexRef.current);\n apiRef.current = {\n setSex: (s) => { loadedRef.current?.removeAttribute(\"data-loaded\"); setReadyRef.current(false); loadModel(s); },\n refresh,\n };\n\n const raycaster = new THREE.Raycaster();\n const ndc = new THREE.Vector2();\n const pick = (clientX: number, clientY: number): string | null => {\n if (!bodyMesh) return null;\n const rect = renderer.domElement.getBoundingClientRect();\n ndc.set(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);\n raycaster.setFromCamera(ndc, camera);\n const hits = raycaster.intersectObject(bodyMesh, true);\n if (!hits.length) return null;\n const p = hits[0].point;\n // Nearest SELECTABLE anchor — a region hidden/locked by the per-app config\n // is skipped, so it never highlights or gets picked (2D-parity).\n let best: string | null = null, bd = Infinity;\n for (const k of ANCHOR_KEYS) {\n if (!isSelectable(k, configRef.current ?? {})) continue;\n const a = ANCHORS[k];\n const d = (p.x - a[0]) ** 2 + (p.y - a[1]) ** 2 + (p.z - a[2]) ** 2;\n if (d < bd) { bd = d; best = k; }\n }\n return best;\n };\n\n let downX = 0, downY = 0, downT = 0;\n const canvas = renderer.domElement;\n const onDown = (e: PointerEvent) => { downX = e.clientX; downY = e.clientY; downT = Date.now(); };\n const onMove = (e: PointerEvent) => {\n if ((e.buttons || 0) !== 0) return;\n const k = pick(e.clientX, e.clientY);\n if (k === hovered) return;\n const prev = hovered; hovered = k;\n if (prev) colorRegion(prev, restingHex(prev));\n if (k) colorRegion(k, paletteRef.current.hover);\n canvas.style.cursor = k ? \"pointer\" : \"default\";\n renderFrame();\n };\n const onUp = (e: PointerEvent) => {\n if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6 || Date.now() - downT > 450) return;\n const k = pick(e.clientX, e.clientY);\n if (k) setSelectedRef.current(k);\n };\n canvas.addEventListener(\"pointerdown\", onDown);\n canvas.addEventListener(\"pointermove\", onMove);\n canvas.addEventListener(\"pointerup\", onUp);\n\n renderFrame();\n kick(); // settle initial damping / start auto-rotate if enabled\n const onVis = () => { if (document.hidden) { cancelAnimationFrame(raf); pumping = false; raf = 0; } else kick(); };\n document.addEventListener(\"visibilitychange\", onVis);\n const onResize = () => { W = el.clientWidth || 520; H = el.clientHeight || 600; camera.aspect = W / H; camera.updateProjectionMatrix(); renderer.setSize(W, H); renderFrame(); };\n window.addEventListener(\"resize\", onResize);\n\n return () => {\n cancelAnimationFrame(raf);\n window.removeEventListener(\"resize\", onResize);\n document.removeEventListener(\"visibilitychange\", onVis);\n controls.removeEventListener(\"change\", kick);\n canvas.removeEventListener(\"pointerdown\", onDown);\n canvas.removeEventListener(\"pointermove\", onMove);\n canvas.removeEventListener(\"pointerup\", onUp);\n controls.dispose();\n renderer.dispose();\n if (canvas.parentNode) canvas.parentNode.removeChild(canvas);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // React → scene: recolour on selection / report / palette change; swap model on sex change.\n useEffect(() => { apiRef.current?.refresh(); }, [selected, report, palette]);\n // Swap the model on sex change — but skip the first run: the mount effect\n // already loaded sexRef.current, so this avoids a redundant reload (matters on mobile).\n const sexInited = useRef(false);\n useEffect(() => {\n if (!sexInited.current) { sexInited.current = true; return; }\n apiRef.current?.setSex(sex);\n }, [sex]);\n\n const pointOf = (k: string) => report.find((p) => p.region === k);\n const setPain = (k: string, intensity: number, type?: PainType) => {\n commit([...report.filter((p) => p.region !== k), { region: k, intensity, type, timestamp: new Date().toISOString() }]);\n };\n const removePain = (k: string) => { commit(report.filter((p) => p.region !== k)); setSelected(null); };\n const changeSex = (s: BodyMap3DSex) => { if (sexProp === undefined) setInternalSex(s); onSexChange?.(s); };\n\n const region = selected ? REGIONS.find((r) => r.key === selected) : null;\n const current = selected ? pointOf(selected) : undefined;\n\n return (\n <div data-testid=\"bodymap3d-root\" className={className} style={{ fontFamily: \"system-ui, sans-serif\", color: \"#1e293b\" }}>\n {showSexToggle && (\n <div style={{ display: \"flex\", gap: 16, flexWrap: \"wrap\", marginBottom: 12, fontSize: 13 }}>\n <div style={{ display: \"flex\", gap: 6, alignItems: \"center\" }}>\n <button data-testid=\"bodymap3d-sex-male\" onClick={() => changeSex(\"male\")} style={seg(sex === \"male\")}>{UI.male}</button>\n <button data-testid=\"bodymap3d-sex-female\" onClick={() => changeSex(\"female\")} style={seg(sex === \"female\")}>{UI.female}</button>\n </div>\n </div>\n )}\n <span ref={loadedRef} data-testid=\"bodymap3d-loaded\" style={{ display: \"none\" }} />\n {ready && <span data-testid=\"bodymap3d-ready\" style={{ position: \"absolute\", width: 1, height: 1, opacity: 0, pointerEvents: \"none\" }} />}\n <div style={{ display: \"flex\", gap: 18, alignItems: \"flex-start\", flexWrap: \"wrap\" }}>\n {unsupported ? (\n <div data-testid=\"bodymap3d-unsupported\" style={{ flex: \"1 1 520px\", minWidth: 320, height: \"60vh\", borderRadius: 16, background: \"#0e1424\", color: \"#cbd5e1\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\", padding: 24, textAlign: \"center\", fontSize: 14 }}>\n 3D kræver WebGL, som ikke er tilgængeligt her.\n </div>\n ) : (\n <div ref={mountRef} data-testid=\"bodymap3d-canvas\" style={{ flex: \"1 1 520px\", height: \"60vh\", minWidth: 320, borderRadius: 16, overflow: \"hidden\", background: \"#0e1424\", touchAction: \"none\" }} />\n )}\n <div style={{ flex: \"1 1 300px\", minWidth: 260 }}>\n {region ? (\n <div style={{ border: \"1px solid #e2e8f0\", borderRadius: 14, padding: 16, background: \"#fff\" }}>\n <div style={{ display: \"flex\", justifyContent: \"space-between\", alignItems: \"center\", marginBottom: 12 }}>\n <b style={{ fontSize: 16 }}>{nameOf(region.key)}</b>\n <span style={{ font: \"11px ui-monospace, monospace\", color: \"#64748b\", background: \"#f1f5f9\", borderRadius: 6, padding: \"2px 7px\" }}>{region.code}{region.side ? \" · \" + region.side : \"\"}</span>\n </div>\n <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: \".06em\", textTransform: \"uppercase\", color: \"#94a3b8\", marginBottom: 7 }}>{L.intensity}</div>\n <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 4, marginBottom: 12 }}>\n {Array.from({ length: 11 }, (_, i) => (\n <button key={i} data-testid={`bodymap3d-intensity-${i}`} onClick={() => setPain(region.key, i, current?.type)} style={{ ...btn, width: 30, height: 30, background: current?.intensity === i ? \"#0e8f8a\" : \"#fff\", color: current?.intensity === i ? \"#fff\" : \"#1e293b\" }}>{i}</button>\n ))}\n </div>\n <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: \".06em\", textTransform: \"uppercase\", color: \"#94a3b8\", marginBottom: 7 }}>{L.quality}</div>\n <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 6, marginBottom: 14 }}>\n {PAIN_TYPES.map((t) => (\n <button key={t} data-testid={`bodymap3d-type-${t}`} onClick={() => setPain(region.key, current?.intensity ?? 5, t)} style={{ ...btn, borderRadius: 999, padding: \"6px 12px\", background: current?.type === t ? \"#1e293b\" : \"#fff\", color: current?.type === t ? \"#fff\" : \"#64748b\" }}>{L.qualities[t] ?? t}</button>\n ))}\n </div>\n {current && <button data-testid=\"bodymap3d-remove\" onClick={() => removePain(region.key)} style={{ ...btn, color: \"#ef4444\", borderColor: \"#f6c9c9\" }}>{L.remove}</button>}\n </div>\n ) : (\n <div data-testid=\"bodymap3d-empty\" style={{ border: \"1px solid #e2e8f0\", borderRadius: 14, padding: 16, background: \"#fff\", color: \"#94a3b8\", fontSize: 13.5 }}>{UI.hoverHint}</div>\n )}\n </div>\n </div>\n </div>\n );\n}\n\n/** Re-export for convenience so a 3D-only consumer can serialize without a second import. */\nexport { serializeReport };\n"]}
|
package/dist/three.d.cts
CHANGED
|
@@ -131,6 +131,10 @@ interface BodyMap3DProps {
|
|
|
131
131
|
ui?: Partial<BodyMap3DUiLabels>;
|
|
132
132
|
defaultSex?: BodyMap3DSex;
|
|
133
133
|
onSexChange?: (sex: BodyMap3DSex) => void;
|
|
134
|
+
/** Controlled body type — when set, the parent owns it (e.g. from the user's profile); `onSexChange` still fires. */
|
|
135
|
+
sex?: BodyMap3DSex;
|
|
136
|
+
/** Show the Male/Female toggle (default true). Set false when `sex` comes from a profile and the picker would just be noise. */
|
|
137
|
+
showSexToggle?: boolean;
|
|
134
138
|
/** Slowly auto-rotate until the user interacts (default true). */
|
|
135
139
|
autoRotate?: boolean;
|
|
136
140
|
className?: string;
|
package/dist/three.d.ts
CHANGED
|
@@ -131,6 +131,10 @@ interface BodyMap3DProps {
|
|
|
131
131
|
ui?: Partial<BodyMap3DUiLabels>;
|
|
132
132
|
defaultSex?: BodyMap3DSex;
|
|
133
133
|
onSexChange?: (sex: BodyMap3DSex) => void;
|
|
134
|
+
/** Controlled body type — when set, the parent owns it (e.g. from the user's profile); `onSexChange` still fires. */
|
|
135
|
+
sex?: BodyMap3DSex;
|
|
136
|
+
/** Show the Male/Female toggle (default true). Set false when `sex` comes from a profile and the picker would just be noise. */
|
|
137
|
+
showSexToggle?: boolean;
|
|
134
138
|
/** Slowly auto-rotate until the user interacts (default true). */
|
|
135
139
|
autoRotate?: boolean;
|
|
136
140
|
className?: string;
|
package/dist/three.js
CHANGED
|
@@ -233,6 +233,8 @@ function BodyMap3D(props) {
|
|
|
233
233
|
labels,
|
|
234
234
|
ui,
|
|
235
235
|
defaultSex = "male",
|
|
236
|
+
sex: sexProp,
|
|
237
|
+
showSexToggle = true,
|
|
236
238
|
onSexChange,
|
|
237
239
|
autoRotate = true,
|
|
238
240
|
className
|
|
@@ -244,7 +246,8 @@ function BodyMap3D(props) {
|
|
|
244
246
|
const loadedRef = useRef(null);
|
|
245
247
|
const [unsupported, setUnsupported] = useState(false);
|
|
246
248
|
const [ready, setReady] = useState(false);
|
|
247
|
-
const [
|
|
249
|
+
const [internalSex, setInternalSex] = useState(defaultSex);
|
|
250
|
+
const sex = sexProp ?? internalSex;
|
|
248
251
|
const [selected, setSelected] = useState(null);
|
|
249
252
|
const [internal, setInternal] = useState(defaultValue ?? []);
|
|
250
253
|
const report = value ?? internal;
|
|
@@ -262,6 +265,8 @@ function BodyMap3D(props) {
|
|
|
262
265
|
configRef.current = config;
|
|
263
266
|
const modelsRef = useRef(models);
|
|
264
267
|
modelsRef.current = models;
|
|
268
|
+
const sexRef = useRef(sex);
|
|
269
|
+
sexRef.current = sex;
|
|
265
270
|
const setSelectedRef = useRef(setSelected);
|
|
266
271
|
setSelectedRef.current = setSelected;
|
|
267
272
|
const setReadyRef = useRef(setReady);
|
|
@@ -407,7 +412,7 @@ function BodyMap3D(props) {
|
|
|
407
412
|
}
|
|
408
413
|
});
|
|
409
414
|
};
|
|
410
|
-
loadModel(
|
|
415
|
+
loadModel(sexRef.current);
|
|
411
416
|
apiRef.current = {
|
|
412
417
|
setSex: (s) => {
|
|
413
418
|
loadedRef.current?.removeAttribute("data-loaded");
|
|
@@ -499,7 +504,12 @@ function BodyMap3D(props) {
|
|
|
499
504
|
useEffect(() => {
|
|
500
505
|
apiRef.current?.refresh();
|
|
501
506
|
}, [selected, report, palette]);
|
|
507
|
+
const sexInited = useRef(false);
|
|
502
508
|
useEffect(() => {
|
|
509
|
+
if (!sexInited.current) {
|
|
510
|
+
sexInited.current = true;
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
503
513
|
apiRef.current?.setSex(sex);
|
|
504
514
|
}, [sex]);
|
|
505
515
|
const pointOf = (k) => report.find((p) => p.region === k);
|
|
@@ -511,13 +521,13 @@ function BodyMap3D(props) {
|
|
|
511
521
|
setSelected(null);
|
|
512
522
|
};
|
|
513
523
|
const changeSex = (s) => {
|
|
514
|
-
|
|
524
|
+
if (sexProp === void 0) setInternalSex(s);
|
|
515
525
|
onSexChange?.(s);
|
|
516
526
|
};
|
|
517
527
|
const region = selected ? REGIONS.find((r) => r.key === selected) : null;
|
|
518
528
|
const current = selected ? pointOf(selected) : void 0;
|
|
519
529
|
return /* @__PURE__ */ jsxs("div", { "data-testid": "bodymap3d-root", className, style: { fontFamily: "system-ui, sans-serif", color: "#1e293b" }, children: [
|
|
520
|
-
/* @__PURE__ */ jsx("div", { style: { display: "flex", gap: 16, flexWrap: "wrap", marginBottom: 12, fontSize: 13 }, children: /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [
|
|
530
|
+
showSexToggle && /* @__PURE__ */ jsx("div", { style: { display: "flex", gap: 16, flexWrap: "wrap", marginBottom: 12, fontSize: 13 }, children: /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [
|
|
521
531
|
/* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-male", onClick: () => changeSex("male"), style: seg(sex === "male"), children: UI.male }),
|
|
522
532
|
/* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-female", onClick: () => changeSex("female"), style: seg(sex === "female"), children: UI.female })
|
|
523
533
|
] }) }),
|
package/dist/three.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/react.tsx","../src/three.tsx"],"names":["useRef","useState","useEffect","jsxs","jsx"],"mappings":"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","file":"three.js","sourcesContent":["// @broberg/bodymap — headless core (F052.1).\n//\n// Framework-neutral: the region taxonomy + the PainReport data model (zod) + a\n// selection engine + per-app region config. NO React/Preact/DOM import — the 2D\n// (SVG) and 3D (Three.js) renderers, and all three FD apps, share this one\n// contract. Output is a structured PainReport, never a bare image.\n\nimport { z } from \"zod\";\n\nexport type Side = \"left\" | \"right\";\n\nexport interface BodyRegion {\n /** Stable, unique identifier (snake_case) — the key used in a PainReport. */\n key: string;\n /** Human label (Danish). */\n label: string;\n /** Clinical short code (unique). */\n code: string;\n /** Body side, when the region is paired. */\n side?: Side;\n}\n\n/** The canonical body regions — the AUTHORITATIVE fd-sundhed clinical taxonomy\n * (docs/BODYMAP-TAKSONOMI.md, broberg-ai/fd-sundhed @360842f): 18 SIDE-LESS\n * clinical codes + a separate `side` field (L/R on limbs, C=center on axis\n * regions). The `key` is a unique per-side identifier; `code` is the side-less\n * clinical code that goes on the bodymap/v1 wire. NOT an anatomical atlas —\n * ~30 named surface regions for a pain-map. The 2D front renderer draws the\n * front-visible subset; the 3D body (F052.6) drives them all. */\nexport const REGIONS: readonly BodyRegion[] = [\n // axis / centre-line (serialised side \"center\")\n { key: \"head\", label: \"Hoved\", code: \"HEAD\" },\n { key: \"neck\", label: \"Nakke\", code: \"NECK\" },\n { key: \"chest\", label: \"Bryst\", code: \"CHEST\" },\n { key: \"thora\", label: \"Øvre ryg (thorakal)\", code: \"THORA\" },\n { key: \"lumbar\", label: \"Lænd (lumbal)\", code: \"LUMBAR\" },\n { key: \"groin\", label: \"Lyske\", code: \"GROIN\" },\n // paired limbs / sides (L / R)\n { key: \"shoulder_left\", label: \"Skulder, venstre\", code: \"SHOULDER\", side: \"left\" },\n { key: \"shoulder_right\", label: \"Skulder, højre\", code: \"SHOULDER\", side: \"right\" },\n { key: \"uarm_left\", label: \"Overarm, venstre\", code: \"UARM\", side: \"left\" },\n { key: \"uarm_right\", label: \"Overarm, højre\", code: \"UARM\", side: \"right\" },\n { key: \"elbow_left\", label: \"Albue, venstre\", code: \"ELBOW\", side: \"left\" },\n { key: \"elbow_right\", label: \"Albue, højre\", code: \"ELBOW\", side: \"right\" },\n { key: \"farm_left\", label: \"Underarm, venstre\", code: \"FARM\", side: \"left\" },\n { key: \"farm_right\", label: \"Underarm, højre\", code: \"FARM\", side: \"right\" },\n { key: \"wrist_left\", label: \"Håndled, venstre\", code: \"WRIST\", side: \"left\" },\n { key: \"wrist_right\", label: \"Håndled, højre\", code: \"WRIST\", side: \"right\" },\n { key: \"hand_left\", label: \"Hånd, venstre\", code: \"HAND\", side: \"left\" },\n { key: \"hand_right\", label: \"Hånd, højre\", code: \"HAND\", side: \"right\" },\n { key: \"hip_left\", label: \"Hofte, venstre\", code: \"HIP\", side: \"left\" },\n { key: \"hip_right\", label: \"Hofte, højre\", code: \"HIP\", side: \"right\" },\n { key: \"thigh_left\", label: \"Lår, venstre\", code: \"THIGH\", side: \"left\" },\n { key: \"thigh_right\", label: \"Lår, højre\", code: \"THIGH\", side: \"right\" },\n { key: \"knee_left\", label: \"Knæ, venstre\", code: \"KNEE\", side: \"left\" },\n { key: \"knee_right\", label: \"Knæ, højre\", code: \"KNEE\", side: \"right\" },\n { key: \"lowleg_left\", label: \"Underben, venstre\", code: \"LOWLEG\", side: \"left\" },\n { key: \"lowleg_right\", label: \"Underben, højre\", code: \"LOWLEG\", side: \"right\" },\n { key: \"ankle_left\", label: \"Ankel, venstre\", code: \"ANKLE\", side: \"left\" },\n { key: \"ankle_right\", label: \"Ankel, højre\", code: \"ANKLE\", side: \"right\" },\n { key: \"foot_left\", label: \"Fod, venstre\", code: \"FOOT\", side: \"left\" },\n { key: \"foot_right\", label: \"Fod, højre\", code: \"FOOT\", side: \"right\" },\n];\n\nconst REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));\nexport const REGION_KEYS: readonly string[] = REGIONS.map((r) => r.key);\n\n/** Look up a region by its key. */\nexport function getRegion(key: string): BodyRegion | undefined {\n return REGIONS.find((r) => r.key === key);\n}\n\n// ---- PainReport model ---------------------------------------------------\n\nexport const PAIN_TYPES = [\"stikkende\", \"dump\", \"konstant\", \"jagende\"] as const;\nexport type PainType = (typeof PAIN_TYPES)[number];\n\n/** One marked pain point. `region` MUST be a known region key; `intensity` is a\n * 0-10 integer; `type` is optional but constrained; `timestamp` is an ISO string. */\nexport const painPointSchema = z.object({\n region: z.string().refine((k) => REGION_KEY_SET.has(k), { message: \"unknown region\" }),\n intensity: z.number().int().min(0).max(10),\n type: z.enum(PAIN_TYPES).optional(),\n timestamp: z.string(),\n});\nexport type PainPoint = z.infer<typeof painPointSchema>;\n\nexport const painReportSchema = z.array(painPointSchema);\nexport type PainReport = PainPoint[];\n\n// ---- Selection engine (framework-agnostic) ------------------------------\n\nexport interface PainSelection {\n /** Mark (or update) pain on a region. One point per region — latest wins. */\n set(region: string, intensity: number, type?: PainType): PainPoint;\n remove(region: string): boolean;\n get(region: string): PainPoint | undefined;\n has(region: string): boolean;\n clear(): void;\n /** The current, validated PainReport. */\n getReport(): PainReport;\n}\n\nexport interface PainSelectionOptions {\n /** Injectable clock (ISO string) — defaults to `new Date().toISOString()`. */\n now?: () => string;\n}\n\n/** Create a selection engine seeded with an optional report. Pure state — no\n * DOM, no framework, no network. One point per region. */\nexport function createPainSelection(\n initial: PainReport = [],\n opts: PainSelectionOptions = {},\n): PainSelection {\n const now = opts.now ?? (() => new Date().toISOString());\n const map = new Map<string, PainPoint>();\n for (const p of initial) {\n const v = painPointSchema.parse(p);\n map.set(v.region, v);\n }\n return {\n set(region, intensity, type) {\n const point = painPointSchema.parse({ region, intensity, type, timestamp: now() });\n map.set(point.region, point);\n return point;\n },\n remove: (region) => map.delete(region),\n get: (region) => map.get(region),\n has: (region) => map.has(region),\n clear: () => map.clear(),\n getReport: () => painReportSchema.parse(Array.from(map.values())),\n };\n}\n\n// ---- Per-app region config (the toggle) ---------------------------------\n\nexport interface RegionSetting {\n /** Render this region at all. Default true. */\n visible?: boolean;\n /** Allow marking pain on this region. Default true. */\n selectable?: boolean;\n}\n\n/** Per-app config keyed by region key. An absent key ⇒ visible + selectable. */\nexport type RegionConfig = Record<string, RegionSetting>;\n\n/** The regions an app should render, honouring `visible` (default true). */\nexport function resolveRegions(config: RegionConfig = {}): BodyRegion[] {\n return REGIONS.filter((r) => config[r.key]?.visible ?? true);\n}\n\n/** Whether a region may be marked. A hidden region is never selectable. */\nexport function isSelectable(key: string, config: RegionConfig = {}): boolean {\n const s = config[key];\n if (s?.visible === false) return false;\n return s?.selectable ?? true;\n}\n\n// ---- palette (consumer-defined colours — shared by the 2D + 3D renderers) ---\n\n/** Colour control for the body renderers. Consumers pass a palette to theme the\n * body base colour, the hover + selected highlights, the pain-heat colours, and\n * optional per-region base colours. All values are CSS/hex colour strings. */\nexport interface BodymapPalette {\n /** Base body colour (an unmarked region). */\n body: string;\n /** Region highlight on hover (before click). */\n hover: string;\n /** A region selected (clicked) but not yet given an intensity. */\n selected: string;\n /** Pain-intensity heat colours: low (0-3), mid (4-6), high (7-10). */\n heat: { low: string; mid: string; high: string };\n /** Optional per-region base-colour overrides (region key → colour). */\n regions?: Record<string, string>;\n}\n\n/** The fleet default palette. Override any field per consumer. */\nexport const defaultPalette: BodymapPalette = {\n body: \"#d2d7de\",\n hover: \"#8fd0cd\",\n selected: \"#5cc4b7\",\n heat: { low: \"#fcd34d\", mid: \"#fb923c\", high: \"#ef4444\" },\n};\n\n/** The heat colour for a pain intensity, honouring the palette. */\nexport function heatFor(intensity: number, palette: BodymapPalette = defaultPalette): string {\n return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;\n}\n\n/** The base colour for a region (a per-region override, else the body colour). */\nexport function baseColorFor(regionKey: string, palette: BodymapPalette = defaultPalette): string {\n return palette.regions?.[regionKey] ?? palette.body;\n}\n\n// ---- bodymap/v1 serialization (the shared cross-app / native wire format) ---\n//\n// The shape every consumer + the native mobile apps read (aligned with\n// fd-sundhed's bodymap/v1: region CODE + side + intensity + quality + view).\n// The internal PainReport keys on the region KEY; this maps key -> clinical CODE\n// so the report is portable and human-readable on the wire.\n\nexport type BodyView = \"front\" | \"back\" | \"left\" | \"right\";\n/** Side in the serialized report — a midline region (no side) becomes \"center\". */\nexport type SerializedSide = \"left\" | \"right\" | \"center\";\n\nexport interface SerializedPainPoint {\n /** Clinical region CODE (e.g. \"LUMB\"). */\n region: string;\n side: SerializedSide;\n intensity: number;\n quality?: PainType;\n}\n\nexport interface BodymapReportV1 {\n schema: \"bodymap/v1\";\n view: BodyView;\n points: SerializedPainPoint[];\n}\n\nexport const bodymapReportV1Schema = z.object({\n schema: z.literal(\"bodymap/v1\"),\n view: z.enum([\"front\", \"back\", \"left\", \"right\"]),\n points: z.array(\n z.object({\n region: z.string(),\n side: z.enum([\"left\", \"right\", \"center\"]),\n intensity: z.number().int().min(0).max(10),\n quality: z.enum(PAIN_TYPES).optional(),\n }),\n ),\n});\n\n/** Serialize a PainReport to the shared `bodymap/v1` wire format. */\nexport function serializeReport(\n report: PainReport,\n opts: { view?: BodyView } = {},\n): BodymapReportV1 {\n return {\n schema: \"bodymap/v1\",\n view: opts.view ?? \"front\",\n points: report.map((p) => {\n const r = getRegion(p.region);\n return {\n region: r?.code ?? p.region,\n side: (r?.side ?? \"center\") as SerializedSide,\n intensity: p.intensity,\n quality: p.type,\n };\n }),\n };\n}\n\n/** Parse a `bodymap/v1` report back into an internal PainReport. Region CODE →\n * key; a point whose code is unknown to this taxonomy is dropped. */\nexport function deserializeReport(\n env: unknown,\n now: () => string = () => new Date().toISOString(),\n): PainReport {\n const parsed = bodymapReportV1Schema.parse(env);\n // code is side-less, so a point is identified by code + side.\n const byCodeSide = new Map(\n REGIONS.map((r) => [`${r.code}|${r.side ?? \"center\"}`, r.key] as const),\n );\n const out: PainReport = [];\n for (const sp of parsed.points) {\n const key = byCodeSide.get(`${sp.region}|${sp.side}`);\n if (!key) continue;\n out.push(\n painPointSchema.parse({\n region: key,\n intensity: sp.intensity,\n type: sp.quality,\n timestamp: now(),\n }),\n );\n }\n return out;\n}\n","// @broberg/bodymap/react — the 2D SVG renderer (F052.2).\n//\n// <BodyMap> is the shippable MVP: a genderless, colour-blocked body a patient\n// clicks to mark where it hurts. Selecting a region opens a custom intensity\n// (0-10) + quality control (NO native inputs) that upserts a validated\n// PainReport and fires onChange. Honours the per-app RegionConfig. The 3D\n// <BodyMap3D> (F052.6) will render the SAME core on the SAME onChange contract.\n//\n// F052.8 touch-first + F052.11 a11y: the body is PRIMARILY a mobile surface, so\n// every region has a >=44px transparent hit-area, the body pinch-zooms + pans\n// (with a tap-vs-drag guard so a gesture never mis-marks), controls are\n// thumb-sized and the layout stacks on a narrow viewport. Everything is also\n// fully keyboard-operable with live-state ARIA labels + visible focus.\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n REGIONS,\n PAIN_TYPES,\n painPointSchema,\n resolveRegions,\n isSelectable,\n getRegion,\n heatFor,\n baseColorFor,\n type BodyRegion,\n type BodymapPalette,\n type PainReport,\n type PainType,\n type RegionConfig,\n} from \"./index.js\";\n\n// ---- geometry: each region → an SVG shape (a neutral colour-blockout) --------\n\ntype Shape =\n | { el: \"circle\"; cx: number; cy: number; r: number }\n | { el: \"ellipse\"; cx: number; cy: number; rx: number; ry: number }\n | { el: \"rect\"; x: number; y: number; width: number; height: number; rx: number };\n\n// Front-view geometry (viewBox 260×470). The 2D MVP renders the front-visible\n// regions; back-only regions (thora, lumbar) + hip are driven by the 3D body /\n// a later back view. \"left\"/\"right\" are placed self-view (viewer's left = key\n// left) for the MVP — the facing-the-patient mirror is a later config option.\nconst SHAPES: Record<string, Shape> = {\n head: { el: \"circle\", cx: 130, cy: 40, r: 26 },\n neck: { el: \"rect\", x: 120, y: 62, width: 20, height: 16, rx: 6 },\n chest: { el: \"rect\", x: 100, y: 84, width: 60, height: 104, rx: 16 },\n groin: { el: \"rect\", x: 106, y: 190, width: 48, height: 30, rx: 13 },\n // left arm (viewer-left)\n shoulder_left: { el: \"circle\", cx: 88, cy: 98, r: 14 },\n uarm_left: { el: \"rect\", x: 66, y: 104, width: 18, height: 54, rx: 9 },\n elbow_left: { el: \"circle\", cx: 73, cy: 164, r: 10 },\n farm_left: { el: \"rect\", x: 62, y: 174, width: 16, height: 50, rx: 8 },\n wrist_left: { el: \"circle\", cx: 68, cy: 230, r: 8 },\n hand_left: { el: \"ellipse\", cx: 67, cy: 250, rx: 11, ry: 13 },\n // right arm (viewer-right)\n shoulder_right: { el: \"circle\", cx: 172, cy: 98, r: 14 },\n uarm_right: { el: \"rect\", x: 176, y: 104, width: 18, height: 54, rx: 9 },\n elbow_right: { el: \"circle\", cx: 187, cy: 164, r: 10 },\n farm_right: { el: \"rect\", x: 182, y: 174, width: 16, height: 50, rx: 8 },\n wrist_right: { el: \"circle\", cx: 192, cy: 230, r: 8 },\n hand_right: { el: \"ellipse\", cx: 193, cy: 250, rx: 11, ry: 13 },\n // legs\n thigh_left: { el: \"rect\", x: 104, y: 224, width: 22, height: 80, rx: 11 },\n thigh_right: { el: \"rect\", x: 134, y: 224, width: 22, height: 80, rx: 11 },\n knee_left: { el: \"circle\", cx: 115, cy: 312, r: 13 },\n knee_right: { el: \"circle\", cx: 145, cy: 312, r: 13 },\n lowleg_left: { el: \"rect\", x: 106, y: 322, width: 18, height: 74, rx: 9 },\n lowleg_right: { el: \"rect\", x: 136, y: 322, width: 18, height: 74, rx: 9 },\n ankle_left: { el: \"circle\", cx: 115, cy: 404, r: 9 },\n ankle_right: { el: \"circle\", cx: 145, cy: 404, r: 9 },\n foot_left: { el: \"ellipse\", cx: 115, cy: 424, rx: 14, ry: 10 },\n foot_right: { el: \"ellipse\", cx: 145, cy: 424, rx: 14, ry: 10 },\n};\n\nconst FILL: Record<string, string> = {\n head: \"#cbb7ec\", neck: \"#b8a3e6\", chest: \"#e26d6d\", groin: \"#e88a52\",\n shoulder_left: \"#f6c667\", shoulder_right: \"#f6c667\",\n uarm_left: \"#7ececb\", uarm_right: \"#7ececb\",\n elbow_left: \"#86cfcc\", elbow_right: \"#86cfcc\",\n farm_left: \"#8fd0cd\", farm_right: \"#8fd0cd\",\n wrist_left: \"#9ad6d3\", wrist_right: \"#9ad6d3\",\n hand_left: \"#a7ddda\", hand_right: \"#a7ddda\",\n thigh_left: \"#7fb2e8\", thigh_right: \"#7fb2e8\",\n knee_left: \"#c79be0\", knee_right: \"#c79be0\",\n lowleg_left: \"#e39ec6\", lowleg_right: \"#e39ec6\",\n ankle_left: \"#eeaab9\", ankle_right: \"#eeaab9\",\n foot_left: \"#f0a5a5\", foot_right: \"#f0a5a5\",\n};\n\n// Back-view geometry (same viewBox 260×470 + same limb/head geometry as the\n// front, so a marked limb sits at the same spot in both views). The torso swaps\n// front regions (chest, groin) for the posterior ones — THORA (upper back),\n// LUMBAR (lower back) and HIP (buttocks, paired) — the pilot's blocker regions.\n// Same self-view L/R convention as the front (patient-left = viewer-left).\nconst SHAPES_BACK: Record<string, Shape> = {\n head: { el: \"circle\", cx: 130, cy: 40, r: 26 },\n neck: { el: \"rect\", x: 120, y: 62, width: 20, height: 16, rx: 6 },\n thora: { el: \"rect\", x: 100, y: 84, width: 60, height: 58, rx: 15 },\n lumbar: { el: \"rect\", x: 104, y: 144, width: 52, height: 44, rx: 13 },\n hip_left: { el: \"rect\", x: 100, y: 190, width: 27, height: 32, rx: 13 },\n hip_right: { el: \"rect\", x: 133, y: 190, width: 27, height: 32, rx: 13 },\n // arms — shared with the front (back of the arm sits at the same x/y)\n shoulder_left: { el: \"circle\", cx: 88, cy: 98, r: 14 },\n uarm_left: { el: \"rect\", x: 66, y: 104, width: 18, height: 54, rx: 9 },\n elbow_left: { el: \"circle\", cx: 73, cy: 164, r: 10 },\n farm_left: { el: \"rect\", x: 62, y: 174, width: 16, height: 50, rx: 8 },\n wrist_left: { el: \"circle\", cx: 68, cy: 230, r: 8 },\n hand_left: { el: \"ellipse\", cx: 67, cy: 250, rx: 11, ry: 13 },\n shoulder_right: { el: \"circle\", cx: 172, cy: 98, r: 14 },\n uarm_right: { el: \"rect\", x: 176, y: 104, width: 18, height: 54, rx: 9 },\n elbow_right: { el: \"circle\", cx: 187, cy: 164, r: 10 },\n farm_right: { el: \"rect\", x: 182, y: 174, width: 16, height: 50, rx: 8 },\n wrist_right: { el: \"circle\", cx: 192, cy: 230, r: 8 },\n hand_right: { el: \"ellipse\", cx: 193, cy: 250, rx: 11, ry: 13 },\n // legs — back (thigh→calf→heel, same x/y as the front)\n thigh_left: { el: \"rect\", x: 104, y: 224, width: 22, height: 80, rx: 11 },\n thigh_right: { el: \"rect\", x: 134, y: 224, width: 22, height: 80, rx: 11 },\n knee_left: { el: \"circle\", cx: 115, cy: 312, r: 13 },\n knee_right: { el: \"circle\", cx: 145, cy: 312, r: 13 },\n lowleg_left: { el: \"rect\", x: 106, y: 322, width: 18, height: 74, rx: 9 },\n lowleg_right: { el: \"rect\", x: 136, y: 322, width: 18, height: 74, rx: 9 },\n ankle_left: { el: \"circle\", cx: 115, cy: 404, r: 9 },\n ankle_right: { el: \"circle\", cx: 145, cy: 404, r: 9 },\n foot_left: { el: \"ellipse\", cx: 115, cy: 424, rx: 14, ry: 10 },\n foot_right: { el: \"ellipse\", cx: 145, cy: 424, rx: 14, ry: 10 },\n};\n\nconst FILL_BACK: Record<string, string> = {\n ...FILL,\n thora: \"#e2896d\",\n lumbar: \"#d97fa0\",\n hip_left: \"#c99bd6\", hip_right: \"#c99bd6\",\n};\n\nexport type BodyView2D = \"front\" | \"back\";\n\n// ---- i18n (F052.12) — every UI string is overridable; da + en ship ----------\n// Region CODE + keys + the bodymap/v1 wire are NEVER touched — labels are\n// display-only, so a report is byte-identical in any language.\n\nexport interface BodyMapLabels {\n /** Region key → display name. */\n regions: Record<string, string>;\n /** PainType → display word (e.g. \"stikkende\" → \"Stabbing\"). */\n qualities: Record<string, string>;\n intensity: string;\n quality: string;\n remove: string;\n front: string;\n back: string;\n empty: string;\n /** Compare view (F052.13). */\n before: string;\n after: string;\n noChange: string;\n change: { new: string; resolved: string; improved: string; worse: string };\n /** Accessible name for a marked region. */\n ariaMarked: (name: string, intensity: number, quality?: string) => string;\n /** Accessible name for an unmarked, selectable region. */\n ariaUnmarked: (name: string) => string;\n svgLabel: string;\n viewLabel: string;\n zoomLabel: string;\n zoomIn: string;\n zoomOut: string;\n zoomReset: string;\n}\n\nconst daRegions: Record<string, string> = Object.fromEntries(REGIONS.map((r) => [r.key, r.label]));\nconst EN_CODE: Record<string, string> = {\n HEAD: \"Head\", NECK: \"Neck\", CHEST: \"Chest\", THORA: \"Upper back\", LUMBAR: \"Lower back\", GROIN: \"Groin\",\n SHOULDER: \"Shoulder\", UARM: \"Upper arm\", ELBOW: \"Elbow\", FARM: \"Forearm\", WRIST: \"Wrist\", HAND: \"Hand\",\n HIP: \"Hip\", THIGH: \"Thigh\", KNEE: \"Knee\", LOWLEG: \"Lower leg\", ANKLE: \"Ankle\", FOOT: \"Foot\",\n};\nconst enRegions: Record<string, string> = Object.fromEntries(\n REGIONS.map((r) => [r.key, EN_CODE[r.code] + (r.side ? (r.side === \"left\" ? \", left\" : \", right\") : \"\")]),\n);\n\nexport const LABELS_DA: BodyMapLabels = {\n regions: daRegions,\n qualities: { stikkende: \"stikkende\", dump: \"dump\", konstant: \"konstant\", jagende: \"jagende\" },\n intensity: \"Intensitet (0-10)\",\n quality: \"Kvalitet\",\n remove: \"Fjern punkt\",\n front: \"Forfra\",\n back: \"Bagfra\",\n empty: \"Vælg en kropsdel for at markere smerte.\",\n before: \"Før\",\n after: \"Efter\",\n noChange: \"Ingen ændring\",\n change: { new: \"nyt\", resolved: \"forsvundet\", improved: \"bedre\", worse: \"værre\" },\n ariaMarked: (n, i, q) => `${n}, smerte ${i} af 10${q ? \", \" + q : \"\"}`,\n ariaUnmarked: (n) => `${n}, ikke markeret. Aktivér for at markere smerte.`,\n svgLabel: \"Kropskort — vælg hvor det gør ondt\",\n viewLabel: \"Visning\",\n zoomLabel: \"Zoom\",\n zoomIn: \"Zoom ind\",\n zoomOut: \"Zoom ud\",\n zoomReset: \"Nulstil zoom\",\n};\n\nexport const LABELS_EN: BodyMapLabels = {\n regions: enRegions,\n qualities: { stikkende: \"stabbing\", dump: \"dull\", konstant: \"constant\", jagende: \"shooting\" },\n intensity: \"Intensity (0-10)\",\n quality: \"Quality\",\n remove: \"Remove point\",\n front: \"Front\",\n back: \"Back\",\n empty: \"Pick a body part to mark pain.\",\n before: \"Before\",\n after: \"After\",\n noChange: \"No change\",\n change: { new: \"new\", resolved: \"resolved\", improved: \"improved\", worse: \"worse\" },\n ariaMarked: (n, i, q) => `${n}, pain ${i} of 10${q ? \", \" + q : \"\"}`,\n ariaUnmarked: (n) => `${n}, not marked. Activate to mark pain.`,\n svgLabel: \"Body map — pick where it hurts\",\n viewLabel: \"View\",\n zoomLabel: \"Zoom\",\n zoomIn: \"Zoom in\",\n zoomOut: \"Zoom out\",\n zoomReset: \"Reset zoom\",\n};\n\nexport type BodyMapLocale = \"da\" | \"en\";\n\nfunction resolveLabels(locale: BodyMapLocale, overrides?: Partial<BodyMapLabels>): BodyMapLabels {\n const base = locale === \"en\" ? LABELS_EN : LABELS_DA;\n if (!overrides) return base;\n return {\n ...base,\n ...overrides,\n regions: { ...base.regions, ...overrides.regions },\n qualities: { ...base.qualities, ...overrides.qualities },\n };\n}\n\nconst VBW = 260;\nconst VBH = 470;\n// The SVG renders ~320px wide → ~1.23 px per viewBox unit. A hit-area needs\n// ~36 units to clear the 44px touch minimum; small regions get an invisible\n// padded hit-shape at least this big (zoom handles genuinely dense clusters).\nconst MIN_HIT = 18; // half-extent (radius) in viewBox units\n\nfunction center(s: Shape): { x: number; y: number } {\n if (s.el === \"rect\") return { x: s.x + s.width / 2, y: s.y + s.height / 2 };\n return { x: s.cx, y: s.cy };\n}\n// An enlarged, invisible hit target so small regions clear the 44px touch min.\nfunction hitShape(s: Shape): Shape {\n if (s.el === \"circle\") return { el: \"circle\", cx: s.cx, cy: s.cy, r: Math.max(s.r, MIN_HIT) };\n if (s.el === \"ellipse\")\n return { el: \"ellipse\", cx: s.cx, cy: s.cy, rx: Math.max(s.rx, MIN_HIT), ry: Math.max(s.ry, MIN_HIT) };\n const c = center(s);\n const w = Math.max(s.width, MIN_HIT * 2);\n const h = Math.max(s.height, MIN_HIT * 2);\n return { el: \"rect\", x: c.x - w / 2, y: c.y - h / 2, width: w, height: h, rx: s.rx };\n}\nfunction heat(v: number): string {\n return v >= 7 ? \"#ef4444\" : v >= 4 ? \"#fb923c\" : \"#fcd34d\";\n}\nfunction heatTier(v: number): \"low\" | \"mid\" | \"high\" {\n return v >= 7 ? \"high\" : v >= 4 ? \"mid\" : \"low\";\n}\n// F052.10 palette: a `palette` prop wins (direct colour); with no prop, a CSS-var\n// chain (--bmap-region-<key> → --bmap-body) themes with the default rainbow as\n// the fallback. Marked regions likewise: palette.heat or --bmap-heat-<tier>.\nfunction baseFill(key: string, rainbow: string, palette?: BodymapPalette): string {\n return palette ? baseColorFor(key, palette) : `var(--bmap-region-${key}, var(--bmap-body, ${rainbow}))`;\n}\nfunction markedFill(v: number, palette?: BodymapPalette): string {\n return palette ? heatFor(v, palette) : `var(--bmap-heat-${heatTier(v)}, ${heat(v)})`;\n}\nfunction shapeEl(s: Shape, props: Record<string, unknown>) {\n if (s.el === \"circle\") return <circle cx={s.cx} cy={s.cy} r={s.r} {...props} />;\n if (s.el === \"ellipse\") return <ellipse cx={s.cx} cy={s.cy} rx={s.rx} ry={s.ry} {...props} />;\n return <rect x={s.x} y={s.y} width={s.width} height={s.height} rx={s.rx} {...props} />;\n}\n\n// ---- one-time style injection (no external CSS; theme via CSS variables) -----\n\nconst STYLE_ID = \"broberg-bodymap-styles\";\nconst STYLE = `\n.bmap{--bmap-accent:var(--primary,#0e8f8a);--bmap-line:#e2e8f0;--bmap-panel:#fff;--bmap-ink:#1e293b;--bmap-muted:#64748b;\n display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--bmap-ink)}\n.bmap__stage{display:flex;flex-direction:column;gap:10px;flex:0 1 340px;min-width:0;max-width:360px}\n.bmap__bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}\n.bmap__viewtoggle{display:inline-flex;background:#f1f5f9;border:1px solid var(--bmap-line);border-radius:10px;padding:3px;gap:2px}\n.bmap__vbtn{font:inherit;font-size:13px;font-weight:600;color:#556274;background:none;border:0;border-radius:7px;padding:8px 16px;cursor:pointer;transition:color .12s,background .12s,transform .1s}\n.bmap__vbtn:hover{color:var(--bmap-ink)}\n.bmap__vbtn:active{transform:scale(.97)}\n.bmap__vbtn--on{background:#fff;color:var(--bmap-ink);box-shadow:0 1px 2px rgba(15,23,42,.14)}\n.bmap__zoom{display:inline-flex;gap:4px;margin-left:auto}\n.bmap__zoom button{font:inherit;font-weight:700;font-size:16px;line-height:1;width:36px;height:36px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-ink);border-radius:8px;cursor:pointer;transition:border-color .12s,transform .1s;display:flex;align-items:center;justify-content:center}\n.bmap__zoom button:hover{border-color:var(--bmap-accent)}\n.bmap__zoom button:active{transform:scale(.9)}\n.bmap__zoom button:disabled{opacity:.4;cursor:default}\n.bmap__svg{width:100%;height:auto;display:block;touch-action:none;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;background:transparent;border-radius:14px}\n.bmap__vis{stroke:#fff;stroke-width:2.4;pointer-events:none;transition:filter .12s,stroke .12s,stroke-width .12s}\n.bmap__vis--sel{stroke:var(--bmap-accent);stroke-width:3.6}\n.bmap__hit{fill:transparent;cursor:pointer;outline:none}\n.bmap__hit--locked{cursor:default}\n.bmap__hit:hover + .bmap__vis{filter:brightness(1.08) saturate(1.15)}\n.bmap__hit:focus-visible + .bmap__vis{stroke:var(--bmap-accent);stroke-width:4;stroke-dasharray:4 2.5}\n.bmap__heatn{font:700 12px ui-sans-serif;fill:#fff;pointer-events:none}\n.bmap__panel{flex:1 1 240px;min-width:220px;border:1px solid var(--bmap-line);border-radius:14px;padding:16px 18px;background:var(--bmap-panel)}\n.bmap__panel--empty{color:var(--bmap-muted);font-size:13.5px}\n.bmap__ph{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px}\n.bmap__ph b{font-size:16px}\n.bmap__code{font:11px ui-monospace,monospace;color:var(--bmap-muted);background:#f1f5f9;border-radius:6px;padding:2px 7px}\n.bmap__lbl{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--bmap-muted);margin:0 0 7px}\n.bmap__scale{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}\n.bmap__i{font:inherit;font-size:14px;font-weight:600;width:38px;height:38px;border-radius:9px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-ink);cursor:pointer;transition:border-color .1s,background .1s,transform .1s}\n.bmap__i:hover{border-color:var(--bmap-accent)}\n.bmap__i:active{transform:scale(.9)}\n.bmap__i--on{background:var(--bmap-accent);border-color:var(--bmap-accent);color:#fff}\n.bmap__chips{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:14px}\n.bmap__chip{font:inherit;font-size:13px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-muted);border-radius:999px;padding:9px 15px;cursor:pointer;transition:.1s}\n.bmap__chip:hover{border-color:var(--bmap-accent);color:var(--bmap-accent)}\n.bmap__chip:active{transform:scale(.96)}\n.bmap__chip--on{background:var(--bmap-ink);border-color:var(--bmap-ink);color:#fff}\n.bmap__rm{font:inherit;font-size:14px;font-weight:600;border:1px solid #f6c9c9;background:#fff;color:#ef4444;border-radius:9px;padding:10px 15px;cursor:pointer;transition:.1s}\n.bmap__rm:hover{background:#fef2f2}\n.bmap__rm:active{transform:scale(.97)}\n.bmap__i:focus-visible,.bmap__chip:focus-visible,.bmap__vbtn:focus-visible,.bmap__rm:focus-visible,.bmap__zoom button:focus-visible{outline:2px solid var(--bmap-accent);outline-offset:2px}\n.bmapc{--bmap-accent:var(--primary,#0e8f8a);--bmap-line:#e2e8f0;--bmap-ink:#1e293b;--bmap-muted:#64748b;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--bmap-ink)}\n.bmapc__bodies{display:flex;gap:14px;flex-wrap:wrap}\n.bmapc__fig{flex:1 1 150px;min-width:130px;max-width:220px;margin:0;text-align:center}\n.bmapc__cap{font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#556274;margin-bottom:6px}\n.bmapc__svg{width:100%;height:auto;display:block}\n.bmapc__vis{stroke:#fff;stroke-width:2.4}\n.bmapc__n{font:700 12px ui-sans-serif;fill:#fff;pointer-events:none}\n.bmapc__delta{margin-top:16px;display:flex;flex-direction:column;gap:6px;max-width:460px}\n.bmapc__row{display:flex;align-items:center;gap:10px;font-size:13.5px;border:1px solid var(--bmap-line);border-radius:9px;padding:7px 11px}\n.bmapc__rname{font-weight:600;flex:1 1 auto}\n.bmapc__rval{font:12px ui-monospace,monospace;color:#556274;white-space:nowrap}\n.bmapc__badge{font-size:11px;font-weight:700;border-radius:6px;padding:2px 8px;white-space:nowrap}\n.bmapc__badge--improved{background:#dcfce7;color:#166534}\n.bmapc__badge--worse{background:#fee2e2;color:#991b1b}\n.bmapc__badge--new{background:#fef3c7;color:#92400e}\n.bmapc__badge--resolved{background:#e2e8f0;color:#475569}\n.bmapc__empty{color:#556274;font-size:13.5px;margin-top:14px}\n@media (max-width:560px){\n .bmap{flex-direction:column;gap:14px}\n .bmap__stage{max-width:100%;width:100%;flex-basis:auto}\n .bmap__panel{width:100%}\n .bmap__i{width:46px;height:46px;font-size:16px}\n .bmap__chip{padding:11px 17px;font-size:15px}\n .bmap__vbtn{padding:10px 18px;font-size:15px}\n}\n@media (prefers-reduced-motion:reduce){.bmap *{transition:none !important}}\n`;\n\nfunction ensureStyles(): void {\n if (typeof document === \"undefined\") return;\n if (document.getElementById(STYLE_ID)) return;\n const el = document.createElement(\"style\");\n el.id = STYLE_ID;\n el.textContent = STYLE;\n document.head.appendChild(el);\n}\n\n// ---- component ---------------------------------------------------------------\n\nexport interface BodyMapProps {\n /** Controlled report. Omit for an uncontrolled component (use defaultValue). */\n value?: PainReport;\n /** Initial report when uncontrolled. */\n defaultValue?: PainReport;\n /** Fired with the full validated PainReport on every change. */\n onChange?: (report: PainReport) => void;\n /** Per-app region config (visible / selectable). */\n config?: RegionConfig;\n /** Initial body view (front / back). Default \"front\". */\n defaultView?: BodyView2D;\n /** Fired when the user toggles the front/back view — pass it to\n * serializeReport({ view }) so the wire report carries the active view. */\n onViewChange?: (view: BodyView2D) => void;\n /** Colour theme (F052.10). Overrides the default rainbow: body/hover/selected/\n * heat + optional per-region base colours. Without it, the rainbow is used\n * (themeable via --bmap-body / --bmap-region-<key> / --bmap-heat-* CSS vars). */\n palette?: BodymapPalette;\n /** UI language (F052.12). \"da\" (default) or \"en\". */\n locale?: BodyMapLocale;\n /** Override individual UI strings (region names, headings, quality words, …).\n * Merged over the locale set — region CODE + wire format are never affected. */\n labels?: Partial<BodyMapLabels>;\n /** Display-only mode (F052.9): render a saved report coloured by intensity\n * with NO picker and no interactivity. For journals, PDFs, clinician views. */\n readOnly?: boolean;\n className?: string;\n}\n\nconst ZMIN = 1;\nconst ZMAX = 4;\nconst clampPan = (p: { x: number; y: number }, z: number) => ({\n x: Math.min(0, Math.max(VBW * (1 - z), p.x)),\n y: Math.min(0, Math.max(VBH * (1 - z), p.y)),\n});\n\n/** 2D SVG body pain-map. Click/tap a region → set intensity + quality → PainReport. */\nexport function BodyMap({\n value, defaultValue, onChange, config = {}, defaultView = \"front\", onViewChange,\n palette, locale = \"da\", labels, readOnly = false, className,\n}: BodyMapProps) {\n useEffect(ensureStyles, []);\n const [internal, setInternal] = useState<PainReport>(defaultValue ?? []);\n const [selected, setSelected] = useState<string | null>(null);\n const [view, setView] = useState<BodyView2D>(defaultView);\n const [zoom, setZoom] = useState(1);\n const [pan, setPan] = useState({ x: 0, y: 0 });\n const report = value ?? internal;\n const shapes = view === \"back\" ? SHAPES_BACK : SHAPES;\n const fills = view === \"back\" ? FILL_BACK : FILL;\n const L = resolveLabels(locale, labels);\n const nameOf = (key: string) => L.regions[key] ?? getRegion(key)?.label ?? key;\n\n const svgRef = useRef<SVGSVGElement>(null);\n const zoomRef = useRef(1);\n const panRef = useRef({ x: 0, y: 0 });\n const pointers = useRef(new Map<number, { x: number; y: number }>());\n const gesture = useRef({ startDist: 0, startZoom: 1, midVB: { x: 0, y: 0 }, start: { x: 0, y: 0 }, moved: false });\n const suppressClick = useRef(false);\n\n const setZP = (z: number, p: { x: number; y: number }) => {\n const cz = Math.max(ZMIN, Math.min(ZMAX, z));\n const cp = clampPan(p, cz);\n zoomRef.current = cz; panRef.current = cp;\n setZoom(cz); setPan(cp);\n };\n const screenToVB = (cx: number, cy: number) => {\n const el = svgRef.current;\n if (!el) return { x: VBW / 2, y: VBH / 2 };\n const r = el.getBoundingClientRect();\n if (!r.width || !r.height) return { x: VBW / 2, y: VBH / 2 };\n return { x: ((cx - r.left) / r.width) * VBW, y: ((cy - r.top) / r.height) * VBH };\n };\n // Zoom keeping the given viewBox focal point under the cursor/fingers.\n const zoomTo = (nz: number, focalVB: { x: number; y: number }) => {\n const z = Math.max(ZMIN, Math.min(ZMAX, nz));\n const p = panRef.current;\n setZP(z, { x: focalVB.x - (focalVB.x - p.x) * (z / zoomRef.current), y: focalVB.y - (focalVB.y - p.y) * (z / zoomRef.current) });\n };\n const zoomBtn = (factor: number) => zoomTo(zoomRef.current * factor, { x: VBW / 2, y: VBH / 2 });\n const resetZoom = () => setZP(1, { x: 0, y: 0 });\n\n const onPointerDown = (e: React.PointerEvent) => {\n pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });\n const pts = [...pointers.current.values()];\n if (pts.length === 2) {\n gesture.current.startDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);\n gesture.current.startZoom = zoomRef.current;\n gesture.current.midVB = screenToVB((pts[0].x + pts[1].x) / 2, (pts[0].y + pts[1].y) / 2);\n gesture.current.moved = true;\n suppressClick.current = true;\n } else {\n gesture.current.start = { x: e.clientX, y: e.clientY };\n gesture.current.moved = false;\n }\n };\n const onPointerMove = (e: React.PointerEvent) => {\n const prev = pointers.current.get(e.pointerId);\n if (!prev) return;\n pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });\n const pts = [...pointers.current.values()];\n if (pts.length >= 2) {\n const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);\n if (gesture.current.startDist > 0) zoomTo(gesture.current.startZoom * (dist / gesture.current.startDist), gesture.current.midVB);\n return;\n }\n const el = svgRef.current;\n const rect = el?.getBoundingClientRect();\n if (Math.hypot(e.clientX - gesture.current.start.x, e.clientY - gesture.current.start.y) > 6) {\n gesture.current.moved = true;\n suppressClick.current = true;\n }\n if (zoomRef.current > 1 && rect && rect.width) {\n const dxVB = ((e.clientX - prev.x) / rect.width) * VBW;\n const dyVB = ((e.clientY - prev.y) / rect.height) * VBH;\n setZP(zoomRef.current, { x: panRef.current.x + dxVB, y: panRef.current.y + dyVB });\n }\n };\n const onPointerUp = (e: React.PointerEvent) => {\n pointers.current.delete(e.pointerId);\n if (pointers.current.size === 0) window.setTimeout(() => { suppressClick.current = false; }, 60);\n };\n\n const changeView = (v: BodyView2D) => {\n if (v === view) return;\n setView(v);\n const sh = v === \"back\" ? SHAPES_BACK : SHAPES;\n if (selected && !sh[selected]) setSelected(null); // selected region absent in the new view\n onViewChange?.(v);\n };\n\n const commit = (next: PainReport) => {\n if (value === undefined) setInternal(next);\n onChange?.(next);\n };\n const pointOf = (key: string) => report.find((p) => p.region === key);\n const setPain = (key: string, intensity: number, type?: PainType) => {\n const point = painPointSchema.parse({ region: key, intensity, type, timestamp: new Date().toISOString() });\n commit([...report.filter((p) => p.region !== key), point]);\n };\n const removePain = (key: string) => {\n commit(report.filter((p) => p.region !== key));\n setSelected(null);\n };\n const pickRegion = (key: string) => {\n if (suppressClick.current) { suppressClick.current = false; return; } // a pan/pinch just ended — not a tap\n setSelected(key);\n };\n\n const regions = resolveRegions(config);\n const region: BodyRegion | undefined = selected ? REGIONS.find((r) => r.key === selected) : undefined;\n const current = selected ? pointOf(selected) : undefined;\n const rootCls = [\"bmap\", className].filter(Boolean).join(\" \");\n // palette.selected drives the focus/selected stroke via --bmap-accent.\n const rootStyle = palette ? ({ \"--bmap-accent\": palette.selected } as React.CSSProperties) : undefined;\n const ariaFor = (r: BodyRegion, marked?: PainReport[number], interactive = true): string => {\n if (marked) return L.ariaMarked(nameOf(r.key), marked.intensity, marked.type ? L.qualities[marked.type] : undefined);\n return interactive ? L.ariaUnmarked(nameOf(r.key)) : nameOf(r.key);\n };\n\n return (\n <div className={rootCls} data-testid=\"bodymap-root\" style={rootStyle}>\n <div className=\"bmap__stage\">\n <div className=\"bmap__bar\">\n <div className=\"bmap__viewtoggle\" role=\"group\" aria-label={L.viewLabel}>\n <button\n type=\"button\"\n className={\"bmap__vbtn\" + (view === \"front\" ? \" bmap__vbtn--on\" : \"\")}\n data-testid=\"bodymap-view-front\"\n aria-pressed={view === \"front\"}\n onClick={() => changeView(\"front\")}\n >\n {L.front}\n </button>\n <button\n type=\"button\"\n className={\"bmap__vbtn\" + (view === \"back\" ? \" bmap__vbtn--on\" : \"\")}\n data-testid=\"bodymap-view-back\"\n aria-pressed={view === \"back\"}\n onClick={() => changeView(\"back\")}\n >\n {L.back}\n </button>\n </div>\n <div className=\"bmap__zoom\" role=\"group\" aria-label={L.zoomLabel}>\n <button type=\"button\" data-testid=\"bodymap-zoom-out\" aria-label={L.zoomOut} disabled={zoom <= ZMIN} onClick={() => zoomBtn(1 / 1.4)}>−</button>\n <button type=\"button\" data-testid=\"bodymap-zoom-reset\" aria-label={L.zoomReset} disabled={zoom === 1 && pan.x === 0 && pan.y === 0} onClick={resetZoom}>⤢</button>\n <button type=\"button\" data-testid=\"bodymap-zoom-in\" aria-label={L.zoomIn} disabled={zoom >= ZMAX} onClick={() => zoomBtn(1.4)}>+</button>\n </div>\n </div>\n <svg\n ref={svgRef}\n className=\"bmap__svg\"\n viewBox=\"0 0 260 470\"\n role=\"group\"\n aria-label={L.svgLabel}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n onPointerCancel={onPointerUp}\n onWheel={(e) => zoomTo(zoomRef.current * (e.deltaY < 0 ? 1.12 : 1 / 1.12), screenToVB(e.clientX, e.clientY))}\n >\n <g transform={`translate(${pan.x} ${pan.y}) scale(${zoom})`}>\n {regions.map((r) => {\n const s = shapes[r.key];\n if (!s) return null;\n const marked = pointOf(r.key);\n const interactive = isSelectable(r.key, config) && !readOnly;\n const visCls = [\"bmap__vis\", selected === r.key && \"bmap__vis--sel\"].filter(Boolean).join(\" \");\n const fill = marked ? markedFill(marked.intensity, palette) : baseFill(r.key, fills[r.key] ?? \"#cbd5e1\", palette);\n const hs = hitShape(s);\n return (\n <g key={r.key}>\n {shapeEl(hs, {\n className: \"bmap__hit\" + (interactive ? \"\" : \" bmap__hit--locked\"),\n \"data-testid\": `bodymap-region-${r.key}`,\n role: interactive ? \"button\" : \"img\",\n tabIndex: interactive ? 0 : -1,\n \"aria-label\": ariaFor(r, marked, interactive),\n \"aria-pressed\": interactive ? selected === r.key : undefined,\n onClick: interactive ? () => pickRegion(r.key) : undefined,\n onKeyDown: interactive\n ? (e: React.KeyboardEvent) => { if (e.key === \"Enter\" || e.key === \" \") { e.preventDefault(); suppressClick.current = false; setSelected(r.key); } }\n : undefined,\n })}\n {shapeEl(s, { className: visCls, fill, opacity: !readOnly && !isSelectable(r.key, config) ? 0.4 : 1 })}\n </g>\n );\n })}\n {/* intensity numbers on marked regions visible in the current view */}\n {report.map((p) => {\n const s = shapes[p.region];\n if (!s || config[p.region]?.visible === false) return null;\n const c = center(s);\n return (\n <text key={p.region} x={c.x} y={c.y + 4} textAnchor=\"middle\" className=\"bmap__heatn\">\n {p.intensity}\n </text>\n );\n })}\n </g>\n </svg>\n </div>\n\n {/* picker panel — hidden entirely in readOnly (display-only) mode */}\n {readOnly ? null : region ? (\n <div className=\"bmap__panel\" data-testid=\"bodymap-panel\">\n <div className=\"bmap__ph\">\n <b>{nameOf(region.key)}</b>\n <span className=\"bmap__code\">{region.code}</span>\n </div>\n <p className=\"bmap__lbl\" id=\"bmap-intensity-lbl\">{L.intensity}</p>\n <div className=\"bmap__scale\" role=\"group\" aria-labelledby=\"bmap-intensity-lbl\">\n {Array.from({ length: 11 }, (_, n) => (\n <button\n key={n}\n type=\"button\"\n className={\"bmap__i\" + (current?.intensity === n ? \" bmap__i--on\" : \"\")}\n data-testid={`bodymap-intensity-${n}`}\n aria-pressed={current?.intensity === n}\n onClick={() => setPain(region.key, n, current?.type)}\n >\n {n}\n </button>\n ))}\n </div>\n <p className=\"bmap__lbl\" id=\"bmap-quality-lbl\">{L.quality}</p>\n <div className=\"bmap__chips\" role=\"group\" aria-labelledby=\"bmap-quality-lbl\">\n {PAIN_TYPES.map((t) => (\n <button\n key={t}\n type=\"button\"\n className={\"bmap__chip\" + (current?.type === t ? \" bmap__chip--on\" : \"\")}\n data-testid={`bodymap-type-${t}`}\n aria-pressed={current?.type === t}\n onClick={() => setPain(region.key, current?.intensity ?? 5, t)}\n >\n {L.qualities[t]}\n </button>\n ))}\n </div>\n {current && (\n <button type=\"button\" className=\"bmap__rm\" data-testid=\"bodymap-remove\" onClick={() => removePain(region.key)}>\n {L.remove}\n </button>\n )}\n </div>\n ) : (\n <div className=\"bmap__panel bmap__panel--empty\" data-testid=\"bodymap-panel\">\n {L.empty}\n </div>\n )}\n </div>\n );\n}\n\n// ---- <BodyMapCompare> — before/after progress (F052.13) ---------------------\n// Two read-only bodies side by side + a per-region change list (improved /\n// worse / new / resolved). Pure UI over two PainReports — no network. NB: the\n// \"nudge the user to update their report over time\" is the CONSUMER app's job\n// (fd-sundhed's mobile app via @broberg/webpush), NOT this component.\n\ntype RegionChange = \"new\" | \"resolved\" | \"improved\" | \"worse\" | \"same\";\nfunction changeOf(b?: number, a?: number): RegionChange {\n if (b == null && a != null) return \"new\";\n if (b != null && a == null) return \"resolved\";\n if (b == null || a == null) return \"same\";\n return a < b ? \"improved\" : a > b ? \"worse\" : \"same\";\n}\n\nfunction CompareFigure({ report, view, palette, caption, testid }: {\n report: PainReport; view: BodyView2D; palette?: BodymapPalette; caption: string; testid: string;\n}) {\n const shapes = view === \"back\" ? SHAPES_BACK : SHAPES;\n const fills = view === \"back\" ? FILL_BACK : FILL;\n return (\n <figure className=\"bmapc__fig\" data-testid={testid}>\n <figcaption className=\"bmapc__cap\">{caption}</figcaption>\n <svg className=\"bmapc__svg\" viewBox=\"0 0 260 470\" role=\"img\" aria-label={caption}>\n {REGIONS.map((r) => {\n const s = shapes[r.key];\n if (!s) return null;\n const m = report.find((p) => p.region === r.key);\n const fill = m ? markedFill(m.intensity, palette) : baseFill(r.key, fills[r.key] ?? \"#cbd5e1\", palette);\n return <g key={r.key}>{shapeEl(s, { className: \"bmapc__vis\", fill })}</g>;\n })}\n {report.map((p) => {\n const s = shapes[p.region];\n if (!s) return null;\n const c = center(s);\n return (\n <text key={p.region} x={c.x} y={c.y + 4} textAnchor=\"middle\" className=\"bmapc__n\">{p.intensity}</text>\n );\n })}\n </svg>\n </figure>\n );\n}\n\nexport interface BodyMapCompareProps {\n /** The earlier report. */\n before: PainReport;\n /** The later report. */\n after: PainReport;\n palette?: BodymapPalette;\n locale?: BodyMapLocale;\n labels?: Partial<BodyMapLabels>;\n defaultView?: BodyView2D;\n className?: string;\n}\n\n/** Read-only before/after view: two bodies side by side + a per-region change list. */\nexport function BodyMapCompare({\n before, after, palette, locale = \"da\", labels, defaultView = \"front\", className,\n}: BodyMapCompareProps) {\n useEffect(ensureStyles, []);\n const [view, setView] = useState<BodyView2D>(defaultView);\n const L = resolveLabels(locale, labels);\n const nameOf = (k: string) => L.regions[k] ?? getRegion(k)?.label ?? k;\n const rootStyle = palette ? ({ \"--bmap-accent\": palette.selected } as React.CSSProperties) : undefined;\n\n const keys = Array.from(new Set([...before, ...after].map((p) => p.region)));\n const changes = keys\n .map((k) => ({\n key: k,\n b: before.find((p) => p.region === k)?.intensity,\n a: after.find((p) => p.region === k)?.intensity,\n status: changeOf(before.find((p) => p.region === k)?.intensity, after.find((p) => p.region === k)?.intensity),\n }))\n .filter((c) => c.status !== \"same\")\n .sort((x, y) => (x.key < y.key ? -1 : 1));\n\n return (\n <div className={[\"bmapc\", className].filter(Boolean).join(\" \")} data-testid=\"bodymap-compare\" style={rootStyle}>\n <div className=\"bmap__viewtoggle\" role=\"group\" aria-label={L.viewLabel} style={{ marginBottom: 12 }}>\n <button type=\"button\" className={\"bmap__vbtn\" + (view === \"front\" ? \" bmap__vbtn--on\" : \"\")} data-testid=\"bodymap-compare-view-front\" aria-pressed={view === \"front\"} onClick={() => setView(\"front\")}>{L.front}</button>\n <button type=\"button\" className={\"bmap__vbtn\" + (view === \"back\" ? \" bmap__vbtn--on\" : \"\")} data-testid=\"bodymap-compare-view-back\" aria-pressed={view === \"back\"} onClick={() => setView(\"back\")}>{L.back}</button>\n </div>\n <div className=\"bmapc__bodies\">\n <CompareFigure report={before} view={view} palette={palette} caption={L.before} testid=\"bodymap-compare-before\" />\n <CompareFigure report={after} view={view} palette={palette} caption={L.after} testid=\"bodymap-compare-after\" />\n </div>\n {changes.length ? (\n <div className=\"bmapc__delta\" data-testid=\"bodymap-compare-delta\">\n {changes.map((c) => (\n <div key={c.key} className=\"bmapc__row\" data-testid={`bodymap-compare-delta-${c.key}`}>\n <span className=\"bmapc__rname\">{nameOf(c.key)}</span>\n <span className=\"bmapc__rval\">{c.b ?? \"–\"} → {c.a ?? \"–\"}</span>\n <span className={`bmapc__badge bmapc__badge--${c.status}`}>{L.change[c.status as Exclude<RegionChange, \"same\">]}</span>\n </div>\n ))}\n </div>\n ) : (\n <div className=\"bmapc__empty\" data-testid=\"bodymap-compare-delta\">{L.noChange}</div>\n )}\n </div>\n );\n}\n","/**\n * @broberg/bodymap/three — a rotatable 3D body pain-map on the SAME core.\n *\n * `<BodyMap3D>` wraps a VANILLA three.js scene (NOT react-three-fiber, so it\n * runs in React, Preact and a Capacitor webview alike): a realistic Blender\n * Studio human base mesh (CC0), rotatable (drag) + zoomable (scroll), with\n * hover-highlight and click-to-mark — a click colours the body part by pain\n * intensity and produces the same structured PainReport (bodymap/v1) the 2D\n * renderer does. Region hit-testing is anchor-based: every mesh vertex is\n * assigned to its nearest region anchor (true per-zone mesh segmentation is a\n * later refinement — the wire + interaction are identical either way).\n *\n * `three` is an OPTIONAL peer — only consumers importing this subpath pull it\n * in. The body models are NOT bundled: pass `models={{ male, female }}` as URLs\n * you host (the package ships reference GLBs under `@broberg/bodymap/models/`).\n */\nimport { useEffect, useRef, useState } from \"react\";\nimport * as THREE from \"three\";\nimport { GLTFLoader } from \"three/examples/jsm/loaders/GLTFLoader.js\";\nimport { OrbitControls } from \"three/examples/jsm/controls/OrbitControls.js\";\nimport {\n REGIONS,\n PAIN_TYPES,\n getRegion,\n isSelectable,\n serializeReport,\n heatFor,\n baseColorFor,\n defaultPalette,\n type BodymapPalette,\n type PainReport,\n type PainType,\n type RegionConfig,\n} from \"./index.js\";\nimport {\n LABELS_DA,\n LABELS_EN,\n type BodyMapLabels,\n type BodyMapLocale,\n} from \"./react.js\";\n\nexport type BodyMap3DSex = \"male\" | \"female\";\n\n/** URLs of the body GLB models the consumer hosts (reference GLBs ship under `@broberg/bodymap/models/`). */\nexport interface BodyMap3DModels {\n male: string;\n female: string;\n}\n\n/** The 3D-only control strings (the shared labels come from `locale`/`labels`). */\nexport interface BodyMap3DUiLabels {\n male: string;\n female: string;\n hoverHint: string;\n}\n\nconst UI_DA: BodyMap3DUiLabels = { male: \"Mand\", female: \"Kvinde\", hoverHint: \"Hover for at fremhæve · klik en kropsdel for at markere smerte.\" };\nconst UI_EN: BodyMap3DUiLabels = { male: \"Male\", female: \"Female\", hoverHint: \"Hover to highlight · tap a body part to mark pain.\" };\n\n// Region anchors in normalised body space (height ~1.9, feet y=0, front +z),\n// then x-flipped so the patient's own left maps to \"venstre\" (self-view).\nconst ANCHORS: Record<string, [number, number, number]> = {\n head: [0, 1.79, 0.02], neck: [0, 1.57, 0.0],\n chest: [0, 1.42, 0.11], thora: [0, 1.42, -0.12], lumbar: [0, 1.13, -0.13], groin: [0, 0.92, 0.09],\n shoulder_left: [-0.2, 1.5, 0], shoulder_right: [0.2, 1.5, 0],\n uarm_left: [-0.27, 1.3, 0.0], uarm_right: [0.27, 1.3, 0.0],\n elbow_left: [-0.31, 1.08, 0], elbow_right: [0.31, 1.08, 0],\n farm_left: [-0.34, 0.93, 0.02], farm_right: [0.34, 0.93, 0.02],\n wrist_left: [-0.36, 0.79, 0.02], wrist_right: [0.36, 0.79, 0.02],\n hand_left: [-0.37, 0.68, 0.03], hand_right: [0.37, 0.68, 0.03],\n hip_left: [-0.14, 1.02, -0.03], hip_right: [0.14, 1.02, -0.03],\n thigh_left: [-0.1, 0.68, 0.05], thigh_right: [0.1, 0.68, 0.05],\n knee_left: [-0.1, 0.4, 0.06], knee_right: [0.1, 0.4, 0.06],\n lowleg_left: [-0.1, 0.22, 0.04], lowleg_right: [0.1, 0.22, 0.04],\n ankle_left: [-0.1, 0.05, 0.02], ankle_right: [0.1, 0.05, 0.02],\n foot_left: [-0.1, 0.02, 0.11], foot_right: [0.1, 0.02, 0.11],\n};\nconst ANCHOR_KEYS = Object.keys(ANCHORS);\nfor (const k of ANCHOR_KEYS) ANCHORS[k][0] = -ANCHORS[k][0];\n\nfunction mergeLabels(locale: BodyMapLocale, overrides?: Partial<BodyMapLabels>): BodyMapLabels {\n const base = locale === \"en\" ? LABELS_EN : LABELS_DA;\n if (!overrides) return base;\n return {\n ...base,\n ...overrides,\n regions: { ...base.regions, ...overrides.regions },\n qualities: { ...base.qualities, ...overrides.qualities },\n };\n}\n\nfunction webglAvailable(): boolean {\n try {\n const c = document.createElement(\"canvas\");\n return !!(c.getContext(\"webgl\") || c.getContext(\"experimental-webgl\"));\n } catch {\n return false;\n }\n}\n\nexport interface BodyMap3DProps {\n /** URLs of the male/female body GLBs (you host them; reference GLBs ship under `@broberg/bodymap/models/`). */\n models: BodyMap3DModels;\n value?: PainReport;\n defaultValue?: PainReport;\n onChange?: (report: PainReport) => void;\n /** Per-app region config — a non-selectable region is not pickable in 3D either. */\n config?: RegionConfig;\n palette?: BodymapPalette;\n locale?: BodyMapLocale;\n labels?: Partial<BodyMapLabels>;\n /** Override the 3D-only control strings (male/female/hoverHint). */\n ui?: Partial<BodyMap3DUiLabels>;\n defaultSex?: BodyMap3DSex;\n onSexChange?: (sex: BodyMap3DSex) => void;\n /** Slowly auto-rotate until the user interacts (default true). */\n autoRotate?: boolean;\n className?: string;\n}\n\nconst btn: React.CSSProperties = { font: \"inherit\", cursor: \"pointer\", borderRadius: 8, border: \"1px solid #e2e8f0\", background: \"#fff\", padding: \"6px 9px\" };\nconst seg = (on: boolean): React.CSSProperties => ({ ...btn, background: on ? \"#0e8f8a\" : \"#fff\", color: on ? \"#fff\" : \"#1e293b\", fontWeight: 600 });\n\nexport function BodyMap3D(props: BodyMap3DProps) {\n const {\n models, value, defaultValue, onChange, config, palette = defaultPalette,\n locale = \"da\", labels, ui, defaultSex = \"male\", onSexChange,\n autoRotate = true, className,\n } = props;\n\n const L = mergeLabels(locale, labels);\n const UI = { ...(locale === \"en\" ? UI_EN : UI_DA), ...ui };\n const nameOf = (key: string) => L.regions[key] ?? getRegion(key)?.label ?? key;\n\n const mountRef = useRef<HTMLDivElement>(null);\n const loadedRef = useRef<HTMLSpanElement>(null);\n const [unsupported, setUnsupported] = useState(false);\n const [ready, setReady] = useState(false);\n const [sex, setSex] = useState<BodyMap3DSex>(defaultSex);\n const [selected, setSelected] = useState<string | null>(null);\n const [internal, setInternal] = useState<PainReport>(defaultValue ?? []);\n const report = value ?? internal;\n\n const commit = (next: PainReport) => {\n if (value === undefined) setInternal(next);\n onChange?.(next);\n };\n\n // Latest state visible to the once-mounted imperative scene.\n const reportRef = useRef(report); reportRef.current = report;\n const selectedRef = useRef(selected); selectedRef.current = selected;\n const paletteRef = useRef(palette); paletteRef.current = palette;\n const configRef = useRef(config); configRef.current = config;\n const modelsRef = useRef(models); modelsRef.current = models;\n const setSelectedRef = useRef(setSelected); setSelectedRef.current = setSelected;\n const setReadyRef = useRef(setReady); setReadyRef.current = setReady;\n const apiRef = useRef<{ setSex: (s: BodyMap3DSex) => void; refresh: () => void } | null>(null);\n\n useEffect(() => {\n if (!webglAvailable()) { setUnsupported(true); return; }\n const el = mountRef.current;\n if (!el) return;\n let W = el.clientWidth || 520, H = el.clientHeight || 600;\n\n const scene = new THREE.Scene();\n scene.background = new THREE.Color(0x0e1424);\n const camera = new THREE.PerspectiveCamera(32, W / H, 0.1, 100);\n camera.position.set(0, 1.05, 4.4);\n let renderer: THREE.WebGLRenderer;\n try {\n renderer = new THREE.WebGLRenderer({ antialias: true });\n } catch {\n setUnsupported(true);\n return;\n }\n renderer.setSize(W, H);\n renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n el.appendChild(renderer.domElement);\n scene.add(new THREE.HemisphereLight(0xffffff, 0x223044, 1.05));\n const kl = new THREE.DirectionalLight(0xffffff, 1.5); kl.position.set(3, 5, 4); scene.add(kl);\n const rl = new THREE.DirectionalLight(0x88aaff, 0.7); rl.position.set(-4, 2, -3); scene.add(rl);\n\n const controls = new OrbitControls(camera, renderer.domElement);\n controls.enableDamping = true; controls.dampingFactor = 0.08;\n controls.autoRotate = autoRotate; controls.autoRotateSpeed = 1.1;\n controls.minDistance = 2.2; controls.maxDistance = 8; controls.enablePan = false;\n controls.target.set(0, 0.95, 0);\n controls.addEventListener(\"start\", () => { controls.autoRotate = false; });\n\n // On-demand rendering: render only while auto-rotating or while damping is\n // still settling, then STOP — so the page goes idle. That saves battery on\n // mobile (bodymap is primarily a mobile surface) AND lets a headless\n // Lens/Playwright run actually land clicks instead of starving on a\n // never-quiet WebGL loop (fd-sundhed's F052.6-preview finding). A control\n // 'change' / interaction / state change kicks the pump back to life.\n let raf = 0;\n let pumping = false;\n const renderFrame = () => renderer.render(scene, camera);\n const pump = () => {\n const moved = controls.update();\n renderFrame();\n if (moved || controls.autoRotate) { raf = requestAnimationFrame(pump); }\n else { pumping = false; raf = 0; }\n };\n const kick = () => { if (!pumping && !document.hidden) { pumping = true; raf = requestAnimationFrame(pump); } };\n controls.addEventListener(\"change\", kick);\n\n let modelRoot: THREE.Object3D | null = null;\n let bodyMesh: THREE.Mesh | null = null;\n let vertexRegion: string[] = [];\n let colorAttr: THREE.BufferAttribute | null = null;\n let hovered: string | null = null;\n const loader = new GLTFLoader();\n const anchorVecs = ANCHOR_KEYS.map((k) => new THREE.Vector3(...ANCHORS[k]));\n const tmp = new THREE.Color();\n\n const restingHex = (key: string): string => {\n const pt = reportRef.current.find((p) => p.region === key);\n if (pt) return heatFor(pt.intensity, paletteRef.current);\n if (selectedRef.current === key) return paletteRef.current.selected;\n return baseColorFor(key, paletteRef.current);\n };\n const colorRegion = (key: string, hex: string) => {\n if (!colorAttr) return;\n tmp.set(hex);\n for (let i = 0; i < vertexRegion.length; i++) if (vertexRegion[i] === key) colorAttr.setXYZ(i, tmp.r, tmp.g, tmp.b);\n colorAttr.needsUpdate = true;\n };\n const refresh = () => { for (const k of ANCHOR_KEYS) colorRegion(k, hovered === k ? paletteRef.current.hover : restingHex(k)); renderFrame(); };\n\n const loadModel = (which: BodyMap3DSex) => {\n const url = which === \"female\" ? modelsRef.current.female : modelsRef.current.male;\n loader.load(url, (gltf) => {\n if (modelRoot) scene.remove(modelRoot);\n const model = gltf.scene;\n const box = new THREE.Box3().setFromObject(model);\n const size = new THREE.Vector3(); box.getSize(size);\n const center = new THREE.Vector3(); box.getCenter(center);\n const scale = 1.9 / size.y;\n model.scale.setScalar(scale);\n model.position.set(-center.x * scale, -box.min.y * scale, -center.z * scale);\n model.updateMatrixWorld(true);\n bodyMesh = null;\n model.traverse((o) => { const m = o as THREE.Mesh; if (m.isMesh && !bodyMesh) bodyMesh = m; });\n if (bodyMesh) {\n const geo = (bodyMesh as THREE.Mesh).geometry as THREE.BufferGeometry;\n geo.computeVertexNormals();\n const pos = geo.getAttribute(\"position\") as THREE.BufferAttribute;\n const n = pos.count;\n const cols = new Float32Array(n * 3);\n vertexRegion = new Array(n);\n const v = new THREE.Vector3();\n for (let i = 0; i < n; i++) {\n v.fromBufferAttribute(pos, i).applyMatrix4((bodyMesh as THREE.Mesh).matrixWorld);\n let best = 0, bd = Infinity;\n for (let a = 0; a < anchorVecs.length; a++) { const d = v.distanceToSquared(anchorVecs[a]); if (d < bd) { bd = d; best = a; } }\n vertexRegion[i] = ANCHOR_KEYS[best];\n }\n colorAttr = new THREE.BufferAttribute(cols, 3);\n geo.setAttribute(\"color\", colorAttr);\n (bodyMesh as THREE.Mesh).material = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.72, metalness: 0.04 });\n refresh();\n }\n modelRoot = model;\n scene.add(model);\n renderFrame();\n kick();\n setReadyRef.current(true);\n if (loadedRef.current) { loadedRef.current.setAttribute(\"data-loaded\", \"true\"); loadedRef.current.setAttribute(\"data-model\", which); }\n });\n };\n loadModel(defaultSex);\n apiRef.current = {\n setSex: (s) => { loadedRef.current?.removeAttribute(\"data-loaded\"); setReadyRef.current(false); loadModel(s); },\n refresh,\n };\n\n const raycaster = new THREE.Raycaster();\n const ndc = new THREE.Vector2();\n const pick = (clientX: number, clientY: number): string | null => {\n if (!bodyMesh) return null;\n const rect = renderer.domElement.getBoundingClientRect();\n ndc.set(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);\n raycaster.setFromCamera(ndc, camera);\n const hits = raycaster.intersectObject(bodyMesh, true);\n if (!hits.length) return null;\n const p = hits[0].point;\n // Nearest SELECTABLE anchor — a region hidden/locked by the per-app config\n // is skipped, so it never highlights or gets picked (2D-parity).\n let best: string | null = null, bd = Infinity;\n for (const k of ANCHOR_KEYS) {\n if (!isSelectable(k, configRef.current ?? {})) continue;\n const a = ANCHORS[k];\n const d = (p.x - a[0]) ** 2 + (p.y - a[1]) ** 2 + (p.z - a[2]) ** 2;\n if (d < bd) { bd = d; best = k; }\n }\n return best;\n };\n\n let downX = 0, downY = 0, downT = 0;\n const canvas = renderer.domElement;\n const onDown = (e: PointerEvent) => { downX = e.clientX; downY = e.clientY; downT = Date.now(); };\n const onMove = (e: PointerEvent) => {\n if ((e.buttons || 0) !== 0) return;\n const k = pick(e.clientX, e.clientY);\n if (k === hovered) return;\n const prev = hovered; hovered = k;\n if (prev) colorRegion(prev, restingHex(prev));\n if (k) colorRegion(k, paletteRef.current.hover);\n canvas.style.cursor = k ? \"pointer\" : \"default\";\n renderFrame();\n };\n const onUp = (e: PointerEvent) => {\n if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6 || Date.now() - downT > 450) return;\n const k = pick(e.clientX, e.clientY);\n if (k) setSelectedRef.current(k);\n };\n canvas.addEventListener(\"pointerdown\", onDown);\n canvas.addEventListener(\"pointermove\", onMove);\n canvas.addEventListener(\"pointerup\", onUp);\n\n renderFrame();\n kick(); // settle initial damping / start auto-rotate if enabled\n const onVis = () => { if (document.hidden) { cancelAnimationFrame(raf); pumping = false; raf = 0; } else kick(); };\n document.addEventListener(\"visibilitychange\", onVis);\n const onResize = () => { W = el.clientWidth || 520; H = el.clientHeight || 600; camera.aspect = W / H; camera.updateProjectionMatrix(); renderer.setSize(W, H); renderFrame(); };\n window.addEventListener(\"resize\", onResize);\n\n return () => {\n cancelAnimationFrame(raf);\n window.removeEventListener(\"resize\", onResize);\n document.removeEventListener(\"visibilitychange\", onVis);\n controls.removeEventListener(\"change\", kick);\n canvas.removeEventListener(\"pointerdown\", onDown);\n canvas.removeEventListener(\"pointermove\", onMove);\n canvas.removeEventListener(\"pointerup\", onUp);\n controls.dispose();\n renderer.dispose();\n if (canvas.parentNode) canvas.parentNode.removeChild(canvas);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // React → scene: recolour on selection / report / palette change; swap model on sex change.\n useEffect(() => { apiRef.current?.refresh(); }, [selected, report, palette]);\n useEffect(() => { apiRef.current?.setSex(sex); }, [sex]);\n\n const pointOf = (k: string) => report.find((p) => p.region === k);\n const setPain = (k: string, intensity: number, type?: PainType) => {\n commit([...report.filter((p) => p.region !== k), { region: k, intensity, type, timestamp: new Date().toISOString() }]);\n };\n const removePain = (k: string) => { commit(report.filter((p) => p.region !== k)); setSelected(null); };\n const changeSex = (s: BodyMap3DSex) => { setSex(s); onSexChange?.(s); };\n\n const region = selected ? REGIONS.find((r) => r.key === selected) : null;\n const current = selected ? pointOf(selected) : undefined;\n\n return (\n <div data-testid=\"bodymap3d-root\" className={className} style={{ fontFamily: \"system-ui, sans-serif\", color: \"#1e293b\" }}>\n <div style={{ display: \"flex\", gap: 16, flexWrap: \"wrap\", marginBottom: 12, fontSize: 13 }}>\n <div style={{ display: \"flex\", gap: 6, alignItems: \"center\" }}>\n <button data-testid=\"bodymap3d-sex-male\" onClick={() => changeSex(\"male\")} style={seg(sex === \"male\")}>{UI.male}</button>\n <button data-testid=\"bodymap3d-sex-female\" onClick={() => changeSex(\"female\")} style={seg(sex === \"female\")}>{UI.female}</button>\n </div>\n </div>\n <span ref={loadedRef} data-testid=\"bodymap3d-loaded\" style={{ display: \"none\" }} />\n {ready && <span data-testid=\"bodymap3d-ready\" style={{ position: \"absolute\", width: 1, height: 1, opacity: 0, pointerEvents: \"none\" }} />}\n <div style={{ display: \"flex\", gap: 18, alignItems: \"flex-start\", flexWrap: \"wrap\" }}>\n {unsupported ? (\n <div data-testid=\"bodymap3d-unsupported\" style={{ flex: \"1 1 520px\", minWidth: 320, height: \"60vh\", borderRadius: 16, background: \"#0e1424\", color: \"#cbd5e1\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\", padding: 24, textAlign: \"center\", fontSize: 14 }}>\n 3D kræver WebGL, som ikke er tilgængeligt her.\n </div>\n ) : (\n <div ref={mountRef} data-testid=\"bodymap3d-canvas\" style={{ flex: \"1 1 520px\", height: \"60vh\", minWidth: 320, borderRadius: 16, overflow: \"hidden\", background: \"#0e1424\", touchAction: \"none\" }} />\n )}\n <div style={{ flex: \"1 1 300px\", minWidth: 260 }}>\n {region ? (\n <div style={{ border: \"1px solid #e2e8f0\", borderRadius: 14, padding: 16, background: \"#fff\" }}>\n <div style={{ display: \"flex\", justifyContent: \"space-between\", alignItems: \"center\", marginBottom: 12 }}>\n <b style={{ fontSize: 16 }}>{nameOf(region.key)}</b>\n <span style={{ font: \"11px ui-monospace, monospace\", color: \"#64748b\", background: \"#f1f5f9\", borderRadius: 6, padding: \"2px 7px\" }}>{region.code}{region.side ? \" · \" + region.side : \"\"}</span>\n </div>\n <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: \".06em\", textTransform: \"uppercase\", color: \"#94a3b8\", marginBottom: 7 }}>{L.intensity}</div>\n <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 4, marginBottom: 12 }}>\n {Array.from({ length: 11 }, (_, i) => (\n <button key={i} data-testid={`bodymap3d-intensity-${i}`} onClick={() => setPain(region.key, i, current?.type)} style={{ ...btn, width: 30, height: 30, background: current?.intensity === i ? \"#0e8f8a\" : \"#fff\", color: current?.intensity === i ? \"#fff\" : \"#1e293b\" }}>{i}</button>\n ))}\n </div>\n <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: \".06em\", textTransform: \"uppercase\", color: \"#94a3b8\", marginBottom: 7 }}>{L.quality}</div>\n <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 6, marginBottom: 14 }}>\n {PAIN_TYPES.map((t) => (\n <button key={t} data-testid={`bodymap3d-type-${t}`} onClick={() => setPain(region.key, current?.intensity ?? 5, t)} style={{ ...btn, borderRadius: 999, padding: \"6px 12px\", background: current?.type === t ? \"#1e293b\" : \"#fff\", color: current?.type === t ? \"#fff\" : \"#64748b\" }}>{L.qualities[t] ?? t}</button>\n ))}\n </div>\n {current && <button data-testid=\"bodymap3d-remove\" onClick={() => removePain(region.key)} style={{ ...btn, color: \"#ef4444\", borderColor: \"#f6c9c9\" }}>{L.remove}</button>}\n </div>\n ) : (\n <div data-testid=\"bodymap3d-empty\" style={{ border: \"1px solid #e2e8f0\", borderRadius: 14, padding: 16, background: \"#fff\", color: \"#94a3b8\", fontSize: 13.5 }}>{UI.hoverHint}</div>\n )}\n </div>\n </div>\n </div>\n );\n}\n\n/** Re-export for convenience so a 3D-only consumer can serialize without a second import. */\nexport { serializeReport };\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/react.tsx","../src/three.tsx"],"names":["useRef","useState","useEffect","jsxs","jsx"],"mappings":"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","file":"three.js","sourcesContent":["// @broberg/bodymap — headless core (F052.1).\n//\n// Framework-neutral: the region taxonomy + the PainReport data model (zod) + a\n// selection engine + per-app region config. NO React/Preact/DOM import — the 2D\n// (SVG) and 3D (Three.js) renderers, and all three FD apps, share this one\n// contract. Output is a structured PainReport, never a bare image.\n\nimport { z } from \"zod\";\n\nexport type Side = \"left\" | \"right\";\n\nexport interface BodyRegion {\n /** Stable, unique identifier (snake_case) — the key used in a PainReport. */\n key: string;\n /** Human label (Danish). */\n label: string;\n /** Clinical short code (unique). */\n code: string;\n /** Body side, when the region is paired. */\n side?: Side;\n}\n\n/** The canonical body regions — the AUTHORITATIVE fd-sundhed clinical taxonomy\n * (docs/BODYMAP-TAKSONOMI.md, broberg-ai/fd-sundhed @360842f): 18 SIDE-LESS\n * clinical codes + a separate `side` field (L/R on limbs, C=center on axis\n * regions). The `key` is a unique per-side identifier; `code` is the side-less\n * clinical code that goes on the bodymap/v1 wire. NOT an anatomical atlas —\n * ~30 named surface regions for a pain-map. The 2D front renderer draws the\n * front-visible subset; the 3D body (F052.6) drives them all. */\nexport const REGIONS: readonly BodyRegion[] = [\n // axis / centre-line (serialised side \"center\")\n { key: \"head\", label: \"Hoved\", code: \"HEAD\" },\n { key: \"neck\", label: \"Nakke\", code: \"NECK\" },\n { key: \"chest\", label: \"Bryst\", code: \"CHEST\" },\n { key: \"thora\", label: \"Øvre ryg (thorakal)\", code: \"THORA\" },\n { key: \"lumbar\", label: \"Lænd (lumbal)\", code: \"LUMBAR\" },\n { key: \"groin\", label: \"Lyske\", code: \"GROIN\" },\n // paired limbs / sides (L / R)\n { key: \"shoulder_left\", label: \"Skulder, venstre\", code: \"SHOULDER\", side: \"left\" },\n { key: \"shoulder_right\", label: \"Skulder, højre\", code: \"SHOULDER\", side: \"right\" },\n { key: \"uarm_left\", label: \"Overarm, venstre\", code: \"UARM\", side: \"left\" },\n { key: \"uarm_right\", label: \"Overarm, højre\", code: \"UARM\", side: \"right\" },\n { key: \"elbow_left\", label: \"Albue, venstre\", code: \"ELBOW\", side: \"left\" },\n { key: \"elbow_right\", label: \"Albue, højre\", code: \"ELBOW\", side: \"right\" },\n { key: \"farm_left\", label: \"Underarm, venstre\", code: \"FARM\", side: \"left\" },\n { key: \"farm_right\", label: \"Underarm, højre\", code: \"FARM\", side: \"right\" },\n { key: \"wrist_left\", label: \"Håndled, venstre\", code: \"WRIST\", side: \"left\" },\n { key: \"wrist_right\", label: \"Håndled, højre\", code: \"WRIST\", side: \"right\" },\n { key: \"hand_left\", label: \"Hånd, venstre\", code: \"HAND\", side: \"left\" },\n { key: \"hand_right\", label: \"Hånd, højre\", code: \"HAND\", side: \"right\" },\n { key: \"hip_left\", label: \"Hofte, venstre\", code: \"HIP\", side: \"left\" },\n { key: \"hip_right\", label: \"Hofte, højre\", code: \"HIP\", side: \"right\" },\n { key: \"thigh_left\", label: \"Lår, venstre\", code: \"THIGH\", side: \"left\" },\n { key: \"thigh_right\", label: \"Lår, højre\", code: \"THIGH\", side: \"right\" },\n { key: \"knee_left\", label: \"Knæ, venstre\", code: \"KNEE\", side: \"left\" },\n { key: \"knee_right\", label: \"Knæ, højre\", code: \"KNEE\", side: \"right\" },\n { key: \"lowleg_left\", label: \"Underben, venstre\", code: \"LOWLEG\", side: \"left\" },\n { key: \"lowleg_right\", label: \"Underben, højre\", code: \"LOWLEG\", side: \"right\" },\n { key: \"ankle_left\", label: \"Ankel, venstre\", code: \"ANKLE\", side: \"left\" },\n { key: \"ankle_right\", label: \"Ankel, højre\", code: \"ANKLE\", side: \"right\" },\n { key: \"foot_left\", label: \"Fod, venstre\", code: \"FOOT\", side: \"left\" },\n { key: \"foot_right\", label: \"Fod, højre\", code: \"FOOT\", side: \"right\" },\n];\n\nconst REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));\nexport const REGION_KEYS: readonly string[] = REGIONS.map((r) => r.key);\n\n/** Look up a region by its key. */\nexport function getRegion(key: string): BodyRegion | undefined {\n return REGIONS.find((r) => r.key === key);\n}\n\n// ---- PainReport model ---------------------------------------------------\n\nexport const PAIN_TYPES = [\"stikkende\", \"dump\", \"konstant\", \"jagende\"] as const;\nexport type PainType = (typeof PAIN_TYPES)[number];\n\n/** One marked pain point. `region` MUST be a known region key; `intensity` is a\n * 0-10 integer; `type` is optional but constrained; `timestamp` is an ISO string. */\nexport const painPointSchema = z.object({\n region: z.string().refine((k) => REGION_KEY_SET.has(k), { message: \"unknown region\" }),\n intensity: z.number().int().min(0).max(10),\n type: z.enum(PAIN_TYPES).optional(),\n timestamp: z.string(),\n});\nexport type PainPoint = z.infer<typeof painPointSchema>;\n\nexport const painReportSchema = z.array(painPointSchema);\nexport type PainReport = PainPoint[];\n\n// ---- Selection engine (framework-agnostic) ------------------------------\n\nexport interface PainSelection {\n /** Mark (or update) pain on a region. One point per region — latest wins. */\n set(region: string, intensity: number, type?: PainType): PainPoint;\n remove(region: string): boolean;\n get(region: string): PainPoint | undefined;\n has(region: string): boolean;\n clear(): void;\n /** The current, validated PainReport. */\n getReport(): PainReport;\n}\n\nexport interface PainSelectionOptions {\n /** Injectable clock (ISO string) — defaults to `new Date().toISOString()`. */\n now?: () => string;\n}\n\n/** Create a selection engine seeded with an optional report. Pure state — no\n * DOM, no framework, no network. One point per region. */\nexport function createPainSelection(\n initial: PainReport = [],\n opts: PainSelectionOptions = {},\n): PainSelection {\n const now = opts.now ?? (() => new Date().toISOString());\n const map = new Map<string, PainPoint>();\n for (const p of initial) {\n const v = painPointSchema.parse(p);\n map.set(v.region, v);\n }\n return {\n set(region, intensity, type) {\n const point = painPointSchema.parse({ region, intensity, type, timestamp: now() });\n map.set(point.region, point);\n return point;\n },\n remove: (region) => map.delete(region),\n get: (region) => map.get(region),\n has: (region) => map.has(region),\n clear: () => map.clear(),\n getReport: () => painReportSchema.parse(Array.from(map.values())),\n };\n}\n\n// ---- Per-app region config (the toggle) ---------------------------------\n\nexport interface RegionSetting {\n /** Render this region at all. Default true. */\n visible?: boolean;\n /** Allow marking pain on this region. Default true. */\n selectable?: boolean;\n}\n\n/** Per-app config keyed by region key. An absent key ⇒ visible + selectable. */\nexport type RegionConfig = Record<string, RegionSetting>;\n\n/** The regions an app should render, honouring `visible` (default true). */\nexport function resolveRegions(config: RegionConfig = {}): BodyRegion[] {\n return REGIONS.filter((r) => config[r.key]?.visible ?? true);\n}\n\n/** Whether a region may be marked. A hidden region is never selectable. */\nexport function isSelectable(key: string, config: RegionConfig = {}): boolean {\n const s = config[key];\n if (s?.visible === false) return false;\n return s?.selectable ?? true;\n}\n\n// ---- palette (consumer-defined colours — shared by the 2D + 3D renderers) ---\n\n/** Colour control for the body renderers. Consumers pass a palette to theme the\n * body base colour, the hover + selected highlights, the pain-heat colours, and\n * optional per-region base colours. All values are CSS/hex colour strings. */\nexport interface BodymapPalette {\n /** Base body colour (an unmarked region). */\n body: string;\n /** Region highlight on hover (before click). */\n hover: string;\n /** A region selected (clicked) but not yet given an intensity. */\n selected: string;\n /** Pain-intensity heat colours: low (0-3), mid (4-6), high (7-10). */\n heat: { low: string; mid: string; high: string };\n /** Optional per-region base-colour overrides (region key → colour). */\n regions?: Record<string, string>;\n}\n\n/** The fleet default palette. Override any field per consumer. */\nexport const defaultPalette: BodymapPalette = {\n body: \"#d2d7de\",\n hover: \"#8fd0cd\",\n selected: \"#5cc4b7\",\n heat: { low: \"#fcd34d\", mid: \"#fb923c\", high: \"#ef4444\" },\n};\n\n/** The heat colour for a pain intensity, honouring the palette. */\nexport function heatFor(intensity: number, palette: BodymapPalette = defaultPalette): string {\n return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;\n}\n\n/** The base colour for a region (a per-region override, else the body colour). */\nexport function baseColorFor(regionKey: string, palette: BodymapPalette = defaultPalette): string {\n return palette.regions?.[regionKey] ?? palette.body;\n}\n\n// ---- bodymap/v1 serialization (the shared cross-app / native wire format) ---\n//\n// The shape every consumer + the native mobile apps read (aligned with\n// fd-sundhed's bodymap/v1: region CODE + side + intensity + quality + view).\n// The internal PainReport keys on the region KEY; this maps key -> clinical CODE\n// so the report is portable and human-readable on the wire.\n\nexport type BodyView = \"front\" | \"back\" | \"left\" | \"right\";\n/** Side in the serialized report — a midline region (no side) becomes \"center\". */\nexport type SerializedSide = \"left\" | \"right\" | \"center\";\n\nexport interface SerializedPainPoint {\n /** Clinical region CODE (e.g. \"LUMB\"). */\n region: string;\n side: SerializedSide;\n intensity: number;\n quality?: PainType;\n}\n\nexport interface BodymapReportV1 {\n schema: \"bodymap/v1\";\n view: BodyView;\n points: SerializedPainPoint[];\n}\n\nexport const bodymapReportV1Schema = z.object({\n schema: z.literal(\"bodymap/v1\"),\n view: z.enum([\"front\", \"back\", \"left\", \"right\"]),\n points: z.array(\n z.object({\n region: z.string(),\n side: z.enum([\"left\", \"right\", \"center\"]),\n intensity: z.number().int().min(0).max(10),\n quality: z.enum(PAIN_TYPES).optional(),\n }),\n ),\n});\n\n/** Serialize a PainReport to the shared `bodymap/v1` wire format. */\nexport function serializeReport(\n report: PainReport,\n opts: { view?: BodyView } = {},\n): BodymapReportV1 {\n return {\n schema: \"bodymap/v1\",\n view: opts.view ?? \"front\",\n points: report.map((p) => {\n const r = getRegion(p.region);\n return {\n region: r?.code ?? p.region,\n side: (r?.side ?? \"center\") as SerializedSide,\n intensity: p.intensity,\n quality: p.type,\n };\n }),\n };\n}\n\n/** Parse a `bodymap/v1` report back into an internal PainReport. Region CODE →\n * key; a point whose code is unknown to this taxonomy is dropped. */\nexport function deserializeReport(\n env: unknown,\n now: () => string = () => new Date().toISOString(),\n): PainReport {\n const parsed = bodymapReportV1Schema.parse(env);\n // code is side-less, so a point is identified by code + side.\n const byCodeSide = new Map(\n REGIONS.map((r) => [`${r.code}|${r.side ?? \"center\"}`, r.key] as const),\n );\n const out: PainReport = [];\n for (const sp of parsed.points) {\n const key = byCodeSide.get(`${sp.region}|${sp.side}`);\n if (!key) continue;\n out.push(\n painPointSchema.parse({\n region: key,\n intensity: sp.intensity,\n type: sp.quality,\n timestamp: now(),\n }),\n );\n }\n return out;\n}\n","// @broberg/bodymap/react — the 2D SVG renderer (F052.2).\n//\n// <BodyMap> is the shippable MVP: a genderless, colour-blocked body a patient\n// clicks to mark where it hurts. Selecting a region opens a custom intensity\n// (0-10) + quality control (NO native inputs) that upserts a validated\n// PainReport and fires onChange. Honours the per-app RegionConfig. The 3D\n// <BodyMap3D> (F052.6) will render the SAME core on the SAME onChange contract.\n//\n// F052.8 touch-first + F052.11 a11y: the body is PRIMARILY a mobile surface, so\n// every region has a >=44px transparent hit-area, the body pinch-zooms + pans\n// (with a tap-vs-drag guard so a gesture never mis-marks), controls are\n// thumb-sized and the layout stacks on a narrow viewport. Everything is also\n// fully keyboard-operable with live-state ARIA labels + visible focus.\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n REGIONS,\n PAIN_TYPES,\n painPointSchema,\n resolveRegions,\n isSelectable,\n getRegion,\n heatFor,\n baseColorFor,\n type BodyRegion,\n type BodymapPalette,\n type PainReport,\n type PainType,\n type RegionConfig,\n} from \"./index.js\";\n\n// ---- geometry: each region → an SVG shape (a neutral colour-blockout) --------\n\ntype Shape =\n | { el: \"circle\"; cx: number; cy: number; r: number }\n | { el: \"ellipse\"; cx: number; cy: number; rx: number; ry: number }\n | { el: \"rect\"; x: number; y: number; width: number; height: number; rx: number };\n\n// Front-view geometry (viewBox 260×470). The 2D MVP renders the front-visible\n// regions; back-only regions (thora, lumbar) + hip are driven by the 3D body /\n// a later back view. \"left\"/\"right\" are placed self-view (viewer's left = key\n// left) for the MVP — the facing-the-patient mirror is a later config option.\nconst SHAPES: Record<string, Shape> = {\n head: { el: \"circle\", cx: 130, cy: 40, r: 26 },\n neck: { el: \"rect\", x: 120, y: 62, width: 20, height: 16, rx: 6 },\n chest: { el: \"rect\", x: 100, y: 84, width: 60, height: 104, rx: 16 },\n groin: { el: \"rect\", x: 106, y: 190, width: 48, height: 30, rx: 13 },\n // left arm (viewer-left)\n shoulder_left: { el: \"circle\", cx: 88, cy: 98, r: 14 },\n uarm_left: { el: \"rect\", x: 66, y: 104, width: 18, height: 54, rx: 9 },\n elbow_left: { el: \"circle\", cx: 73, cy: 164, r: 10 },\n farm_left: { el: \"rect\", x: 62, y: 174, width: 16, height: 50, rx: 8 },\n wrist_left: { el: \"circle\", cx: 68, cy: 230, r: 8 },\n hand_left: { el: \"ellipse\", cx: 67, cy: 250, rx: 11, ry: 13 },\n // right arm (viewer-right)\n shoulder_right: { el: \"circle\", cx: 172, cy: 98, r: 14 },\n uarm_right: { el: \"rect\", x: 176, y: 104, width: 18, height: 54, rx: 9 },\n elbow_right: { el: \"circle\", cx: 187, cy: 164, r: 10 },\n farm_right: { el: \"rect\", x: 182, y: 174, width: 16, height: 50, rx: 8 },\n wrist_right: { el: \"circle\", cx: 192, cy: 230, r: 8 },\n hand_right: { el: \"ellipse\", cx: 193, cy: 250, rx: 11, ry: 13 },\n // legs\n thigh_left: { el: \"rect\", x: 104, y: 224, width: 22, height: 80, rx: 11 },\n thigh_right: { el: \"rect\", x: 134, y: 224, width: 22, height: 80, rx: 11 },\n knee_left: { el: \"circle\", cx: 115, cy: 312, r: 13 },\n knee_right: { el: \"circle\", cx: 145, cy: 312, r: 13 },\n lowleg_left: { el: \"rect\", x: 106, y: 322, width: 18, height: 74, rx: 9 },\n lowleg_right: { el: \"rect\", x: 136, y: 322, width: 18, height: 74, rx: 9 },\n ankle_left: { el: \"circle\", cx: 115, cy: 404, r: 9 },\n ankle_right: { el: \"circle\", cx: 145, cy: 404, r: 9 },\n foot_left: { el: \"ellipse\", cx: 115, cy: 424, rx: 14, ry: 10 },\n foot_right: { el: \"ellipse\", cx: 145, cy: 424, rx: 14, ry: 10 },\n};\n\nconst FILL: Record<string, string> = {\n head: \"#cbb7ec\", neck: \"#b8a3e6\", chest: \"#e26d6d\", groin: \"#e88a52\",\n shoulder_left: \"#f6c667\", shoulder_right: \"#f6c667\",\n uarm_left: \"#7ececb\", uarm_right: \"#7ececb\",\n elbow_left: \"#86cfcc\", elbow_right: \"#86cfcc\",\n farm_left: \"#8fd0cd\", farm_right: \"#8fd0cd\",\n wrist_left: \"#9ad6d3\", wrist_right: \"#9ad6d3\",\n hand_left: \"#a7ddda\", hand_right: \"#a7ddda\",\n thigh_left: \"#7fb2e8\", thigh_right: \"#7fb2e8\",\n knee_left: \"#c79be0\", knee_right: \"#c79be0\",\n lowleg_left: \"#e39ec6\", lowleg_right: \"#e39ec6\",\n ankle_left: \"#eeaab9\", ankle_right: \"#eeaab9\",\n foot_left: \"#f0a5a5\", foot_right: \"#f0a5a5\",\n};\n\n// Back-view geometry (same viewBox 260×470 + same limb/head geometry as the\n// front, so a marked limb sits at the same spot in both views). The torso swaps\n// front regions (chest, groin) for the posterior ones — THORA (upper back),\n// LUMBAR (lower back) and HIP (buttocks, paired) — the pilot's blocker regions.\n// Same self-view L/R convention as the front (patient-left = viewer-left).\nconst SHAPES_BACK: Record<string, Shape> = {\n head: { el: \"circle\", cx: 130, cy: 40, r: 26 },\n neck: { el: \"rect\", x: 120, y: 62, width: 20, height: 16, rx: 6 },\n thora: { el: \"rect\", x: 100, y: 84, width: 60, height: 58, rx: 15 },\n lumbar: { el: \"rect\", x: 104, y: 144, width: 52, height: 44, rx: 13 },\n hip_left: { el: \"rect\", x: 100, y: 190, width: 27, height: 32, rx: 13 },\n hip_right: { el: \"rect\", x: 133, y: 190, width: 27, height: 32, rx: 13 },\n // arms — shared with the front (back of the arm sits at the same x/y)\n shoulder_left: { el: \"circle\", cx: 88, cy: 98, r: 14 },\n uarm_left: { el: \"rect\", x: 66, y: 104, width: 18, height: 54, rx: 9 },\n elbow_left: { el: \"circle\", cx: 73, cy: 164, r: 10 },\n farm_left: { el: \"rect\", x: 62, y: 174, width: 16, height: 50, rx: 8 },\n wrist_left: { el: \"circle\", cx: 68, cy: 230, r: 8 },\n hand_left: { el: \"ellipse\", cx: 67, cy: 250, rx: 11, ry: 13 },\n shoulder_right: { el: \"circle\", cx: 172, cy: 98, r: 14 },\n uarm_right: { el: \"rect\", x: 176, y: 104, width: 18, height: 54, rx: 9 },\n elbow_right: { el: \"circle\", cx: 187, cy: 164, r: 10 },\n farm_right: { el: \"rect\", x: 182, y: 174, width: 16, height: 50, rx: 8 },\n wrist_right: { el: \"circle\", cx: 192, cy: 230, r: 8 },\n hand_right: { el: \"ellipse\", cx: 193, cy: 250, rx: 11, ry: 13 },\n // legs — back (thigh→calf→heel, same x/y as the front)\n thigh_left: { el: \"rect\", x: 104, y: 224, width: 22, height: 80, rx: 11 },\n thigh_right: { el: \"rect\", x: 134, y: 224, width: 22, height: 80, rx: 11 },\n knee_left: { el: \"circle\", cx: 115, cy: 312, r: 13 },\n knee_right: { el: \"circle\", cx: 145, cy: 312, r: 13 },\n lowleg_left: { el: \"rect\", x: 106, y: 322, width: 18, height: 74, rx: 9 },\n lowleg_right: { el: \"rect\", x: 136, y: 322, width: 18, height: 74, rx: 9 },\n ankle_left: { el: \"circle\", cx: 115, cy: 404, r: 9 },\n ankle_right: { el: \"circle\", cx: 145, cy: 404, r: 9 },\n foot_left: { el: \"ellipse\", cx: 115, cy: 424, rx: 14, ry: 10 },\n foot_right: { el: \"ellipse\", cx: 145, cy: 424, rx: 14, ry: 10 },\n};\n\nconst FILL_BACK: Record<string, string> = {\n ...FILL,\n thora: \"#e2896d\",\n lumbar: \"#d97fa0\",\n hip_left: \"#c99bd6\", hip_right: \"#c99bd6\",\n};\n\nexport type BodyView2D = \"front\" | \"back\";\n\n// ---- i18n (F052.12) — every UI string is overridable; da + en ship ----------\n// Region CODE + keys + the bodymap/v1 wire are NEVER touched — labels are\n// display-only, so a report is byte-identical in any language.\n\nexport interface BodyMapLabels {\n /** Region key → display name. */\n regions: Record<string, string>;\n /** PainType → display word (e.g. \"stikkende\" → \"Stabbing\"). */\n qualities: Record<string, string>;\n intensity: string;\n quality: string;\n remove: string;\n front: string;\n back: string;\n empty: string;\n /** Compare view (F052.13). */\n before: string;\n after: string;\n noChange: string;\n change: { new: string; resolved: string; improved: string; worse: string };\n /** Accessible name for a marked region. */\n ariaMarked: (name: string, intensity: number, quality?: string) => string;\n /** Accessible name for an unmarked, selectable region. */\n ariaUnmarked: (name: string) => string;\n svgLabel: string;\n viewLabel: string;\n zoomLabel: string;\n zoomIn: string;\n zoomOut: string;\n zoomReset: string;\n}\n\nconst daRegions: Record<string, string> = Object.fromEntries(REGIONS.map((r) => [r.key, r.label]));\nconst EN_CODE: Record<string, string> = {\n HEAD: \"Head\", NECK: \"Neck\", CHEST: \"Chest\", THORA: \"Upper back\", LUMBAR: \"Lower back\", GROIN: \"Groin\",\n SHOULDER: \"Shoulder\", UARM: \"Upper arm\", ELBOW: \"Elbow\", FARM: \"Forearm\", WRIST: \"Wrist\", HAND: \"Hand\",\n HIP: \"Hip\", THIGH: \"Thigh\", KNEE: \"Knee\", LOWLEG: \"Lower leg\", ANKLE: \"Ankle\", FOOT: \"Foot\",\n};\nconst enRegions: Record<string, string> = Object.fromEntries(\n REGIONS.map((r) => [r.key, EN_CODE[r.code] + (r.side ? (r.side === \"left\" ? \", left\" : \", right\") : \"\")]),\n);\n\nexport const LABELS_DA: BodyMapLabels = {\n regions: daRegions,\n qualities: { stikkende: \"stikkende\", dump: \"dump\", konstant: \"konstant\", jagende: \"jagende\" },\n intensity: \"Intensitet (0-10)\",\n quality: \"Kvalitet\",\n remove: \"Fjern punkt\",\n front: \"Forfra\",\n back: \"Bagfra\",\n empty: \"Vælg en kropsdel for at markere smerte.\",\n before: \"Før\",\n after: \"Efter\",\n noChange: \"Ingen ændring\",\n change: { new: \"nyt\", resolved: \"forsvundet\", improved: \"bedre\", worse: \"værre\" },\n ariaMarked: (n, i, q) => `${n}, smerte ${i} af 10${q ? \", \" + q : \"\"}`,\n ariaUnmarked: (n) => `${n}, ikke markeret. Aktivér for at markere smerte.`,\n svgLabel: \"Kropskort — vælg hvor det gør ondt\",\n viewLabel: \"Visning\",\n zoomLabel: \"Zoom\",\n zoomIn: \"Zoom ind\",\n zoomOut: \"Zoom ud\",\n zoomReset: \"Nulstil zoom\",\n};\n\nexport const LABELS_EN: BodyMapLabels = {\n regions: enRegions,\n qualities: { stikkende: \"stabbing\", dump: \"dull\", konstant: \"constant\", jagende: \"shooting\" },\n intensity: \"Intensity (0-10)\",\n quality: \"Quality\",\n remove: \"Remove point\",\n front: \"Front\",\n back: \"Back\",\n empty: \"Pick a body part to mark pain.\",\n before: \"Before\",\n after: \"After\",\n noChange: \"No change\",\n change: { new: \"new\", resolved: \"resolved\", improved: \"improved\", worse: \"worse\" },\n ariaMarked: (n, i, q) => `${n}, pain ${i} of 10${q ? \", \" + q : \"\"}`,\n ariaUnmarked: (n) => `${n}, not marked. Activate to mark pain.`,\n svgLabel: \"Body map — pick where it hurts\",\n viewLabel: \"View\",\n zoomLabel: \"Zoom\",\n zoomIn: \"Zoom in\",\n zoomOut: \"Zoom out\",\n zoomReset: \"Reset zoom\",\n};\n\nexport type BodyMapLocale = \"da\" | \"en\";\n\nfunction resolveLabels(locale: BodyMapLocale, overrides?: Partial<BodyMapLabels>): BodyMapLabels {\n const base = locale === \"en\" ? LABELS_EN : LABELS_DA;\n if (!overrides) return base;\n return {\n ...base,\n ...overrides,\n regions: { ...base.regions, ...overrides.regions },\n qualities: { ...base.qualities, ...overrides.qualities },\n };\n}\n\nconst VBW = 260;\nconst VBH = 470;\n// The SVG renders ~320px wide → ~1.23 px per viewBox unit. A hit-area needs\n// ~36 units to clear the 44px touch minimum; small regions get an invisible\n// padded hit-shape at least this big (zoom handles genuinely dense clusters).\nconst MIN_HIT = 18; // half-extent (radius) in viewBox units\n\nfunction center(s: Shape): { x: number; y: number } {\n if (s.el === \"rect\") return { x: s.x + s.width / 2, y: s.y + s.height / 2 };\n return { x: s.cx, y: s.cy };\n}\n// An enlarged, invisible hit target so small regions clear the 44px touch min.\nfunction hitShape(s: Shape): Shape {\n if (s.el === \"circle\") return { el: \"circle\", cx: s.cx, cy: s.cy, r: Math.max(s.r, MIN_HIT) };\n if (s.el === \"ellipse\")\n return { el: \"ellipse\", cx: s.cx, cy: s.cy, rx: Math.max(s.rx, MIN_HIT), ry: Math.max(s.ry, MIN_HIT) };\n const c = center(s);\n const w = Math.max(s.width, MIN_HIT * 2);\n const h = Math.max(s.height, MIN_HIT * 2);\n return { el: \"rect\", x: c.x - w / 2, y: c.y - h / 2, width: w, height: h, rx: s.rx };\n}\nfunction heat(v: number): string {\n return v >= 7 ? \"#ef4444\" : v >= 4 ? \"#fb923c\" : \"#fcd34d\";\n}\nfunction heatTier(v: number): \"low\" | \"mid\" | \"high\" {\n return v >= 7 ? \"high\" : v >= 4 ? \"mid\" : \"low\";\n}\n// F052.10 palette: a `palette` prop wins (direct colour); with no prop, a CSS-var\n// chain (--bmap-region-<key> → --bmap-body) themes with the default rainbow as\n// the fallback. Marked regions likewise: palette.heat or --bmap-heat-<tier>.\nfunction baseFill(key: string, rainbow: string, palette?: BodymapPalette): string {\n return palette ? baseColorFor(key, palette) : `var(--bmap-region-${key}, var(--bmap-body, ${rainbow}))`;\n}\nfunction markedFill(v: number, palette?: BodymapPalette): string {\n return palette ? heatFor(v, palette) : `var(--bmap-heat-${heatTier(v)}, ${heat(v)})`;\n}\nfunction shapeEl(s: Shape, props: Record<string, unknown>) {\n if (s.el === \"circle\") return <circle cx={s.cx} cy={s.cy} r={s.r} {...props} />;\n if (s.el === \"ellipse\") return <ellipse cx={s.cx} cy={s.cy} rx={s.rx} ry={s.ry} {...props} />;\n return <rect x={s.x} y={s.y} width={s.width} height={s.height} rx={s.rx} {...props} />;\n}\n\n// ---- one-time style injection (no external CSS; theme via CSS variables) -----\n\nconst STYLE_ID = \"broberg-bodymap-styles\";\nconst STYLE = `\n.bmap{--bmap-accent:var(--primary,#0e8f8a);--bmap-line:#e2e8f0;--bmap-panel:#fff;--bmap-ink:#1e293b;--bmap-muted:#64748b;\n display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--bmap-ink)}\n.bmap__stage{display:flex;flex-direction:column;gap:10px;flex:0 1 340px;min-width:0;max-width:360px}\n.bmap__bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}\n.bmap__viewtoggle{display:inline-flex;background:#f1f5f9;border:1px solid var(--bmap-line);border-radius:10px;padding:3px;gap:2px}\n.bmap__vbtn{font:inherit;font-size:13px;font-weight:600;color:#556274;background:none;border:0;border-radius:7px;padding:8px 16px;cursor:pointer;transition:color .12s,background .12s,transform .1s}\n.bmap__vbtn:hover{color:var(--bmap-ink)}\n.bmap__vbtn:active{transform:scale(.97)}\n.bmap__vbtn--on{background:#fff;color:var(--bmap-ink);box-shadow:0 1px 2px rgba(15,23,42,.14)}\n.bmap__zoom{display:inline-flex;gap:4px;margin-left:auto}\n.bmap__zoom button{font:inherit;font-weight:700;font-size:16px;line-height:1;width:36px;height:36px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-ink);border-radius:8px;cursor:pointer;transition:border-color .12s,transform .1s;display:flex;align-items:center;justify-content:center}\n.bmap__zoom button:hover{border-color:var(--bmap-accent)}\n.bmap__zoom button:active{transform:scale(.9)}\n.bmap__zoom button:disabled{opacity:.4;cursor:default}\n.bmap__svg{width:100%;height:auto;display:block;touch-action:none;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;background:transparent;border-radius:14px}\n.bmap__vis{stroke:#fff;stroke-width:2.4;pointer-events:none;transition:filter .12s,stroke .12s,stroke-width .12s}\n.bmap__vis--sel{stroke:var(--bmap-accent);stroke-width:3.6}\n.bmap__hit{fill:transparent;cursor:pointer;outline:none}\n.bmap__hit--locked{cursor:default}\n.bmap__hit:hover + .bmap__vis{filter:brightness(1.08) saturate(1.15)}\n.bmap__hit:focus-visible + .bmap__vis{stroke:var(--bmap-accent);stroke-width:4;stroke-dasharray:4 2.5}\n.bmap__heatn{font:700 12px ui-sans-serif;fill:#fff;pointer-events:none}\n.bmap__panel{flex:1 1 240px;min-width:220px;border:1px solid var(--bmap-line);border-radius:14px;padding:16px 18px;background:var(--bmap-panel)}\n.bmap__panel--empty{color:var(--bmap-muted);font-size:13.5px}\n.bmap__ph{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px}\n.bmap__ph b{font-size:16px}\n.bmap__code{font:11px ui-monospace,monospace;color:var(--bmap-muted);background:#f1f5f9;border-radius:6px;padding:2px 7px}\n.bmap__lbl{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--bmap-muted);margin:0 0 7px}\n.bmap__scale{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}\n.bmap__i{font:inherit;font-size:14px;font-weight:600;width:38px;height:38px;border-radius:9px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-ink);cursor:pointer;transition:border-color .1s,background .1s,transform .1s}\n.bmap__i:hover{border-color:var(--bmap-accent)}\n.bmap__i:active{transform:scale(.9)}\n.bmap__i--on{background:var(--bmap-accent);border-color:var(--bmap-accent);color:#fff}\n.bmap__chips{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:14px}\n.bmap__chip{font:inherit;font-size:13px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-muted);border-radius:999px;padding:9px 15px;cursor:pointer;transition:.1s}\n.bmap__chip:hover{border-color:var(--bmap-accent);color:var(--bmap-accent)}\n.bmap__chip:active{transform:scale(.96)}\n.bmap__chip--on{background:var(--bmap-ink);border-color:var(--bmap-ink);color:#fff}\n.bmap__rm{font:inherit;font-size:14px;font-weight:600;border:1px solid #f6c9c9;background:#fff;color:#ef4444;border-radius:9px;padding:10px 15px;cursor:pointer;transition:.1s}\n.bmap__rm:hover{background:#fef2f2}\n.bmap__rm:active{transform:scale(.97)}\n.bmap__i:focus-visible,.bmap__chip:focus-visible,.bmap__vbtn:focus-visible,.bmap__rm:focus-visible,.bmap__zoom button:focus-visible{outline:2px solid var(--bmap-accent);outline-offset:2px}\n.bmapc{--bmap-accent:var(--primary,#0e8f8a);--bmap-line:#e2e8f0;--bmap-ink:#1e293b;--bmap-muted:#64748b;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--bmap-ink)}\n.bmapc__bodies{display:flex;gap:14px;flex-wrap:wrap}\n.bmapc__fig{flex:1 1 150px;min-width:130px;max-width:220px;margin:0;text-align:center}\n.bmapc__cap{font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#556274;margin-bottom:6px}\n.bmapc__svg{width:100%;height:auto;display:block}\n.bmapc__vis{stroke:#fff;stroke-width:2.4}\n.bmapc__n{font:700 12px ui-sans-serif;fill:#fff;pointer-events:none}\n.bmapc__delta{margin-top:16px;display:flex;flex-direction:column;gap:6px;max-width:460px}\n.bmapc__row{display:flex;align-items:center;gap:10px;font-size:13.5px;border:1px solid var(--bmap-line);border-radius:9px;padding:7px 11px}\n.bmapc__rname{font-weight:600;flex:1 1 auto}\n.bmapc__rval{font:12px ui-monospace,monospace;color:#556274;white-space:nowrap}\n.bmapc__badge{font-size:11px;font-weight:700;border-radius:6px;padding:2px 8px;white-space:nowrap}\n.bmapc__badge--improved{background:#dcfce7;color:#166534}\n.bmapc__badge--worse{background:#fee2e2;color:#991b1b}\n.bmapc__badge--new{background:#fef3c7;color:#92400e}\n.bmapc__badge--resolved{background:#e2e8f0;color:#475569}\n.bmapc__empty{color:#556274;font-size:13.5px;margin-top:14px}\n@media (max-width:560px){\n .bmap{flex-direction:column;gap:14px}\n .bmap__stage{max-width:100%;width:100%;flex-basis:auto}\n .bmap__panel{width:100%}\n .bmap__i{width:46px;height:46px;font-size:16px}\n .bmap__chip{padding:11px 17px;font-size:15px}\n .bmap__vbtn{padding:10px 18px;font-size:15px}\n}\n@media (prefers-reduced-motion:reduce){.bmap *{transition:none !important}}\n`;\n\nfunction ensureStyles(): void {\n if (typeof document === \"undefined\") return;\n if (document.getElementById(STYLE_ID)) return;\n const el = document.createElement(\"style\");\n el.id = STYLE_ID;\n el.textContent = STYLE;\n document.head.appendChild(el);\n}\n\n// ---- component ---------------------------------------------------------------\n\nexport interface BodyMapProps {\n /** Controlled report. Omit for an uncontrolled component (use defaultValue). */\n value?: PainReport;\n /** Initial report when uncontrolled. */\n defaultValue?: PainReport;\n /** Fired with the full validated PainReport on every change. */\n onChange?: (report: PainReport) => void;\n /** Per-app region config (visible / selectable). */\n config?: RegionConfig;\n /** Initial body view (front / back). Default \"front\". */\n defaultView?: BodyView2D;\n /** Fired when the user toggles the front/back view — pass it to\n * serializeReport({ view }) so the wire report carries the active view. */\n onViewChange?: (view: BodyView2D) => void;\n /** Colour theme (F052.10). Overrides the default rainbow: body/hover/selected/\n * heat + optional per-region base colours. Without it, the rainbow is used\n * (themeable via --bmap-body / --bmap-region-<key> / --bmap-heat-* CSS vars). */\n palette?: BodymapPalette;\n /** UI language (F052.12). \"da\" (default) or \"en\". */\n locale?: BodyMapLocale;\n /** Override individual UI strings (region names, headings, quality words, …).\n * Merged over the locale set — region CODE + wire format are never affected. */\n labels?: Partial<BodyMapLabels>;\n /** Display-only mode (F052.9): render a saved report coloured by intensity\n * with NO picker and no interactivity. For journals, PDFs, clinician views. */\n readOnly?: boolean;\n className?: string;\n}\n\nconst ZMIN = 1;\nconst ZMAX = 4;\nconst clampPan = (p: { x: number; y: number }, z: number) => ({\n x: Math.min(0, Math.max(VBW * (1 - z), p.x)),\n y: Math.min(0, Math.max(VBH * (1 - z), p.y)),\n});\n\n/** 2D SVG body pain-map. Click/tap a region → set intensity + quality → PainReport. */\nexport function BodyMap({\n value, defaultValue, onChange, config = {}, defaultView = \"front\", onViewChange,\n palette, locale = \"da\", labels, readOnly = false, className,\n}: BodyMapProps) {\n useEffect(ensureStyles, []);\n const [internal, setInternal] = useState<PainReport>(defaultValue ?? []);\n const [selected, setSelected] = useState<string | null>(null);\n const [view, setView] = useState<BodyView2D>(defaultView);\n const [zoom, setZoom] = useState(1);\n const [pan, setPan] = useState({ x: 0, y: 0 });\n const report = value ?? internal;\n const shapes = view === \"back\" ? SHAPES_BACK : SHAPES;\n const fills = view === \"back\" ? FILL_BACK : FILL;\n const L = resolveLabels(locale, labels);\n const nameOf = (key: string) => L.regions[key] ?? getRegion(key)?.label ?? key;\n\n const svgRef = useRef<SVGSVGElement>(null);\n const zoomRef = useRef(1);\n const panRef = useRef({ x: 0, y: 0 });\n const pointers = useRef(new Map<number, { x: number; y: number }>());\n const gesture = useRef({ startDist: 0, startZoom: 1, midVB: { x: 0, y: 0 }, start: { x: 0, y: 0 }, moved: false });\n const suppressClick = useRef(false);\n\n const setZP = (z: number, p: { x: number; y: number }) => {\n const cz = Math.max(ZMIN, Math.min(ZMAX, z));\n const cp = clampPan(p, cz);\n zoomRef.current = cz; panRef.current = cp;\n setZoom(cz); setPan(cp);\n };\n const screenToVB = (cx: number, cy: number) => {\n const el = svgRef.current;\n if (!el) return { x: VBW / 2, y: VBH / 2 };\n const r = el.getBoundingClientRect();\n if (!r.width || !r.height) return { x: VBW / 2, y: VBH / 2 };\n return { x: ((cx - r.left) / r.width) * VBW, y: ((cy - r.top) / r.height) * VBH };\n };\n // Zoom keeping the given viewBox focal point under the cursor/fingers.\n const zoomTo = (nz: number, focalVB: { x: number; y: number }) => {\n const z = Math.max(ZMIN, Math.min(ZMAX, nz));\n const p = panRef.current;\n setZP(z, { x: focalVB.x - (focalVB.x - p.x) * (z / zoomRef.current), y: focalVB.y - (focalVB.y - p.y) * (z / zoomRef.current) });\n };\n const zoomBtn = (factor: number) => zoomTo(zoomRef.current * factor, { x: VBW / 2, y: VBH / 2 });\n const resetZoom = () => setZP(1, { x: 0, y: 0 });\n\n const onPointerDown = (e: React.PointerEvent) => {\n pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });\n const pts = [...pointers.current.values()];\n if (pts.length === 2) {\n gesture.current.startDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);\n gesture.current.startZoom = zoomRef.current;\n gesture.current.midVB = screenToVB((pts[0].x + pts[1].x) / 2, (pts[0].y + pts[1].y) / 2);\n gesture.current.moved = true;\n suppressClick.current = true;\n } else {\n gesture.current.start = { x: e.clientX, y: e.clientY };\n gesture.current.moved = false;\n }\n };\n const onPointerMove = (e: React.PointerEvent) => {\n const prev = pointers.current.get(e.pointerId);\n if (!prev) return;\n pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });\n const pts = [...pointers.current.values()];\n if (pts.length >= 2) {\n const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);\n if (gesture.current.startDist > 0) zoomTo(gesture.current.startZoom * (dist / gesture.current.startDist), gesture.current.midVB);\n return;\n }\n const el = svgRef.current;\n const rect = el?.getBoundingClientRect();\n if (Math.hypot(e.clientX - gesture.current.start.x, e.clientY - gesture.current.start.y) > 6) {\n gesture.current.moved = true;\n suppressClick.current = true;\n }\n if (zoomRef.current > 1 && rect && rect.width) {\n const dxVB = ((e.clientX - prev.x) / rect.width) * VBW;\n const dyVB = ((e.clientY - prev.y) / rect.height) * VBH;\n setZP(zoomRef.current, { x: panRef.current.x + dxVB, y: panRef.current.y + dyVB });\n }\n };\n const onPointerUp = (e: React.PointerEvent) => {\n pointers.current.delete(e.pointerId);\n if (pointers.current.size === 0) window.setTimeout(() => { suppressClick.current = false; }, 60);\n };\n\n const changeView = (v: BodyView2D) => {\n if (v === view) return;\n setView(v);\n const sh = v === \"back\" ? SHAPES_BACK : SHAPES;\n if (selected && !sh[selected]) setSelected(null); // selected region absent in the new view\n onViewChange?.(v);\n };\n\n const commit = (next: PainReport) => {\n if (value === undefined) setInternal(next);\n onChange?.(next);\n };\n const pointOf = (key: string) => report.find((p) => p.region === key);\n const setPain = (key: string, intensity: number, type?: PainType) => {\n const point = painPointSchema.parse({ region: key, intensity, type, timestamp: new Date().toISOString() });\n commit([...report.filter((p) => p.region !== key), point]);\n };\n const removePain = (key: string) => {\n commit(report.filter((p) => p.region !== key));\n setSelected(null);\n };\n const pickRegion = (key: string) => {\n if (suppressClick.current) { suppressClick.current = false; return; } // a pan/pinch just ended — not a tap\n setSelected(key);\n };\n\n const regions = resolveRegions(config);\n const region: BodyRegion | undefined = selected ? REGIONS.find((r) => r.key === selected) : undefined;\n const current = selected ? pointOf(selected) : undefined;\n const rootCls = [\"bmap\", className].filter(Boolean).join(\" \");\n // palette.selected drives the focus/selected stroke via --bmap-accent.\n const rootStyle = palette ? ({ \"--bmap-accent\": palette.selected } as React.CSSProperties) : undefined;\n const ariaFor = (r: BodyRegion, marked?: PainReport[number], interactive = true): string => {\n if (marked) return L.ariaMarked(nameOf(r.key), marked.intensity, marked.type ? L.qualities[marked.type] : undefined);\n return interactive ? L.ariaUnmarked(nameOf(r.key)) : nameOf(r.key);\n };\n\n return (\n <div className={rootCls} data-testid=\"bodymap-root\" style={rootStyle}>\n <div className=\"bmap__stage\">\n <div className=\"bmap__bar\">\n <div className=\"bmap__viewtoggle\" role=\"group\" aria-label={L.viewLabel}>\n <button\n type=\"button\"\n className={\"bmap__vbtn\" + (view === \"front\" ? \" bmap__vbtn--on\" : \"\")}\n data-testid=\"bodymap-view-front\"\n aria-pressed={view === \"front\"}\n onClick={() => changeView(\"front\")}\n >\n {L.front}\n </button>\n <button\n type=\"button\"\n className={\"bmap__vbtn\" + (view === \"back\" ? \" bmap__vbtn--on\" : \"\")}\n data-testid=\"bodymap-view-back\"\n aria-pressed={view === \"back\"}\n onClick={() => changeView(\"back\")}\n >\n {L.back}\n </button>\n </div>\n <div className=\"bmap__zoom\" role=\"group\" aria-label={L.zoomLabel}>\n <button type=\"button\" data-testid=\"bodymap-zoom-out\" aria-label={L.zoomOut} disabled={zoom <= ZMIN} onClick={() => zoomBtn(1 / 1.4)}>−</button>\n <button type=\"button\" data-testid=\"bodymap-zoom-reset\" aria-label={L.zoomReset} disabled={zoom === 1 && pan.x === 0 && pan.y === 0} onClick={resetZoom}>⤢</button>\n <button type=\"button\" data-testid=\"bodymap-zoom-in\" aria-label={L.zoomIn} disabled={zoom >= ZMAX} onClick={() => zoomBtn(1.4)}>+</button>\n </div>\n </div>\n <svg\n ref={svgRef}\n className=\"bmap__svg\"\n viewBox=\"0 0 260 470\"\n role=\"group\"\n aria-label={L.svgLabel}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n onPointerCancel={onPointerUp}\n onWheel={(e) => zoomTo(zoomRef.current * (e.deltaY < 0 ? 1.12 : 1 / 1.12), screenToVB(e.clientX, e.clientY))}\n >\n <g transform={`translate(${pan.x} ${pan.y}) scale(${zoom})`}>\n {regions.map((r) => {\n const s = shapes[r.key];\n if (!s) return null;\n const marked = pointOf(r.key);\n const interactive = isSelectable(r.key, config) && !readOnly;\n const visCls = [\"bmap__vis\", selected === r.key && \"bmap__vis--sel\"].filter(Boolean).join(\" \");\n const fill = marked ? markedFill(marked.intensity, palette) : baseFill(r.key, fills[r.key] ?? \"#cbd5e1\", palette);\n const hs = hitShape(s);\n return (\n <g key={r.key}>\n {shapeEl(hs, {\n className: \"bmap__hit\" + (interactive ? \"\" : \" bmap__hit--locked\"),\n \"data-testid\": `bodymap-region-${r.key}`,\n role: interactive ? \"button\" : \"img\",\n tabIndex: interactive ? 0 : -1,\n \"aria-label\": ariaFor(r, marked, interactive),\n \"aria-pressed\": interactive ? selected === r.key : undefined,\n onClick: interactive ? () => pickRegion(r.key) : undefined,\n onKeyDown: interactive\n ? (e: React.KeyboardEvent) => { if (e.key === \"Enter\" || e.key === \" \") { e.preventDefault(); suppressClick.current = false; setSelected(r.key); } }\n : undefined,\n })}\n {shapeEl(s, { className: visCls, fill, opacity: !readOnly && !isSelectable(r.key, config) ? 0.4 : 1 })}\n </g>\n );\n })}\n {/* intensity numbers on marked regions visible in the current view */}\n {report.map((p) => {\n const s = shapes[p.region];\n if (!s || config[p.region]?.visible === false) return null;\n const c = center(s);\n return (\n <text key={p.region} x={c.x} y={c.y + 4} textAnchor=\"middle\" className=\"bmap__heatn\">\n {p.intensity}\n </text>\n );\n })}\n </g>\n </svg>\n </div>\n\n {/* picker panel — hidden entirely in readOnly (display-only) mode */}\n {readOnly ? null : region ? (\n <div className=\"bmap__panel\" data-testid=\"bodymap-panel\">\n <div className=\"bmap__ph\">\n <b>{nameOf(region.key)}</b>\n <span className=\"bmap__code\">{region.code}</span>\n </div>\n <p className=\"bmap__lbl\" id=\"bmap-intensity-lbl\">{L.intensity}</p>\n <div className=\"bmap__scale\" role=\"group\" aria-labelledby=\"bmap-intensity-lbl\">\n {Array.from({ length: 11 }, (_, n) => (\n <button\n key={n}\n type=\"button\"\n className={\"bmap__i\" + (current?.intensity === n ? \" bmap__i--on\" : \"\")}\n data-testid={`bodymap-intensity-${n}`}\n aria-pressed={current?.intensity === n}\n onClick={() => setPain(region.key, n, current?.type)}\n >\n {n}\n </button>\n ))}\n </div>\n <p className=\"bmap__lbl\" id=\"bmap-quality-lbl\">{L.quality}</p>\n <div className=\"bmap__chips\" role=\"group\" aria-labelledby=\"bmap-quality-lbl\">\n {PAIN_TYPES.map((t) => (\n <button\n key={t}\n type=\"button\"\n className={\"bmap__chip\" + (current?.type === t ? \" bmap__chip--on\" : \"\")}\n data-testid={`bodymap-type-${t}`}\n aria-pressed={current?.type === t}\n onClick={() => setPain(region.key, current?.intensity ?? 5, t)}\n >\n {L.qualities[t]}\n </button>\n ))}\n </div>\n {current && (\n <button type=\"button\" className=\"bmap__rm\" data-testid=\"bodymap-remove\" onClick={() => removePain(region.key)}>\n {L.remove}\n </button>\n )}\n </div>\n ) : (\n <div className=\"bmap__panel bmap__panel--empty\" data-testid=\"bodymap-panel\">\n {L.empty}\n </div>\n )}\n </div>\n );\n}\n\n// ---- <BodyMapCompare> — before/after progress (F052.13) ---------------------\n// Two read-only bodies side by side + a per-region change list (improved /\n// worse / new / resolved). Pure UI over two PainReports — no network. NB: the\n// \"nudge the user to update their report over time\" is the CONSUMER app's job\n// (fd-sundhed's mobile app via @broberg/webpush), NOT this component.\n\ntype RegionChange = \"new\" | \"resolved\" | \"improved\" | \"worse\" | \"same\";\nfunction changeOf(b?: number, a?: number): RegionChange {\n if (b == null && a != null) return \"new\";\n if (b != null && a == null) return \"resolved\";\n if (b == null || a == null) return \"same\";\n return a < b ? \"improved\" : a > b ? \"worse\" : \"same\";\n}\n\nfunction CompareFigure({ report, view, palette, caption, testid }: {\n report: PainReport; view: BodyView2D; palette?: BodymapPalette; caption: string; testid: string;\n}) {\n const shapes = view === \"back\" ? SHAPES_BACK : SHAPES;\n const fills = view === \"back\" ? FILL_BACK : FILL;\n return (\n <figure className=\"bmapc__fig\" data-testid={testid}>\n <figcaption className=\"bmapc__cap\">{caption}</figcaption>\n <svg className=\"bmapc__svg\" viewBox=\"0 0 260 470\" role=\"img\" aria-label={caption}>\n {REGIONS.map((r) => {\n const s = shapes[r.key];\n if (!s) return null;\n const m = report.find((p) => p.region === r.key);\n const fill = m ? markedFill(m.intensity, palette) : baseFill(r.key, fills[r.key] ?? \"#cbd5e1\", palette);\n return <g key={r.key}>{shapeEl(s, { className: \"bmapc__vis\", fill })}</g>;\n })}\n {report.map((p) => {\n const s = shapes[p.region];\n if (!s) return null;\n const c = center(s);\n return (\n <text key={p.region} x={c.x} y={c.y + 4} textAnchor=\"middle\" className=\"bmapc__n\">{p.intensity}</text>\n );\n })}\n </svg>\n </figure>\n );\n}\n\nexport interface BodyMapCompareProps {\n /** The earlier report. */\n before: PainReport;\n /** The later report. */\n after: PainReport;\n palette?: BodymapPalette;\n locale?: BodyMapLocale;\n labels?: Partial<BodyMapLabels>;\n defaultView?: BodyView2D;\n className?: string;\n}\n\n/** Read-only before/after view: two bodies side by side + a per-region change list. */\nexport function BodyMapCompare({\n before, after, palette, locale = \"da\", labels, defaultView = \"front\", className,\n}: BodyMapCompareProps) {\n useEffect(ensureStyles, []);\n const [view, setView] = useState<BodyView2D>(defaultView);\n const L = resolveLabels(locale, labels);\n const nameOf = (k: string) => L.regions[k] ?? getRegion(k)?.label ?? k;\n const rootStyle = palette ? ({ \"--bmap-accent\": palette.selected } as React.CSSProperties) : undefined;\n\n const keys = Array.from(new Set([...before, ...after].map((p) => p.region)));\n const changes = keys\n .map((k) => ({\n key: k,\n b: before.find((p) => p.region === k)?.intensity,\n a: after.find((p) => p.region === k)?.intensity,\n status: changeOf(before.find((p) => p.region === k)?.intensity, after.find((p) => p.region === k)?.intensity),\n }))\n .filter((c) => c.status !== \"same\")\n .sort((x, y) => (x.key < y.key ? -1 : 1));\n\n return (\n <div className={[\"bmapc\", className].filter(Boolean).join(\" \")} data-testid=\"bodymap-compare\" style={rootStyle}>\n <div className=\"bmap__viewtoggle\" role=\"group\" aria-label={L.viewLabel} style={{ marginBottom: 12 }}>\n <button type=\"button\" className={\"bmap__vbtn\" + (view === \"front\" ? \" bmap__vbtn--on\" : \"\")} data-testid=\"bodymap-compare-view-front\" aria-pressed={view === \"front\"} onClick={() => setView(\"front\")}>{L.front}</button>\n <button type=\"button\" className={\"bmap__vbtn\" + (view === \"back\" ? \" bmap__vbtn--on\" : \"\")} data-testid=\"bodymap-compare-view-back\" aria-pressed={view === \"back\"} onClick={() => setView(\"back\")}>{L.back}</button>\n </div>\n <div className=\"bmapc__bodies\">\n <CompareFigure report={before} view={view} palette={palette} caption={L.before} testid=\"bodymap-compare-before\" />\n <CompareFigure report={after} view={view} palette={palette} caption={L.after} testid=\"bodymap-compare-after\" />\n </div>\n {changes.length ? (\n <div className=\"bmapc__delta\" data-testid=\"bodymap-compare-delta\">\n {changes.map((c) => (\n <div key={c.key} className=\"bmapc__row\" data-testid={`bodymap-compare-delta-${c.key}`}>\n <span className=\"bmapc__rname\">{nameOf(c.key)}</span>\n <span className=\"bmapc__rval\">{c.b ?? \"–\"} → {c.a ?? \"–\"}</span>\n <span className={`bmapc__badge bmapc__badge--${c.status}`}>{L.change[c.status as Exclude<RegionChange, \"same\">]}</span>\n </div>\n ))}\n </div>\n ) : (\n <div className=\"bmapc__empty\" data-testid=\"bodymap-compare-delta\">{L.noChange}</div>\n )}\n </div>\n );\n}\n","/**\n * @broberg/bodymap/three — a rotatable 3D body pain-map on the SAME core.\n *\n * `<BodyMap3D>` wraps a VANILLA three.js scene (NOT react-three-fiber, so it\n * runs in React, Preact and a Capacitor webview alike): a realistic Blender\n * Studio human base mesh (CC0), rotatable (drag) + zoomable (scroll), with\n * hover-highlight and click-to-mark — a click colours the body part by pain\n * intensity and produces the same structured PainReport (bodymap/v1) the 2D\n * renderer does. Region hit-testing is anchor-based: every mesh vertex is\n * assigned to its nearest region anchor (true per-zone mesh segmentation is a\n * later refinement — the wire + interaction are identical either way).\n *\n * `three` is an OPTIONAL peer — only consumers importing this subpath pull it\n * in. The body models are NOT bundled: pass `models={{ male, female }}` as URLs\n * you host (the package ships reference GLBs under `@broberg/bodymap/models/`).\n */\nimport { useEffect, useRef, useState } from \"react\";\nimport * as THREE from \"three\";\nimport { GLTFLoader } from \"three/examples/jsm/loaders/GLTFLoader.js\";\nimport { OrbitControls } from \"three/examples/jsm/controls/OrbitControls.js\";\nimport {\n REGIONS,\n PAIN_TYPES,\n getRegion,\n isSelectable,\n serializeReport,\n heatFor,\n baseColorFor,\n defaultPalette,\n type BodymapPalette,\n type PainReport,\n type PainType,\n type RegionConfig,\n} from \"./index.js\";\nimport {\n LABELS_DA,\n LABELS_EN,\n type BodyMapLabels,\n type BodyMapLocale,\n} from \"./react.js\";\n\nexport type BodyMap3DSex = \"male\" | \"female\";\n\n/** URLs of the body GLB models the consumer hosts (reference GLBs ship under `@broberg/bodymap/models/`). */\nexport interface BodyMap3DModels {\n male: string;\n female: string;\n}\n\n/** The 3D-only control strings (the shared labels come from `locale`/`labels`). */\nexport interface BodyMap3DUiLabels {\n male: string;\n female: string;\n hoverHint: string;\n}\n\nconst UI_DA: BodyMap3DUiLabels = { male: \"Mand\", female: \"Kvinde\", hoverHint: \"Hover for at fremhæve · klik en kropsdel for at markere smerte.\" };\nconst UI_EN: BodyMap3DUiLabels = { male: \"Male\", female: \"Female\", hoverHint: \"Hover to highlight · tap a body part to mark pain.\" };\n\n// Region anchors in normalised body space (height ~1.9, feet y=0, front +z),\n// then x-flipped so the patient's own left maps to \"venstre\" (self-view).\nconst ANCHORS: Record<string, [number, number, number]> = {\n head: [0, 1.79, 0.02], neck: [0, 1.57, 0.0],\n chest: [0, 1.42, 0.11], thora: [0, 1.42, -0.12], lumbar: [0, 1.13, -0.13], groin: [0, 0.92, 0.09],\n shoulder_left: [-0.2, 1.5, 0], shoulder_right: [0.2, 1.5, 0],\n uarm_left: [-0.27, 1.3, 0.0], uarm_right: [0.27, 1.3, 0.0],\n elbow_left: [-0.31, 1.08, 0], elbow_right: [0.31, 1.08, 0],\n farm_left: [-0.34, 0.93, 0.02], farm_right: [0.34, 0.93, 0.02],\n wrist_left: [-0.36, 0.79, 0.02], wrist_right: [0.36, 0.79, 0.02],\n hand_left: [-0.37, 0.68, 0.03], hand_right: [0.37, 0.68, 0.03],\n hip_left: [-0.14, 1.02, -0.03], hip_right: [0.14, 1.02, -0.03],\n thigh_left: [-0.1, 0.68, 0.05], thigh_right: [0.1, 0.68, 0.05],\n knee_left: [-0.1, 0.4, 0.06], knee_right: [0.1, 0.4, 0.06],\n lowleg_left: [-0.1, 0.22, 0.04], lowleg_right: [0.1, 0.22, 0.04],\n ankle_left: [-0.1, 0.05, 0.02], ankle_right: [0.1, 0.05, 0.02],\n foot_left: [-0.1, 0.02, 0.11], foot_right: [0.1, 0.02, 0.11],\n};\nconst ANCHOR_KEYS = Object.keys(ANCHORS);\nfor (const k of ANCHOR_KEYS) ANCHORS[k][0] = -ANCHORS[k][0];\n\nfunction mergeLabels(locale: BodyMapLocale, overrides?: Partial<BodyMapLabels>): BodyMapLabels {\n const base = locale === \"en\" ? LABELS_EN : LABELS_DA;\n if (!overrides) return base;\n return {\n ...base,\n ...overrides,\n regions: { ...base.regions, ...overrides.regions },\n qualities: { ...base.qualities, ...overrides.qualities },\n };\n}\n\nfunction webglAvailable(): boolean {\n try {\n const c = document.createElement(\"canvas\");\n return !!(c.getContext(\"webgl\") || c.getContext(\"experimental-webgl\"));\n } catch {\n return false;\n }\n}\n\nexport interface BodyMap3DProps {\n /** URLs of the male/female body GLBs (you host them; reference GLBs ship under `@broberg/bodymap/models/`). */\n models: BodyMap3DModels;\n value?: PainReport;\n defaultValue?: PainReport;\n onChange?: (report: PainReport) => void;\n /** Per-app region config — a non-selectable region is not pickable in 3D either. */\n config?: RegionConfig;\n palette?: BodymapPalette;\n locale?: BodyMapLocale;\n labels?: Partial<BodyMapLabels>;\n /** Override the 3D-only control strings (male/female/hoverHint). */\n ui?: Partial<BodyMap3DUiLabels>;\n defaultSex?: BodyMap3DSex;\n onSexChange?: (sex: BodyMap3DSex) => void;\n /** Controlled body type — when set, the parent owns it (e.g. from the user's profile); `onSexChange` still fires. */\n sex?: BodyMap3DSex;\n /** Show the Male/Female toggle (default true). Set false when `sex` comes from a profile and the picker would just be noise. */\n showSexToggle?: boolean;\n /** Slowly auto-rotate until the user interacts (default true). */\n autoRotate?: boolean;\n className?: string;\n}\n\nconst btn: React.CSSProperties = { font: \"inherit\", cursor: \"pointer\", borderRadius: 8, border: \"1px solid #e2e8f0\", background: \"#fff\", padding: \"6px 9px\" };\nconst seg = (on: boolean): React.CSSProperties => ({ ...btn, background: on ? \"#0e8f8a\" : \"#fff\", color: on ? \"#fff\" : \"#1e293b\", fontWeight: 600 });\n\nexport function BodyMap3D(props: BodyMap3DProps) {\n const {\n models, value, defaultValue, onChange, config, palette = defaultPalette,\n locale = \"da\", labels, ui, defaultSex = \"male\", sex: sexProp, showSexToggle = true, onSexChange,\n autoRotate = true, className,\n } = props;\n\n const L = mergeLabels(locale, labels);\n const UI = { ...(locale === \"en\" ? UI_EN : UI_DA), ...ui };\n const nameOf = (key: string) => L.regions[key] ?? getRegion(key)?.label ?? key;\n\n const mountRef = useRef<HTMLDivElement>(null);\n const loadedRef = useRef<HTMLSpanElement>(null);\n const [unsupported, setUnsupported] = useState(false);\n const [ready, setReady] = useState(false);\n const [internalSex, setInternalSex] = useState<BodyMap3DSex>(defaultSex);\n const sex = sexProp ?? internalSex; // controlled when `sex` is passed, else internal\n const [selected, setSelected] = useState<string | null>(null);\n const [internal, setInternal] = useState<PainReport>(defaultValue ?? []);\n const report = value ?? internal;\n\n const commit = (next: PainReport) => {\n if (value === undefined) setInternal(next);\n onChange?.(next);\n };\n\n // Latest state visible to the once-mounted imperative scene.\n const reportRef = useRef(report); reportRef.current = report;\n const selectedRef = useRef(selected); selectedRef.current = selected;\n const paletteRef = useRef(palette); paletteRef.current = palette;\n const configRef = useRef(config); configRef.current = config;\n const modelsRef = useRef(models); modelsRef.current = models;\n const sexRef = useRef(sex); sexRef.current = sex;\n const setSelectedRef = useRef(setSelected); setSelectedRef.current = setSelected;\n const setReadyRef = useRef(setReady); setReadyRef.current = setReady;\n const apiRef = useRef<{ setSex: (s: BodyMap3DSex) => void; refresh: () => void } | null>(null);\n\n useEffect(() => {\n if (!webglAvailable()) { setUnsupported(true); return; }\n const el = mountRef.current;\n if (!el) return;\n let W = el.clientWidth || 520, H = el.clientHeight || 600;\n\n const scene = new THREE.Scene();\n scene.background = new THREE.Color(0x0e1424);\n const camera = new THREE.PerspectiveCamera(32, W / H, 0.1, 100);\n camera.position.set(0, 1.05, 4.4);\n let renderer: THREE.WebGLRenderer;\n try {\n renderer = new THREE.WebGLRenderer({ antialias: true });\n } catch {\n setUnsupported(true);\n return;\n }\n renderer.setSize(W, H);\n renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n el.appendChild(renderer.domElement);\n scene.add(new THREE.HemisphereLight(0xffffff, 0x223044, 1.05));\n const kl = new THREE.DirectionalLight(0xffffff, 1.5); kl.position.set(3, 5, 4); scene.add(kl);\n const rl = new THREE.DirectionalLight(0x88aaff, 0.7); rl.position.set(-4, 2, -3); scene.add(rl);\n\n const controls = new OrbitControls(camera, renderer.domElement);\n controls.enableDamping = true; controls.dampingFactor = 0.08;\n controls.autoRotate = autoRotate; controls.autoRotateSpeed = 1.1;\n controls.minDistance = 2.2; controls.maxDistance = 8; controls.enablePan = false;\n controls.target.set(0, 0.95, 0);\n controls.addEventListener(\"start\", () => { controls.autoRotate = false; });\n\n // On-demand rendering: render only while auto-rotating or while damping is\n // still settling, then STOP — so the page goes idle. That saves battery on\n // mobile (bodymap is primarily a mobile surface) AND lets a headless\n // Lens/Playwright run actually land clicks instead of starving on a\n // never-quiet WebGL loop (fd-sundhed's F052.6-preview finding). A control\n // 'change' / interaction / state change kicks the pump back to life.\n let raf = 0;\n let pumping = false;\n const renderFrame = () => renderer.render(scene, camera);\n const pump = () => {\n const moved = controls.update();\n renderFrame();\n if (moved || controls.autoRotate) { raf = requestAnimationFrame(pump); }\n else { pumping = false; raf = 0; }\n };\n const kick = () => { if (!pumping && !document.hidden) { pumping = true; raf = requestAnimationFrame(pump); } };\n controls.addEventListener(\"change\", kick);\n\n let modelRoot: THREE.Object3D | null = null;\n let bodyMesh: THREE.Mesh | null = null;\n let vertexRegion: string[] = [];\n let colorAttr: THREE.BufferAttribute | null = null;\n let hovered: string | null = null;\n const loader = new GLTFLoader();\n const anchorVecs = ANCHOR_KEYS.map((k) => new THREE.Vector3(...ANCHORS[k]));\n const tmp = new THREE.Color();\n\n const restingHex = (key: string): string => {\n const pt = reportRef.current.find((p) => p.region === key);\n if (pt) return heatFor(pt.intensity, paletteRef.current);\n if (selectedRef.current === key) return paletteRef.current.selected;\n return baseColorFor(key, paletteRef.current);\n };\n const colorRegion = (key: string, hex: string) => {\n if (!colorAttr) return;\n tmp.set(hex);\n for (let i = 0; i < vertexRegion.length; i++) if (vertexRegion[i] === key) colorAttr.setXYZ(i, tmp.r, tmp.g, tmp.b);\n colorAttr.needsUpdate = true;\n };\n const refresh = () => { for (const k of ANCHOR_KEYS) colorRegion(k, hovered === k ? paletteRef.current.hover : restingHex(k)); renderFrame(); };\n\n const loadModel = (which: BodyMap3DSex) => {\n const url = which === \"female\" ? modelsRef.current.female : modelsRef.current.male;\n loader.load(url, (gltf) => {\n if (modelRoot) scene.remove(modelRoot);\n const model = gltf.scene;\n const box = new THREE.Box3().setFromObject(model);\n const size = new THREE.Vector3(); box.getSize(size);\n const center = new THREE.Vector3(); box.getCenter(center);\n const scale = 1.9 / size.y;\n model.scale.setScalar(scale);\n model.position.set(-center.x * scale, -box.min.y * scale, -center.z * scale);\n model.updateMatrixWorld(true);\n bodyMesh = null;\n model.traverse((o) => { const m = o as THREE.Mesh; if (m.isMesh && !bodyMesh) bodyMesh = m; });\n if (bodyMesh) {\n const geo = (bodyMesh as THREE.Mesh).geometry as THREE.BufferGeometry;\n geo.computeVertexNormals();\n const pos = geo.getAttribute(\"position\") as THREE.BufferAttribute;\n const n = pos.count;\n const cols = new Float32Array(n * 3);\n vertexRegion = new Array(n);\n const v = new THREE.Vector3();\n for (let i = 0; i < n; i++) {\n v.fromBufferAttribute(pos, i).applyMatrix4((bodyMesh as THREE.Mesh).matrixWorld);\n let best = 0, bd = Infinity;\n for (let a = 0; a < anchorVecs.length; a++) { const d = v.distanceToSquared(anchorVecs[a]); if (d < bd) { bd = d; best = a; } }\n vertexRegion[i] = ANCHOR_KEYS[best];\n }\n colorAttr = new THREE.BufferAttribute(cols, 3);\n geo.setAttribute(\"color\", colorAttr);\n (bodyMesh as THREE.Mesh).material = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.72, metalness: 0.04 });\n refresh();\n }\n modelRoot = model;\n scene.add(model);\n renderFrame();\n kick();\n setReadyRef.current(true);\n if (loadedRef.current) { loadedRef.current.setAttribute(\"data-loaded\", \"true\"); loadedRef.current.setAttribute(\"data-model\", which); }\n });\n };\n loadModel(sexRef.current);\n apiRef.current = {\n setSex: (s) => { loadedRef.current?.removeAttribute(\"data-loaded\"); setReadyRef.current(false); loadModel(s); },\n refresh,\n };\n\n const raycaster = new THREE.Raycaster();\n const ndc = new THREE.Vector2();\n const pick = (clientX: number, clientY: number): string | null => {\n if (!bodyMesh) return null;\n const rect = renderer.domElement.getBoundingClientRect();\n ndc.set(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);\n raycaster.setFromCamera(ndc, camera);\n const hits = raycaster.intersectObject(bodyMesh, true);\n if (!hits.length) return null;\n const p = hits[0].point;\n // Nearest SELECTABLE anchor — a region hidden/locked by the per-app config\n // is skipped, so it never highlights or gets picked (2D-parity).\n let best: string | null = null, bd = Infinity;\n for (const k of ANCHOR_KEYS) {\n if (!isSelectable(k, configRef.current ?? {})) continue;\n const a = ANCHORS[k];\n const d = (p.x - a[0]) ** 2 + (p.y - a[1]) ** 2 + (p.z - a[2]) ** 2;\n if (d < bd) { bd = d; best = k; }\n }\n return best;\n };\n\n let downX = 0, downY = 0, downT = 0;\n const canvas = renderer.domElement;\n const onDown = (e: PointerEvent) => { downX = e.clientX; downY = e.clientY; downT = Date.now(); };\n const onMove = (e: PointerEvent) => {\n if ((e.buttons || 0) !== 0) return;\n const k = pick(e.clientX, e.clientY);\n if (k === hovered) return;\n const prev = hovered; hovered = k;\n if (prev) colorRegion(prev, restingHex(prev));\n if (k) colorRegion(k, paletteRef.current.hover);\n canvas.style.cursor = k ? \"pointer\" : \"default\";\n renderFrame();\n };\n const onUp = (e: PointerEvent) => {\n if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6 || Date.now() - downT > 450) return;\n const k = pick(e.clientX, e.clientY);\n if (k) setSelectedRef.current(k);\n };\n canvas.addEventListener(\"pointerdown\", onDown);\n canvas.addEventListener(\"pointermove\", onMove);\n canvas.addEventListener(\"pointerup\", onUp);\n\n renderFrame();\n kick(); // settle initial damping / start auto-rotate if enabled\n const onVis = () => { if (document.hidden) { cancelAnimationFrame(raf); pumping = false; raf = 0; } else kick(); };\n document.addEventListener(\"visibilitychange\", onVis);\n const onResize = () => { W = el.clientWidth || 520; H = el.clientHeight || 600; camera.aspect = W / H; camera.updateProjectionMatrix(); renderer.setSize(W, H); renderFrame(); };\n window.addEventListener(\"resize\", onResize);\n\n return () => {\n cancelAnimationFrame(raf);\n window.removeEventListener(\"resize\", onResize);\n document.removeEventListener(\"visibilitychange\", onVis);\n controls.removeEventListener(\"change\", kick);\n canvas.removeEventListener(\"pointerdown\", onDown);\n canvas.removeEventListener(\"pointermove\", onMove);\n canvas.removeEventListener(\"pointerup\", onUp);\n controls.dispose();\n renderer.dispose();\n if (canvas.parentNode) canvas.parentNode.removeChild(canvas);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // React → scene: recolour on selection / report / palette change; swap model on sex change.\n useEffect(() => { apiRef.current?.refresh(); }, [selected, report, palette]);\n // Swap the model on sex change — but skip the first run: the mount effect\n // already loaded sexRef.current, so this avoids a redundant reload (matters on mobile).\n const sexInited = useRef(false);\n useEffect(() => {\n if (!sexInited.current) { sexInited.current = true; return; }\n apiRef.current?.setSex(sex);\n }, [sex]);\n\n const pointOf = (k: string) => report.find((p) => p.region === k);\n const setPain = (k: string, intensity: number, type?: PainType) => {\n commit([...report.filter((p) => p.region !== k), { region: k, intensity, type, timestamp: new Date().toISOString() }]);\n };\n const removePain = (k: string) => { commit(report.filter((p) => p.region !== k)); setSelected(null); };\n const changeSex = (s: BodyMap3DSex) => { if (sexProp === undefined) setInternalSex(s); onSexChange?.(s); };\n\n const region = selected ? REGIONS.find((r) => r.key === selected) : null;\n const current = selected ? pointOf(selected) : undefined;\n\n return (\n <div data-testid=\"bodymap3d-root\" className={className} style={{ fontFamily: \"system-ui, sans-serif\", color: \"#1e293b\" }}>\n {showSexToggle && (\n <div style={{ display: \"flex\", gap: 16, flexWrap: \"wrap\", marginBottom: 12, fontSize: 13 }}>\n <div style={{ display: \"flex\", gap: 6, alignItems: \"center\" }}>\n <button data-testid=\"bodymap3d-sex-male\" onClick={() => changeSex(\"male\")} style={seg(sex === \"male\")}>{UI.male}</button>\n <button data-testid=\"bodymap3d-sex-female\" onClick={() => changeSex(\"female\")} style={seg(sex === \"female\")}>{UI.female}</button>\n </div>\n </div>\n )}\n <span ref={loadedRef} data-testid=\"bodymap3d-loaded\" style={{ display: \"none\" }} />\n {ready && <span data-testid=\"bodymap3d-ready\" style={{ position: \"absolute\", width: 1, height: 1, opacity: 0, pointerEvents: \"none\" }} />}\n <div style={{ display: \"flex\", gap: 18, alignItems: \"flex-start\", flexWrap: \"wrap\" }}>\n {unsupported ? (\n <div data-testid=\"bodymap3d-unsupported\" style={{ flex: \"1 1 520px\", minWidth: 320, height: \"60vh\", borderRadius: 16, background: \"#0e1424\", color: \"#cbd5e1\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\", padding: 24, textAlign: \"center\", fontSize: 14 }}>\n 3D kræver WebGL, som ikke er tilgængeligt her.\n </div>\n ) : (\n <div ref={mountRef} data-testid=\"bodymap3d-canvas\" style={{ flex: \"1 1 520px\", height: \"60vh\", minWidth: 320, borderRadius: 16, overflow: \"hidden\", background: \"#0e1424\", touchAction: \"none\" }} />\n )}\n <div style={{ flex: \"1 1 300px\", minWidth: 260 }}>\n {region ? (\n <div style={{ border: \"1px solid #e2e8f0\", borderRadius: 14, padding: 16, background: \"#fff\" }}>\n <div style={{ display: \"flex\", justifyContent: \"space-between\", alignItems: \"center\", marginBottom: 12 }}>\n <b style={{ fontSize: 16 }}>{nameOf(region.key)}</b>\n <span style={{ font: \"11px ui-monospace, monospace\", color: \"#64748b\", background: \"#f1f5f9\", borderRadius: 6, padding: \"2px 7px\" }}>{region.code}{region.side ? \" · \" + region.side : \"\"}</span>\n </div>\n <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: \".06em\", textTransform: \"uppercase\", color: \"#94a3b8\", marginBottom: 7 }}>{L.intensity}</div>\n <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 4, marginBottom: 12 }}>\n {Array.from({ length: 11 }, (_, i) => (\n <button key={i} data-testid={`bodymap3d-intensity-${i}`} onClick={() => setPain(region.key, i, current?.type)} style={{ ...btn, width: 30, height: 30, background: current?.intensity === i ? \"#0e8f8a\" : \"#fff\", color: current?.intensity === i ? \"#fff\" : \"#1e293b\" }}>{i}</button>\n ))}\n </div>\n <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: \".06em\", textTransform: \"uppercase\", color: \"#94a3b8\", marginBottom: 7 }}>{L.quality}</div>\n <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 6, marginBottom: 14 }}>\n {PAIN_TYPES.map((t) => (\n <button key={t} data-testid={`bodymap3d-type-${t}`} onClick={() => setPain(region.key, current?.intensity ?? 5, t)} style={{ ...btn, borderRadius: 999, padding: \"6px 12px\", background: current?.type === t ? \"#1e293b\" : \"#fff\", color: current?.type === t ? \"#fff\" : \"#64748b\" }}>{L.qualities[t] ?? t}</button>\n ))}\n </div>\n {current && <button data-testid=\"bodymap3d-remove\" onClick={() => removePain(region.key)} style={{ ...btn, color: \"#ef4444\", borderColor: \"#f6c9c9\" }}>{L.remove}</button>}\n </div>\n ) : (\n <div data-testid=\"bodymap3d-empty\" style={{ border: \"1px solid #e2e8f0\", borderRadius: 14, padding: 16, background: \"#fff\", color: \"#94a3b8\", fontSize: 13.5 }}>{UI.hoverHint}</div>\n )}\n </div>\n </div>\n </div>\n );\n}\n\n/** Re-export for convenience so a 3D-only consumer can serialize without a second import. */\nexport { serializeReport };\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/bodymap",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Interactive body pain-map: a body a patient clicks to mark where it hurts, producing a structured PainReport (bodymap/v1). Framework-neutral core (region taxonomy + zod PainReport model + selection) + a 2D SVG React renderer + a rotatable 3D renderer (vanilla three.js) on the SAME core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|