@broberg/bodymap 0.3.0 → 0.5.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 +116 -0
- package/dist/index.cjs +22 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +70 -1
- package/dist/index.d.ts +70 -1
- package/dist/index.js +20 -1
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +32 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +45 -1
- package/dist/react.d.ts +45 -1
- package/dist/react.js +32 -1
- package/dist/react.js.map +1 -1
- package/dist/three.cjs +28 -3
- package/dist/three.cjs.map +1 -1
- package/dist/three.d.cts +59 -0
- package/dist/three.d.ts +59 -0
- package/dist/three.js +28 -3
- package/dist/three.js.map +1 -1
- package/package.json +1 -1
package/dist/three.d.cts
CHANGED
|
@@ -31,6 +31,40 @@ interface RegionSetting {
|
|
|
31
31
|
}
|
|
32
32
|
/** Per-app config keyed by region key. An absent key ⇒ visible + selectable. */
|
|
33
33
|
type RegionConfig = Record<string, RegionSetting>;
|
|
34
|
+
/**
|
|
35
|
+
* What a pick on a region should DO (F052.20).
|
|
36
|
+
*
|
|
37
|
+
* Lives in the core because the 2D and 3D renderers share no click code, and a
|
|
38
|
+
* rule written twice is a rule that drifts. This repo measured the cost of that
|
|
39
|
+
* twice on 2026-08-28 alone: a fix applied to one half of a pair, and a sibling
|
|
40
|
+
* branch that carried the same defect with no test on it.
|
|
41
|
+
*
|
|
42
|
+
* "clear" the region is already marked → picking it again removes the mark
|
|
43
|
+
* "select" unmarked → open it for marking
|
|
44
|
+
* "ignore" not selectable (read-only or config) → nothing happens
|
|
45
|
+
*
|
|
46
|
+
* Three outcomes, not a boolean: "nothing happened because it is locked" and
|
|
47
|
+
* "nothing happened because we removed the mark" must never look alike to a
|
|
48
|
+
* caller.
|
|
49
|
+
*/
|
|
50
|
+
type PickOutcome = "clear" | "select" | "ignore";
|
|
51
|
+
/**
|
|
52
|
+
* What a pick did, handed to the consuming app so it can make a sound or a buzz.
|
|
53
|
+
*
|
|
54
|
+
* The outcome is the one `decidePick` ACTUALLY returned, never the intent to tap
|
|
55
|
+
* — so a tap on a locked region can not announce itself as a removal, and a tap
|
|
56
|
+
* the pan/pinch guard swallowed emits nothing at all (it never gets here).
|
|
57
|
+
*
|
|
58
|
+
* It deliberately reuses `PickOutcome` rather than introducing a second
|
|
59
|
+
* three-word vocabulary. Two enums meaning the same thing is a drift bug waiting
|
|
60
|
+
* for the first person who adds a fourth outcome to only one of them.
|
|
61
|
+
*/
|
|
62
|
+
interface FeedbackSignal {
|
|
63
|
+
outcome: PickOutcome;
|
|
64
|
+
/** The region key that was picked. */
|
|
65
|
+
region: string;
|
|
66
|
+
}
|
|
67
|
+
type FeedbackFn = (signal: FeedbackSignal) => void;
|
|
34
68
|
/** Colour control for the body renderers. Consumers pass a palette to theme the
|
|
35
69
|
* body base colour, the hover + selected highlights, the pain-heat colours, and
|
|
36
70
|
* optional per-region base colours. All values are CSS/hex colour strings. */
|
|
@@ -182,6 +216,31 @@ interface BodyMap3DProps {
|
|
|
182
216
|
* (default true). Set false for a patient/employee-facing flow where the code is
|
|
183
217
|
* internal jargon and the readable region name is enough. */
|
|
184
218
|
showRegionCode?: boolean;
|
|
219
|
+
/** Soften the colour transition between neighbouring regions (F052.21).
|
|
220
|
+
*
|
|
221
|
+
* DEFAULT FALSE, and the default is the accessible one (F052.23). The blend
|
|
222
|
+
* mixes the BODY colour into a marked region near its boundary — which on a
|
|
223
|
+
* SMALL mark is most of the mark. Measured on a phone with a consumer's
|
|
224
|
+
* palette and one knee marked: the mark's average contrast against the body
|
|
225
|
+
* fell from 4.71:1 to 3.59:1, i.e. below the WCAG AA threshold of 4.5:1, on a
|
|
226
|
+
* surface a public authority is legally required to meet.
|
|
227
|
+
*
|
|
228
|
+
* At the single strongest pixel the cost is small (5.12 → 4.78) — which is
|
|
229
|
+
* why a spot-check misses it and the AVERAGE is what a person sees.
|
|
230
|
+
*
|
|
231
|
+
* Turn it on when the marked areas are LARGE and you have no accessibility
|
|
232
|
+
* duty: the soft seam genuinely looks better there, and that is why it is
|
|
233
|
+
* still here rather than deleted. */
|
|
234
|
+
seam?: boolean;
|
|
235
|
+
/** Fired after every pick with what ACTUALLY happened (F052.22): "select",
|
|
236
|
+
* "clear" or "ignore". Wire it to a sound (`@broberg/soundkit`) or to native
|
|
237
|
+
* haptics (Capacitor `Haptics.impact()` — the only route to a real buzz on an
|
|
238
|
+
* iPhone, where web vibration does not exist). */
|
|
239
|
+
onFeedback?: FeedbackFn;
|
|
240
|
+
/** Web vibration on select/clear. Default true; silently inert where
|
|
241
|
+
* `navigator.vibrate` is absent (every browser on iPhone, most desktops).
|
|
242
|
+
* Set false to keep the signal but drop the buzz. */
|
|
243
|
+
haptics?: boolean;
|
|
185
244
|
className?: string;
|
|
186
245
|
}
|
|
187
246
|
declare function BodyMap3D(props: BodyMap3DProps): react.JSX.Element;
|
package/dist/three.d.ts
CHANGED
|
@@ -31,6 +31,40 @@ interface RegionSetting {
|
|
|
31
31
|
}
|
|
32
32
|
/** Per-app config keyed by region key. An absent key ⇒ visible + selectable. */
|
|
33
33
|
type RegionConfig = Record<string, RegionSetting>;
|
|
34
|
+
/**
|
|
35
|
+
* What a pick on a region should DO (F052.20).
|
|
36
|
+
*
|
|
37
|
+
* Lives in the core because the 2D and 3D renderers share no click code, and a
|
|
38
|
+
* rule written twice is a rule that drifts. This repo measured the cost of that
|
|
39
|
+
* twice on 2026-08-28 alone: a fix applied to one half of a pair, and a sibling
|
|
40
|
+
* branch that carried the same defect with no test on it.
|
|
41
|
+
*
|
|
42
|
+
* "clear" the region is already marked → picking it again removes the mark
|
|
43
|
+
* "select" unmarked → open it for marking
|
|
44
|
+
* "ignore" not selectable (read-only or config) → nothing happens
|
|
45
|
+
*
|
|
46
|
+
* Three outcomes, not a boolean: "nothing happened because it is locked" and
|
|
47
|
+
* "nothing happened because we removed the mark" must never look alike to a
|
|
48
|
+
* caller.
|
|
49
|
+
*/
|
|
50
|
+
type PickOutcome = "clear" | "select" | "ignore";
|
|
51
|
+
/**
|
|
52
|
+
* What a pick did, handed to the consuming app so it can make a sound or a buzz.
|
|
53
|
+
*
|
|
54
|
+
* The outcome is the one `decidePick` ACTUALLY returned, never the intent to tap
|
|
55
|
+
* — so a tap on a locked region can not announce itself as a removal, and a tap
|
|
56
|
+
* the pan/pinch guard swallowed emits nothing at all (it never gets here).
|
|
57
|
+
*
|
|
58
|
+
* It deliberately reuses `PickOutcome` rather than introducing a second
|
|
59
|
+
* three-word vocabulary. Two enums meaning the same thing is a drift bug waiting
|
|
60
|
+
* for the first person who adds a fourth outcome to only one of them.
|
|
61
|
+
*/
|
|
62
|
+
interface FeedbackSignal {
|
|
63
|
+
outcome: PickOutcome;
|
|
64
|
+
/** The region key that was picked. */
|
|
65
|
+
region: string;
|
|
66
|
+
}
|
|
67
|
+
type FeedbackFn = (signal: FeedbackSignal) => void;
|
|
34
68
|
/** Colour control for the body renderers. Consumers pass a palette to theme the
|
|
35
69
|
* body base colour, the hover + selected highlights, the pain-heat colours, and
|
|
36
70
|
* optional per-region base colours. All values are CSS/hex colour strings. */
|
|
@@ -182,6 +216,31 @@ interface BodyMap3DProps {
|
|
|
182
216
|
* (default true). Set false for a patient/employee-facing flow where the code is
|
|
183
217
|
* internal jargon and the readable region name is enough. */
|
|
184
218
|
showRegionCode?: boolean;
|
|
219
|
+
/** Soften the colour transition between neighbouring regions (F052.21).
|
|
220
|
+
*
|
|
221
|
+
* DEFAULT FALSE, and the default is the accessible one (F052.23). The blend
|
|
222
|
+
* mixes the BODY colour into a marked region near its boundary — which on a
|
|
223
|
+
* SMALL mark is most of the mark. Measured on a phone with a consumer's
|
|
224
|
+
* palette and one knee marked: the mark's average contrast against the body
|
|
225
|
+
* fell from 4.71:1 to 3.59:1, i.e. below the WCAG AA threshold of 4.5:1, on a
|
|
226
|
+
* surface a public authority is legally required to meet.
|
|
227
|
+
*
|
|
228
|
+
* At the single strongest pixel the cost is small (5.12 → 4.78) — which is
|
|
229
|
+
* why a spot-check misses it and the AVERAGE is what a person sees.
|
|
230
|
+
*
|
|
231
|
+
* Turn it on when the marked areas are LARGE and you have no accessibility
|
|
232
|
+
* duty: the soft seam genuinely looks better there, and that is why it is
|
|
233
|
+
* still here rather than deleted. */
|
|
234
|
+
seam?: boolean;
|
|
235
|
+
/** Fired after every pick with what ACTUALLY happened (F052.22): "select",
|
|
236
|
+
* "clear" or "ignore". Wire it to a sound (`@broberg/soundkit`) or to native
|
|
237
|
+
* haptics (Capacitor `Haptics.impact()` — the only route to a real buzz on an
|
|
238
|
+
* iPhone, where web vibration does not exist). */
|
|
239
|
+
onFeedback?: FeedbackFn;
|
|
240
|
+
/** Web vibration on select/clear. Default true; silently inert where
|
|
241
|
+
* `navigator.vibrate` is absent (every browser on iPhone, most desktops).
|
|
242
|
+
* Set false to keep the signal but drop the buzz. */
|
|
243
|
+
haptics?: boolean;
|
|
185
244
|
className?: string;
|
|
186
245
|
}
|
|
187
246
|
declare function BodyMap3D(props: BodyMap3DProps): react.JSX.Element;
|
package/dist/three.js
CHANGED
|
@@ -62,6 +62,25 @@ function isSelectable(key, config = {}) {
|
|
|
62
62
|
if (s?.visible === false) return false;
|
|
63
63
|
return s?.selectable ?? true;
|
|
64
64
|
}
|
|
65
|
+
var VIBRATION_PATTERNS = {
|
|
66
|
+
select: [12],
|
|
67
|
+
clear: [8, 40, 8],
|
|
68
|
+
ignore: []
|
|
69
|
+
};
|
|
70
|
+
function requestVibration(pattern, nav = globalThis.navigator) {
|
|
71
|
+
if (pattern.length === 0) return "skipped";
|
|
72
|
+
if (typeof nav?.vibrate !== "function") return "unsupported";
|
|
73
|
+
try {
|
|
74
|
+
return nav.vibrate([...pattern]) ? "requested" : "declined";
|
|
75
|
+
} catch {
|
|
76
|
+
return "declined";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function emitFeedback(outcome, region, opts = {}) {
|
|
80
|
+
opts.onFeedback?.({ outcome, region });
|
|
81
|
+
if (opts.haptics === false) return "skipped";
|
|
82
|
+
return requestVibration(VIBRATION_PATTERNS[outcome], opts.nav);
|
|
83
|
+
}
|
|
65
84
|
var defaultUi = {
|
|
66
85
|
text: "#1e293b",
|
|
67
86
|
mutedText: "#475569",
|
|
@@ -265,6 +284,9 @@ function BodyMap3D(props) {
|
|
|
265
284
|
autoRotate = true,
|
|
266
285
|
canvasHeight = "60vh",
|
|
267
286
|
showRegionCode = true,
|
|
287
|
+
onFeedback,
|
|
288
|
+
haptics,
|
|
289
|
+
seam = false,
|
|
268
290
|
className
|
|
269
291
|
} = props;
|
|
270
292
|
const chrome = uiColors(palette);
|
|
@@ -302,6 +324,8 @@ function BodyMap3D(props) {
|
|
|
302
324
|
const pickRef = useRef(() => "ignore");
|
|
303
325
|
const setReadyRef = useRef(setReady);
|
|
304
326
|
setReadyRef.current = setReady;
|
|
327
|
+
const seamRef = useRef(seam);
|
|
328
|
+
seamRef.current = seam;
|
|
305
329
|
const apiRef = useRef(null);
|
|
306
330
|
useEffect(() => {
|
|
307
331
|
if (!webglAvailable()) {
|
|
@@ -395,7 +419,7 @@ function BodyMap3D(props) {
|
|
|
395
419
|
};
|
|
396
420
|
for (let i = 0; i < vertexRegion.length; i++) {
|
|
397
421
|
tmp.copy(col(vertexRegion[i]));
|
|
398
|
-
const w = vertexBlend[i];
|
|
422
|
+
const w = seamRef.current ? vertexBlend[i] : 0;
|
|
399
423
|
if (w > 0) {
|
|
400
424
|
tmp2.copy(col(vertexNeighbour[i]));
|
|
401
425
|
tmp.lerp(tmp2, w);
|
|
@@ -429,7 +453,7 @@ function BodyMap3D(props) {
|
|
|
429
453
|
});
|
|
430
454
|
if (bodyMesh) {
|
|
431
455
|
const geo = bodyMesh.geometry;
|
|
432
|
-
geo.computeVertexNormals();
|
|
456
|
+
if (!geo.getAttribute("normal")) geo.computeVertexNormals();
|
|
433
457
|
const pos = geo.getAttribute("position");
|
|
434
458
|
const n = pos.count;
|
|
435
459
|
const cols = new Float32Array(n * 3);
|
|
@@ -566,7 +590,7 @@ function BodyMap3D(props) {
|
|
|
566
590
|
}, []);
|
|
567
591
|
useEffect(() => {
|
|
568
592
|
apiRef.current?.refresh();
|
|
569
|
-
}, [selected, report, palette]);
|
|
593
|
+
}, [selected, report, palette, seam]);
|
|
570
594
|
const sexInited = useRef(false);
|
|
571
595
|
useEffect(() => {
|
|
572
596
|
if (!sexInited.current) {
|
|
@@ -585,6 +609,7 @@ function BodyMap3D(props) {
|
|
|
585
609
|
};
|
|
586
610
|
pickRef.current = (k) => {
|
|
587
611
|
const what = decidePick(k, reportRef.current, configRef.current ?? {});
|
|
612
|
+
emitFeedback(what, k, { onFeedback, haptics });
|
|
588
613
|
if (what === "clear") removePain(k);
|
|
589
614
|
else if (what === "select") setSelected(k);
|
|
590
615
|
return what;
|