@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 +77 -0
- package/dist/three.cjs +109 -30
- package/dist/three.cjs.map +1 -1
- package/dist/three.d.cts +33 -0
- package/dist/three.d.ts +33 -0
- package/dist/three.js +109 -30
- package/dist/three.js.map +1 -1
- package/package.json +1 -1
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(
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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 };
|