@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/dist/three.d.cts CHANGED
@@ -168,6 +168,10 @@ interface BodyMap3DModels {
168
168
  }
169
169
  /** The 3D-only control strings (the shared labels come from `locale`/`labels`). */
170
170
  interface BodyMap3DUiLabels {
171
+ /** Label for the built-in fullscreen control (F052.24). */
172
+ expand?: string;
173
+ /** Label for the same control while fullscreen. */
174
+ collapse?: string;
171
175
  male: string;
172
176
  female: string;
173
177
  hoverHint: string;
@@ -216,6 +220,19 @@ interface BodyMap3DProps {
216
220
  * (default true). Set false for a patient/employee-facing flow where the code is
217
221
  * internal jargon and the readable region name is enough. */
218
222
  showRegionCode?: boolean;
223
+ /** Fill the viewport with the body (F052.24). Controlled: pass it and you own
224
+ * it; `onFullscreenChange` still fires so your own button can drive it.
225
+ *
226
+ * This is a VIEWPORT FILL, not the browser Fullscreen API. iOS Safari does not
227
+ * support fullscreen on an arbitrary element at all, and the iPhone is this
228
+ * component's primary surface — so the mechanism that works everywhere is the
229
+ * one that ships, and it behaves identically on every platform. */
230
+ fullscreen?: boolean;
231
+ /** Fired when the built-in control or the Escape key changes it. */
232
+ onFullscreenChange?: (fullscreen: boolean) => void;
233
+ /** Show the built-in expand/collapse control (default true). Set false and
234
+ * drive `fullscreen` from your own button. */
235
+ showFullscreenButton?: boolean;
219
236
  /** Soften the colour transition between neighbouring regions (F052.21).
220
237
  *
221
238
  * DEFAULT FALSE, and the default is the accessible one (F052.23). The blend
package/dist/three.d.ts CHANGED
@@ -168,6 +168,10 @@ interface BodyMap3DModels {
168
168
  }
169
169
  /** The 3D-only control strings (the shared labels come from `locale`/`labels`). */
170
170
  interface BodyMap3DUiLabels {
171
+ /** Label for the built-in fullscreen control (F052.24). */
172
+ expand?: string;
173
+ /** Label for the same control while fullscreen. */
174
+ collapse?: string;
171
175
  male: string;
172
176
  female: string;
173
177
  hoverHint: string;
@@ -216,6 +220,19 @@ interface BodyMap3DProps {
216
220
  * (default true). Set false for a patient/employee-facing flow where the code is
217
221
  * internal jargon and the readable region name is enough. */
218
222
  showRegionCode?: boolean;
223
+ /** Fill the viewport with the body (F052.24). Controlled: pass it and you own
224
+ * it; `onFullscreenChange` still fires so your own button can drive it.
225
+ *
226
+ * This is a VIEWPORT FILL, not the browser Fullscreen API. iOS Safari does not
227
+ * support fullscreen on an arbitrary element at all, and the iPhone is this
228
+ * component's primary surface — so the mechanism that works everywhere is the
229
+ * one that ships, and it behaves identically on every platform. */
230
+ fullscreen?: boolean;
231
+ /** Fired when the built-in control or the Escape key changes it. */
232
+ onFullscreenChange?: (fullscreen: boolean) => void;
233
+ /** Show the built-in expand/collapse control (default true). Set false and
234
+ * drive `fullscreen` from your own button. */
235
+ showFullscreenButton?: boolean;
219
236
  /** Soften the colour transition between neighbouring regions (F052.21).
220
237
  *
221
238
  * DEFAULT FALSE, and the default is the accessible one (F052.23). The blend
package/dist/three.js CHANGED
@@ -199,8 +199,8 @@ var LABELS_EN = {
199
199
  zoomOut: "Zoom out",
200
200
  zoomReset: "Reset zoom"
201
201
  };
202
- var UI_DA = { male: "Mand", female: "Kvinde", hoverHint: "Hover for at fremh\xE6ve \xB7 klik en kropsdel for at markere smerte." };
203
- var UI_EN = { male: "Male", female: "Female", hoverHint: "Hover to highlight \xB7 tap a body part to mark pain." };
202
+ 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" };
203
+ var UI_EN = { male: "Male", female: "Female", hoverHint: "Hover to highlight \xB7 tap a body part to mark pain.", expand: "Expand", collapse: "Close" };
204
204
  var ANCHORS = {
205
205
  head: [0, 1.79, 0.02],
206
206
  neck: [0, 1.57, 0],
@@ -287,6 +287,9 @@ function BodyMap3D(props) {
287
287
  onFeedback,
288
288
  haptics,
289
289
  seam = false,
290
+ fullscreen: fullscreenProp,
291
+ onFullscreenChange,
292
+ showFullscreenButton = true,
290
293
  className
291
294
  } = props;
292
295
  const chrome = uiColors(palette);
@@ -326,6 +329,12 @@ function BodyMap3D(props) {
326
329
  setReadyRef.current = setReady;
327
330
  const seamRef = useRef(seam);
328
331
  seamRef.current = seam;
332
+ const [internalFs, setInternalFs] = useState(false);
333
+ const isFullscreen = fullscreenProp ?? internalFs;
334
+ const setFullscreen = (v) => {
335
+ if (fullscreenProp === void 0) setInternalFs(v);
336
+ onFullscreenChange?.(v);
337
+ };
329
338
  const apiRef = useRef(null);
330
339
  useEffect(() => {
331
340
  if (!webglAvailable()) {
@@ -575,8 +584,11 @@ function BodyMap3D(props) {
575
584
  renderFrame();
576
585
  };
577
586
  window.addEventListener("resize", onResize);
587
+ const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => onResize()) : null;
588
+ ro?.observe(el);
578
589
  return () => {
579
590
  cancelAnimationFrame(raf);
591
+ ro?.disconnect();
580
592
  window.removeEventListener("resize", onResize);
581
593
  document.removeEventListener("visibilitychange", onVis);
582
594
  controls.removeEventListener("change", kick);
@@ -588,6 +600,14 @@ function BodyMap3D(props) {
588
600
  if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
589
601
  };
590
602
  }, []);
603
+ useEffect(() => {
604
+ if (!isFullscreen) return;
605
+ const onKey = (e) => {
606
+ if (e.key === "Escape") setFullscreen(false);
607
+ };
608
+ window.addEventListener("keydown", onKey);
609
+ return () => window.removeEventListener("keydown", onKey);
610
+ });
591
611
  useEffect(() => {
592
612
  apiRef.current?.refresh();
593
613
  }, [selected, report, palette, seam]);
@@ -622,31 +642,87 @@ function BodyMap3D(props) {
622
642
  const current = selected ? pointOf(selected) : void 0;
623
643
  const anySelectable = REGIONS.some((r) => isSelectable(r.key, config ?? {}));
624
644
  const showEmptyHint = anySelectable || ui?.hoverHint !== void 0;
625
- return /* @__PURE__ */ jsxs("div", { "data-testid": "bodymap3d-root", className, style: { fontFamily: "system-ui, sans-serif", color: "#1e293b" }, children: [
626
- showSexToggle && /* @__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: [
627
- /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-male", onClick: () => changeSex("male"), style: seg(sex === "male"), children: UI.male }),
628
- /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-female", onClick: () => changeSex("female"), style: seg(sex === "female"), children: UI.female })
629
- ] }) }),
630
- /* @__PURE__ */ jsx("span", { ref: loadedRef, "data-testid": "bodymap3d-loaded", style: { display: "none" } }),
631
- ready && /* @__PURE__ */ jsx("span", { "data-testid": "bodymap3d-ready", style: { position: "absolute", width: 1, height: 1, opacity: 0, pointerEvents: "none" } }),
632
- /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 18, alignItems: "flex-start", flexWrap: "wrap" }, children: [
633
- unsupported ? /* @__PURE__ */ 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__ */ 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" } }),
634
- /* @__PURE__ */ jsx("div", { style: { flex: "1 1 300px", minWidth: 260 }, children: region ? /* @__PURE__ */ jsxs("div", { style: { border: `1px solid ${chrome.border}`, borderRadius: 14, padding: 16, background: chrome.panelBg }, children: [
635
- /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }, children: [
636
- /* @__PURE__ */ jsx("b", { style: { fontSize: 16 }, children: nameOf(region.key) }),
637
- showRegionCode && /* @__PURE__ */ 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: [
638
- region.code,
639
- region.side ? " \xB7 " + region.side : ""
640
- ] })
641
- ] }),
642
- /* @__PURE__ */ jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.intensity }),
643
- /* @__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, 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)) }),
644
- /* @__PURE__ */ jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.quality }),
645
- /* @__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)) }),
646
- current && /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-remove", onClick: () => removePain(region.key), style: { ...btn, color: chrome.danger, borderColor: "#f6c9c9" }, children: L.remove })
647
- ] }) : showEmptyHint ? /* @__PURE__ */ 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 })
648
- ] })
649
- ] });
645
+ return /* @__PURE__ */ jsxs(
646
+ "div",
647
+ {
648
+ "data-testid": "bodymap3d-root",
649
+ className,
650
+ "data-fullscreen": isFullscreen ? "true" : void 0,
651
+ style: {
652
+ fontFamily: "system-ui, sans-serif",
653
+ color: "#1e293b",
654
+ // A viewport fill, not the Fullscreen API see the `fullscreen` prop.
655
+ // `fixed`+`inset:0` behaves the same on iOS Safari, where element
656
+ // fullscreen does not exist at all, as it does everywhere else.
657
+ ...isFullscreen ? {
658
+ position: "fixed",
659
+ inset: 0,
660
+ zIndex: 2147483e3,
661
+ background: chrome.panelBg,
662
+ padding: 12,
663
+ display: "flex",
664
+ flexDirection: "column",
665
+ overflow: "auto"
666
+ } : null
667
+ },
668
+ children: [
669
+ showSexToggle && /* @__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: [
670
+ /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-male", onClick: () => changeSex("male"), style: seg(sex === "male"), children: UI.male }),
671
+ /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-female", onClick: () => changeSex("female"), style: seg(sex === "female"), children: UI.female })
672
+ ] }) }),
673
+ /* @__PURE__ */ jsx("span", { ref: loadedRef, "data-testid": "bodymap3d-loaded", style: { display: "none" } }),
674
+ ready && /* @__PURE__ */ jsx("span", { "data-testid": "bodymap3d-ready", style: { position: "absolute", width: 1, height: 1, opacity: 0, pointerEvents: "none" } }),
675
+ showFullscreenButton && !unsupported && /* @__PURE__ */ jsx("div", { style: { display: "flex", justifyContent: "flex-end", marginBottom: 8 }, children: /* @__PURE__ */ jsx(
676
+ "button",
677
+ {
678
+ type: "button",
679
+ "data-testid": "bodymap3d-fullscreen",
680
+ "aria-pressed": isFullscreen,
681
+ "aria-label": isFullscreen ? UI.collapse ?? "Close" : UI.expand ?? "Expand",
682
+ onClick: () => setFullscreen(!isFullscreen),
683
+ style: { ...btn, color: chrome.text, borderColor: chrome.border, background: chrome.panelBg },
684
+ children: isFullscreen ? UI.collapse ?? "Close" : UI.expand ?? "Expand"
685
+ }
686
+ ) }),
687
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 18, alignItems: "flex-start", flexWrap: "wrap", ...isFullscreen ? { flex: "1 1 auto", minHeight: 0 } : null }, children: [
688
+ unsupported ? /* @__PURE__ */ 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__ */ jsx(
689
+ "div",
690
+ {
691
+ ref: mountRef,
692
+ "data-testid": "bodymap3d-canvas",
693
+ style: {
694
+ flex: "1 1 520px",
695
+ // In fullscreen the box takes the height it is GIVEN rather than a
696
+ // fixed `canvasHeight`; the ResizeObserver above is what makes the
697
+ // picture follow it.
698
+ height: isFullscreen ? "100%" : canvasH,
699
+ minWidth: 320,
700
+ minHeight: isFullscreen ? 0 : void 0,
701
+ borderRadius: 16,
702
+ overflow: "hidden",
703
+ background: "#0e1424",
704
+ touchAction: "none"
705
+ }
706
+ }
707
+ ),
708
+ /* @__PURE__ */ jsx("div", { style: { flex: "1 1 300px", minWidth: 260 }, children: region ? /* @__PURE__ */ jsxs("div", { style: { border: `1px solid ${chrome.border}`, borderRadius: 14, padding: 16, background: chrome.panelBg }, children: [
709
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }, children: [
710
+ /* @__PURE__ */ jsx("b", { style: { fontSize: 16 }, children: nameOf(region.key) }),
711
+ showRegionCode && /* @__PURE__ */ 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: [
712
+ region.code,
713
+ region.side ? " \xB7 " + region.side : ""
714
+ ] })
715
+ ] }),
716
+ /* @__PURE__ */ jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.intensity }),
717
+ /* @__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, 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)) }),
718
+ /* @__PURE__ */ jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.quality }),
719
+ /* @__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)) }),
720
+ current && /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-remove", onClick: () => removePain(region.key), style: { ...btn, color: chrome.danger, borderColor: "#f6c9c9" }, children: L.remove })
721
+ ] }) : showEmptyHint ? /* @__PURE__ */ 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 })
722
+ ] })
723
+ ]
724
+ }
725
+ );
650
726
  }
651
727
 
652
728
  export { BodyMap3D, seamBlend, serializeReport };