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