{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "body-map",
  "title": "Body map",
  "description": "Pick regions on a front and back body outline to record where pain or symptoms are.",
  "dependencies": [
    "@base-ui/react@^1.8.0",
    "class-variance-authority@^0.7.1",
    "cn@^0.5.0"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "registry/base-nova/body-map/body-map.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"cn\"\n\nimport { Button } from \"@/components/ui/button\"\n\nexport type BodyMapView = \"front\" | \"back\"\nexport type BodyMapRegion = {\n  id: string\n  label: string\n  view: BodyMapView\n  /** SVG path data in the 200 × 440 body coordinate space. */\n  d: string\n  /** Reflects the path across the body's midline. */\n  mirrored?: boolean\n}\nexport type BodyMapProps = {\n  value: readonly string[]\n  onValueChange: (value: string[]) => void\n  /** Defaults to bodyMapRegions. Replace labels or shapes to customize. */\n  regions?: readonly BodyMapRegion[]\n  views?: readonly BodyMapView[]\n  /** Submits one hidden input per selected region inside a form. */\n  name?: string\n  label?: string\n  className?: string\n}\n\nconst width = 200\nconst height = 440\n\n// Paired shapes are drawn on the viewer's left. Facing the viewer that is the\n// person's right side; seen from behind it is their left.\nconst shapes = {\n  head: \"M100 6C114 6 122 18 122 31C122 45 112 55 100 55C88 55 78 45 78 31C78 18 86 6 100 6Z\",\n  neck: \"M91 52C96 56 104 56 109 52L113 72L87 72Z\",\n  shoulder: \"M87 72C74 73 62 76 56 84C52 90 50 98 50 106L68 106L72 86Z\",\n  chest: \"M100 72L87 72L72 86L73 132L100 132Z\",\n  upperAbdomen: \"M100 132L73 132C73 145 74 157 75 168L100 168Z\",\n  lowerAbdomen: \"M100 168L75 168C74 182 72 196 71 210L100 210Z\",\n  groin: \"M71 210L129 210L127 222L100 238L73 222Z\",\n  thigh:\n    \"M71 210L73 222L100 238L98 318L77 318C73 292 70 262 69 236C69 226 70 218 71 210Z\",\n  lowerBack: \"M100 132L73 132C73 152 74 172 74 192L100 192Z\",\n  buttock: \"M74 192L100 192L100 240C88 246 76 243 70 236C70 220 72 206 74 192Z\",\n  thighBack:\n    \"M70 236C76 243 88 246 100 240L98 318L77 318C73 292 70 262 70 236Z\",\n  knee: \"M77 318L98 318L97 344L78 344Z\",\n  shin: \"M78 344L97 344L95 405L83 405C80 386 77 366 78 344Z\",\n  foot: \"M83 405L95 405L97 428C91 433 80 433 74 430C74 423 78 414 83 405Z\",\n  upperArm: \"M50 106L68 106C67 126 66 146 64 166L46 164C46 144 48 124 50 106Z\",\n  forearm: \"M46 164L64 166C62 186 60 206 58 226L42 224C42 204 44 184 46 164Z\",\n  hand: \"M42 224L58 226C60 236 59 248 55 258C49 263 41 260 38 251C36 241 38 231 42 224Z\",\n}\n\nfunction region(\n  view: BodyMapView,\n  id: string,\n  label: string,\n  d: string\n): BodyMapRegion {\n  return { id, label, view, d }\n}\n\n// \"{side}\" in an ID or label becomes \"right\" or \"left\".\nfunction pair(\n  view: BodyMapView,\n  id: string,\n  label: string,\n  d: string\n): BodyMapRegion[] {\n  return ([\"right\", \"left\"] as const).map((side) => {\n    const text = label.replace(\"{side}\", side)\n    return {\n      id: id.replace(\"{side}\", side),\n      label: text.charAt(0).toUpperCase() + text.slice(1),\n      view,\n      d,\n      mirrored: (view === \"front\") === (side === \"left\"),\n    }\n  })\n}\n\nexport const bodyMapRegions: readonly BodyMapRegion[] = [\n  region(\"front\", \"head\", \"Head and face\", shapes.head),\n  region(\"front\", \"neck\", \"Neck\", shapes.neck),\n  ...pair(\"front\", \"{side}-shoulder\", \"{side} shoulder\", shapes.shoulder),\n  ...pair(\"front\", \"{side}-chest\", \"{side} chest\", shapes.chest),\n  ...pair(\n    \"front\",\n    \"{side}-upper-abdomen\",\n    \"{side} upper abdomen\",\n    shapes.upperAbdomen\n  ),\n  ...pair(\n    \"front\",\n    \"{side}-lower-abdomen\",\n    \"{side} lower abdomen\",\n    shapes.lowerAbdomen\n  ),\n  region(\"front\", \"groin\", \"Groin\", shapes.groin),\n  ...pair(\"front\", \"{side}-upper-arm\", \"{side} upper arm\", shapes.upperArm),\n  ...pair(\"front\", \"{side}-forearm\", \"{side} forearm\", shapes.forearm),\n  ...pair(\"front\", \"{side}-palm\", \"{side} palm\", shapes.hand),\n  ...pair(\"front\", \"{side}-thigh\", \"front of {side} thigh\", shapes.thigh),\n  ...pair(\"front\", \"{side}-knee\", \"{side} knee\", shapes.knee),\n  ...pair(\"front\", \"{side}-shin\", \"{side} shin\", shapes.shin),\n  ...pair(\"front\", \"{side}-foot\", \"top of {side} foot\", shapes.foot),\n  region(\"back\", \"back-of-head\", \"Back of head\", shapes.head),\n  region(\"back\", \"back-of-neck\", \"Back of neck\", shapes.neck),\n  ...pair(\n    \"back\",\n    \"{side}-shoulder-back\",\n    \"back of {side} shoulder\",\n    shapes.shoulder\n  ),\n  ...pair(\"back\", \"{side}-upper-back\", \"{side} upper back\", shapes.chest),\n  ...pair(\"back\", \"{side}-lower-back\", \"{side} lower back\", shapes.lowerBack),\n  ...pair(\"back\", \"{side}-buttock\", \"{side} buttock\", shapes.buttock),\n  ...pair(\n    \"back\",\n    \"{side}-upper-arm-back\",\n    \"back of {side} upper arm\",\n    shapes.upperArm\n  ),\n  ...pair(\n    \"back\",\n    \"{side}-forearm-back\",\n    \"back of {side} forearm\",\n    shapes.forearm\n  ),\n  ...pair(\"back\", \"{side}-hand-back\", \"back of {side} hand\", shapes.hand),\n  ...pair(\n    \"back\",\n    \"{side}-thigh-back\",\n    \"back of {side} thigh\",\n    shapes.thighBack\n  ),\n  ...pair(\"back\", \"{side}-knee-back\", \"back of {side} knee\", shapes.knee),\n  ...pair(\"back\", \"{side}-calf\", \"{side} calf\", shapes.shin),\n  ...pair(\"back\", \"{side}-heel\", \"{side} heel\", shapes.foot),\n]\n\nconst viewLabels: Record<BodyMapView, string> = {\n  front: \"Front\",\n  back: \"Back\",\n}\n\n// The middle of a path's bounding box, close enough for arrow key\n// navigation. Paths use absolute coordinate pairs only.\nexport function regionCenter(region: BodyMapRegion): [number, number] {\n  const numbers = region.d.match(/-?\\d*\\.?\\d+/g)?.map(Number) ?? [0, 0]\n  const xs = numbers.filter((_, index) => index % 2 === 0)\n  const ys = numbers.filter((_, index) => index % 2 === 1)\n  const x = (Math.min(...xs) + Math.max(...xs)) / 2\n  const y = (Math.min(...ys) + Math.max(...ys)) / 2\n  return [region.mirrored ? width - x : x, y]\n}\n\nconst directions: Record<string, [number, number]> = {\n  ArrowLeft: [-1, 0],\n  ArrowRight: [1, 0],\n  ArrowUp: [0, -1],\n  ArrowDown: [0, 1],\n}\n\n/**\n * The nearest region within 45 degrees of an arrow key's direction, favoring\n * straight lines.\n */\nexport function nextRegion(\n  regions: readonly BodyMapRegion[],\n  from: BodyMapRegion,\n  key: string\n): BodyMapRegion | undefined {\n  const direction = directions[key]\n  if (!direction) return undefined\n  const [dx, dy] = direction\n  const [fx, fy] = regionCenter(from)\n  let best: BodyMapRegion | undefined\n  let bestScore = Infinity\n  for (const candidate of regions) {\n    if (candidate === from) continue\n    const [cx, cy] = regionCenter(candidate)\n    const along = (cx - fx) * dx + (cy - fy) * dy\n    const across = Math.abs((cx - fx) * dy - (cy - fy) * dx)\n    if (along <= 1 || across > along) continue\n    const score = along + across * 2\n    if (score < bestScore) {\n      best = candidate\n      bestScore = score\n    }\n  }\n  return best\n}\n\n/** Toggles an ID, keeping known IDs in selection order. */\nexport function toggleRegion(\n  value: readonly string[],\n  id: string,\n  regions: readonly BodyMapRegion[] = bodyMapRegions\n): string[] {\n  const known = new Set(regions.map((region) => region.id))\n  const next = value.filter((item) => known.has(item) && item !== id)\n  return value.includes(id) ? next : [...next, id]\n}\n\nexport function BodyMap({\n  value,\n  onValueChange,\n  regions = bodyMapRegions,\n  views = [\"front\", \"back\"],\n  name,\n  label = \"Body map\",\n  className,\n}: BodyMapProps) {\n  const helpId = React.useId()\n  const statusId = React.useId()\n  const selected = new Set(value)\n  const byId = new Map(regions.map((region) => [region.id, region]))\n  const chosen = value.flatMap((id) => byId.get(id) ?? [])\n  const [hovered, setHovered] = React.useState<string | null>(null)\n  const [active, setActive] = React.useState<Record<string, string>>({})\n  const [focusRing, setFocusRing] = React.useState<string | null>(null)\n  const elements = React.useRef(new Map<string, SVGPathElement>())\n\n  function toggle(id: string) {\n    onValueChange(toggleRegion(value, id, regions))\n  }\n\n  return (\n    <div\n      data-slot=\"body-map\"\n      role=\"group\"\n      aria-label={label}\n      className={cn(\"flex flex-col gap-4\", className)}\n    >\n      <p id={helpId} className=\"sr-only\">\n        Each view is one tab stop. Arrow keys move between regions; Space or\n        Enter selects or clears the focused region.\n      </p>\n      <div\n        className=\"grid gap-4\"\n        style={{\n          gridTemplateColumns: `repeat(${views.length}, minmax(0, 1fr))`,\n        }}\n      >\n        {views.map((view) => {\n          const items = regions.filter((region) => region.view === view)\n          // The tab stop is the last region focused in this view, else the\n          // first selected one, else the first region.\n          const stop =\n            items.find((region) => region.id === active[view]) ??\n            items.find((region) => selected.has(region.id)) ??\n            items[0]\n          const ring = items.find((region) => region.id === focusRing)\n          const hint = items.find((region) => region.id === hovered)?.label\n          // Facing the viewer, the person's right side is on the left.\n          const [start, end] = view === \"front\" ? [\"R\", \"L\"] : [\"L\", \"R\"]\n          return (\n            <figure\n              key={view}\n              className=\"m-0 flex min-w-0 flex-col items-center gap-2\"\n            >\n              <figcaption className=\"text-sm font-medium\">\n                {viewLabels[view]}\n              </figcaption>\n              <svg\n                viewBox={`0 0 ${width} ${height}`}\n                role=\"group\"\n                aria-label={`${viewLabels[view]} of body`}\n                aria-describedby={helpId}\n                className=\"h-auto w-full max-w-48 touch-manipulation overflow-visible\"\n              >\n                <text\n                  x={12}\n                  y={20}\n                  aria-hidden=\"true\"\n                  className=\"fill-muted-foreground text-[11px] font-medium\"\n                >\n                  {start}\n                </text>\n                <text\n                  x={width - 12}\n                  y={20}\n                  textAnchor=\"end\"\n                  aria-hidden=\"true\"\n                  className=\"fill-muted-foreground text-[11px] font-medium\"\n                >\n                  {end}\n                </text>\n                {items.map((region) => {\n                  const checked = selected.has(region.id)\n                  return (\n                    <path\n                      key={region.id}\n                      ref={(element) => {\n                        if (element) elements.current.set(region.id, element)\n                        else elements.current.delete(region.id)\n                      }}\n                      d={region.d}\n                      transform={\n                        region.mirrored\n                          ? `matrix(-1 0 0 1 ${width} 0)`\n                          : undefined\n                      }\n                      role=\"checkbox\"\n                      aria-checked={checked}\n                      aria-label={region.label}\n                      tabIndex={region === stop ? 0 : -1}\n                      className=\"cursor-pointer fill-muted-foreground/20 stroke-background transition-colors outline-none hover:fill-muted-foreground/40 aria-checked:fill-primary aria-checked:hover:fill-primary/80\"\n                      strokeWidth={1.5}\n                      strokeLinejoin=\"round\"\n                      onClick={() => toggle(region.id)}\n                      onPointerEnter={() => setHovered(region.id)}\n                      onPointerLeave={() =>\n                        setHovered((id) => (id === region.id ? null : id))\n                      }\n                      onFocus={(event) => {\n                        setActive((current) => ({\n                          ...current,\n                          [view]: region.id,\n                        }))\n                        setFocusRing(\n                          event.currentTarget.matches(\":focus-visible\")\n                            ? region.id\n                            : null\n                        )\n                      }}\n                      onBlur={() =>\n                        setFocusRing((id) => (id === region.id ? null : id))\n                      }\n                      onKeyDown={(event) => {\n                        let target: BodyMapRegion | undefined\n                        if (event.key === \"Home\") target = items[0]\n                        else if (event.key === \"End\") target = items.at(-1)\n                        else if (event.key === \" \" || event.key === \"Enter\") {\n                          event.preventDefault()\n                          toggle(region.id)\n                          return\n                        } else target = nextRegion(items, region, event.key)\n                        if (!target && !(event.key in directions)) return\n                        event.preventDefault()\n                        if (target) elements.current.get(target.id)?.focus()\n                      }}\n                    />\n                  )\n                })}\n                {/* Drawn last so the outline is not covered by neighbors. */}\n                {ring && (\n                  <path\n                    d={ring.d}\n                    transform={\n                      ring.mirrored ? `matrix(-1 0 0 1 ${width} 0)` : undefined\n                    }\n                    aria-hidden=\"true\"\n                    className=\"pointer-events-none fill-none stroke-ring\"\n                    strokeWidth={3}\n                    strokeLinejoin=\"round\"\n                  />\n                )}\n              </svg>\n              <p\n                aria-hidden=\"true\"\n                className=\"min-h-5 text-center text-xs text-muted-foreground\"\n              >\n                {hint}\n              </p>\n            </figure>\n          )\n        })}\n      </div>\n      <div className=\"flex flex-col gap-2\">\n        <p\n          id={statusId}\n          role=\"status\"\n          className=\"text-sm text-muted-foreground\"\n        >\n          {chosen.length === 0\n            ? \"No regions selected\"\n            : `${chosen.length} ${chosen.length === 1 ? \"region\" : \"regions\"} selected`}\n        </p>\n        {chosen.length > 0 && (\n          <ul aria-labelledby={statusId} className=\"flex flex-wrap gap-1.5\">\n            {chosen.map((region) => (\n              <li key={region.id}>\n                <Button\n                  variant=\"secondary\"\n                  size=\"xs\"\n                  aria-label={`Remove ${region.label}`}\n                  onClick={() => toggle(region.id)}\n                >\n                  {region.label}\n                  <span aria-hidden=\"true\">×</span>\n                </Button>\n              </li>\n            ))}\n            <li>\n              <Button\n                variant=\"ghost\"\n                size=\"xs\"\n                onClick={() => onValueChange([])}\n              >\n                Clear all\n              </Button>\n            </li>\n          </ul>\n        )}\n      </div>\n      {name &&\n        chosen.map((region) => (\n          <input key={region.id} type=\"hidden\" name={name} value={region.id} />\n        ))}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "Body map docs: https://organui.com/docs/components/body-map",
  "categories": [
    "clinical"
  ],
  "type": "registry:ui"
}