@broberg/bodymap 0.13.2 → 0.14.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
@@ -274,6 +274,14 @@ this component's primary surface. A `position: fixed; inset: 0` overlay behaves
274
274
  identically on every platform, needs no user-gesture dance, and cannot leave you
275
275
  with a control that silently does nothing on the one device that matters most.
276
276
 
277
+ **On a narrow screen the panel goes UNDER the body (0.14.0).** Side by side needs
278
+ `FS_ROW_MIN` (838px) of content width. Below that, fullscreen stacks: the body
279
+ shrinks to what the panel leaves (never under 160px) and the panel sits on screen
280
+ beneath it. Before 0.14.0 the panel wrapped below a full-height canvas — on an
281
+ iPhone every intensity button sat under the bottom edge, so a patient could mark
282
+ a body part and not say how much it hurt. Wider than 838px, nothing changed.
283
+ `stackInFullscreen(width)` is exported if you need the same decision.
284
+
277
285
  ### It ships with a `ResizeObserver`, and that is not incidental
278
286
 
279
287
  Before 0.6.0 the canvas measured the element **once at mount** and only re-measured
package/dist/three.cjs CHANGED
@@ -312,6 +312,11 @@ function webglAvailable() {
312
312
  }
313
313
  }
314
314
  var INTENSITY_SIZE = 46;
315
+ var TOUCH_MIN = 44;
316
+ var FS_ROW_MIN = 520 + 18 + 300;
317
+ function stackInFullscreen(contentWidth) {
318
+ return contentWidth < FS_ROW_MIN;
319
+ }
315
320
  function shouldShowHint(opts) {
316
321
  if (opts.openRegion) return false;
317
322
  if (opts.hint === false) return false;
@@ -404,6 +409,17 @@ function BodyMap3D(props) {
404
409
  onFullscreenChange?.(v);
405
410
  };
406
411
  const apiRef = react.useRef(null);
412
+ const rowRef = react.useRef(null);
413
+ const [rowWidth, setRowWidth] = react.useState(0);
414
+ react.useEffect(() => {
415
+ const el = rowRef.current;
416
+ if (!isFullscreen || !el) return;
417
+ setRowWidth(el.clientWidth);
418
+ const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => setRowWidth(el.clientWidth)) : null;
419
+ ro?.observe(el);
420
+ return () => ro?.disconnect();
421
+ }, [isFullscreen]);
422
+ const stacked = isFullscreen && stackInFullscreen(rowWidth);
407
423
  react.useEffect(() => {
408
424
  if (!webglAvailable()) {
409
425
  setUnsupported(true);
@@ -782,70 +798,90 @@ function BodyMap3D(props) {
782
798
  children: isFullscreen ? "\xD7" : UI.expand ?? "Expand"
783
799
  }
784
800
  ) }),
785
- /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", gap: 18, alignItems: "flex-start", flexWrap: "wrap", ...isFullscreen ? { flex: "1 1 auto", minHeight: 0 } : null }, children: [
786
- unsupported ? /* @__PURE__ */ jsxRuntime.jsx(
787
- "div",
788
- {
789
- "data-testid": "bodymap3d-unsupported",
790
- style: { flex: "1 1 520px", minWidth: 320, height: canvasH, borderRadius: 16, background: chrome.panelBg, color: chrome.mutedText, border: `1px solid ${chrome.border}`, display: "flex", alignItems: "center", justifyContent: "center", padding: 24, textAlign: "center", fontSize: 14 },
791
- children: "3D kr\xE6ver WebGL, som ikke er tilg\xE6ngeligt her."
792
- }
793
- ) : /* @__PURE__ */ jsxRuntime.jsx(
794
- "div",
795
- {
796
- ref: mountRef,
797
- "data-testid": "bodymap3d-canvas",
798
- style: {
799
- flex: "1 1 520px",
800
- // In fullscreen the box takes the height it is GIVEN rather than a
801
- // fixed `canvasHeight`; the ResizeObserver above is what makes the
802
- // picture follow it.
803
- height: isFullscreen ? "100%" : canvasH,
804
- minWidth: 320,
805
- minHeight: isFullscreen ? 0 : void 0,
806
- borderRadius: 16,
807
- overflow: "hidden",
808
- background: chrome.stageBg,
809
- touchAction: "none"
810
- }
811
- }
812
- ),
813
- /* @__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: [
814
- /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }, children: [
815
- /* @__PURE__ */ jsxRuntime.jsx("b", { style: { fontSize: 16 }, children: nameOf(region.key) }),
816
- 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: [
817
- region.code,
818
- region.side ? " \xB7 " + region.side : ""
819
- ] }),
820
- /* @__PURE__ */ jsxRuntime.jsx(
821
- "button",
801
+ /* @__PURE__ */ jsxRuntime.jsxs(
802
+ "div",
803
+ {
804
+ ref: rowRef,
805
+ "data-layout": stacked ? "stacked" : void 0,
806
+ style: {
807
+ display: "flex",
808
+ gap: 18,
809
+ alignItems: "flex-start",
810
+ flexWrap: "wrap",
811
+ ...isFullscreen ? { flex: "1 1 auto", minHeight: 0 } : null,
812
+ // F052.35 — a column that never wraps: the stage takes what the panel
813
+ // leaves, so the panel is always inside the screen.
814
+ ...stacked ? { flexDirection: "column", flexWrap: "nowrap", alignItems: "stretch", gap: 12 } : null
815
+ },
816
+ children: [
817
+ unsupported ? /* @__PURE__ */ jsxRuntime.jsx(
818
+ "div",
822
819
  {
823
- type: "button",
824
- "data-testid": "bodymap3d-close",
825
- "aria-label": L.close,
826
- onClick: () => setSelected(null),
827
- style: { ...btn(chrome), width: 40, height: 40, minWidth: 40, padding: 0, borderRadius: "50%", fontSize: 20, lineHeight: 1, color: chrome.mutedText, borderColor: chrome.border, background: chrome.panelBg },
828
- children: "\xD7"
820
+ "data-testid": "bodymap3d-unsupported",
821
+ style: { flex: "1 1 520px", minWidth: 320, height: canvasH, borderRadius: 16, background: chrome.panelBg, color: chrome.mutedText, border: `1px solid ${chrome.border}`, display: "flex", alignItems: "center", justifyContent: "center", padding: 24, textAlign: "center", fontSize: 14 },
822
+ children: "3D kr\xE6ver WebGL, som ikke er tilg\xE6ngeligt her."
829
823
  }
830
- )
831
- ] }),
832
- /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.intensity }),
833
- /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 12 }, children: Array.from({ length: 11 }, (_, i) => (
834
- /* F052.34 — CIRCLES, and all eleven the same size.
835
- Dimensioned by «10», the widest label: sizing by the average
836
- would make the two-digit one an ellipse among circles, which
837
- is worse than eleven rounded squares because the odd one out
838
- becomes a different SHAPE rather than slightly wider.
839
- 46px is the SIBLING's touch size, not a new number: the 2D
840
- renderer already used it and this one was on 30px, under the
841
- >=44px floor F052.8 set. See INTENSITY_SIZE. */
842
- /* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": `bodymap3d-intensity-${i}`, onClick: () => setPain(region.key, i, current?.type), style: { ...btn(chrome), display: "inline-flex", alignItems: "center", justifyContent: "center", width: INTENSITY_SIZE, height: INTENSITY_SIZE, minWidth: INTENSITY_SIZE, padding: 0, borderRadius: "50%", background: current?.intensity === i ? chrome.accent : chrome.panelBg, color: current?.intensity === i ? chrome.accentText : chrome.text }, children: i }, i)
843
- )) }),
844
- /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.quality }),
845
- /* @__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(chrome), borderRadius: 999, padding: "6px 12px", background: current?.type === t ? chrome.text : chrome.panelBg, color: current?.type === t ? chrome.panelBg : chrome.mutedText }, children: L.qualities[t] ?? t }, t)) }),
846
- current && /* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": "bodymap3d-remove", onClick: () => removePain(region.key), style: { ...btn(chrome), color: chrome.danger, borderColor: chrome.dangerBorder }, children: L.remove })
847
- ] }) : showEmptyHint && !hintAtStage ? /* @__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: hintText }) : null })
848
- ] }),
824
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
825
+ "div",
826
+ {
827
+ ref: mountRef,
828
+ "data-testid": "bodymap3d-canvas",
829
+ style: {
830
+ flex: "1 1 520px",
831
+ // In fullscreen the box takes the height it is GIVEN rather than a
832
+ // fixed `canvasHeight`; the ResizeObserver above is what makes the
833
+ // picture follow it.
834
+ height: isFullscreen ? "100%" : canvasH,
835
+ minWidth: 320,
836
+ minHeight: isFullscreen ? 0 : void 0,
837
+ // Stacked: the stage fills what is left above the panel, and
838
+ // keeps a floor so the body never disappears behind it.
839
+ ...stacked ? { flex: "1 1 0", height: "auto", minWidth: 0, minHeight: 160 } : null,
840
+ borderRadius: 16,
841
+ overflow: "hidden",
842
+ background: chrome.stageBg,
843
+ touchAction: "none"
844
+ }
845
+ }
846
+ ),
847
+ /* @__PURE__ */ jsxRuntime.jsx("div", { "data-testid": "bodymap3d-panel-col", style: { flex: "1 1 300px", minWidth: 260, ...stacked ? { flex: "0 1 auto", minWidth: 0, minHeight: 0, overflowY: "auto" } : null }, children: region ? /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { border: `1px solid ${chrome.border}`, borderRadius: 14, padding: 16, background: chrome.panelBg }, children: [
848
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }, children: [
849
+ /* @__PURE__ */ jsxRuntime.jsx("b", { style: { fontSize: 16 }, children: nameOf(region.key) }),
850
+ 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: [
851
+ region.code,
852
+ region.side ? " \xB7 " + region.side : ""
853
+ ] }),
854
+ /* @__PURE__ */ jsxRuntime.jsx(
855
+ "button",
856
+ {
857
+ type: "button",
858
+ "data-testid": "bodymap3d-close",
859
+ "aria-label": L.close,
860
+ onClick: () => setSelected(null),
861
+ style: { ...btn(chrome), width: TOUCH_MIN, height: TOUCH_MIN, minWidth: TOUCH_MIN, padding: 0, borderRadius: "50%", fontSize: 20, lineHeight: 1, color: chrome.mutedText, borderColor: chrome.border, background: chrome.panelBg },
862
+ children: "\xD7"
863
+ }
864
+ )
865
+ ] }),
866
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.intensity }),
867
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 12 }, children: Array.from({ length: 11 }, (_, i) => (
868
+ /* F052.34 — CIRCLES, and all eleven the same size.
869
+ Dimensioned by «10», the widest label: sizing by the average
870
+ would make the two-digit one an ellipse among circles, which
871
+ is worse than eleven rounded squares because the odd one out
872
+ becomes a different SHAPE rather than slightly wider.
873
+ 46px is the SIBLING's touch size, not a new number: the 2D
874
+ renderer already used it and this one was on 30px, under the
875
+ >=44px floor F052.8 set. See INTENSITY_SIZE. */
876
+ /* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": `bodymap3d-intensity-${i}`, onClick: () => setPain(region.key, i, current?.type), style: { ...btn(chrome), display: "inline-flex", alignItems: "center", justifyContent: "center", width: INTENSITY_SIZE, height: INTENSITY_SIZE, minWidth: INTENSITY_SIZE, padding: 0, borderRadius: "50%", background: current?.intensity === i ? chrome.accent : chrome.panelBg, color: current?.intensity === i ? chrome.accentText : chrome.text }, children: i }, i)
877
+ )) }),
878
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.quality }),
879
+ /* @__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(chrome), borderRadius: 999, padding: "6px 12px", background: current?.type === t ? chrome.text : chrome.panelBg, color: current?.type === t ? chrome.panelBg : chrome.mutedText }, children: L.qualities[t] ?? t }, t)) }),
880
+ current && /* @__PURE__ */ jsxRuntime.jsx("button", { "data-testid": "bodymap3d-remove", onClick: () => removePain(region.key), style: { ...btn(chrome), color: chrome.danger, borderColor: chrome.dangerBorder }, children: L.remove })
881
+ ] }) : showEmptyHint && !hintAtStage ? /* @__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: hintText }) : null })
882
+ ]
883
+ }
884
+ ),
849
885
  showEmptyHint && hintAtStage && /* F052.31 — pinned INSIDE the fullscreen surface, not in the column that
850
886
  wraps beneath a full-height canvas. That wrap is what put the note at
851
887
  858-912 in an 852px window on a real iPhone: entirely offscreen, and
@@ -877,13 +913,16 @@ function BodyMap3D(props) {
877
913
  }
878
914
 
879
915
  exports.BodyMap3D = BodyMap3D;
916
+ exports.FS_ROW_MIN = FS_ROW_MIN;
880
917
  exports.INTENSITY_SIZE = INTENSITY_SIZE;
881
918
  exports.TAP_MAX_DISTANCE = TAP_MAX_DISTANCE;
919
+ exports.TOUCH_MIN = TOUCH_MIN;
882
920
  exports.ensureNormals = ensureNormals;
883
921
  exports.isTap = isTap;
884
922
  exports.seamBlend = seamBlend;
885
923
  exports.seamWeight = seamWeight;
886
924
  exports.serializeReport = serializeReport;
887
925
  exports.shouldShowHint = shouldShowHint;
926
+ exports.stackInFullscreen = stackInFullscreen;
888
927
  //# sourceMappingURL=three.cjs.map
889
928
  //# sourceMappingURL=three.cjs.map