@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 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 + a 2D SVG React renderer (front view). A rotatable 3D renderer
8
- (vanilla Three.js) and a 2D back view land in follow-up `0.1.x` releases on the
9
- same core.
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
- - 2D **back view** (LUMBAR / THORA / HIP posterior) — `0.1.x`
103
- - Rotatable **3D** renderer (vanilla Three.js, realistic Blender Studio CC0 body,
104
- click-to-colour by pain) — `0.1.x`
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:var(--bmap-muted);background:none;border:0;border-radius:7px;padding:8px 16px;cursor:pointer;transition:color .12s,background .12s,transform .1s}
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