@broberg/bodymap 0.4.0 → 0.6.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 +77 -0
- package/dist/three.cjs +109 -30
- package/dist/three.cjs.map +1 -1
- package/dist/three.d.cts +33 -0
- package/dist/three.d.ts +33 -0
- package/dist/three.js +109 -30
- package/dist/three.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -211,6 +211,83 @@ had *arrived* on a device that never *showed* it.)
|
|
|
211
211
|
`VIBRATION_PATTERNS.ignore` is empty on purpose: a tap that changed nothing must
|
|
212
212
|
not feel like it did.
|
|
213
213
|
|
|
214
|
+
## Fullscreen — fill the viewport with the body
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
// built-in control (default)
|
|
218
|
+
<BodyMap3D models={models} />
|
|
219
|
+
|
|
220
|
+
// your own button — you own the state
|
|
221
|
+
const [big, setBig] = useState(false);
|
|
222
|
+
<BodyMap3D models={models} fullscreen={big} onFullscreenChange={setBig} showFullscreenButton={false} />
|
|
223
|
+
<button onClick={() => setBig(true)}>Se stor</button>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`Escape` leaves it. The pain report is untouched by entering or leaving.
|
|
227
|
+
|
|
228
|
+
**It is a viewport fill, not the browser Fullscreen API — deliberately.** iOS
|
|
229
|
+
Safari does not support fullscreen on an arbitrary element at all, and a phone is
|
|
230
|
+
this component's primary surface. A `position: fixed; inset: 0` overlay behaves
|
|
231
|
+
identically on every platform, needs no user-gesture dance, and cannot leave you
|
|
232
|
+
with a control that silently does nothing on the one device that matters most.
|
|
233
|
+
|
|
234
|
+
### It ships with a `ResizeObserver`, and that is not incidental
|
|
235
|
+
|
|
236
|
+
Before 0.6.0 the canvas measured the element **once at mount** and only re-measured
|
|
237
|
+
on a **window** resize. Everything below changes the ELEMENT without touching the
|
|
238
|
+
window, and each one left the picture at its old size — a body that renders small,
|
|
239
|
+
off-centre, or clipped, with nothing wrong in the code because the camera maths is
|
|
240
|
+
correct and the numbers it was handed are stale:
|
|
241
|
+
|
|
242
|
+
- entering or leaving fullscreen
|
|
243
|
+
- switching `canvasHeight` at your own breakpoint
|
|
244
|
+
- a container animating open, or a wizard step revealing the panel
|
|
245
|
+
- **a phone's URL bar collapsing, which changes what `vh` means**
|
|
246
|
+
|
|
247
|
+
If you saw a badly framed body on a phone before 0.6.0, this is the first thing to
|
|
248
|
+
retest.
|
|
249
|
+
|
|
250
|
+
## `seam` — soft region edges are OPT-IN, and here is why
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
<BodyMap3D seam /> // soft colour transition between regions
|
|
254
|
+
<BodyMap3D /> // default: flat, full-contrast regions
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**Default off, because the default has to be the accessible one.** The blend mixes
|
|
258
|
+
the *body* colour into a marked region near its boundary — and on a **small** mark,
|
|
259
|
+
the boundary is most of the mark.
|
|
260
|
+
|
|
261
|
+
Measured on a phone (`iphone-15`), a consumer's real palette, one knee marked,
|
|
262
|
+
averaged over every pixel of the mark:
|
|
263
|
+
|
|
264
|
+
| | mark colour | contrast vs body |
|
|
265
|
+
|---|---|---|
|
|
266
|
+
| `seam` on | `rgb(114, 58, 79)` | **3.59:1** — fails WCAG AA |
|
|
267
|
+
| `seam` off (default) | `rgb(111, 22, 56)` | **4.71:1** — passes |
|
|
268
|
+
|
|
269
|
+
At the single strongest pixel the cost is small (5.12 → 4.78), which is why a
|
|
270
|
+
spot-check misses it. The average is what a person sees.
|
|
271
|
+
|
|
272
|
+
**Turn it on** when marked areas are large and you have no accessibility duty — it
|
|
273
|
+
genuinely looks better there. **Leave it off** for anything a public authority,
|
|
274
|
+
a clinician or a partially-sighted user will read.
|
|
275
|
+
|
|
276
|
+
## Smooth skin: we do not recompute the model's normals
|
|
277
|
+
|
|
278
|
+
If your `.glb` ships normals, the renderer uses them. It only calls
|
|
279
|
+
`computeVertexNormals()` when the file has none.
|
|
280
|
+
|
|
281
|
+
This matters more than it sounds. `computeVertexNormals()` averages face normals
|
|
282
|
+
**per vertex index**, and a body mesh has split vertices along its UV seams — two
|
|
283
|
+
copies at the same position averaging different faces. The two sides then light
|
|
284
|
+
differently, drawing a visible crease along every seam: a line down the torso, and
|
|
285
|
+
across the chest, waist, shoulders, knees, elbows and ankles. The figure looks
|
|
286
|
+
assembled from parts. Measured side by side: with the call, every line is there;
|
|
287
|
+
without it, none are.
|
|
288
|
+
|
|
289
|
+
If you supply your own model, **ship it with normals.**
|
|
290
|
+
|
|
214
291
|
## Colour control — `BodymapPalette`
|
|
215
292
|
|
|
216
293
|
Both renderers theme off one palette (consumer-defined):
|
package/dist/three.cjs
CHANGED
|
@@ -221,8 +221,8 @@ var LABELS_EN = {
|
|
|
221
221
|
zoomOut: "Zoom out",
|
|
222
222
|
zoomReset: "Reset zoom"
|
|
223
223
|
};
|
|
224
|
-
var UI_DA = { male: "Mand", female: "Kvinde", hoverHint: "Hover for at fremh\xE6ve \xB7 klik en kropsdel for at markere smerte." };
|
|
225
|
-
var UI_EN = { male: "Male", female: "Female", hoverHint: "Hover to highlight \xB7 tap a body part to mark pain." };
|
|
224
|
+
var UI_DA = { male: "Mand", female: "Kvinde", hoverHint: "Hover for at fremh\xE6ve \xB7 klik en kropsdel for at markere smerte.", expand: "Vis stor", collapse: "Luk stor visning" };
|
|
225
|
+
var UI_EN = { male: "Male", female: "Female", hoverHint: "Hover to highlight \xB7 tap a body part to mark pain.", expand: "Expand", collapse: "Close" };
|
|
226
226
|
var ANCHORS = {
|
|
227
227
|
head: [0, 1.79, 0.02],
|
|
228
228
|
neck: [0, 1.57, 0],
|
|
@@ -308,6 +308,10 @@ function BodyMap3D(props) {
|
|
|
308
308
|
showRegionCode = true,
|
|
309
309
|
onFeedback,
|
|
310
310
|
haptics,
|
|
311
|
+
seam = false,
|
|
312
|
+
fullscreen: fullscreenProp,
|
|
313
|
+
onFullscreenChange,
|
|
314
|
+
showFullscreenButton = true,
|
|
311
315
|
className
|
|
312
316
|
} = props;
|
|
313
317
|
const chrome = uiColors(palette);
|
|
@@ -345,6 +349,14 @@ function BodyMap3D(props) {
|
|
|
345
349
|
const pickRef = react.useRef(() => "ignore");
|
|
346
350
|
const setReadyRef = react.useRef(setReady);
|
|
347
351
|
setReadyRef.current = setReady;
|
|
352
|
+
const seamRef = react.useRef(seam);
|
|
353
|
+
seamRef.current = seam;
|
|
354
|
+
const [internalFs, setInternalFs] = react.useState(false);
|
|
355
|
+
const isFullscreen = fullscreenProp ?? internalFs;
|
|
356
|
+
const setFullscreen = (v) => {
|
|
357
|
+
if (fullscreenProp === void 0) setInternalFs(v);
|
|
358
|
+
onFullscreenChange?.(v);
|
|
359
|
+
};
|
|
348
360
|
const apiRef = react.useRef(null);
|
|
349
361
|
react.useEffect(() => {
|
|
350
362
|
if (!webglAvailable()) {
|
|
@@ -438,7 +450,7 @@ function BodyMap3D(props) {
|
|
|
438
450
|
};
|
|
439
451
|
for (let i = 0; i < vertexRegion.length; i++) {
|
|
440
452
|
tmp.copy(col(vertexRegion[i]));
|
|
441
|
-
const w = vertexBlend[i];
|
|
453
|
+
const w = seamRef.current ? vertexBlend[i] : 0;
|
|
442
454
|
if (w > 0) {
|
|
443
455
|
tmp2.copy(col(vertexNeighbour[i]));
|
|
444
456
|
tmp.lerp(tmp2, w);
|
|
@@ -472,7 +484,7 @@ function BodyMap3D(props) {
|
|
|
472
484
|
});
|
|
473
485
|
if (bodyMesh) {
|
|
474
486
|
const geo = bodyMesh.geometry;
|
|
475
|
-
geo.computeVertexNormals();
|
|
487
|
+
if (!geo.getAttribute("normal")) geo.computeVertexNormals();
|
|
476
488
|
const pos = geo.getAttribute("position");
|
|
477
489
|
const n = pos.count;
|
|
478
490
|
const cols = new Float32Array(n * 3);
|
|
@@ -594,8 +606,11 @@ function BodyMap3D(props) {
|
|
|
594
606
|
renderFrame();
|
|
595
607
|
};
|
|
596
608
|
window.addEventListener("resize", onResize);
|
|
609
|
+
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => onResize()) : null;
|
|
610
|
+
ro?.observe(el);
|
|
597
611
|
return () => {
|
|
598
612
|
cancelAnimationFrame(raf);
|
|
613
|
+
ro?.disconnect();
|
|
599
614
|
window.removeEventListener("resize", onResize);
|
|
600
615
|
document.removeEventListener("visibilitychange", onVis);
|
|
601
616
|
controls.removeEventListener("change", kick);
|
|
@@ -607,9 +622,17 @@ function BodyMap3D(props) {
|
|
|
607
622
|
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
|
608
623
|
};
|
|
609
624
|
}, []);
|
|
625
|
+
react.useEffect(() => {
|
|
626
|
+
if (!isFullscreen) return;
|
|
627
|
+
const onKey = (e) => {
|
|
628
|
+
if (e.key === "Escape") setFullscreen(false);
|
|
629
|
+
};
|
|
630
|
+
window.addEventListener("keydown", onKey);
|
|
631
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
632
|
+
});
|
|
610
633
|
react.useEffect(() => {
|
|
611
634
|
apiRef.current?.refresh();
|
|
612
|
-
}, [selected, report, palette]);
|
|
635
|
+
}, [selected, report, palette, seam]);
|
|
613
636
|
const sexInited = react.useRef(false);
|
|
614
637
|
react.useEffect(() => {
|
|
615
638
|
if (!sexInited.current) {
|
|
@@ -641,31 +664,87 @@ function BodyMap3D(props) {
|
|
|
641
664
|
const current = selected ? pointOf(selected) : void 0;
|
|
642
665
|
const anySelectable = REGIONS.some((r) => isSelectable(r.key, config ?? {}));
|
|
643
666
|
const showEmptyHint = anySelectable || ui?.hoverHint !== void 0;
|
|
644
|
-
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
667
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
668
|
+
"div",
|
|
669
|
+
{
|
|
670
|
+
"data-testid": "bodymap3d-root",
|
|
671
|
+
className,
|
|
672
|
+
"data-fullscreen": isFullscreen ? "true" : void 0,
|
|
673
|
+
style: {
|
|
674
|
+
fontFamily: "system-ui, sans-serif",
|
|
675
|
+
color: "#1e293b",
|
|
676
|
+
// A viewport fill, not the Fullscreen API — see the `fullscreen` prop.
|
|
677
|
+
// `fixed`+`inset:0` behaves the same on iOS Safari, where element
|
|
678
|
+
// fullscreen does not exist at all, as it does everywhere else.
|
|
679
|
+
...isFullscreen ? {
|
|
680
|
+
position: "fixed",
|
|
681
|
+
inset: 0,
|
|
682
|
+
zIndex: 2147483e3,
|
|
683
|
+
background: chrome.panelBg,
|
|
684
|
+
padding: 12,
|
|
685
|
+
display: "flex",
|
|
686
|
+
flexDirection: "column",
|
|
687
|
+
overflow: "auto"
|
|
688
|
+
} : null
|
|
689
|
+
},
|
|
690
|
+
children: [
|
|
691
|
+
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: [
|
|
692
|
+
/* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": "bodymap3d-sex-male", onClick: () => changeSex("male"), style: seg(sex === "male"), children: UI.male }),
|
|
693
|
+
/* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": "bodymap3d-sex-female", onClick: () => changeSex("female"), style: seg(sex === "female"), children: UI.female })
|
|
694
|
+
] }) }),
|
|
695
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { ref: loadedRef, "data-testid": "bodymap3d-loaded", style: { display: "none" } }),
|
|
696
|
+
ready && /* @__PURE__ */ jsxRuntime.jsx("span", { "data-testid": "bodymap3d-ready", style: { position: "absolute", width: 1, height: 1, opacity: 0, pointerEvents: "none" } }),
|
|
697
|
+
showFullscreenButton && !unsupported && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", justifyContent: "flex-end", marginBottom: 8 }, children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
698
|
+
"button",
|
|
699
|
+
{
|
|
700
|
+
type: "button",
|
|
701
|
+
"data-testid": "bodymap3d-fullscreen",
|
|
702
|
+
"aria-pressed": isFullscreen,
|
|
703
|
+
"aria-label": isFullscreen ? UI.collapse ?? "Close" : UI.expand ?? "Expand",
|
|
704
|
+
onClick: () => setFullscreen(!isFullscreen),
|
|
705
|
+
style: { ...btn, color: chrome.text, borderColor: chrome.border, background: chrome.panelBg },
|
|
706
|
+
children: isFullscreen ? UI.collapse ?? "Close" : UI.expand ?? "Expand"
|
|
707
|
+
}
|
|
708
|
+
) }),
|
|
709
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", gap: 18, alignItems: "flex-start", flexWrap: "wrap", ...isFullscreen ? { flex: "1 1 auto", minHeight: 0 } : null }, children: [
|
|
710
|
+
unsupported ? /* @__PURE__ */ jsxRuntime.jsx("div", { "data-testid": "bodymap3d-unsupported", style: { flex: "1 1 520px", minWidth: 320, height: canvasH, 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(
|
|
711
|
+
"div",
|
|
712
|
+
{
|
|
713
|
+
ref: mountRef,
|
|
714
|
+
"data-testid": "bodymap3d-canvas",
|
|
715
|
+
style: {
|
|
716
|
+
flex: "1 1 520px",
|
|
717
|
+
// In fullscreen the box takes the height it is GIVEN rather than a
|
|
718
|
+
// fixed `canvasHeight`; the ResizeObserver above is what makes the
|
|
719
|
+
// picture follow it.
|
|
720
|
+
height: isFullscreen ? "100%" : canvasH,
|
|
721
|
+
minWidth: 320,
|
|
722
|
+
minHeight: isFullscreen ? 0 : void 0,
|
|
723
|
+
borderRadius: 16,
|
|
724
|
+
overflow: "hidden",
|
|
725
|
+
background: "#0e1424",
|
|
726
|
+
touchAction: "none"
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
),
|
|
730
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { style: { flex: "1 1 300px", minWidth: 260 }, children: region ? /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { border: `1px solid ${chrome.border}`, borderRadius: 14, padding: 16, background: chrome.panelBg }, children: [
|
|
731
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }, children: [
|
|
732
|
+
/* @__PURE__ */ jsxRuntime.jsx("b", { style: { fontSize: 16 }, children: nameOf(region.key) }),
|
|
733
|
+
showRegionCode && /* @__PURE__ */ jsxRuntime.jsxs("span", { "data-testid": "bodymap3d-region-code", style: { font: "11px ui-monospace, monospace", color: chrome.mutedText, background: chrome.badgeBg, borderRadius: 6, padding: "2px 7px" }, children: [
|
|
734
|
+
region.code,
|
|
735
|
+
region.side ? " \xB7 " + region.side : ""
|
|
736
|
+
] })
|
|
737
|
+
] }),
|
|
738
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.intensity }),
|
|
739
|
+
/* @__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, display: "inline-flex", alignItems: "center", justifyContent: "center", minWidth: 30, height: 30, padding: 0, background: current?.intensity === i ? "#0e8f8a" : "#fff", color: current?.intensity === i ? "#fff" : "#1e293b" }, children: i }, i)) }),
|
|
740
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.quality }),
|
|
741
|
+
/* @__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)) }),
|
|
742
|
+
current && /* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": "bodymap3d-remove", onClick: () => removePain(region.key), style: { ...btn, color: chrome.danger, borderColor: "#f6c9c9" }, children: L.remove })
|
|
743
|
+
] }) : showEmptyHint ? /* @__PURE__ */ jsxRuntime.jsx("div", { "data-testid": "bodymap3d-empty", style: { border: `1px solid ${chrome.border}`, borderRadius: 14, padding: 16, background: chrome.panelBg, color: chrome.mutedText, fontSize: 13.5 }, children: UI.hoverHint }) : null })
|
|
744
|
+
] })
|
|
745
|
+
]
|
|
746
|
+
}
|
|
747
|
+
);
|
|
669
748
|
}
|
|
670
749
|
|
|
671
750
|
exports.BodyMap3D = BodyMap3D;
|