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