@broberg/bodymap 0.5.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 +36 -0
- package/dist/three.cjs +103 -27
- package/dist/three.cjs.map +1 -1
- package/dist/three.d.cts +17 -0
- package/dist/three.d.ts +17 -0
- package/dist/three.js +103 -27
- package/dist/three.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -211,6 +211,42 @@ 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
|
+
|
|
214
250
|
## `seam` — soft region edges are OPT-IN, and here is why
|
|
215
251
|
|
|
216
252
|
```tsx
|
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],
|
|
@@ -309,6 +309,9 @@ function BodyMap3D(props) {
|
|
|
309
309
|
onFeedback,
|
|
310
310
|
haptics,
|
|
311
311
|
seam = false,
|
|
312
|
+
fullscreen: fullscreenProp,
|
|
313
|
+
onFullscreenChange,
|
|
314
|
+
showFullscreenButton = true,
|
|
312
315
|
className
|
|
313
316
|
} = props;
|
|
314
317
|
const chrome = uiColors(palette);
|
|
@@ -348,6 +351,12 @@ function BodyMap3D(props) {
|
|
|
348
351
|
setReadyRef.current = setReady;
|
|
349
352
|
const seamRef = react.useRef(seam);
|
|
350
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
|
+
};
|
|
351
360
|
const apiRef = react.useRef(null);
|
|
352
361
|
react.useEffect(() => {
|
|
353
362
|
if (!webglAvailable()) {
|
|
@@ -597,8 +606,11 @@ function BodyMap3D(props) {
|
|
|
597
606
|
renderFrame();
|
|
598
607
|
};
|
|
599
608
|
window.addEventListener("resize", onResize);
|
|
609
|
+
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => onResize()) : null;
|
|
610
|
+
ro?.observe(el);
|
|
600
611
|
return () => {
|
|
601
612
|
cancelAnimationFrame(raf);
|
|
613
|
+
ro?.disconnect();
|
|
602
614
|
window.removeEventListener("resize", onResize);
|
|
603
615
|
document.removeEventListener("visibilitychange", onVis);
|
|
604
616
|
controls.removeEventListener("change", kick);
|
|
@@ -610,6 +622,14 @@ function BodyMap3D(props) {
|
|
|
610
622
|
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
|
611
623
|
};
|
|
612
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
|
+
});
|
|
613
633
|
react.useEffect(() => {
|
|
614
634
|
apiRef.current?.refresh();
|
|
615
635
|
}, [selected, report, palette, seam]);
|
|
@@ -644,31 +664,87 @@ function BodyMap3D(props) {
|
|
|
644
664
|
const current = selected ? pointOf(selected) : void 0;
|
|
645
665
|
const anySelectable = REGIONS.some((r) => isSelectable(r.key, config ?? {}));
|
|
646
666
|
const showEmptyHint = anySelectable || ui?.hoverHint !== void 0;
|
|
647
|
-
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
+
);
|
|
672
748
|
}
|
|
673
749
|
|
|
674
750
|
exports.BodyMap3D = BodyMap3D;
|