@broberg/bodymap 0.1.3 → 0.2.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 +45 -7
- package/dist/react.cjs +96 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +24 -1
- package/dist/react.d.ts +24 -1
- package/dist/react.js +96 -2
- package/dist/react.js.map +1 -1
- package/dist/three.cjs +571 -0
- package/dist/three.cjs.map +1 -0
- package/dist/three.d.cts +140 -0
- package/dist/three.d.ts +140 -0
- package/dist/three.js +548 -0
- package/dist/three.js.map +1 -0
- package/models/body-female.glb +0 -0
- package/models/body-male.glb +0 -0
- package/package.json +18 -4
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@ Interactive body **pain-map**: a genderless body a patient clicks to mark where
|
|
|
4
4
|
hurts, producing a structured `PainReport` (the shared `bodymap/v1` wire format) —
|
|
5
5
|
never a bare image. The clinical region taxonomy, the data model and the selection
|
|
6
6
|
engine are **framework-neutral**; the renderer is swappable. This release ships the
|
|
7
|
-
headless core
|
|
8
|
-
(vanilla
|
|
9
|
-
|
|
7
|
+
headless core, a 2D SVG React renderer (front + back view) **and** a rotatable
|
|
8
|
+
3D renderer (vanilla three.js) — all on the same core and the same
|
|
9
|
+
`bodymap/v1` wire.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npm i @broberg/bodymap
|
|
@@ -78,6 +78,45 @@ type RegionConfig = Record<string, { visible?: boolean; selectable?: boolean }>;
|
|
|
78
78
|
An absent key is visible + selectable. Hidden ⇒ never selectable. `resolveRegions`
|
|
79
79
|
and `isSelectable` apply the config for custom renderers.
|
|
80
80
|
|
|
81
|
+
## 3D renderer (React + three.js)
|
|
82
|
+
|
|
83
|
+
A rotatable 3D body on the **same** core + wire — a realistic Blender Studio human
|
|
84
|
+
base mesh (CC0), drag to rotate, scroll to zoom, hover to highlight, click a body
|
|
85
|
+
part to mark pain (the part colours by intensity). Vanilla three.js (not
|
|
86
|
+
react-three-fiber) so it runs in React, Preact and a Capacitor webview alike.
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { BodyMap3D } from "@broberg/bodymap/three";
|
|
90
|
+
|
|
91
|
+
<BodyMap3D
|
|
92
|
+
models={{ male: "/models/body-male.glb", female: "/models/body-female.glb" }}
|
|
93
|
+
onChange={(report) => save(report)} // same PainReport (bodymap/v1)
|
|
94
|
+
palette={palette} // same BodymapPalette
|
|
95
|
+
locale="da" // da | en (region names + UI)
|
|
96
|
+
autoRotate={false} // idle when static (battery + Lens-friendly)
|
|
97
|
+
/>;
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- **`three` is an optional peer** — only this subpath pulls it in (`npm i three`).
|
|
101
|
+
The 2D renderer + core stay three-free.
|
|
102
|
+
- **You host the models.** The package ships reference GLBs at
|
|
103
|
+
`@broberg/bodymap/models/body-male.glb` + `…/body-female.glb` (~512 KB each) —
|
|
104
|
+
copy them to your `public/` (or `import url from "@broberg/bodymap/models/body-male.glb?url"`
|
|
105
|
+
with a bundler that emits asset URLs) and pass the URLs via `models`. Nothing is
|
|
106
|
+
bundled into the JS, and no model is fetched from a third party.
|
|
107
|
+
- **On-demand rendering.** The scene renders only while auto-rotating or while a
|
|
108
|
+
gesture is settling, then goes idle — so it doesn't drain a phone's battery and a
|
|
109
|
+
headless Lens/Playwright run can actually land clicks.
|
|
110
|
+
- **WebGL-safe.** With no WebGL context the component renders a graceful fallback
|
|
111
|
+
instead of crashing.
|
|
112
|
+
- **Same contract as 2D.** `onChange` emits the identical `PainReport`, so swapping
|
|
113
|
+
`<BodyMap>` for `<BodyMap3D>` needs zero change to your report handling. It honours
|
|
114
|
+
the per-app `config` too — a non-selectable region isn't pickable in 3D either.
|
|
115
|
+
- **No WebXR/VR.** This is a rotatable in-page 3D canvas, not an immersive session —
|
|
116
|
+
WebXR is intentionally out (a Capacitor webview can't host it reliably).
|
|
117
|
+
- Every control carries a `data-testid` (`bodymap3d-canvas`, `bodymap3d-sex-*`,
|
|
118
|
+
`bodymap3d-intensity-<n>`, `bodymap3d-type-<quality>`, `bodymap3d-ready`).
|
|
119
|
+
|
|
81
120
|
## Colour control — `BodymapPalette`
|
|
82
121
|
|
|
83
122
|
Both renderers theme off one palette (consumer-defined):
|
|
@@ -99,10 +138,9 @@ baseColorFor("chest", palette); // → "#e6e9f2" (per-region override, else
|
|
|
99
138
|
|
|
100
139
|
## Roadmap
|
|
101
140
|
|
|
102
|
-
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
- Preact adapter
|
|
141
|
+
- True **per-zone mesh segmentation** for the 3D renderer (v0.2.0 assigns each
|
|
142
|
+
vertex to its nearest region anchor; sharp painted zones come next)
|
|
143
|
+
- **Preact** adapter
|
|
106
144
|
|
|
107
145
|
## License
|
|
108
146
|
|
package/dist/react.cjs
CHANGED
|
@@ -215,6 +215,10 @@ var LABELS_DA = {
|
|
|
215
215
|
front: "Forfra",
|
|
216
216
|
back: "Bagfra",
|
|
217
217
|
empty: "V\xE6lg en kropsdel for at markere smerte.",
|
|
218
|
+
before: "F\xF8r",
|
|
219
|
+
after: "Efter",
|
|
220
|
+
noChange: "Ingen \xE6ndring",
|
|
221
|
+
change: { new: "nyt", resolved: "forsvundet", improved: "bedre", worse: "v\xE6rre" },
|
|
218
222
|
ariaMarked: (n, i, q) => `${n}, smerte ${i} af 10${q ? ", " + q : ""}`,
|
|
219
223
|
ariaUnmarked: (n) => `${n}, ikke markeret. Aktiv\xE9r for at markere smerte.`,
|
|
220
224
|
svgLabel: "Kropskort \u2014 v\xE6lg hvor det g\xF8r ondt",
|
|
@@ -233,6 +237,10 @@ var LABELS_EN = {
|
|
|
233
237
|
front: "Front",
|
|
234
238
|
back: "Back",
|
|
235
239
|
empty: "Pick a body part to mark pain.",
|
|
240
|
+
before: "Before",
|
|
241
|
+
after: "After",
|
|
242
|
+
noChange: "No change",
|
|
243
|
+
change: { new: "new", resolved: "resolved", improved: "improved", worse: "worse" },
|
|
236
244
|
ariaMarked: (n, i, q) => `${n}, pain ${i} of 10${q ? ", " + q : ""}`,
|
|
237
245
|
ariaUnmarked: (n) => `${n}, not marked. Activate to mark pain.`,
|
|
238
246
|
svgLabel: "Body map \u2014 pick where it hurts",
|
|
@@ -292,7 +300,7 @@ var STYLE = `
|
|
|
292
300
|
.bmap__stage{display:flex;flex-direction:column;gap:10px;flex:0 1 340px;min-width:0;max-width:360px}
|
|
293
301
|
.bmap__bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
294
302
|
.bmap__viewtoggle{display:inline-flex;background:#f1f5f9;border:1px solid var(--bmap-line);border-radius:10px;padding:3px;gap:2px}
|
|
295
|
-
.bmap__vbtn{font:inherit;font-size:13px;font-weight:600;color
|
|
303
|
+
.bmap__vbtn{font:inherit;font-size:13px;font-weight:600;color:#556274;background:none;border:0;border-radius:7px;padding:8px 16px;cursor:pointer;transition:color .12s,background .12s,transform .1s}
|
|
296
304
|
.bmap__vbtn:hover{color:var(--bmap-ink)}
|
|
297
305
|
.bmap__vbtn:active{transform:scale(.97)}
|
|
298
306
|
.bmap__vbtn--on{background:#fff;color:var(--bmap-ink);box-shadow:0 1px 2px rgba(15,23,42,.14)}
|
|
@@ -329,6 +337,23 @@ var STYLE = `
|
|
|
329
337
|
.bmap__rm:hover{background:#fef2f2}
|
|
330
338
|
.bmap__rm:active{transform:scale(.97)}
|
|
331
339
|
.bmap__i:focus-visible,.bmap__chip:focus-visible,.bmap__vbtn:focus-visible,.bmap__rm:focus-visible,.bmap__zoom button:focus-visible{outline:2px solid var(--bmap-accent);outline-offset:2px}
|
|
340
|
+
.bmapc{--bmap-accent:var(--primary,#0e8f8a);--bmap-line:#e2e8f0;--bmap-ink:#1e293b;--bmap-muted:#64748b;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--bmap-ink)}
|
|
341
|
+
.bmapc__bodies{display:flex;gap:14px;flex-wrap:wrap}
|
|
342
|
+
.bmapc__fig{flex:1 1 150px;min-width:130px;max-width:220px;margin:0;text-align:center}
|
|
343
|
+
.bmapc__cap{font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#556274;margin-bottom:6px}
|
|
344
|
+
.bmapc__svg{width:100%;height:auto;display:block}
|
|
345
|
+
.bmapc__vis{stroke:#fff;stroke-width:2.4}
|
|
346
|
+
.bmapc__n{font:700 12px ui-sans-serif;fill:#fff;pointer-events:none}
|
|
347
|
+
.bmapc__delta{margin-top:16px;display:flex;flex-direction:column;gap:6px;max-width:460px}
|
|
348
|
+
.bmapc__row{display:flex;align-items:center;gap:10px;font-size:13.5px;border:1px solid var(--bmap-line);border-radius:9px;padding:7px 11px}
|
|
349
|
+
.bmapc__rname{font-weight:600;flex:1 1 auto}
|
|
350
|
+
.bmapc__rval{font:12px ui-monospace,monospace;color:#556274;white-space:nowrap}
|
|
351
|
+
.bmapc__badge{font-size:11px;font-weight:700;border-radius:6px;padding:2px 8px;white-space:nowrap}
|
|
352
|
+
.bmapc__badge--improved{background:#dcfce7;color:#166534}
|
|
353
|
+
.bmapc__badge--worse{background:#fee2e2;color:#991b1b}
|
|
354
|
+
.bmapc__badge--new{background:#fef3c7;color:#92400e}
|
|
355
|
+
.bmapc__badge--resolved{background:#e2e8f0;color:#475569}
|
|
356
|
+
.bmapc__empty{color:#556274;font-size:13.5px;margin-top:14px}
|
|
332
357
|
@media (max-width:560px){
|
|
333
358
|
.bmap{flex-direction:column;gap:14px}
|
|
334
359
|
.bmap__stage{max-width:100%;width:100%;flex-basis:auto}
|
|
@@ -603,8 +628,78 @@ function BodyMap({
|
|
|
603
628
|
] }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bmap__panel bmap__panel--empty", "data-testid": "bodymap-panel", children: L.empty })
|
|
604
629
|
] });
|
|
605
630
|
}
|
|
631
|
+
function changeOf(b, a) {
|
|
632
|
+
if (b == null && a != null) return "new";
|
|
633
|
+
if (b != null && a == null) return "resolved";
|
|
634
|
+
if (b == null || a == null) return "same";
|
|
635
|
+
return a < b ? "improved" : a > b ? "worse" : "same";
|
|
636
|
+
}
|
|
637
|
+
function CompareFigure({ report, view, palette, caption, testid }) {
|
|
638
|
+
const shapes = view === "back" ? SHAPES_BACK : SHAPES;
|
|
639
|
+
const fills = view === "back" ? FILL_BACK : FILL;
|
|
640
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("figure", { className: "bmapc__fig", "data-testid": testid, children: [
|
|
641
|
+
/* @__PURE__ */ jsxRuntime.jsx("figcaption", { className: "bmapc__cap", children: caption }),
|
|
642
|
+
/* @__PURE__ */ jsxRuntime.jsxs("svg", { className: "bmapc__svg", viewBox: "0 0 260 470", role: "img", "aria-label": caption, children: [
|
|
643
|
+
REGIONS.map((r) => {
|
|
644
|
+
const s = shapes[r.key];
|
|
645
|
+
if (!s) return null;
|
|
646
|
+
const m = report.find((p) => p.region === r.key);
|
|
647
|
+
const fill = m ? markedFill(m.intensity, palette) : baseFill(r.key, fills[r.key] ?? "#cbd5e1", palette);
|
|
648
|
+
return /* @__PURE__ */ jsxRuntime.jsx("g", { children: shapeEl(s, { className: "bmapc__vis", fill }) }, r.key);
|
|
649
|
+
}),
|
|
650
|
+
report.map((p) => {
|
|
651
|
+
const s = shapes[p.region];
|
|
652
|
+
if (!s) return null;
|
|
653
|
+
const c = center(s);
|
|
654
|
+
return /* @__PURE__ */ jsxRuntime.jsx("text", { x: c.x, y: c.y + 4, textAnchor: "middle", className: "bmapc__n", children: p.intensity }, p.region);
|
|
655
|
+
})
|
|
656
|
+
] })
|
|
657
|
+
] });
|
|
658
|
+
}
|
|
659
|
+
function BodyMapCompare({
|
|
660
|
+
before,
|
|
661
|
+
after,
|
|
662
|
+
palette,
|
|
663
|
+
locale = "da",
|
|
664
|
+
labels,
|
|
665
|
+
defaultView = "front",
|
|
666
|
+
className
|
|
667
|
+
}) {
|
|
668
|
+
react.useEffect(ensureStyles, []);
|
|
669
|
+
const [view, setView] = react.useState(defaultView);
|
|
670
|
+
const L = resolveLabels(locale, labels);
|
|
671
|
+
const nameOf = (k) => L.regions[k] ?? getRegion(k)?.label ?? k;
|
|
672
|
+
const rootStyle = palette ? { "--bmap-accent": palette.selected } : void 0;
|
|
673
|
+
const keys = Array.from(new Set([...before, ...after].map((p) => p.region)));
|
|
674
|
+
const changes = keys.map((k) => ({
|
|
675
|
+
key: k,
|
|
676
|
+
b: before.find((p) => p.region === k)?.intensity,
|
|
677
|
+
a: after.find((p) => p.region === k)?.intensity,
|
|
678
|
+
status: changeOf(before.find((p) => p.region === k)?.intensity, after.find((p) => p.region === k)?.intensity)
|
|
679
|
+
})).filter((c) => c.status !== "same").sort((x, y) => x.key < y.key ? -1 : 1);
|
|
680
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: ["bmapc", className].filter(Boolean).join(" "), "data-testid": "bodymap-compare", style: rootStyle, children: [
|
|
681
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "bmap__viewtoggle", role: "group", "aria-label": L.viewLabel, style: { marginBottom: 12 }, children: [
|
|
682
|
+
/* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "bmap__vbtn" + (view === "front" ? " bmap__vbtn--on" : ""), "data-testid": "bodymap-compare-view-front", "aria-pressed": view === "front", onClick: () => setView("front"), children: L.front }),
|
|
683
|
+
/* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "bmap__vbtn" + (view === "back" ? " bmap__vbtn--on" : ""), "data-testid": "bodymap-compare-view-back", "aria-pressed": view === "back", onClick: () => setView("back"), children: L.back })
|
|
684
|
+
] }),
|
|
685
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "bmapc__bodies", children: [
|
|
686
|
+
/* @__PURE__ */ jsxRuntime.jsx(CompareFigure, { report: before, view, palette, caption: L.before, testid: "bodymap-compare-before" }),
|
|
687
|
+
/* @__PURE__ */ jsxRuntime.jsx(CompareFigure, { report: after, view, palette, caption: L.after, testid: "bodymap-compare-after" })
|
|
688
|
+
] }),
|
|
689
|
+
changes.length ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bmapc__delta", "data-testid": "bodymap-compare-delta", children: changes.map((c) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "bmapc__row", "data-testid": `bodymap-compare-delta-${c.key}`, children: [
|
|
690
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "bmapc__rname", children: nameOf(c.key) }),
|
|
691
|
+
/* @__PURE__ */ jsxRuntime.jsxs("span", { className: "bmapc__rval", children: [
|
|
692
|
+
c.b ?? "\u2013",
|
|
693
|
+
" \u2192 ",
|
|
694
|
+
c.a ?? "\u2013"
|
|
695
|
+
] }),
|
|
696
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: `bmapc__badge bmapc__badge--${c.status}`, children: L.change[c.status] })
|
|
697
|
+
] }, c.key)) }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bmapc__empty", "data-testid": "bodymap-compare-delta", children: L.noChange })
|
|
698
|
+
] });
|
|
699
|
+
}
|
|
606
700
|
|
|
607
701
|
exports.BodyMap = BodyMap;
|
|
702
|
+
exports.BodyMapCompare = BodyMapCompare;
|
|
608
703
|
exports.LABELS_DA = LABELS_DA;
|
|
609
704
|
exports.LABELS_EN = LABELS_EN;
|
|
610
705
|
//# sourceMappingURL=react.cjs.map
|