@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/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,35 @@ 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;
236
+ /** Soften the colour transition between neighbouring regions (F052.21).
237
+ *
238
+ * DEFAULT FALSE, and the default is the accessible one (F052.23). The blend
239
+ * mixes the BODY colour into a marked region near its boundary — which on a
240
+ * SMALL mark is most of the mark. Measured on a phone with a consumer's
241
+ * palette and one knee marked: the mark's average contrast against the body
242
+ * fell from 4.71:1 to 3.59:1, i.e. below the WCAG AA threshold of 4.5:1, on a
243
+ * surface a public authority is legally required to meet.
244
+ *
245
+ * At the single strongest pixel the cost is small (5.12 → 4.78) — which is
246
+ * why a spot-check misses it and the AVERAGE is what a person sees.
247
+ *
248
+ * Turn it on when the marked areas are LARGE and you have no accessibility
249
+ * duty: the soft seam genuinely looks better there, and that is why it is
250
+ * still here rather than deleted. */
251
+ seam?: boolean;
219
252
  /** Fired after every pick with what ACTUALLY happened (F052.22): "select",
220
253
  * "clear" or "ignore". Wire it to a sound (`@broberg/soundkit`) or to native
221
254
  * haptics (Capacitor `Haptics.impact()` — the only route to a real buzz on an
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,35 @@ 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;
236
+ /** Soften the colour transition between neighbouring regions (F052.21).
237
+ *
238
+ * DEFAULT FALSE, and the default is the accessible one (F052.23). The blend
239
+ * mixes the BODY colour into a marked region near its boundary — which on a
240
+ * SMALL mark is most of the mark. Measured on a phone with a consumer's
241
+ * palette and one knee marked: the mark's average contrast against the body
242
+ * fell from 4.71:1 to 3.59:1, i.e. below the WCAG AA threshold of 4.5:1, on a
243
+ * surface a public authority is legally required to meet.
244
+ *
245
+ * At the single strongest pixel the cost is small (5.12 → 4.78) — which is
246
+ * why a spot-check misses it and the AVERAGE is what a person sees.
247
+ *
248
+ * Turn it on when the marked areas are LARGE and you have no accessibility
249
+ * duty: the soft seam genuinely looks better there, and that is why it is
250
+ * still here rather than deleted. */
251
+ seam?: boolean;
219
252
  /** Fired after every pick with what ACTUALLY happened (F052.22): "select",
220
253
  * "clear" or "ignore". Wire it to a sound (`@broberg/soundkit`) or to native
221
254
  * haptics (Capacitor `Haptics.impact()` — the only route to a real buzz on an
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],
@@ -286,6 +286,10 @@ function BodyMap3D(props) {
286
286
  showRegionCode = true,
287
287
  onFeedback,
288
288
  haptics,
289
+ seam = false,
290
+ fullscreen: fullscreenProp,
291
+ onFullscreenChange,
292
+ showFullscreenButton = true,
289
293
  className
290
294
  } = props;
291
295
  const chrome = uiColors(palette);
@@ -323,6 +327,14 @@ function BodyMap3D(props) {
323
327
  const pickRef = useRef(() => "ignore");
324
328
  const setReadyRef = useRef(setReady);
325
329
  setReadyRef.current = setReady;
330
+ const seamRef = useRef(seam);
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
+ };
326
338
  const apiRef = useRef(null);
327
339
  useEffect(() => {
328
340
  if (!webglAvailable()) {
@@ -416,7 +428,7 @@ function BodyMap3D(props) {
416
428
  };
417
429
  for (let i = 0; i < vertexRegion.length; i++) {
418
430
  tmp.copy(col(vertexRegion[i]));
419
- const w = vertexBlend[i];
431
+ const w = seamRef.current ? vertexBlend[i] : 0;
420
432
  if (w > 0) {
421
433
  tmp2.copy(col(vertexNeighbour[i]));
422
434
  tmp.lerp(tmp2, w);
@@ -450,7 +462,7 @@ function BodyMap3D(props) {
450
462
  });
451
463
  if (bodyMesh) {
452
464
  const geo = bodyMesh.geometry;
453
- geo.computeVertexNormals();
465
+ if (!geo.getAttribute("normal")) geo.computeVertexNormals();
454
466
  const pos = geo.getAttribute("position");
455
467
  const n = pos.count;
456
468
  const cols = new Float32Array(n * 3);
@@ -572,8 +584,11 @@ function BodyMap3D(props) {
572
584
  renderFrame();
573
585
  };
574
586
  window.addEventListener("resize", onResize);
587
+ const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => onResize()) : null;
588
+ ro?.observe(el);
575
589
  return () => {
576
590
  cancelAnimationFrame(raf);
591
+ ro?.disconnect();
577
592
  window.removeEventListener("resize", onResize);
578
593
  document.removeEventListener("visibilitychange", onVis);
579
594
  controls.removeEventListener("change", kick);
@@ -585,9 +600,17 @@ function BodyMap3D(props) {
585
600
  if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
586
601
  };
587
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
+ });
588
611
  useEffect(() => {
589
612
  apiRef.current?.refresh();
590
- }, [selected, report, palette]);
613
+ }, [selected, report, palette, seam]);
591
614
  const sexInited = useRef(false);
592
615
  useEffect(() => {
593
616
  if (!sexInited.current) {
@@ -619,31 +642,87 @@ function BodyMap3D(props) {
619
642
  const current = selected ? pointOf(selected) : void 0;
620
643
  const anySelectable = REGIONS.some((r) => isSelectable(r.key, config ?? {}));
621
644
  const showEmptyHint = anySelectable || ui?.hoverHint !== void 0;
622
- return /* @__PURE__ */ jsxs("div", { "data-testid": "bodymap3d-root", className, style: { fontFamily: "system-ui, sans-serif", color: "#1e293b" }, children: [
623
- 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: [
624
- /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-male", onClick: () => changeSex("male"), style: seg(sex === "male"), children: UI.male }),
625
- /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-female", onClick: () => changeSex("female"), style: seg(sex === "female"), children: UI.female })
626
- ] }) }),
627
- /* @__PURE__ */ jsx("span", { ref: loadedRef, "data-testid": "bodymap3d-loaded", style: { display: "none" } }),
628
- ready && /* @__PURE__ */ jsx("span", { "data-testid": "bodymap3d-ready", style: { position: "absolute", width: 1, height: 1, opacity: 0, pointerEvents: "none" } }),
629
- /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 18, alignItems: "flex-start", flexWrap: "wrap" }, children: [
630
- 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" } }),
631
- /* @__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: [
632
- /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }, children: [
633
- /* @__PURE__ */ jsx("b", { style: { fontSize: 16 }, children: nameOf(region.key) }),
634
- 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: [
635
- region.code,
636
- region.side ? " \xB7 " + region.side : ""
637
- ] })
638
- ] }),
639
- /* @__PURE__ */ jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.intensity }),
640
- /* @__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)) }),
641
- /* @__PURE__ */ jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.quality }),
642
- /* @__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)) }),
643
- current && /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-remove", onClick: () => removePain(region.key), style: { ...btn, color: chrome.danger, borderColor: "#f6c9c9" }, children: L.remove })
644
- ] }) : 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 })
645
- ] })
646
- ] });
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
+ );
647
726
  }
648
727
 
649
728
  export { BodyMap3D, seamBlend, serializeReport };