@broberg/bodymap 0.1.4 → 0.2.0

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/dist/three.js ADDED
@@ -0,0 +1,548 @@
1
+ import { useRef, useState, useEffect } from 'react';
2
+ import * as THREE from 'three';
3
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
4
+ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
5
+ import { z } from 'zod';
6
+ import { jsxs, jsx } from 'react/jsx-runtime';
7
+
8
+ // src/three.tsx
9
+ var REGIONS = [
10
+ // axis / centre-line (serialised side "center")
11
+ { key: "head", label: "Hoved", code: "HEAD" },
12
+ { key: "neck", label: "Nakke", code: "NECK" },
13
+ { key: "chest", label: "Bryst", code: "CHEST" },
14
+ { key: "thora", label: "\xD8vre ryg (thorakal)", code: "THORA" },
15
+ { key: "lumbar", label: "L\xE6nd (lumbal)", code: "LUMBAR" },
16
+ { key: "groin", label: "Lyske", code: "GROIN" },
17
+ // paired limbs / sides (L / R)
18
+ { key: "shoulder_left", label: "Skulder, venstre", code: "SHOULDER", side: "left" },
19
+ { key: "shoulder_right", label: "Skulder, h\xF8jre", code: "SHOULDER", side: "right" },
20
+ { key: "uarm_left", label: "Overarm, venstre", code: "UARM", side: "left" },
21
+ { key: "uarm_right", label: "Overarm, h\xF8jre", code: "UARM", side: "right" },
22
+ { key: "elbow_left", label: "Albue, venstre", code: "ELBOW", side: "left" },
23
+ { key: "elbow_right", label: "Albue, h\xF8jre", code: "ELBOW", side: "right" },
24
+ { key: "farm_left", label: "Underarm, venstre", code: "FARM", side: "left" },
25
+ { key: "farm_right", label: "Underarm, h\xF8jre", code: "FARM", side: "right" },
26
+ { key: "wrist_left", label: "H\xE5ndled, venstre", code: "WRIST", side: "left" },
27
+ { key: "wrist_right", label: "H\xE5ndled, h\xF8jre", code: "WRIST", side: "right" },
28
+ { key: "hand_left", label: "H\xE5nd, venstre", code: "HAND", side: "left" },
29
+ { key: "hand_right", label: "H\xE5nd, h\xF8jre", code: "HAND", side: "right" },
30
+ { key: "hip_left", label: "Hofte, venstre", code: "HIP", side: "left" },
31
+ { key: "hip_right", label: "Hofte, h\xF8jre", code: "HIP", side: "right" },
32
+ { key: "thigh_left", label: "L\xE5r, venstre", code: "THIGH", side: "left" },
33
+ { key: "thigh_right", label: "L\xE5r, h\xF8jre", code: "THIGH", side: "right" },
34
+ { key: "knee_left", label: "Kn\xE6, venstre", code: "KNEE", side: "left" },
35
+ { key: "knee_right", label: "Kn\xE6, h\xF8jre", code: "KNEE", side: "right" },
36
+ { key: "lowleg_left", label: "Underben, venstre", code: "LOWLEG", side: "left" },
37
+ { key: "lowleg_right", label: "Underben, h\xF8jre", code: "LOWLEG", side: "right" },
38
+ { key: "ankle_left", label: "Ankel, venstre", code: "ANKLE", side: "left" },
39
+ { key: "ankle_right", label: "Ankel, h\xF8jre", code: "ANKLE", side: "right" },
40
+ { key: "foot_left", label: "Fod, venstre", code: "FOOT", side: "left" },
41
+ { key: "foot_right", label: "Fod, h\xF8jre", code: "FOOT", side: "right" }
42
+ ];
43
+ var REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));
44
+ REGIONS.map((r) => r.key);
45
+ function getRegion(key) {
46
+ return REGIONS.find((r) => r.key === key);
47
+ }
48
+ var PAIN_TYPES = ["stikkende", "dump", "konstant", "jagende"];
49
+ var painPointSchema = z.object({
50
+ region: z.string().refine((k) => REGION_KEY_SET.has(k), { message: "unknown region" }),
51
+ intensity: z.number().int().min(0).max(10),
52
+ type: z.enum(PAIN_TYPES).optional(),
53
+ timestamp: z.string()
54
+ });
55
+ z.array(painPointSchema);
56
+ function isSelectable(key, config = {}) {
57
+ const s = config[key];
58
+ if (s?.visible === false) return false;
59
+ return s?.selectable ?? true;
60
+ }
61
+ var defaultPalette = {
62
+ body: "#d2d7de",
63
+ hover: "#8fd0cd",
64
+ selected: "#5cc4b7",
65
+ heat: { low: "#fcd34d", mid: "#fb923c", high: "#ef4444" }
66
+ };
67
+ function heatFor(intensity, palette = defaultPalette) {
68
+ return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;
69
+ }
70
+ function baseColorFor(regionKey, palette = defaultPalette) {
71
+ return palette.regions?.[regionKey] ?? palette.body;
72
+ }
73
+ z.object({
74
+ schema: z.literal("bodymap/v1"),
75
+ view: z.enum(["front", "back", "left", "right"]),
76
+ points: z.array(
77
+ z.object({
78
+ region: z.string(),
79
+ side: z.enum(["left", "right", "center"]),
80
+ intensity: z.number().int().min(0).max(10),
81
+ quality: z.enum(PAIN_TYPES).optional()
82
+ })
83
+ )
84
+ });
85
+ function serializeReport(report, opts = {}) {
86
+ return {
87
+ schema: "bodymap/v1",
88
+ view: opts.view ?? "front",
89
+ points: report.map((p) => {
90
+ const r = getRegion(p.region);
91
+ return {
92
+ region: r?.code ?? p.region,
93
+ side: r?.side ?? "center",
94
+ intensity: p.intensity,
95
+ quality: p.type
96
+ };
97
+ })
98
+ };
99
+ }
100
+ var daRegions = Object.fromEntries(REGIONS.map((r) => [r.key, r.label]));
101
+ var EN_CODE = {
102
+ HEAD: "Head",
103
+ NECK: "Neck",
104
+ CHEST: "Chest",
105
+ THORA: "Upper back",
106
+ LUMBAR: "Lower back",
107
+ GROIN: "Groin",
108
+ SHOULDER: "Shoulder",
109
+ UARM: "Upper arm",
110
+ ELBOW: "Elbow",
111
+ FARM: "Forearm",
112
+ WRIST: "Wrist",
113
+ HAND: "Hand",
114
+ HIP: "Hip",
115
+ THIGH: "Thigh",
116
+ KNEE: "Knee",
117
+ LOWLEG: "Lower leg",
118
+ ANKLE: "Ankle",
119
+ FOOT: "Foot"
120
+ };
121
+ var enRegions = Object.fromEntries(
122
+ REGIONS.map((r) => [r.key, EN_CODE[r.code] + (r.side ? r.side === "left" ? ", left" : ", right" : "")])
123
+ );
124
+ var LABELS_DA = {
125
+ regions: daRegions,
126
+ qualities: { stikkende: "stikkende", dump: "dump", konstant: "konstant", jagende: "jagende" },
127
+ intensity: "Intensitet (0-10)",
128
+ quality: "Kvalitet",
129
+ remove: "Fjern punkt",
130
+ front: "Forfra",
131
+ back: "Bagfra",
132
+ empty: "V\xE6lg en kropsdel for at markere smerte.",
133
+ before: "F\xF8r",
134
+ after: "Efter",
135
+ noChange: "Ingen \xE6ndring",
136
+ change: { new: "nyt", resolved: "forsvundet", improved: "bedre", worse: "v\xE6rre" },
137
+ ariaMarked: (n, i, q) => `${n}, smerte ${i} af 10${q ? ", " + q : ""}`,
138
+ ariaUnmarked: (n) => `${n}, ikke markeret. Aktiv\xE9r for at markere smerte.`,
139
+ svgLabel: "Kropskort \u2014 v\xE6lg hvor det g\xF8r ondt",
140
+ viewLabel: "Visning",
141
+ zoomLabel: "Zoom",
142
+ zoomIn: "Zoom ind",
143
+ zoomOut: "Zoom ud",
144
+ zoomReset: "Nulstil zoom"
145
+ };
146
+ var LABELS_EN = {
147
+ regions: enRegions,
148
+ qualities: { stikkende: "stabbing", dump: "dull", konstant: "constant", jagende: "shooting" },
149
+ intensity: "Intensity (0-10)",
150
+ quality: "Quality",
151
+ remove: "Remove point",
152
+ front: "Front",
153
+ back: "Back",
154
+ empty: "Pick a body part to mark pain.",
155
+ before: "Before",
156
+ after: "After",
157
+ noChange: "No change",
158
+ change: { new: "new", resolved: "resolved", improved: "improved", worse: "worse" },
159
+ ariaMarked: (n, i, q) => `${n}, pain ${i} of 10${q ? ", " + q : ""}`,
160
+ ariaUnmarked: (n) => `${n}, not marked. Activate to mark pain.`,
161
+ svgLabel: "Body map \u2014 pick where it hurts",
162
+ viewLabel: "View",
163
+ zoomLabel: "Zoom",
164
+ zoomIn: "Zoom in",
165
+ zoomOut: "Zoom out",
166
+ zoomReset: "Reset zoom"
167
+ };
168
+ var UI_DA = { male: "Mand", female: "Kvinde", hoverHint: "Hover for at fremh\xE6ve \xB7 klik en kropsdel for at markere smerte." };
169
+ var UI_EN = { male: "Male", female: "Female", hoverHint: "Hover to highlight \xB7 tap a body part to mark pain." };
170
+ var ANCHORS = {
171
+ head: [0, 1.79, 0.02],
172
+ neck: [0, 1.57, 0],
173
+ chest: [0, 1.42, 0.11],
174
+ thora: [0, 1.42, -0.12],
175
+ lumbar: [0, 1.13, -0.13],
176
+ groin: [0, 0.92, 0.09],
177
+ shoulder_left: [-0.2, 1.5, 0],
178
+ shoulder_right: [0.2, 1.5, 0],
179
+ uarm_left: [-0.27, 1.3, 0],
180
+ uarm_right: [0.27, 1.3, 0],
181
+ elbow_left: [-0.31, 1.08, 0],
182
+ elbow_right: [0.31, 1.08, 0],
183
+ farm_left: [-0.34, 0.93, 0.02],
184
+ farm_right: [0.34, 0.93, 0.02],
185
+ wrist_left: [-0.36, 0.79, 0.02],
186
+ wrist_right: [0.36, 0.79, 0.02],
187
+ hand_left: [-0.37, 0.68, 0.03],
188
+ hand_right: [0.37, 0.68, 0.03],
189
+ hip_left: [-0.14, 1.02, -0.03],
190
+ hip_right: [0.14, 1.02, -0.03],
191
+ thigh_left: [-0.1, 0.68, 0.05],
192
+ thigh_right: [0.1, 0.68, 0.05],
193
+ knee_left: [-0.1, 0.4, 0.06],
194
+ knee_right: [0.1, 0.4, 0.06],
195
+ lowleg_left: [-0.1, 0.22, 0.04],
196
+ lowleg_right: [0.1, 0.22, 0.04],
197
+ ankle_left: [-0.1, 0.05, 0.02],
198
+ ankle_right: [0.1, 0.05, 0.02],
199
+ foot_left: [-0.1, 0.02, 0.11],
200
+ foot_right: [0.1, 0.02, 0.11]
201
+ };
202
+ var ANCHOR_KEYS = Object.keys(ANCHORS);
203
+ for (const k of ANCHOR_KEYS) ANCHORS[k][0] = -ANCHORS[k][0];
204
+ function mergeLabels(locale, overrides) {
205
+ const base = locale === "en" ? LABELS_EN : LABELS_DA;
206
+ if (!overrides) return base;
207
+ return {
208
+ ...base,
209
+ ...overrides,
210
+ regions: { ...base.regions, ...overrides.regions },
211
+ qualities: { ...base.qualities, ...overrides.qualities }
212
+ };
213
+ }
214
+ function webglAvailable() {
215
+ try {
216
+ const c = document.createElement("canvas");
217
+ return !!(c.getContext("webgl") || c.getContext("experimental-webgl"));
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+ var btn = { font: "inherit", cursor: "pointer", borderRadius: 8, border: "1px solid #e2e8f0", background: "#fff", padding: "6px 9px" };
223
+ var seg = (on) => ({ ...btn, background: on ? "#0e8f8a" : "#fff", color: on ? "#fff" : "#1e293b", fontWeight: 600 });
224
+ function BodyMap3D(props) {
225
+ const {
226
+ models,
227
+ value,
228
+ defaultValue,
229
+ onChange,
230
+ config,
231
+ palette = defaultPalette,
232
+ locale = "da",
233
+ labels,
234
+ ui,
235
+ defaultSex = "male",
236
+ onSexChange,
237
+ autoRotate = true,
238
+ className
239
+ } = props;
240
+ const L = mergeLabels(locale, labels);
241
+ const UI = { ...locale === "en" ? UI_EN : UI_DA, ...ui };
242
+ const nameOf = (key) => L.regions[key] ?? getRegion(key)?.label ?? key;
243
+ const mountRef = useRef(null);
244
+ const loadedRef = useRef(null);
245
+ const [unsupported, setUnsupported] = useState(false);
246
+ const [ready, setReady] = useState(false);
247
+ const [sex, setSex] = useState(defaultSex);
248
+ const [selected, setSelected] = useState(null);
249
+ const [internal, setInternal] = useState(defaultValue ?? []);
250
+ const report = value ?? internal;
251
+ const commit = (next) => {
252
+ if (value === void 0) setInternal(next);
253
+ onChange?.(next);
254
+ };
255
+ const reportRef = useRef(report);
256
+ reportRef.current = report;
257
+ const selectedRef = useRef(selected);
258
+ selectedRef.current = selected;
259
+ const paletteRef = useRef(palette);
260
+ paletteRef.current = palette;
261
+ const configRef = useRef(config);
262
+ configRef.current = config;
263
+ const modelsRef = useRef(models);
264
+ modelsRef.current = models;
265
+ const setSelectedRef = useRef(setSelected);
266
+ setSelectedRef.current = setSelected;
267
+ const setReadyRef = useRef(setReady);
268
+ setReadyRef.current = setReady;
269
+ const apiRef = useRef(null);
270
+ useEffect(() => {
271
+ if (!webglAvailable()) {
272
+ setUnsupported(true);
273
+ return;
274
+ }
275
+ const el = mountRef.current;
276
+ if (!el) return;
277
+ let W = el.clientWidth || 520, H = el.clientHeight || 600;
278
+ const scene = new THREE.Scene();
279
+ scene.background = new THREE.Color(922660);
280
+ const camera = new THREE.PerspectiveCamera(32, W / H, 0.1, 100);
281
+ camera.position.set(0, 1.05, 4.4);
282
+ let renderer;
283
+ try {
284
+ renderer = new THREE.WebGLRenderer({ antialias: true });
285
+ } catch {
286
+ setUnsupported(true);
287
+ return;
288
+ }
289
+ renderer.setSize(W, H);
290
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
291
+ el.appendChild(renderer.domElement);
292
+ scene.add(new THREE.HemisphereLight(16777215, 2240580, 1.05));
293
+ const kl = new THREE.DirectionalLight(16777215, 1.5);
294
+ kl.position.set(3, 5, 4);
295
+ scene.add(kl);
296
+ const rl = new THREE.DirectionalLight(8956671, 0.7);
297
+ rl.position.set(-4, 2, -3);
298
+ scene.add(rl);
299
+ const controls = new OrbitControls(camera, renderer.domElement);
300
+ controls.enableDamping = true;
301
+ controls.dampingFactor = 0.08;
302
+ controls.autoRotate = autoRotate;
303
+ controls.autoRotateSpeed = 1.1;
304
+ controls.minDistance = 2.2;
305
+ controls.maxDistance = 8;
306
+ controls.enablePan = false;
307
+ controls.target.set(0, 0.95, 0);
308
+ controls.addEventListener("start", () => {
309
+ controls.autoRotate = false;
310
+ });
311
+ let raf = 0;
312
+ let pumping = false;
313
+ const renderFrame = () => renderer.render(scene, camera);
314
+ const pump = () => {
315
+ const moved = controls.update();
316
+ renderFrame();
317
+ if (moved || controls.autoRotate) {
318
+ raf = requestAnimationFrame(pump);
319
+ } else {
320
+ pumping = false;
321
+ raf = 0;
322
+ }
323
+ };
324
+ const kick = () => {
325
+ if (!pumping && !document.hidden) {
326
+ pumping = true;
327
+ raf = requestAnimationFrame(pump);
328
+ }
329
+ };
330
+ controls.addEventListener("change", kick);
331
+ let modelRoot = null;
332
+ let bodyMesh = null;
333
+ let vertexRegion = [];
334
+ let colorAttr = null;
335
+ let hovered = null;
336
+ const loader = new GLTFLoader();
337
+ const anchorVecs = ANCHOR_KEYS.map((k) => new THREE.Vector3(...ANCHORS[k]));
338
+ const tmp = new THREE.Color();
339
+ const restingHex = (key) => {
340
+ const pt = reportRef.current.find((p) => p.region === key);
341
+ if (pt) return heatFor(pt.intensity, paletteRef.current);
342
+ if (selectedRef.current === key) return paletteRef.current.selected;
343
+ return baseColorFor(key, paletteRef.current);
344
+ };
345
+ const colorRegion = (key, hex) => {
346
+ if (!colorAttr) return;
347
+ tmp.set(hex);
348
+ for (let i = 0; i < vertexRegion.length; i++) if (vertexRegion[i] === key) colorAttr.setXYZ(i, tmp.r, tmp.g, tmp.b);
349
+ colorAttr.needsUpdate = true;
350
+ };
351
+ const refresh = () => {
352
+ for (const k of ANCHOR_KEYS) colorRegion(k, hovered === k ? paletteRef.current.hover : restingHex(k));
353
+ renderFrame();
354
+ };
355
+ const loadModel = (which) => {
356
+ const url = which === "female" ? modelsRef.current.female : modelsRef.current.male;
357
+ loader.load(url, (gltf) => {
358
+ if (modelRoot) scene.remove(modelRoot);
359
+ const model = gltf.scene;
360
+ const box = new THREE.Box3().setFromObject(model);
361
+ const size = new THREE.Vector3();
362
+ box.getSize(size);
363
+ const center = new THREE.Vector3();
364
+ box.getCenter(center);
365
+ const scale = 1.9 / size.y;
366
+ model.scale.setScalar(scale);
367
+ model.position.set(-center.x * scale, -box.min.y * scale, -center.z * scale);
368
+ model.updateMatrixWorld(true);
369
+ bodyMesh = null;
370
+ model.traverse((o) => {
371
+ const m = o;
372
+ if (m.isMesh && !bodyMesh) bodyMesh = m;
373
+ });
374
+ if (bodyMesh) {
375
+ const geo = bodyMesh.geometry;
376
+ geo.computeVertexNormals();
377
+ const pos = geo.getAttribute("position");
378
+ const n = pos.count;
379
+ const cols = new Float32Array(n * 3);
380
+ vertexRegion = new Array(n);
381
+ const v = new THREE.Vector3();
382
+ for (let i = 0; i < n; i++) {
383
+ v.fromBufferAttribute(pos, i).applyMatrix4(bodyMesh.matrixWorld);
384
+ let best = 0, bd = Infinity;
385
+ for (let a = 0; a < anchorVecs.length; a++) {
386
+ const d = v.distanceToSquared(anchorVecs[a]);
387
+ if (d < bd) {
388
+ bd = d;
389
+ best = a;
390
+ }
391
+ }
392
+ vertexRegion[i] = ANCHOR_KEYS[best];
393
+ }
394
+ colorAttr = new THREE.BufferAttribute(cols, 3);
395
+ geo.setAttribute("color", colorAttr);
396
+ bodyMesh.material = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.72, metalness: 0.04 });
397
+ refresh();
398
+ }
399
+ modelRoot = model;
400
+ scene.add(model);
401
+ renderFrame();
402
+ kick();
403
+ setReadyRef.current(true);
404
+ if (loadedRef.current) {
405
+ loadedRef.current.setAttribute("data-loaded", "true");
406
+ loadedRef.current.setAttribute("data-model", which);
407
+ }
408
+ });
409
+ };
410
+ loadModel(defaultSex);
411
+ apiRef.current = {
412
+ setSex: (s) => {
413
+ loadedRef.current?.removeAttribute("data-loaded");
414
+ setReadyRef.current(false);
415
+ loadModel(s);
416
+ },
417
+ refresh
418
+ };
419
+ const raycaster = new THREE.Raycaster();
420
+ const ndc = new THREE.Vector2();
421
+ const pick = (clientX, clientY) => {
422
+ if (!bodyMesh) return null;
423
+ const rect = renderer.domElement.getBoundingClientRect();
424
+ ndc.set((clientX - rect.left) / rect.width * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);
425
+ raycaster.setFromCamera(ndc, camera);
426
+ const hits = raycaster.intersectObject(bodyMesh, true);
427
+ if (!hits.length) return null;
428
+ const p = hits[0].point;
429
+ let best = null, bd = Infinity;
430
+ for (const k of ANCHOR_KEYS) {
431
+ if (!isSelectable(k, configRef.current ?? {})) continue;
432
+ const a = ANCHORS[k];
433
+ const d = (p.x - a[0]) ** 2 + (p.y - a[1]) ** 2 + (p.z - a[2]) ** 2;
434
+ if (d < bd) {
435
+ bd = d;
436
+ best = k;
437
+ }
438
+ }
439
+ return best;
440
+ };
441
+ let downX = 0, downY = 0, downT = 0;
442
+ const canvas = renderer.domElement;
443
+ const onDown = (e) => {
444
+ downX = e.clientX;
445
+ downY = e.clientY;
446
+ downT = Date.now();
447
+ };
448
+ const onMove = (e) => {
449
+ if ((e.buttons || 0) !== 0) return;
450
+ const k = pick(e.clientX, e.clientY);
451
+ if (k === hovered) return;
452
+ const prev = hovered;
453
+ hovered = k;
454
+ if (prev) colorRegion(prev, restingHex(prev));
455
+ if (k) colorRegion(k, paletteRef.current.hover);
456
+ canvas.style.cursor = k ? "pointer" : "default";
457
+ renderFrame();
458
+ };
459
+ const onUp = (e) => {
460
+ if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6 || Date.now() - downT > 450) return;
461
+ const k = pick(e.clientX, e.clientY);
462
+ if (k) setSelectedRef.current(k);
463
+ };
464
+ canvas.addEventListener("pointerdown", onDown);
465
+ canvas.addEventListener("pointermove", onMove);
466
+ canvas.addEventListener("pointerup", onUp);
467
+ renderFrame();
468
+ kick();
469
+ const onVis = () => {
470
+ if (document.hidden) {
471
+ cancelAnimationFrame(raf);
472
+ pumping = false;
473
+ raf = 0;
474
+ } else kick();
475
+ };
476
+ document.addEventListener("visibilitychange", onVis);
477
+ const onResize = () => {
478
+ W = el.clientWidth || 520;
479
+ H = el.clientHeight || 600;
480
+ camera.aspect = W / H;
481
+ camera.updateProjectionMatrix();
482
+ renderer.setSize(W, H);
483
+ renderFrame();
484
+ };
485
+ window.addEventListener("resize", onResize);
486
+ return () => {
487
+ cancelAnimationFrame(raf);
488
+ window.removeEventListener("resize", onResize);
489
+ document.removeEventListener("visibilitychange", onVis);
490
+ controls.removeEventListener("change", kick);
491
+ canvas.removeEventListener("pointerdown", onDown);
492
+ canvas.removeEventListener("pointermove", onMove);
493
+ canvas.removeEventListener("pointerup", onUp);
494
+ controls.dispose();
495
+ renderer.dispose();
496
+ if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
497
+ };
498
+ }, []);
499
+ useEffect(() => {
500
+ apiRef.current?.refresh();
501
+ }, [selected, report, palette]);
502
+ useEffect(() => {
503
+ apiRef.current?.setSex(sex);
504
+ }, [sex]);
505
+ const pointOf = (k) => report.find((p) => p.region === k);
506
+ const setPain = (k, intensity, type) => {
507
+ commit([...report.filter((p) => p.region !== k), { region: k, intensity, type, timestamp: (/* @__PURE__ */ new Date()).toISOString() }]);
508
+ };
509
+ const removePain = (k) => {
510
+ commit(report.filter((p) => p.region !== k));
511
+ setSelected(null);
512
+ };
513
+ const changeSex = (s) => {
514
+ setSex(s);
515
+ onSexChange?.(s);
516
+ };
517
+ const region = selected ? REGIONS.find((r) => r.key === selected) : null;
518
+ const current = selected ? pointOf(selected) : void 0;
519
+ 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: [
521
+ /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-male", onClick: () => changeSex("male"), style: seg(sex === "male"), children: UI.male }),
522
+ /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-female", onClick: () => changeSex("female"), style: seg(sex === "female"), children: UI.female })
523
+ ] }) }),
524
+ /* @__PURE__ */ jsx("span", { ref: loadedRef, "data-testid": "bodymap3d-loaded", style: { display: "none" } }),
525
+ ready && /* @__PURE__ */ jsx("span", { "data-testid": "bodymap3d-ready", style: { position: "absolute", width: 1, height: 1, opacity: 0, pointerEvents: "none" } }),
526
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 18, alignItems: "flex-start", flexWrap: "wrap" }, children: [
527
+ unsupported ? /* @__PURE__ */ jsx("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 }, children: "3D kr\xE6ver WebGL, som ikke er tilg\xE6ngeligt her." }) : /* @__PURE__ */ jsx("div", { ref: mountRef, "data-testid": "bodymap3d-canvas", style: { flex: "1 1 520px", height: "60vh", minWidth: 320, borderRadius: 16, overflow: "hidden", background: "#0e1424", touchAction: "none" } }),
528
+ /* @__PURE__ */ jsx("div", { style: { flex: "1 1 300px", minWidth: 260 }, children: region ? /* @__PURE__ */ jsxs("div", { style: { border: "1px solid #e2e8f0", borderRadius: 14, padding: 16, background: "#fff" }, children: [
529
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }, children: [
530
+ /* @__PURE__ */ jsx("b", { style: { fontSize: 16 }, children: nameOf(region.key) }),
531
+ /* @__PURE__ */ jsxs("span", { style: { font: "11px ui-monospace, monospace", color: "#64748b", background: "#f1f5f9", borderRadius: 6, padding: "2px 7px" }, children: [
532
+ region.code,
533
+ region.side ? " \xB7 " + region.side : ""
534
+ ] })
535
+ ] }),
536
+ /* @__PURE__ */ jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "#94a3b8", marginBottom: 7 }, children: L.intensity }),
537
+ /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 12 }, children: Array.from({ length: 11 }, (_, i) => /* @__PURE__ */ jsx("button", { "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" }, children: i }, i)) }),
538
+ /* @__PURE__ */ jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "#94a3b8", marginBottom: 7 }, children: L.quality }),
539
+ /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 14 }, children: PAIN_TYPES.map((t) => /* @__PURE__ */ jsx("button", { "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" }, children: L.qualities[t] ?? t }, t)) }),
540
+ current && /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-remove", onClick: () => removePain(region.key), style: { ...btn, color: "#ef4444", borderColor: "#f6c9c9" }, children: L.remove })
541
+ ] }) : /* @__PURE__ */ jsx("div", { "data-testid": "bodymap3d-empty", style: { border: "1px solid #e2e8f0", borderRadius: 14, padding: 16, background: "#fff", color: "#94a3b8", fontSize: 13.5 }, children: UI.hoverHint }) })
542
+ ] })
543
+ ] });
544
+ }
545
+
546
+ export { BodyMap3D, serializeReport };
547
+ //# sourceMappingURL=three.js.map
548
+ //# sourceMappingURL=three.js.map