@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/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 colorRegion = (key, hex) => {
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
- tmp.set(hex);
373
- for (let i = 0; i < vertexRegion.length; i++) if (vertexRegion[i] === key) colorAttr.setXYZ(i, tmp.r, tmp.g, tmp.b);
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
- for (const k of ANCHOR_KEYS) colorRegion(k, hovered === k ? paletteRef.current.hover : restingHex(k));
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
- if (prev) colorRegion(prev, restingHex(prev));
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