@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 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("div", { "data-testid": "bodymap3d-root", className, style: { fontFamily: "system-ui, sans-serif", color: "#1e293b" }, children: [
648
- 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: [
649
- /* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": "bodymap3d-sex-male", onClick: () => changeSex("male"), style: seg(sex === "male"), children: UI.male }),
650
- /* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": "bodymap3d-sex-female", onClick: () => changeSex("female"), style: seg(sex === "female"), children: UI.female })
651
- ] }) }),
652
- /* @__PURE__ */ jsxRuntime.jsx("span", { ref: loadedRef, "data-testid": "bodymap3d-loaded", style: { display: "none" } }),
653
- ready && /* @__PURE__ */ jsxRuntime.jsx("span", { "data-testid": "bodymap3d-ready", style: { position: "absolute", width: 1, height: 1, opacity: 0, pointerEvents: "none" } }),
654
- /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", gap: 18, alignItems: "flex-start", flexWrap: "wrap" }, children: [
655
- 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("div", { ref: mountRef, "data-testid": "bodymap3d-canvas", style: { flex: "1 1 520px", height: canvasH, minWidth: 320, borderRadius: 16, overflow: "hidden", background: "#0e1424", touchAction: "none" } }),
656
- /* @__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: [
657
- /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }, children: [
658
- /* @__PURE__ */ jsxRuntime.jsx("b", { style: { fontSize: 16 }, children: nameOf(region.key) }),
659
- 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: [
660
- region.code,
661
- region.side ? " \xB7 " + region.side : ""
662
- ] })
663
- ] }),
664
- /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.intensity }),
665
- /* @__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)) }),
666
- /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.quality }),
667
- /* @__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)) }),
668
- current && /* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": "bodymap3d-remove", onClick: () => removePain(region.key), style: { ...btn, color: chrome.danger, borderColor: "#f6c9c9" }, children: L.remove })
669
- ] }) : 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 })
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;