@broberg/bodymap 0.3.0 → 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. */
@@ -182,6 +216,15 @@ 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
+ /** 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;
185
228
  className?: string;
186
229
  }
187
230
  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,15 @@ 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
+ /** 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;
185
228
  className?: string;
186
229
  }
187
230
  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,8 @@ function BodyMap3D(props) {
265
284
  autoRotate = true,
266
285
  canvasHeight = "60vh",
267
286
  showRegionCode = true,
287
+ onFeedback,
288
+ haptics,
268
289
  className
269
290
  } = props;
270
291
  const chrome = uiColors(palette);
@@ -585,6 +606,7 @@ function BodyMap3D(props) {
585
606
  };
586
607
  pickRef.current = (k) => {
587
608
  const what = decidePick(k, reportRef.current, configRef.current ?? {});
609
+ emitFeedback(what, k, { onFeedback, haptics });
588
610
  if (what === "clear") removePain(k);
589
611
  else if (what === "select") setSelected(k);
590
612
  return what;