@broberg/bodymap 0.2.8 → 0.4.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 +75 -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 +69 -8
- package/dist/three.cjs.map +1 -1
- package/dist/three.d.cts +59 -1
- package/dist/three.d.ts +59 -1
- package/dist/three.js +69 -9
- 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. */
|
|
@@ -138,6 +172,21 @@ interface BodyMap3DUiLabels {
|
|
|
138
172
|
female: string;
|
|
139
173
|
hoverHint: string;
|
|
140
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* How much of the RUNNER-UP region bleeds into this vertex (F052.21).
|
|
177
|
+
*
|
|
178
|
+
* Takes SQUARED distances — what the assignment loop already has.
|
|
179
|
+
*
|
|
180
|
+
* ratio = d1 / (d1 + d2) → 0 at the anchor · 0.5 where two anchors are equidistant
|
|
181
|
+
*
|
|
182
|
+
* Returns 0 well inside a region and rises to 0.5 exactly at the seam, so the
|
|
183
|
+
* nearest region ALWAYS dominates and a vertex can never be painted mostly as
|
|
184
|
+
* its neighbour. Smoothstepped, so the fade has no visible start line of its own.
|
|
185
|
+
*
|
|
186
|
+
* This is a look, not a contract: hit-testing never sees this number, and the
|
|
187
|
+
* PainReport is unchanged.
|
|
188
|
+
*/
|
|
189
|
+
declare function seamBlend(nearestSq: number, secondSq: number): number;
|
|
141
190
|
interface BodyMap3DProps {
|
|
142
191
|
/** URLs of the male/female body GLBs (you host them; reference GLBs ship under `@broberg/bodymap/models/`). */
|
|
143
192
|
models: BodyMap3DModels;
|
|
@@ -167,8 +216,17 @@ interface BodyMap3DProps {
|
|
|
167
216
|
* (default true). Set false for a patient/employee-facing flow where the code is
|
|
168
217
|
* internal jargon and the readable region name is enough. */
|
|
169
218
|
showRegionCode?: boolean;
|
|
219
|
+
/** Fired after every pick with what ACTUALLY happened (F052.22): "select",
|
|
220
|
+
* "clear" or "ignore". Wire it to a sound (`@broberg/soundkit`) or to native
|
|
221
|
+
* haptics (Capacitor `Haptics.impact()` — the only route to a real buzz on an
|
|
222
|
+
* iPhone, where web vibration does not exist). */
|
|
223
|
+
onFeedback?: FeedbackFn;
|
|
224
|
+
/** Web vibration on select/clear. Default true; silently inert where
|
|
225
|
+
* `navigator.vibrate` is absent (every browser on iPhone, most desktops).
|
|
226
|
+
* Set false to keep the signal but drop the buzz. */
|
|
227
|
+
haptics?: boolean;
|
|
170
228
|
className?: string;
|
|
171
229
|
}
|
|
172
230
|
declare function BodyMap3D(props: BodyMap3DProps): react.JSX.Element;
|
|
173
231
|
|
|
174
|
-
export { BodyMap3D, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, serializeReport };
|
|
232
|
+
export { BodyMap3D, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, seamBlend, serializeReport };
|
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. */
|
|
@@ -138,6 +172,21 @@ interface BodyMap3DUiLabels {
|
|
|
138
172
|
female: string;
|
|
139
173
|
hoverHint: string;
|
|
140
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* How much of the RUNNER-UP region bleeds into this vertex (F052.21).
|
|
177
|
+
*
|
|
178
|
+
* Takes SQUARED distances — what the assignment loop already has.
|
|
179
|
+
*
|
|
180
|
+
* ratio = d1 / (d1 + d2) → 0 at the anchor · 0.5 where two anchors are equidistant
|
|
181
|
+
*
|
|
182
|
+
* Returns 0 well inside a region and rises to 0.5 exactly at the seam, so the
|
|
183
|
+
* nearest region ALWAYS dominates and a vertex can never be painted mostly as
|
|
184
|
+
* its neighbour. Smoothstepped, so the fade has no visible start line of its own.
|
|
185
|
+
*
|
|
186
|
+
* This is a look, not a contract: hit-testing never sees this number, and the
|
|
187
|
+
* PainReport is unchanged.
|
|
188
|
+
*/
|
|
189
|
+
declare function seamBlend(nearestSq: number, secondSq: number): number;
|
|
141
190
|
interface BodyMap3DProps {
|
|
142
191
|
/** URLs of the male/female body GLBs (you host them; reference GLBs ship under `@broberg/bodymap/models/`). */
|
|
143
192
|
models: BodyMap3DModels;
|
|
@@ -167,8 +216,17 @@ interface BodyMap3DProps {
|
|
|
167
216
|
* (default true). Set false for a patient/employee-facing flow where the code is
|
|
168
217
|
* internal jargon and the readable region name is enough. */
|
|
169
218
|
showRegionCode?: boolean;
|
|
219
|
+
/** Fired after every pick with what ACTUALLY happened (F052.22): "select",
|
|
220
|
+
* "clear" or "ignore". Wire it to a sound (`@broberg/soundkit`) or to native
|
|
221
|
+
* haptics (Capacitor `Haptics.impact()` — the only route to a real buzz on an
|
|
222
|
+
* iPhone, where web vibration does not exist). */
|
|
223
|
+
onFeedback?: FeedbackFn;
|
|
224
|
+
/** Web vibration on select/clear. Default true; silently inert where
|
|
225
|
+
* `navigator.vibrate` is absent (every browser on iPhone, most desktops).
|
|
226
|
+
* Set false to keep the signal but drop the buzz. */
|
|
227
|
+
haptics?: boolean;
|
|
170
228
|
className?: string;
|
|
171
229
|
}
|
|
172
230
|
declare function BodyMap3D(props: BodyMap3DProps): react.JSX.Element;
|
|
173
231
|
|
|
174
|
-
export { BodyMap3D, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, serializeReport };
|
|
232
|
+
export { BodyMap3D, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, seamBlend, serializeReport };
|
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",
|
|
@@ -214,6 +233,17 @@ var ANCHORS = {
|
|
|
214
233
|
foot_left: [-0.1, 0.02, 0.11],
|
|
215
234
|
foot_right: [0.1, 0.02, 0.11]
|
|
216
235
|
};
|
|
236
|
+
var SEAM = 0.18;
|
|
237
|
+
function seamBlend(nearestSq, secondSq) {
|
|
238
|
+
const d1 = Math.sqrt(nearestSq), d2 = Math.sqrt(secondSq);
|
|
239
|
+
const sum = d1 + d2;
|
|
240
|
+
if (!(sum > 0)) return 0;
|
|
241
|
+
const ratio = d1 / sum;
|
|
242
|
+
const t = (ratio - (0.5 - SEAM)) / SEAM;
|
|
243
|
+
if (t <= 0) return 0;
|
|
244
|
+
if (t >= 1) return 0.5;
|
|
245
|
+
return t * t * (3 - 2 * t) * 0.5;
|
|
246
|
+
}
|
|
217
247
|
var ANCHOR_KEYS = Object.keys(ANCHORS);
|
|
218
248
|
for (const k of ANCHOR_KEYS) ANCHORS[k][0] = -ANCHORS[k][0];
|
|
219
249
|
function mergeLabels(locale, overrides) {
|
|
@@ -254,6 +284,8 @@ function BodyMap3D(props) {
|
|
|
254
284
|
autoRotate = true,
|
|
255
285
|
canvasHeight = "60vh",
|
|
256
286
|
showRegionCode = true,
|
|
287
|
+
onFeedback,
|
|
288
|
+
haptics,
|
|
257
289
|
className
|
|
258
290
|
} = props;
|
|
259
291
|
const chrome = uiColors(palette);
|
|
@@ -356,6 +388,8 @@ function BodyMap3D(props) {
|
|
|
356
388
|
let modelRoot = null;
|
|
357
389
|
let bodyMesh = null;
|
|
358
390
|
let vertexRegion = [];
|
|
391
|
+
let vertexNeighbour = [];
|
|
392
|
+
let vertexBlend = new Float32Array(0);
|
|
359
393
|
let colorAttr = null;
|
|
360
394
|
let hovered = null;
|
|
361
395
|
const loader = new GLTFLoader();
|
|
@@ -367,14 +401,32 @@ function BodyMap3D(props) {
|
|
|
367
401
|
if (selectedRef.current === key) return paletteRef.current.selected;
|
|
368
402
|
return baseColorFor(key, paletteRef.current);
|
|
369
403
|
};
|
|
370
|
-
const
|
|
404
|
+
const tmp2 = new THREE.Color();
|
|
405
|
+
const colourOf = (key) => hovered === key ? paletteRef.current.hover : restingHex(key);
|
|
406
|
+
const paint = () => {
|
|
371
407
|
if (!colorAttr) return;
|
|
372
|
-
|
|
373
|
-
|
|
408
|
+
const cache = /* @__PURE__ */ new Map();
|
|
409
|
+
const col = (k) => {
|
|
410
|
+
let c = cache.get(k);
|
|
411
|
+
if (!c) {
|
|
412
|
+
c = new THREE.Color(colourOf(k));
|
|
413
|
+
cache.set(k, c);
|
|
414
|
+
}
|
|
415
|
+
return c;
|
|
416
|
+
};
|
|
417
|
+
for (let i = 0; i < vertexRegion.length; i++) {
|
|
418
|
+
tmp.copy(col(vertexRegion[i]));
|
|
419
|
+
const w = vertexBlend[i];
|
|
420
|
+
if (w > 0) {
|
|
421
|
+
tmp2.copy(col(vertexNeighbour[i]));
|
|
422
|
+
tmp.lerp(tmp2, w);
|
|
423
|
+
}
|
|
424
|
+
colorAttr.setXYZ(i, tmp.r, tmp.g, tmp.b);
|
|
425
|
+
}
|
|
374
426
|
colorAttr.needsUpdate = true;
|
|
375
427
|
};
|
|
376
428
|
const refresh = () => {
|
|
377
|
-
|
|
429
|
+
paint();
|
|
378
430
|
renderFrame();
|
|
379
431
|
};
|
|
380
432
|
const loadModel = (which) => {
|
|
@@ -403,18 +455,27 @@ function BodyMap3D(props) {
|
|
|
403
455
|
const n = pos.count;
|
|
404
456
|
const cols = new Float32Array(n * 3);
|
|
405
457
|
vertexRegion = new Array(n);
|
|
458
|
+
vertexNeighbour = new Array(n);
|
|
459
|
+
vertexBlend = new Float32Array(n);
|
|
406
460
|
const v = new THREE.Vector3();
|
|
407
461
|
for (let i = 0; i < n; i++) {
|
|
408
462
|
v.fromBufferAttribute(pos, i).applyMatrix4(bodyMesh.matrixWorld);
|
|
409
|
-
let best = 0, bd = Infinity;
|
|
463
|
+
let best = 0, second = 0, bd = Infinity, sd = Infinity;
|
|
410
464
|
for (let a = 0; a < anchorVecs.length; a++) {
|
|
411
465
|
const d = v.distanceToSquared(anchorVecs[a]);
|
|
412
466
|
if (d < bd) {
|
|
467
|
+
sd = bd;
|
|
468
|
+
second = best;
|
|
413
469
|
bd = d;
|
|
414
470
|
best = a;
|
|
471
|
+
} else if (d < sd) {
|
|
472
|
+
sd = d;
|
|
473
|
+
second = a;
|
|
415
474
|
}
|
|
416
475
|
}
|
|
417
476
|
vertexRegion[i] = ANCHOR_KEYS[best];
|
|
477
|
+
vertexNeighbour[i] = ANCHOR_KEYS[second];
|
|
478
|
+
vertexBlend[i] = seamBlend(bd, sd);
|
|
418
479
|
}
|
|
419
480
|
colorAttr = new THREE.BufferAttribute(cols, 3);
|
|
420
481
|
geo.setAttribute("color", colorAttr);
|
|
@@ -474,10 +535,8 @@ function BodyMap3D(props) {
|
|
|
474
535
|
if ((e.buttons || 0) !== 0) return;
|
|
475
536
|
const k = pick(e.clientX, e.clientY);
|
|
476
537
|
if (k === hovered) return;
|
|
477
|
-
const prev = hovered;
|
|
478
538
|
hovered = k;
|
|
479
|
-
|
|
480
|
-
if (k) colorRegion(k, paletteRef.current.hover);
|
|
539
|
+
paint();
|
|
481
540
|
canvas.style.cursor = k ? "pointer" : "default";
|
|
482
541
|
renderFrame();
|
|
483
542
|
};
|
|
@@ -547,6 +606,7 @@ function BodyMap3D(props) {
|
|
|
547
606
|
};
|
|
548
607
|
pickRef.current = (k) => {
|
|
549
608
|
const what = decidePick(k, reportRef.current, configRef.current ?? {});
|
|
609
|
+
emitFeedback(what, k, { onFeedback, haptics });
|
|
550
610
|
if (what === "clear") removePain(k);
|
|
551
611
|
else if (what === "select") setSelected(k);
|
|
552
612
|
return what;
|
|
@@ -586,6 +646,6 @@ function BodyMap3D(props) {
|
|
|
586
646
|
] });
|
|
587
647
|
}
|
|
588
648
|
|
|
589
|
-
export { BodyMap3D, serializeReport };
|
|
649
|
+
export { BodyMap3D, seamBlend, serializeReport };
|
|
590
650
|
//# sourceMappingURL=three.js.map
|
|
591
651
|
//# sourceMappingURL=three.js.map
|