@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/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;