@broberg/bodymap 0.2.5 → 0.2.8

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/index.cjs CHANGED
@@ -73,6 +73,10 @@ function createPainSelection(initial = [], opts = {}) {
73
73
  function resolveRegions(config = {}) {
74
74
  return REGIONS.filter((r) => config[r.key]?.visible ?? true);
75
75
  }
76
+ function decidePick(region, report, config = {}) {
77
+ if (!isSelectable(region, config)) return "ignore";
78
+ return report.some((p) => p.region === region) ? "clear" : "select";
79
+ }
76
80
  function isSelectable(key, config = {}) {
77
81
  const s = config[key];
78
82
  if (s?.visible === false) return false;
@@ -155,6 +159,7 @@ exports.REGION_KEYS = REGION_KEYS;
155
159
  exports.baseColorFor = baseColorFor;
156
160
  exports.bodymapReportV1Schema = bodymapReportV1Schema;
157
161
  exports.createPainSelection = createPainSelection;
162
+ exports.decidePick = decidePick;
158
163
  exports.defaultPalette = defaultPalette;
159
164
  exports.defaultUi = defaultUi;
160
165
  exports.deserializeReport = deserializeReport;
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":["z"],"mappings":"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","file":"index.cjs","sourcesContent":["// @broberg/bodymap — headless core (F052.1).\n//\n// Framework-neutral: the region taxonomy + the PainReport data model (zod) + a\n// selection engine + per-app region config. NO React/Preact/DOM import — the 2D\n// (SVG) and 3D (Three.js) renderers, and all three FD apps, share this one\n// contract. Output is a structured PainReport, never a bare image.\n\nimport { z } from \"zod\";\n\nexport type Side = \"left\" | \"right\";\n\nexport interface BodyRegion {\n /** Stable, unique identifier (snake_case) — the key used in a PainReport. */\n key: string;\n /** Human label (Danish). */\n label: string;\n /** Clinical short code (unique). */\n code: string;\n /** Body side, when the region is paired. */\n side?: Side;\n}\n\n/** The canonical body regions — the AUTHORITATIVE fd-sundhed clinical taxonomy\n * (docs/BODYMAP-TAKSONOMI.md, broberg-ai/fd-sundhed @360842f): 18 SIDE-LESS\n * clinical codes + a separate `side` field (L/R on limbs, C=center on axis\n * regions). The `key` is a unique per-side identifier; `code` is the side-less\n * clinical code that goes on the bodymap/v1 wire. NOT an anatomical atlas —\n * ~30 named surface regions for a pain-map. The 2D front renderer draws the\n * front-visible subset; the 3D body (F052.6) drives them all. */\nexport const REGIONS: readonly BodyRegion[] = [\n // axis / centre-line (serialised side \"center\")\n { key: \"head\", label: \"Hoved\", code: \"HEAD\" },\n { key: \"neck\", label: \"Nakke\", code: \"NECK\" },\n { key: \"chest\", label: \"Bryst\", code: \"CHEST\" },\n { key: \"thora\", label: \"Øvre ryg (thorakal)\", code: \"THORA\" },\n { key: \"lumbar\", label: \"Lænd (lumbal)\", code: \"LUMBAR\" },\n { key: \"groin\", label: \"Lyske\", code: \"GROIN\" },\n // paired limbs / sides (L / R)\n { key: \"shoulder_left\", label: \"Skulder, venstre\", code: \"SHOULDER\", side: \"left\" },\n { key: \"shoulder_right\", label: \"Skulder, højre\", code: \"SHOULDER\", side: \"right\" },\n { key: \"uarm_left\", label: \"Overarm, venstre\", code: \"UARM\", side: \"left\" },\n { key: \"uarm_right\", label: \"Overarm, højre\", code: \"UARM\", side: \"right\" },\n { key: \"elbow_left\", label: \"Albue, venstre\", code: \"ELBOW\", side: \"left\" },\n { key: \"elbow_right\", label: \"Albue, højre\", code: \"ELBOW\", side: \"right\" },\n { key: \"farm_left\", label: \"Underarm, venstre\", code: \"FARM\", side: \"left\" },\n { key: \"farm_right\", label: \"Underarm, højre\", code: \"FARM\", side: \"right\" },\n { key: \"wrist_left\", label: \"Håndled, venstre\", code: \"WRIST\", side: \"left\" },\n { key: \"wrist_right\", label: \"Håndled, højre\", code: \"WRIST\", side: \"right\" },\n { key: \"hand_left\", label: \"Hånd, venstre\", code: \"HAND\", side: \"left\" },\n { key: \"hand_right\", label: \"Hånd, højre\", code: \"HAND\", side: \"right\" },\n { key: \"hip_left\", label: \"Hofte, venstre\", code: \"HIP\", side: \"left\" },\n { key: \"hip_right\", label: \"Hofte, højre\", code: \"HIP\", side: \"right\" },\n { key: \"thigh_left\", label: \"Lår, venstre\", code: \"THIGH\", side: \"left\" },\n { key: \"thigh_right\", label: \"Lår, højre\", code: \"THIGH\", side: \"right\" },\n { key: \"knee_left\", label: \"Knæ, venstre\", code: \"KNEE\", side: \"left\" },\n { key: \"knee_right\", label: \"Knæ, højre\", code: \"KNEE\", side: \"right\" },\n { key: \"lowleg_left\", label: \"Underben, venstre\", code: \"LOWLEG\", side: \"left\" },\n { key: \"lowleg_right\", label: \"Underben, højre\", code: \"LOWLEG\", side: \"right\" },\n { key: \"ankle_left\", label: \"Ankel, venstre\", code: \"ANKLE\", side: \"left\" },\n { key: \"ankle_right\", label: \"Ankel, højre\", code: \"ANKLE\", side: \"right\" },\n { key: \"foot_left\", label: \"Fod, venstre\", code: \"FOOT\", side: \"left\" },\n { key: \"foot_right\", label: \"Fod, højre\", code: \"FOOT\", side: \"right\" },\n];\n\nconst REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));\nexport const REGION_KEYS: readonly string[] = REGIONS.map((r) => r.key);\n\n/** Look up a region by its key. */\nexport function getRegion(key: string): BodyRegion | undefined {\n return REGIONS.find((r) => r.key === key);\n}\n\n// ---- PainReport model ---------------------------------------------------\n\nexport const PAIN_TYPES = [\"stikkende\", \"dump\", \"konstant\", \"jagende\"] as const;\nexport type PainType = (typeof PAIN_TYPES)[number];\n\n/** One marked pain point. `region` MUST be a known region key; `intensity` is a\n * 0-10 integer; `type` is optional but constrained; `timestamp` is an ISO string. */\nexport const painPointSchema = z.object({\n region: z.string().refine((k) => REGION_KEY_SET.has(k), { message: \"unknown region\" }),\n intensity: z.number().int().min(0).max(10),\n type: z.enum(PAIN_TYPES).optional(),\n timestamp: z.string(),\n});\nexport type PainPoint = z.infer<typeof painPointSchema>;\n\nexport const painReportSchema = z.array(painPointSchema);\nexport type PainReport = PainPoint[];\n\n// ---- Selection engine (framework-agnostic) ------------------------------\n\nexport interface PainSelection {\n /** Mark (or update) pain on a region. One point per region — latest wins. */\n set(region: string, intensity: number, type?: PainType): PainPoint;\n remove(region: string): boolean;\n get(region: string): PainPoint | undefined;\n has(region: string): boolean;\n clear(): void;\n /** The current, validated PainReport. */\n getReport(): PainReport;\n}\n\nexport interface PainSelectionOptions {\n /** Injectable clock (ISO string) — defaults to `new Date().toISOString()`. */\n now?: () => string;\n}\n\n/** Create a selection engine seeded with an optional report. Pure state — no\n * DOM, no framework, no network. One point per region. */\nexport function createPainSelection(\n initial: PainReport = [],\n opts: PainSelectionOptions = {},\n): PainSelection {\n const now = opts.now ?? (() => new Date().toISOString());\n const map = new Map<string, PainPoint>();\n for (const p of initial) {\n const v = painPointSchema.parse(p);\n map.set(v.region, v);\n }\n return {\n set(region, intensity, type) {\n const point = painPointSchema.parse({ region, intensity, type, timestamp: now() });\n map.set(point.region, point);\n return point;\n },\n remove: (region) => map.delete(region),\n get: (region) => map.get(region),\n has: (region) => map.has(region),\n clear: () => map.clear(),\n getReport: () => painReportSchema.parse(Array.from(map.values())),\n };\n}\n\n// ---- Per-app region config (the toggle) ---------------------------------\n\nexport interface RegionSetting {\n /** Render this region at all. Default true. */\n visible?: boolean;\n /** Allow marking pain on this region. Default true. */\n selectable?: boolean;\n}\n\n/** Per-app config keyed by region key. An absent key ⇒ visible + selectable. */\nexport type RegionConfig = Record<string, RegionSetting>;\n\n/** The regions an app should render, honouring `visible` (default true). */\nexport function resolveRegions(config: RegionConfig = {}): BodyRegion[] {\n return REGIONS.filter((r) => config[r.key]?.visible ?? true);\n}\n\n/** Whether a region may be marked. A hidden region is never selectable. */\nexport function isSelectable(key: string, config: RegionConfig = {}): boolean {\n const s = config[key];\n if (s?.visible === false) return false;\n return s?.selectable ?? true;\n}\n\n// ---- palette (consumer-defined colours — shared by the 2D + 3D renderers) ---\n\n/** Colour control for the body renderers. Consumers pass a palette to theme the\n * body base colour, the hover + selected highlights, the pain-heat colours, and\n * optional per-region base colours. All values are CSS/hex colour strings. */\nexport interface BodymapPalette {\n /** Base body colour (an unmarked region). */\n body: string;\n /** Region highlight on hover (before click). */\n hover: string;\n /** A region selected (clicked) but not yet given an intensity. */\n selected: string;\n /** Pain-intensity heat colours: low (0-3), mid (4-6), high (7-10). */\n heat: { low: string; mid: string; high: string };\n /** Optional per-region base-colour overrides (region key → colour). */\n regions?: Record<string, string>;\n /**\n * Optional panel-chrome colours (the selection panel, labels, hint box) —\n * NOT the body itself. All optional; anything omitted falls back to\n * {@link defaultUi}. A palette that only themed the body was half a palette:\n * a consumer passing brand colours still got hardcoded chrome. (F052.19)\n */\n ui?: BodymapUiColors;\n}\n\n/** Panel-chrome colours. Every default is WCAG-AA (>=4.5:1) on its own background. */\nexport interface BodymapUiColors {\n /** Primary text (headings, values). */\n text?: string;\n /** Secondary text — section labels, the empty-state hint. */\n mutedText?: string;\n /** Panel background. */\n panelBg?: string;\n /** Panel + control borders. */\n border?: string;\n /** Background behind the region-code badge. */\n badgeBg?: string;\n /** The destructive action (remove a marked region). */\n danger?: string;\n}\n\n/**\n * Default panel chrome. Contrast against `panelBg` (#fff), asserted by\n * `test/contrast.test.ts`:\n * text #1e293b 14.8:1\n * mutedText #475569 7.6:1 (was #94a3b8 at 2.56:1 — WCAG AA failure)\n * danger #dc2626 4.8:1 (was #ef4444 at 3.76:1 — WCAG AA failure)\n * `mutedText` on `badgeBg` (#f1f5f9) is 6.9:1 (was #64748b at 4.34:1).\n */\nexport const defaultUi: Required<BodymapUiColors> = {\n text: \"#1e293b\",\n mutedText: \"#475569\",\n panelBg: \"#fff\",\n border: \"#e2e8f0\",\n badgeBg: \"#f1f5f9\",\n danger: \"#dc2626\",\n};\n\n/** Resolve a palette's chrome colours, filling every gap from {@link defaultUi}. */\nexport function uiColors(palette?: BodymapPalette): Required<BodymapUiColors> {\n return { ...defaultUi, ...(palette?.ui ?? {}) };\n}\n\n/** The fleet default palette. Override any field per consumer. */\nexport const defaultPalette: BodymapPalette = {\n body: \"#d2d7de\",\n hover: \"#8fd0cd\",\n selected: \"#5cc4b7\",\n heat: { low: \"#fcd34d\", mid: \"#fb923c\", high: \"#ef4444\" },\n};\n\n/** The heat colour for a pain intensity, honouring the palette. */\nexport function heatFor(intensity: number, palette: BodymapPalette = defaultPalette): string {\n return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;\n}\n\n/** The base colour for a region (a per-region override, else the body colour). */\nexport function baseColorFor(regionKey: string, palette: BodymapPalette = defaultPalette): string {\n return palette.regions?.[regionKey] ?? palette.body;\n}\n\n// ---- bodymap/v1 serialization (the shared cross-app / native wire format) ---\n//\n// The shape every consumer + the native mobile apps read (aligned with\n// fd-sundhed's bodymap/v1: region CODE + side + intensity + quality + view).\n// The internal PainReport keys on the region KEY; this maps key -> clinical CODE\n// so the report is portable and human-readable on the wire.\n\nexport type BodyView = \"front\" | \"back\" | \"left\" | \"right\";\n/** Side in the serialized report — a midline region (no side) becomes \"center\". */\nexport type SerializedSide = \"left\" | \"right\" | \"center\";\n\nexport interface SerializedPainPoint {\n /** Clinical region CODE (e.g. \"LUMB\"). */\n region: string;\n side: SerializedSide;\n intensity: number;\n quality?: PainType;\n}\n\nexport interface BodymapReportV1 {\n schema: \"bodymap/v1\";\n view: BodyView;\n points: SerializedPainPoint[];\n}\n\nexport const bodymapReportV1Schema = z.object({\n schema: z.literal(\"bodymap/v1\"),\n view: z.enum([\"front\", \"back\", \"left\", \"right\"]),\n points: z.array(\n z.object({\n region: z.string(),\n side: z.enum([\"left\", \"right\", \"center\"]),\n intensity: z.number().int().min(0).max(10),\n quality: z.enum(PAIN_TYPES).optional(),\n }),\n ),\n});\n\n/** Serialize a PainReport to the shared `bodymap/v1` wire format. */\nexport function serializeReport(\n report: PainReport,\n opts: { view?: BodyView } = {},\n): BodymapReportV1 {\n return {\n schema: \"bodymap/v1\",\n view: opts.view ?? \"front\",\n points: report.map((p) => {\n const r = getRegion(p.region);\n return {\n region: r?.code ?? p.region,\n side: (r?.side ?? \"center\") as SerializedSide,\n intensity: p.intensity,\n quality: p.type,\n };\n }),\n };\n}\n\n/** Parse a `bodymap/v1` report back into an internal PainReport. Region CODE →\n * key; a point whose code is unknown to this taxonomy is dropped. */\nexport function deserializeReport(\n env: unknown,\n now: () => string = () => new Date().toISOString(),\n): PainReport {\n const parsed = bodymapReportV1Schema.parse(env);\n // code is side-less, so a point is identified by code + side.\n const byCodeSide = new Map(\n REGIONS.map((r) => [`${r.code}|${r.side ?? \"center\"}`, r.key] as const),\n );\n const out: PainReport = [];\n for (const sp of parsed.points) {\n const key = byCodeSide.get(`${sp.region}|${sp.side}`);\n if (!key) continue;\n out.push(\n painPointSchema.parse({\n region: key,\n intensity: sp.intensity,\n type: sp.quality,\n timestamp: now(),\n }),\n );\n }\n return out;\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":["z"],"mappings":"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","file":"index.cjs","sourcesContent":["// @broberg/bodymap — headless core (F052.1).\n//\n// Framework-neutral: the region taxonomy + the PainReport data model (zod) + a\n// selection engine + per-app region config. NO React/Preact/DOM import — the 2D\n// (SVG) and 3D (Three.js) renderers, and all three FD apps, share this one\n// contract. Output is a structured PainReport, never a bare image.\n\nimport { z } from \"zod\";\n\nexport type Side = \"left\" | \"right\";\n\nexport interface BodyRegion {\n /** Stable, unique identifier (snake_case) — the key used in a PainReport. */\n key: string;\n /** Human label (Danish). */\n label: string;\n /** Clinical short code (unique). */\n code: string;\n /** Body side, when the region is paired. */\n side?: Side;\n}\n\n/** The canonical body regions — the AUTHORITATIVE fd-sundhed clinical taxonomy\n * (docs/BODYMAP-TAKSONOMI.md, broberg-ai/fd-sundhed @360842f): 18 SIDE-LESS\n * clinical codes + a separate `side` field (L/R on limbs, C=center on axis\n * regions). The `key` is a unique per-side identifier; `code` is the side-less\n * clinical code that goes on the bodymap/v1 wire. NOT an anatomical atlas —\n * ~30 named surface regions for a pain-map. The 2D front renderer draws the\n * front-visible subset; the 3D body (F052.6) drives them all. */\nexport const REGIONS: readonly BodyRegion[] = [\n // axis / centre-line (serialised side \"center\")\n { key: \"head\", label: \"Hoved\", code: \"HEAD\" },\n { key: \"neck\", label: \"Nakke\", code: \"NECK\" },\n { key: \"chest\", label: \"Bryst\", code: \"CHEST\" },\n { key: \"thora\", label: \"Øvre ryg (thorakal)\", code: \"THORA\" },\n { key: \"lumbar\", label: \"Lænd (lumbal)\", code: \"LUMBAR\" },\n { key: \"groin\", label: \"Lyske\", code: \"GROIN\" },\n // paired limbs / sides (L / R)\n { key: \"shoulder_left\", label: \"Skulder, venstre\", code: \"SHOULDER\", side: \"left\" },\n { key: \"shoulder_right\", label: \"Skulder, højre\", code: \"SHOULDER\", side: \"right\" },\n { key: \"uarm_left\", label: \"Overarm, venstre\", code: \"UARM\", side: \"left\" },\n { key: \"uarm_right\", label: \"Overarm, højre\", code: \"UARM\", side: \"right\" },\n { key: \"elbow_left\", label: \"Albue, venstre\", code: \"ELBOW\", side: \"left\" },\n { key: \"elbow_right\", label: \"Albue, højre\", code: \"ELBOW\", side: \"right\" },\n { key: \"farm_left\", label: \"Underarm, venstre\", code: \"FARM\", side: \"left\" },\n { key: \"farm_right\", label: \"Underarm, højre\", code: \"FARM\", side: \"right\" },\n { key: \"wrist_left\", label: \"Håndled, venstre\", code: \"WRIST\", side: \"left\" },\n { key: \"wrist_right\", label: \"Håndled, højre\", code: \"WRIST\", side: \"right\" },\n { key: \"hand_left\", label: \"Hånd, venstre\", code: \"HAND\", side: \"left\" },\n { key: \"hand_right\", label: \"Hånd, højre\", code: \"HAND\", side: \"right\" },\n { key: \"hip_left\", label: \"Hofte, venstre\", code: \"HIP\", side: \"left\" },\n { key: \"hip_right\", label: \"Hofte, højre\", code: \"HIP\", side: \"right\" },\n { key: \"thigh_left\", label: \"Lår, venstre\", code: \"THIGH\", side: \"left\" },\n { key: \"thigh_right\", label: \"Lår, højre\", code: \"THIGH\", side: \"right\" },\n { key: \"knee_left\", label: \"Knæ, venstre\", code: \"KNEE\", side: \"left\" },\n { key: \"knee_right\", label: \"Knæ, højre\", code: \"KNEE\", side: \"right\" },\n { key: \"lowleg_left\", label: \"Underben, venstre\", code: \"LOWLEG\", side: \"left\" },\n { key: \"lowleg_right\", label: \"Underben, højre\", code: \"LOWLEG\", side: \"right\" },\n { key: \"ankle_left\", label: \"Ankel, venstre\", code: \"ANKLE\", side: \"left\" },\n { key: \"ankle_right\", label: \"Ankel, højre\", code: \"ANKLE\", side: \"right\" },\n { key: \"foot_left\", label: \"Fod, venstre\", code: \"FOOT\", side: \"left\" },\n { key: \"foot_right\", label: \"Fod, højre\", code: \"FOOT\", side: \"right\" },\n];\n\nconst REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));\nexport const REGION_KEYS: readonly string[] = REGIONS.map((r) => r.key);\n\n/** Look up a region by its key. */\nexport function getRegion(key: string): BodyRegion | undefined {\n return REGIONS.find((r) => r.key === key);\n}\n\n// ---- PainReport model ---------------------------------------------------\n\nexport const PAIN_TYPES = [\"stikkende\", \"dump\", \"konstant\", \"jagende\"] as const;\nexport type PainType = (typeof PAIN_TYPES)[number];\n\n/** One marked pain point. `region` MUST be a known region key; `intensity` is a\n * 0-10 integer; `type` is optional but constrained; `timestamp` is an ISO string. */\nexport const painPointSchema = z.object({\n region: z.string().refine((k) => REGION_KEY_SET.has(k), { message: \"unknown region\" }),\n intensity: z.number().int().min(0).max(10),\n type: z.enum(PAIN_TYPES).optional(),\n timestamp: z.string(),\n});\nexport type PainPoint = z.infer<typeof painPointSchema>;\n\nexport const painReportSchema = z.array(painPointSchema);\nexport type PainReport = PainPoint[];\n\n// ---- Selection engine (framework-agnostic) ------------------------------\n\nexport interface PainSelection {\n /** Mark (or update) pain on a region. One point per region — latest wins. */\n set(region: string, intensity: number, type?: PainType): PainPoint;\n remove(region: string): boolean;\n get(region: string): PainPoint | undefined;\n has(region: string): boolean;\n clear(): void;\n /** The current, validated PainReport. */\n getReport(): PainReport;\n}\n\nexport interface PainSelectionOptions {\n /** Injectable clock (ISO string) — defaults to `new Date().toISOString()`. */\n now?: () => string;\n}\n\n/** Create a selection engine seeded with an optional report. Pure state — no\n * DOM, no framework, no network. One point per region. */\nexport function createPainSelection(\n initial: PainReport = [],\n opts: PainSelectionOptions = {},\n): PainSelection {\n const now = opts.now ?? (() => new Date().toISOString());\n const map = new Map<string, PainPoint>();\n for (const p of initial) {\n const v = painPointSchema.parse(p);\n map.set(v.region, v);\n }\n return {\n set(region, intensity, type) {\n const point = painPointSchema.parse({ region, intensity, type, timestamp: now() });\n map.set(point.region, point);\n return point;\n },\n remove: (region) => map.delete(region),\n get: (region) => map.get(region),\n has: (region) => map.has(region),\n clear: () => map.clear(),\n getReport: () => painReportSchema.parse(Array.from(map.values())),\n };\n}\n\n// ---- Per-app region config (the toggle) ---------------------------------\n\nexport interface RegionSetting {\n /** Render this region at all. Default true. */\n visible?: boolean;\n /** Allow marking pain on this region. Default true. */\n selectable?: boolean;\n}\n\n/** Per-app config keyed by region key. An absent key ⇒ visible + selectable. */\nexport type RegionConfig = Record<string, RegionSetting>;\n\n/** The regions an app should render, honouring `visible` (default true). */\nexport function resolveRegions(config: RegionConfig = {}): BodyRegion[] {\n return REGIONS.filter((r) => config[r.key]?.visible ?? true);\n}\n\n/**\n * What a pick on a region should DO (F052.20).\n *\n * Lives in the core because the 2D and 3D renderers share no click code, and a\n * rule written twice is a rule that drifts. This repo measured the cost of that\n * twice on 2026-08-28 alone: a fix applied to one half of a pair, and a sibling\n * branch that carried the same defect with no test on it.\n *\n * \"clear\" the region is already marked → picking it again removes the mark\n * \"select\" unmarked → open it for marking\n * \"ignore\" not selectable (read-only or config) → nothing happens\n *\n * Three outcomes, not a boolean: \"nothing happened because it is locked\" and\n * \"nothing happened because we removed the mark\" must never look alike to a\n * caller.\n */\nexport type PickOutcome = \"clear\" | \"select\" | \"ignore\";\n\nexport function decidePick(\n region: string,\n report: PainReport,\n config: RegionConfig = {},\n): PickOutcome {\n if (!isSelectable(region, config)) return \"ignore\";\n return report.some((p) => p.region === region) ? \"clear\" : \"select\";\n}\n\n/** Whether a region may be marked. A hidden region is never selectable. */\nexport function isSelectable(key: string, config: RegionConfig = {}): boolean {\n const s = config[key];\n if (s?.visible === false) return false;\n return s?.selectable ?? true;\n}\n\n// ---- palette (consumer-defined colours — shared by the 2D + 3D renderers) ---\n\n/** Colour control for the body renderers. Consumers pass a palette to theme the\n * body base colour, the hover + selected highlights, the pain-heat colours, and\n * optional per-region base colours. All values are CSS/hex colour strings. */\nexport interface BodymapPalette {\n /** Base body colour (an unmarked region). */\n body: string;\n /** Region highlight on hover (before click). */\n hover: string;\n /** A region selected (clicked) but not yet given an intensity. */\n selected: string;\n /** Pain-intensity heat colours: low (0-3), mid (4-6), high (7-10). */\n heat: { low: string; mid: string; high: string };\n /** Optional per-region base-colour overrides (region key → colour). */\n regions?: Record<string, string>;\n /**\n * Optional panel-chrome colours (the selection panel, labels, hint box) —\n * NOT the body itself. All optional; anything omitted falls back to\n * {@link defaultUi}. A palette that only themed the body was half a palette:\n * a consumer passing brand colours still got hardcoded chrome. (F052.19)\n */\n ui?: BodymapUiColors;\n}\n\n/** Panel-chrome colours. Every default is WCAG-AA (>=4.5:1) on its own background. */\nexport interface BodymapUiColors {\n /** Primary text (headings, values). */\n text?: string;\n /** Secondary text — section labels, the empty-state hint. */\n mutedText?: string;\n /** Panel background. */\n panelBg?: string;\n /** Panel + control borders. */\n border?: string;\n /** Background behind the region-code badge. */\n badgeBg?: string;\n /** The destructive action (remove a marked region). */\n danger?: string;\n}\n\n/**\n * Default panel chrome. Contrast against `panelBg` (#fff), asserted by\n * `test/contrast.test.ts`:\n * text #1e293b 14.8:1\n * mutedText #475569 7.6:1 (was #94a3b8 at 2.56:1 — WCAG AA failure)\n * danger #dc2626 4.8:1 (was #ef4444 at 3.76:1 — WCAG AA failure)\n * `mutedText` on `badgeBg` (#f1f5f9) is 6.9:1 (was #64748b at 4.34:1).\n */\nexport const defaultUi: Required<BodymapUiColors> = {\n text: \"#1e293b\",\n mutedText: \"#475569\",\n panelBg: \"#fff\",\n border: \"#e2e8f0\",\n badgeBg: \"#f1f5f9\",\n danger: \"#dc2626\",\n};\n\n/** Resolve a palette's chrome colours, filling every gap from {@link defaultUi}. */\nexport function uiColors(palette?: BodymapPalette): Required<BodymapUiColors> {\n return { ...defaultUi, ...(palette?.ui ?? {}) };\n}\n\n/** The fleet default palette. Override any field per consumer. */\nexport const defaultPalette: BodymapPalette = {\n body: \"#d2d7de\",\n hover: \"#8fd0cd\",\n selected: \"#5cc4b7\",\n heat: { low: \"#fcd34d\", mid: \"#fb923c\", high: \"#ef4444\" },\n};\n\n/** The heat colour for a pain intensity, honouring the palette. */\nexport function heatFor(intensity: number, palette: BodymapPalette = defaultPalette): string {\n return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;\n}\n\n/** The base colour for a region (a per-region override, else the body colour). */\nexport function baseColorFor(regionKey: string, palette: BodymapPalette = defaultPalette): string {\n return palette.regions?.[regionKey] ?? palette.body;\n}\n\n// ---- bodymap/v1 serialization (the shared cross-app / native wire format) ---\n//\n// The shape every consumer + the native mobile apps read (aligned with\n// fd-sundhed's bodymap/v1: region CODE + side + intensity + quality + view).\n// The internal PainReport keys on the region KEY; this maps key -> clinical CODE\n// so the report is portable and human-readable on the wire.\n\nexport type BodyView = \"front\" | \"back\" | \"left\" | \"right\";\n/** Side in the serialized report — a midline region (no side) becomes \"center\". */\nexport type SerializedSide = \"left\" | \"right\" | \"center\";\n\nexport interface SerializedPainPoint {\n /** Clinical region CODE (e.g. \"LUMB\"). */\n region: string;\n side: SerializedSide;\n intensity: number;\n quality?: PainType;\n}\n\nexport interface BodymapReportV1 {\n schema: \"bodymap/v1\";\n view: BodyView;\n points: SerializedPainPoint[];\n}\n\nexport const bodymapReportV1Schema = z.object({\n schema: z.literal(\"bodymap/v1\"),\n view: z.enum([\"front\", \"back\", \"left\", \"right\"]),\n points: z.array(\n z.object({\n region: z.string(),\n side: z.enum([\"left\", \"right\", \"center\"]),\n intensity: z.number().int().min(0).max(10),\n quality: z.enum(PAIN_TYPES).optional(),\n }),\n ),\n});\n\n/** Serialize a PainReport to the shared `bodymap/v1` wire format. */\nexport function serializeReport(\n report: PainReport,\n opts: { view?: BodyView } = {},\n): BodymapReportV1 {\n return {\n schema: \"bodymap/v1\",\n view: opts.view ?? \"front\",\n points: report.map((p) => {\n const r = getRegion(p.region);\n return {\n region: r?.code ?? p.region,\n side: (r?.side ?? \"center\") as SerializedSide,\n intensity: p.intensity,\n quality: p.type,\n };\n }),\n };\n}\n\n/** Parse a `bodymap/v1` report back into an internal PainReport. Region CODE →\n * key; a point whose code is unknown to this taxonomy is dropped. */\nexport function deserializeReport(\n env: unknown,\n now: () => string = () => new Date().toISOString(),\n): PainReport {\n const parsed = bodymapReportV1Schema.parse(env);\n // code is side-less, so a point is identified by code + side.\n const byCodeSide = new Map(\n REGIONS.map((r) => [`${r.code}|${r.side ?? \"center\"}`, r.key] as const),\n );\n const out: PainReport = [];\n for (const sp of parsed.points) {\n const key = byCodeSide.get(`${sp.region}|${sp.side}`);\n if (!key) continue;\n out.push(\n painPointSchema.parse({\n region: key,\n intensity: sp.intensity,\n type: sp.quality,\n timestamp: now(),\n }),\n );\n }\n return out;\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -87,6 +87,24 @@ interface RegionSetting {
87
87
  type RegionConfig = Record<string, RegionSetting>;
88
88
  /** The regions an app should render, honouring `visible` (default true). */
89
89
  declare function resolveRegions(config?: RegionConfig): BodyRegion[];
90
+ /**
91
+ * What a pick on a region should DO (F052.20).
92
+ *
93
+ * Lives in the core because the 2D and 3D renderers share no click code, and a
94
+ * rule written twice is a rule that drifts. This repo measured the cost of that
95
+ * twice on 2026-08-28 alone: a fix applied to one half of a pair, and a sibling
96
+ * branch that carried the same defect with no test on it.
97
+ *
98
+ * "clear" the region is already marked → picking it again removes the mark
99
+ * "select" unmarked → open it for marking
100
+ * "ignore" not selectable (read-only or config) → nothing happens
101
+ *
102
+ * Three outcomes, not a boolean: "nothing happened because it is locked" and
103
+ * "nothing happened because we removed the mark" must never look alike to a
104
+ * caller.
105
+ */
106
+ type PickOutcome = "clear" | "select" | "ignore";
107
+ declare function decidePick(region: string, report: PainReport, config?: RegionConfig): PickOutcome;
90
108
  /** Whether a region may be marked. A hidden region is never selectable. */
91
109
  declare function isSelectable(key: string, config?: RegionConfig): boolean;
92
110
  /** Colour control for the body renderers. Consumers pass a palette to theme the
@@ -208,4 +226,4 @@ declare function serializeReport(report: PainReport, opts?: {
208
226
  * key; a point whose code is unknown to this taxonomy is dropped. */
209
227
  declare function deserializeReport(env: unknown, now?: () => string): PainReport;
210
228
 
211
- export { type BodyRegion, type BodyView, type BodymapPalette, type BodymapReportV1, type BodymapUiColors, PAIN_TYPES, type PainPoint, type PainReport, type PainSelection, type PainSelectionOptions, type PainType, REGIONS, REGION_KEYS, type RegionConfig, type RegionSetting, type SerializedPainPoint, type SerializedSide, type Side, baseColorFor, bodymapReportV1Schema, createPainSelection, defaultPalette, defaultUi, deserializeReport, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, resolveRegions, serializeReport, uiColors };
229
+ export { type BodyRegion, type BodyView, type BodymapPalette, type BodymapReportV1, type BodymapUiColors, PAIN_TYPES, type PainPoint, type PainReport, type PainSelection, type PainSelectionOptions, type PainType, type PickOutcome, REGIONS, REGION_KEYS, type RegionConfig, type RegionSetting, type SerializedPainPoint, type SerializedSide, type Side, baseColorFor, bodymapReportV1Schema, createPainSelection, decidePick, defaultPalette, defaultUi, deserializeReport, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, resolveRegions, serializeReport, uiColors };
package/dist/index.d.ts CHANGED
@@ -87,6 +87,24 @@ interface RegionSetting {
87
87
  type RegionConfig = Record<string, RegionSetting>;
88
88
  /** The regions an app should render, honouring `visible` (default true). */
89
89
  declare function resolveRegions(config?: RegionConfig): BodyRegion[];
90
+ /**
91
+ * What a pick on a region should DO (F052.20).
92
+ *
93
+ * Lives in the core because the 2D and 3D renderers share no click code, and a
94
+ * rule written twice is a rule that drifts. This repo measured the cost of that
95
+ * twice on 2026-08-28 alone: a fix applied to one half of a pair, and a sibling
96
+ * branch that carried the same defect with no test on it.
97
+ *
98
+ * "clear" the region is already marked → picking it again removes the mark
99
+ * "select" unmarked → open it for marking
100
+ * "ignore" not selectable (read-only or config) → nothing happens
101
+ *
102
+ * Three outcomes, not a boolean: "nothing happened because it is locked" and
103
+ * "nothing happened because we removed the mark" must never look alike to a
104
+ * caller.
105
+ */
106
+ type PickOutcome = "clear" | "select" | "ignore";
107
+ declare function decidePick(region: string, report: PainReport, config?: RegionConfig): PickOutcome;
90
108
  /** Whether a region may be marked. A hidden region is never selectable. */
91
109
  declare function isSelectable(key: string, config?: RegionConfig): boolean;
92
110
  /** Colour control for the body renderers. Consumers pass a palette to theme the
@@ -208,4 +226,4 @@ declare function serializeReport(report: PainReport, opts?: {
208
226
  * key; a point whose code is unknown to this taxonomy is dropped. */
209
227
  declare function deserializeReport(env: unknown, now?: () => string): PainReport;
210
228
 
211
- export { type BodyRegion, type BodyView, type BodymapPalette, type BodymapReportV1, type BodymapUiColors, PAIN_TYPES, type PainPoint, type PainReport, type PainSelection, type PainSelectionOptions, type PainType, REGIONS, REGION_KEYS, type RegionConfig, type RegionSetting, type SerializedPainPoint, type SerializedSide, type Side, baseColorFor, bodymapReportV1Schema, createPainSelection, defaultPalette, defaultUi, deserializeReport, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, resolveRegions, serializeReport, uiColors };
229
+ export { type BodyRegion, type BodyView, type BodymapPalette, type BodymapReportV1, type BodymapUiColors, PAIN_TYPES, type PainPoint, type PainReport, type PainSelection, type PainSelectionOptions, type PainType, type PickOutcome, REGIONS, REGION_KEYS, type RegionConfig, type RegionSetting, type SerializedPainPoint, type SerializedSide, type Side, baseColorFor, bodymapReportV1Schema, createPainSelection, decidePick, defaultPalette, defaultUi, deserializeReport, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, resolveRegions, serializeReport, uiColors };
package/dist/index.js CHANGED
@@ -71,6 +71,10 @@ function createPainSelection(initial = [], opts = {}) {
71
71
  function resolveRegions(config = {}) {
72
72
  return REGIONS.filter((r) => config[r.key]?.visible ?? true);
73
73
  }
74
+ function decidePick(region, report, config = {}) {
75
+ if (!isSelectable(region, config)) return "ignore";
76
+ return report.some((p) => p.region === region) ? "clear" : "select";
77
+ }
74
78
  function isSelectable(key, config = {}) {
75
79
  const s = config[key];
76
80
  if (s?.visible === false) return false;
@@ -147,6 +151,6 @@ function deserializeReport(env, now = () => (/* @__PURE__ */ new Date()).toISOSt
147
151
  return out;
148
152
  }
149
153
 
150
- export { PAIN_TYPES, REGIONS, REGION_KEYS, baseColorFor, bodymapReportV1Schema, createPainSelection, defaultPalette, defaultUi, deserializeReport, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, resolveRegions, serializeReport, uiColors };
154
+ export { PAIN_TYPES, REGIONS, REGION_KEYS, baseColorFor, bodymapReportV1Schema, createPainSelection, decidePick, defaultPalette, defaultUi, deserializeReport, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, resolveRegions, serializeReport, uiColors };
151
155
  //# sourceMappingURL=index.js.map
152
156
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// @broberg/bodymap — headless core (F052.1).\n//\n// Framework-neutral: the region taxonomy + the PainReport data model (zod) + a\n// selection engine + per-app region config. NO React/Preact/DOM import — the 2D\n// (SVG) and 3D (Three.js) renderers, and all three FD apps, share this one\n// contract. Output is a structured PainReport, never a bare image.\n\nimport { z } from \"zod\";\n\nexport type Side = \"left\" | \"right\";\n\nexport interface BodyRegion {\n /** Stable, unique identifier (snake_case) — the key used in a PainReport. */\n key: string;\n /** Human label (Danish). */\n label: string;\n /** Clinical short code (unique). */\n code: string;\n /** Body side, when the region is paired. */\n side?: Side;\n}\n\n/** The canonical body regions — the AUTHORITATIVE fd-sundhed clinical taxonomy\n * (docs/BODYMAP-TAKSONOMI.md, broberg-ai/fd-sundhed @360842f): 18 SIDE-LESS\n * clinical codes + a separate `side` field (L/R on limbs, C=center on axis\n * regions). The `key` is a unique per-side identifier; `code` is the side-less\n * clinical code that goes on the bodymap/v1 wire. NOT an anatomical atlas —\n * ~30 named surface regions for a pain-map. The 2D front renderer draws the\n * front-visible subset; the 3D body (F052.6) drives them all. */\nexport const REGIONS: readonly BodyRegion[] = [\n // axis / centre-line (serialised side \"center\")\n { key: \"head\", label: \"Hoved\", code: \"HEAD\" },\n { key: \"neck\", label: \"Nakke\", code: \"NECK\" },\n { key: \"chest\", label: \"Bryst\", code: \"CHEST\" },\n { key: \"thora\", label: \"Øvre ryg (thorakal)\", code: \"THORA\" },\n { key: \"lumbar\", label: \"Lænd (lumbal)\", code: \"LUMBAR\" },\n { key: \"groin\", label: \"Lyske\", code: \"GROIN\" },\n // paired limbs / sides (L / R)\n { key: \"shoulder_left\", label: \"Skulder, venstre\", code: \"SHOULDER\", side: \"left\" },\n { key: \"shoulder_right\", label: \"Skulder, højre\", code: \"SHOULDER\", side: \"right\" },\n { key: \"uarm_left\", label: \"Overarm, venstre\", code: \"UARM\", side: \"left\" },\n { key: \"uarm_right\", label: \"Overarm, højre\", code: \"UARM\", side: \"right\" },\n { key: \"elbow_left\", label: \"Albue, venstre\", code: \"ELBOW\", side: \"left\" },\n { key: \"elbow_right\", label: \"Albue, højre\", code: \"ELBOW\", side: \"right\" },\n { key: \"farm_left\", label: \"Underarm, venstre\", code: \"FARM\", side: \"left\" },\n { key: \"farm_right\", label: \"Underarm, højre\", code: \"FARM\", side: \"right\" },\n { key: \"wrist_left\", label: \"Håndled, venstre\", code: \"WRIST\", side: \"left\" },\n { key: \"wrist_right\", label: \"Håndled, højre\", code: \"WRIST\", side: \"right\" },\n { key: \"hand_left\", label: \"Hånd, venstre\", code: \"HAND\", side: \"left\" },\n { key: \"hand_right\", label: \"Hånd, højre\", code: \"HAND\", side: \"right\" },\n { key: \"hip_left\", label: \"Hofte, venstre\", code: \"HIP\", side: \"left\" },\n { key: \"hip_right\", label: \"Hofte, højre\", code: \"HIP\", side: \"right\" },\n { key: \"thigh_left\", label: \"Lår, venstre\", code: \"THIGH\", side: \"left\" },\n { key: \"thigh_right\", label: \"Lår, højre\", code: \"THIGH\", side: \"right\" },\n { key: \"knee_left\", label: \"Knæ, venstre\", code: \"KNEE\", side: \"left\" },\n { key: \"knee_right\", label: \"Knæ, højre\", code: \"KNEE\", side: \"right\" },\n { key: \"lowleg_left\", label: \"Underben, venstre\", code: \"LOWLEG\", side: \"left\" },\n { key: \"lowleg_right\", label: \"Underben, højre\", code: \"LOWLEG\", side: \"right\" },\n { key: \"ankle_left\", label: \"Ankel, venstre\", code: \"ANKLE\", side: \"left\" },\n { key: \"ankle_right\", label: \"Ankel, højre\", code: \"ANKLE\", side: \"right\" },\n { key: \"foot_left\", label: \"Fod, venstre\", code: \"FOOT\", side: \"left\" },\n { key: \"foot_right\", label: \"Fod, højre\", code: \"FOOT\", side: \"right\" },\n];\n\nconst REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));\nexport const REGION_KEYS: readonly string[] = REGIONS.map((r) => r.key);\n\n/** Look up a region by its key. */\nexport function getRegion(key: string): BodyRegion | undefined {\n return REGIONS.find((r) => r.key === key);\n}\n\n// ---- PainReport model ---------------------------------------------------\n\nexport const PAIN_TYPES = [\"stikkende\", \"dump\", \"konstant\", \"jagende\"] as const;\nexport type PainType = (typeof PAIN_TYPES)[number];\n\n/** One marked pain point. `region` MUST be a known region key; `intensity` is a\n * 0-10 integer; `type` is optional but constrained; `timestamp` is an ISO string. */\nexport const painPointSchema = z.object({\n region: z.string().refine((k) => REGION_KEY_SET.has(k), { message: \"unknown region\" }),\n intensity: z.number().int().min(0).max(10),\n type: z.enum(PAIN_TYPES).optional(),\n timestamp: z.string(),\n});\nexport type PainPoint = z.infer<typeof painPointSchema>;\n\nexport const painReportSchema = z.array(painPointSchema);\nexport type PainReport = PainPoint[];\n\n// ---- Selection engine (framework-agnostic) ------------------------------\n\nexport interface PainSelection {\n /** Mark (or update) pain on a region. One point per region — latest wins. */\n set(region: string, intensity: number, type?: PainType): PainPoint;\n remove(region: string): boolean;\n get(region: string): PainPoint | undefined;\n has(region: string): boolean;\n clear(): void;\n /** The current, validated PainReport. */\n getReport(): PainReport;\n}\n\nexport interface PainSelectionOptions {\n /** Injectable clock (ISO string) — defaults to `new Date().toISOString()`. */\n now?: () => string;\n}\n\n/** Create a selection engine seeded with an optional report. Pure state — no\n * DOM, no framework, no network. One point per region. */\nexport function createPainSelection(\n initial: PainReport = [],\n opts: PainSelectionOptions = {},\n): PainSelection {\n const now = opts.now ?? (() => new Date().toISOString());\n const map = new Map<string, PainPoint>();\n for (const p of initial) {\n const v = painPointSchema.parse(p);\n map.set(v.region, v);\n }\n return {\n set(region, intensity, type) {\n const point = painPointSchema.parse({ region, intensity, type, timestamp: now() });\n map.set(point.region, point);\n return point;\n },\n remove: (region) => map.delete(region),\n get: (region) => map.get(region),\n has: (region) => map.has(region),\n clear: () => map.clear(),\n getReport: () => painReportSchema.parse(Array.from(map.values())),\n };\n}\n\n// ---- Per-app region config (the toggle) ---------------------------------\n\nexport interface RegionSetting {\n /** Render this region at all. Default true. */\n visible?: boolean;\n /** Allow marking pain on this region. Default true. */\n selectable?: boolean;\n}\n\n/** Per-app config keyed by region key. An absent key ⇒ visible + selectable. */\nexport type RegionConfig = Record<string, RegionSetting>;\n\n/** The regions an app should render, honouring `visible` (default true). */\nexport function resolveRegions(config: RegionConfig = {}): BodyRegion[] {\n return REGIONS.filter((r) => config[r.key]?.visible ?? true);\n}\n\n/** Whether a region may be marked. A hidden region is never selectable. */\nexport function isSelectable(key: string, config: RegionConfig = {}): boolean {\n const s = config[key];\n if (s?.visible === false) return false;\n return s?.selectable ?? true;\n}\n\n// ---- palette (consumer-defined colours — shared by the 2D + 3D renderers) ---\n\n/** Colour control for the body renderers. Consumers pass a palette to theme the\n * body base colour, the hover + selected highlights, the pain-heat colours, and\n * optional per-region base colours. All values are CSS/hex colour strings. */\nexport interface BodymapPalette {\n /** Base body colour (an unmarked region). */\n body: string;\n /** Region highlight on hover (before click). */\n hover: string;\n /** A region selected (clicked) but not yet given an intensity. */\n selected: string;\n /** Pain-intensity heat colours: low (0-3), mid (4-6), high (7-10). */\n heat: { low: string; mid: string; high: string };\n /** Optional per-region base-colour overrides (region key → colour). */\n regions?: Record<string, string>;\n /**\n * Optional panel-chrome colours (the selection panel, labels, hint box) —\n * NOT the body itself. All optional; anything omitted falls back to\n * {@link defaultUi}. A palette that only themed the body was half a palette:\n * a consumer passing brand colours still got hardcoded chrome. (F052.19)\n */\n ui?: BodymapUiColors;\n}\n\n/** Panel-chrome colours. Every default is WCAG-AA (>=4.5:1) on its own background. */\nexport interface BodymapUiColors {\n /** Primary text (headings, values). */\n text?: string;\n /** Secondary text — section labels, the empty-state hint. */\n mutedText?: string;\n /** Panel background. */\n panelBg?: string;\n /** Panel + control borders. */\n border?: string;\n /** Background behind the region-code badge. */\n badgeBg?: string;\n /** The destructive action (remove a marked region). */\n danger?: string;\n}\n\n/**\n * Default panel chrome. Contrast against `panelBg` (#fff), asserted by\n * `test/contrast.test.ts`:\n * text #1e293b 14.8:1\n * mutedText #475569 7.6:1 (was #94a3b8 at 2.56:1 — WCAG AA failure)\n * danger #dc2626 4.8:1 (was #ef4444 at 3.76:1 — WCAG AA failure)\n * `mutedText` on `badgeBg` (#f1f5f9) is 6.9:1 (was #64748b at 4.34:1).\n */\nexport const defaultUi: Required<BodymapUiColors> = {\n text: \"#1e293b\",\n mutedText: \"#475569\",\n panelBg: \"#fff\",\n border: \"#e2e8f0\",\n badgeBg: \"#f1f5f9\",\n danger: \"#dc2626\",\n};\n\n/** Resolve a palette's chrome colours, filling every gap from {@link defaultUi}. */\nexport function uiColors(palette?: BodymapPalette): Required<BodymapUiColors> {\n return { ...defaultUi, ...(palette?.ui ?? {}) };\n}\n\n/** The fleet default palette. Override any field per consumer. */\nexport const defaultPalette: BodymapPalette = {\n body: \"#d2d7de\",\n hover: \"#8fd0cd\",\n selected: \"#5cc4b7\",\n heat: { low: \"#fcd34d\", mid: \"#fb923c\", high: \"#ef4444\" },\n};\n\n/** The heat colour for a pain intensity, honouring the palette. */\nexport function heatFor(intensity: number, palette: BodymapPalette = defaultPalette): string {\n return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;\n}\n\n/** The base colour for a region (a per-region override, else the body colour). */\nexport function baseColorFor(regionKey: string, palette: BodymapPalette = defaultPalette): string {\n return palette.regions?.[regionKey] ?? palette.body;\n}\n\n// ---- bodymap/v1 serialization (the shared cross-app / native wire format) ---\n//\n// The shape every consumer + the native mobile apps read (aligned with\n// fd-sundhed's bodymap/v1: region CODE + side + intensity + quality + view).\n// The internal PainReport keys on the region KEY; this maps key -> clinical CODE\n// so the report is portable and human-readable on the wire.\n\nexport type BodyView = \"front\" | \"back\" | \"left\" | \"right\";\n/** Side in the serialized report — a midline region (no side) becomes \"center\". */\nexport type SerializedSide = \"left\" | \"right\" | \"center\";\n\nexport interface SerializedPainPoint {\n /** Clinical region CODE (e.g. \"LUMB\"). */\n region: string;\n side: SerializedSide;\n intensity: number;\n quality?: PainType;\n}\n\nexport interface BodymapReportV1 {\n schema: \"bodymap/v1\";\n view: BodyView;\n points: SerializedPainPoint[];\n}\n\nexport const bodymapReportV1Schema = z.object({\n schema: z.literal(\"bodymap/v1\"),\n view: z.enum([\"front\", \"back\", \"left\", \"right\"]),\n points: z.array(\n z.object({\n region: z.string(),\n side: z.enum([\"left\", \"right\", \"center\"]),\n intensity: z.number().int().min(0).max(10),\n quality: z.enum(PAIN_TYPES).optional(),\n }),\n ),\n});\n\n/** Serialize a PainReport to the shared `bodymap/v1` wire format. */\nexport function serializeReport(\n report: PainReport,\n opts: { view?: BodyView } = {},\n): BodymapReportV1 {\n return {\n schema: \"bodymap/v1\",\n view: opts.view ?? \"front\",\n points: report.map((p) => {\n const r = getRegion(p.region);\n return {\n region: r?.code ?? p.region,\n side: (r?.side ?? \"center\") as SerializedSide,\n intensity: p.intensity,\n quality: p.type,\n };\n }),\n };\n}\n\n/** Parse a `bodymap/v1` report back into an internal PainReport. Region CODE →\n * key; a point whose code is unknown to this taxonomy is dropped. */\nexport function deserializeReport(\n env: unknown,\n now: () => string = () => new Date().toISOString(),\n): PainReport {\n const parsed = bodymapReportV1Schema.parse(env);\n // code is side-less, so a point is identified by code + side.\n const byCodeSide = new Map(\n REGIONS.map((r) => [`${r.code}|${r.side ?? \"center\"}`, r.key] as const),\n );\n const out: PainReport = [];\n for (const sp of parsed.points) {\n const key = byCodeSide.get(`${sp.region}|${sp.side}`);\n if (!key) continue;\n out.push(\n painPointSchema.parse({\n region: key,\n intensity: sp.intensity,\n type: sp.quality,\n timestamp: now(),\n }),\n );\n }\n return out;\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// @broberg/bodymap — headless core (F052.1).\n//\n// Framework-neutral: the region taxonomy + the PainReport data model (zod) + a\n// selection engine + per-app region config. NO React/Preact/DOM import — the 2D\n// (SVG) and 3D (Three.js) renderers, and all three FD apps, share this one\n// contract. Output is a structured PainReport, never a bare image.\n\nimport { z } from \"zod\";\n\nexport type Side = \"left\" | \"right\";\n\nexport interface BodyRegion {\n /** Stable, unique identifier (snake_case) — the key used in a PainReport. */\n key: string;\n /** Human label (Danish). */\n label: string;\n /** Clinical short code (unique). */\n code: string;\n /** Body side, when the region is paired. */\n side?: Side;\n}\n\n/** The canonical body regions — the AUTHORITATIVE fd-sundhed clinical taxonomy\n * (docs/BODYMAP-TAKSONOMI.md, broberg-ai/fd-sundhed @360842f): 18 SIDE-LESS\n * clinical codes + a separate `side` field (L/R on limbs, C=center on axis\n * regions). The `key` is a unique per-side identifier; `code` is the side-less\n * clinical code that goes on the bodymap/v1 wire. NOT an anatomical atlas —\n * ~30 named surface regions for a pain-map. The 2D front renderer draws the\n * front-visible subset; the 3D body (F052.6) drives them all. */\nexport const REGIONS: readonly BodyRegion[] = [\n // axis / centre-line (serialised side \"center\")\n { key: \"head\", label: \"Hoved\", code: \"HEAD\" },\n { key: \"neck\", label: \"Nakke\", code: \"NECK\" },\n { key: \"chest\", label: \"Bryst\", code: \"CHEST\" },\n { key: \"thora\", label: \"Øvre ryg (thorakal)\", code: \"THORA\" },\n { key: \"lumbar\", label: \"Lænd (lumbal)\", code: \"LUMBAR\" },\n { key: \"groin\", label: \"Lyske\", code: \"GROIN\" },\n // paired limbs / sides (L / R)\n { key: \"shoulder_left\", label: \"Skulder, venstre\", code: \"SHOULDER\", side: \"left\" },\n { key: \"shoulder_right\", label: \"Skulder, højre\", code: \"SHOULDER\", side: \"right\" },\n { key: \"uarm_left\", label: \"Overarm, venstre\", code: \"UARM\", side: \"left\" },\n { key: \"uarm_right\", label: \"Overarm, højre\", code: \"UARM\", side: \"right\" },\n { key: \"elbow_left\", label: \"Albue, venstre\", code: \"ELBOW\", side: \"left\" },\n { key: \"elbow_right\", label: \"Albue, højre\", code: \"ELBOW\", side: \"right\" },\n { key: \"farm_left\", label: \"Underarm, venstre\", code: \"FARM\", side: \"left\" },\n { key: \"farm_right\", label: \"Underarm, højre\", code: \"FARM\", side: \"right\" },\n { key: \"wrist_left\", label: \"Håndled, venstre\", code: \"WRIST\", side: \"left\" },\n { key: \"wrist_right\", label: \"Håndled, højre\", code: \"WRIST\", side: \"right\" },\n { key: \"hand_left\", label: \"Hånd, venstre\", code: \"HAND\", side: \"left\" },\n { key: \"hand_right\", label: \"Hånd, højre\", code: \"HAND\", side: \"right\" },\n { key: \"hip_left\", label: \"Hofte, venstre\", code: \"HIP\", side: \"left\" },\n { key: \"hip_right\", label: \"Hofte, højre\", code: \"HIP\", side: \"right\" },\n { key: \"thigh_left\", label: \"Lår, venstre\", code: \"THIGH\", side: \"left\" },\n { key: \"thigh_right\", label: \"Lår, højre\", code: \"THIGH\", side: \"right\" },\n { key: \"knee_left\", label: \"Knæ, venstre\", code: \"KNEE\", side: \"left\" },\n { key: \"knee_right\", label: \"Knæ, højre\", code: \"KNEE\", side: \"right\" },\n { key: \"lowleg_left\", label: \"Underben, venstre\", code: \"LOWLEG\", side: \"left\" },\n { key: \"lowleg_right\", label: \"Underben, højre\", code: \"LOWLEG\", side: \"right\" },\n { key: \"ankle_left\", label: \"Ankel, venstre\", code: \"ANKLE\", side: \"left\" },\n { key: \"ankle_right\", label: \"Ankel, højre\", code: \"ANKLE\", side: \"right\" },\n { key: \"foot_left\", label: \"Fod, venstre\", code: \"FOOT\", side: \"left\" },\n { key: \"foot_right\", label: \"Fod, højre\", code: \"FOOT\", side: \"right\" },\n];\n\nconst REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));\nexport const REGION_KEYS: readonly string[] = REGIONS.map((r) => r.key);\n\n/** Look up a region by its key. */\nexport function getRegion(key: string): BodyRegion | undefined {\n return REGIONS.find((r) => r.key === key);\n}\n\n// ---- PainReport model ---------------------------------------------------\n\nexport const PAIN_TYPES = [\"stikkende\", \"dump\", \"konstant\", \"jagende\"] as const;\nexport type PainType = (typeof PAIN_TYPES)[number];\n\n/** One marked pain point. `region` MUST be a known region key; `intensity` is a\n * 0-10 integer; `type` is optional but constrained; `timestamp` is an ISO string. */\nexport const painPointSchema = z.object({\n region: z.string().refine((k) => REGION_KEY_SET.has(k), { message: \"unknown region\" }),\n intensity: z.number().int().min(0).max(10),\n type: z.enum(PAIN_TYPES).optional(),\n timestamp: z.string(),\n});\nexport type PainPoint = z.infer<typeof painPointSchema>;\n\nexport const painReportSchema = z.array(painPointSchema);\nexport type PainReport = PainPoint[];\n\n// ---- Selection engine (framework-agnostic) ------------------------------\n\nexport interface PainSelection {\n /** Mark (or update) pain on a region. One point per region — latest wins. */\n set(region: string, intensity: number, type?: PainType): PainPoint;\n remove(region: string): boolean;\n get(region: string): PainPoint | undefined;\n has(region: string): boolean;\n clear(): void;\n /** The current, validated PainReport. */\n getReport(): PainReport;\n}\n\nexport interface PainSelectionOptions {\n /** Injectable clock (ISO string) — defaults to `new Date().toISOString()`. */\n now?: () => string;\n}\n\n/** Create a selection engine seeded with an optional report. Pure state — no\n * DOM, no framework, no network. One point per region. */\nexport function createPainSelection(\n initial: PainReport = [],\n opts: PainSelectionOptions = {},\n): PainSelection {\n const now = opts.now ?? (() => new Date().toISOString());\n const map = new Map<string, PainPoint>();\n for (const p of initial) {\n const v = painPointSchema.parse(p);\n map.set(v.region, v);\n }\n return {\n set(region, intensity, type) {\n const point = painPointSchema.parse({ region, intensity, type, timestamp: now() });\n map.set(point.region, point);\n return point;\n },\n remove: (region) => map.delete(region),\n get: (region) => map.get(region),\n has: (region) => map.has(region),\n clear: () => map.clear(),\n getReport: () => painReportSchema.parse(Array.from(map.values())),\n };\n}\n\n// ---- Per-app region config (the toggle) ---------------------------------\n\nexport interface RegionSetting {\n /** Render this region at all. Default true. */\n visible?: boolean;\n /** Allow marking pain on this region. Default true. */\n selectable?: boolean;\n}\n\n/** Per-app config keyed by region key. An absent key ⇒ visible + selectable. */\nexport type RegionConfig = Record<string, RegionSetting>;\n\n/** The regions an app should render, honouring `visible` (default true). */\nexport function resolveRegions(config: RegionConfig = {}): BodyRegion[] {\n return REGIONS.filter((r) => config[r.key]?.visible ?? true);\n}\n\n/**\n * What a pick on a region should DO (F052.20).\n *\n * Lives in the core because the 2D and 3D renderers share no click code, and a\n * rule written twice is a rule that drifts. This repo measured the cost of that\n * twice on 2026-08-28 alone: a fix applied to one half of a pair, and a sibling\n * branch that carried the same defect with no test on it.\n *\n * \"clear\" the region is already marked → picking it again removes the mark\n * \"select\" unmarked → open it for marking\n * \"ignore\" not selectable (read-only or config) → nothing happens\n *\n * Three outcomes, not a boolean: \"nothing happened because it is locked\" and\n * \"nothing happened because we removed the mark\" must never look alike to a\n * caller.\n */\nexport type PickOutcome = \"clear\" | \"select\" | \"ignore\";\n\nexport function decidePick(\n region: string,\n report: PainReport,\n config: RegionConfig = {},\n): PickOutcome {\n if (!isSelectable(region, config)) return \"ignore\";\n return report.some((p) => p.region === region) ? \"clear\" : \"select\";\n}\n\n/** Whether a region may be marked. A hidden region is never selectable. */\nexport function isSelectable(key: string, config: RegionConfig = {}): boolean {\n const s = config[key];\n if (s?.visible === false) return false;\n return s?.selectable ?? true;\n}\n\n// ---- palette (consumer-defined colours — shared by the 2D + 3D renderers) ---\n\n/** Colour control for the body renderers. Consumers pass a palette to theme the\n * body base colour, the hover + selected highlights, the pain-heat colours, and\n * optional per-region base colours. All values are CSS/hex colour strings. */\nexport interface BodymapPalette {\n /** Base body colour (an unmarked region). */\n body: string;\n /** Region highlight on hover (before click). */\n hover: string;\n /** A region selected (clicked) but not yet given an intensity. */\n selected: string;\n /** Pain-intensity heat colours: low (0-3), mid (4-6), high (7-10). */\n heat: { low: string; mid: string; high: string };\n /** Optional per-region base-colour overrides (region key → colour). */\n regions?: Record<string, string>;\n /**\n * Optional panel-chrome colours (the selection panel, labels, hint box) —\n * NOT the body itself. All optional; anything omitted falls back to\n * {@link defaultUi}. A palette that only themed the body was half a palette:\n * a consumer passing brand colours still got hardcoded chrome. (F052.19)\n */\n ui?: BodymapUiColors;\n}\n\n/** Panel-chrome colours. Every default is WCAG-AA (>=4.5:1) on its own background. */\nexport interface BodymapUiColors {\n /** Primary text (headings, values). */\n text?: string;\n /** Secondary text — section labels, the empty-state hint. */\n mutedText?: string;\n /** Panel background. */\n panelBg?: string;\n /** Panel + control borders. */\n border?: string;\n /** Background behind the region-code badge. */\n badgeBg?: string;\n /** The destructive action (remove a marked region). */\n danger?: string;\n}\n\n/**\n * Default panel chrome. Contrast against `panelBg` (#fff), asserted by\n * `test/contrast.test.ts`:\n * text #1e293b 14.8:1\n * mutedText #475569 7.6:1 (was #94a3b8 at 2.56:1 — WCAG AA failure)\n * danger #dc2626 4.8:1 (was #ef4444 at 3.76:1 — WCAG AA failure)\n * `mutedText` on `badgeBg` (#f1f5f9) is 6.9:1 (was #64748b at 4.34:1).\n */\nexport const defaultUi: Required<BodymapUiColors> = {\n text: \"#1e293b\",\n mutedText: \"#475569\",\n panelBg: \"#fff\",\n border: \"#e2e8f0\",\n badgeBg: \"#f1f5f9\",\n danger: \"#dc2626\",\n};\n\n/** Resolve a palette's chrome colours, filling every gap from {@link defaultUi}. */\nexport function uiColors(palette?: BodymapPalette): Required<BodymapUiColors> {\n return { ...defaultUi, ...(palette?.ui ?? {}) };\n}\n\n/** The fleet default palette. Override any field per consumer. */\nexport const defaultPalette: BodymapPalette = {\n body: \"#d2d7de\",\n hover: \"#8fd0cd\",\n selected: \"#5cc4b7\",\n heat: { low: \"#fcd34d\", mid: \"#fb923c\", high: \"#ef4444\" },\n};\n\n/** The heat colour for a pain intensity, honouring the palette. */\nexport function heatFor(intensity: number, palette: BodymapPalette = defaultPalette): string {\n return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;\n}\n\n/** The base colour for a region (a per-region override, else the body colour). */\nexport function baseColorFor(regionKey: string, palette: BodymapPalette = defaultPalette): string {\n return palette.regions?.[regionKey] ?? palette.body;\n}\n\n// ---- bodymap/v1 serialization (the shared cross-app / native wire format) ---\n//\n// The shape every consumer + the native mobile apps read (aligned with\n// fd-sundhed's bodymap/v1: region CODE + side + intensity + quality + view).\n// The internal PainReport keys on the region KEY; this maps key -> clinical CODE\n// so the report is portable and human-readable on the wire.\n\nexport type BodyView = \"front\" | \"back\" | \"left\" | \"right\";\n/** Side in the serialized report — a midline region (no side) becomes \"center\". */\nexport type SerializedSide = \"left\" | \"right\" | \"center\";\n\nexport interface SerializedPainPoint {\n /** Clinical region CODE (e.g. \"LUMB\"). */\n region: string;\n side: SerializedSide;\n intensity: number;\n quality?: PainType;\n}\n\nexport interface BodymapReportV1 {\n schema: \"bodymap/v1\";\n view: BodyView;\n points: SerializedPainPoint[];\n}\n\nexport const bodymapReportV1Schema = z.object({\n schema: z.literal(\"bodymap/v1\"),\n view: z.enum([\"front\", \"back\", \"left\", \"right\"]),\n points: z.array(\n z.object({\n region: z.string(),\n side: z.enum([\"left\", \"right\", \"center\"]),\n intensity: z.number().int().min(0).max(10),\n quality: z.enum(PAIN_TYPES).optional(),\n }),\n ),\n});\n\n/** Serialize a PainReport to the shared `bodymap/v1` wire format. */\nexport function serializeReport(\n report: PainReport,\n opts: { view?: BodyView } = {},\n): BodymapReportV1 {\n return {\n schema: \"bodymap/v1\",\n view: opts.view ?? \"front\",\n points: report.map((p) => {\n const r = getRegion(p.region);\n return {\n region: r?.code ?? p.region,\n side: (r?.side ?? \"center\") as SerializedSide,\n intensity: p.intensity,\n quality: p.type,\n };\n }),\n };\n}\n\n/** Parse a `bodymap/v1` report back into an internal PainReport. Region CODE →\n * key; a point whose code is unknown to this taxonomy is dropped. */\nexport function deserializeReport(\n env: unknown,\n now: () => string = () => new Date().toISOString(),\n): PainReport {\n const parsed = bodymapReportV1Schema.parse(env);\n // code is side-less, so a point is identified by code + side.\n const byCodeSide = new Map(\n REGIONS.map((r) => [`${r.code}|${r.side ?? \"center\"}`, r.key] as const),\n );\n const out: PainReport = [];\n for (const sp of parsed.points) {\n const key = byCodeSide.get(`${sp.region}|${sp.side}`);\n if (!key) continue;\n out.push(\n painPointSchema.parse({\n region: key,\n intensity: sp.intensity,\n type: sp.quality,\n timestamp: now(),\n }),\n );\n }\n return out;\n}\n"]}
package/dist/react.cjs CHANGED
@@ -55,6 +55,10 @@ zod.z.array(painPointSchema);
55
55
  function resolveRegions(config = {}) {
56
56
  return REGIONS.filter((r) => config[r.key]?.visible ?? true);
57
57
  }
58
+ function decidePick(region, report, config = {}) {
59
+ if (!isSelectable(region, config)) return "ignore";
60
+ return report.some((p) => p.region === region) ? "clear" : "select";
61
+ }
58
62
  function isSelectable(key, config = {}) {
59
63
  const s = config[key];
60
64
  if (s?.visible === false) return false;
@@ -504,12 +508,21 @@ function BodyMap({
504
508
  commit(report.filter((p) => p.region !== key));
505
509
  setSelected(null);
506
510
  };
511
+ const applyPick = (key) => {
512
+ const what = decidePick(key, report, config);
513
+ if (what === "ignore") return;
514
+ if (what === "clear") {
515
+ removePain(key);
516
+ return;
517
+ }
518
+ setSelected(key);
519
+ };
507
520
  const pickRegion = (key) => {
508
521
  if (suppressClick.current) {
509
522
  suppressClick.current = false;
510
523
  return;
511
524
  }
512
- setSelected(key);
525
+ applyPick(key);
513
526
  };
514
527
  const regions = resolveRegions(config);
515
528
  const region = selected ? REGIONS.find((r) => r.key === selected) : void 0;
@@ -558,6 +571,7 @@ function BodyMap({
558
571
  {
559
572
  ref: svgRef,
560
573
  className: "bmap__svg",
574
+ "data-testid": "bodymap-svg",
561
575
  viewBox: "0 0 260 470",
562
576
  role: "group",
563
577
  "aria-label": L.svgLabel,
@@ -588,7 +602,7 @@ function BodyMap({
588
602
  if (e.key === "Enter" || e.key === " ") {
589
603
  e.preventDefault();
590
604
  suppressClick.current = false;
591
- setSelected(r.key);
605
+ applyPick(r.key);
592
606
  }
593
607
  } : void 0
594
608
  }),