{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-plan-pad",
  "type": "registry:ui",
  "title": "Plan Pad",
  "description": "N labelled points on a scaled top-down plane: a source and microphones in a room, speakers round a listener. The plane keeps the caller's width × depth aspect, each point is bound to two values with optional height (on an elevation strip) and heading (a polar glyph with a rotate handle), and values are clamped to per-point limits and snapped to the axis step. Pointer capture, grab without a jump, nearest-point picking for near misses; every point, handle and elevation marker is a keyboard slider on the XY pad's coarse/fine convention. Edits arrive as per-field patches bracketed by one end per gesture, which planPadParamHandlers binds to parameter paths for a module face. Underlay and overlay slots draw in world units for walls, image sources or anything else.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/plan-pad.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/plan-pad\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * PlanPad — N labelled points on a scaled, top-down plane.\n *\n * Where `XYPad` puts one point under one finger, a plan pad holds several:\n * a sound source and a pair of microphones in a room, speakers around a\n * listener, emitters in a field. Each point is bound to two values (x, y), and\n * optionally to a direction (drawn as a polar glyph with a rotate handle) and\n * a height (edited on an elevation strip under the plan). The pad is not\n * about rooms; a face composes it with whatever its parameters mean.\n *\n * - **Scale.** The plane keeps the aspect of the caller's `width` × `depth`\n *   (world units: metres, say), so a 6 × 4 room draws as 3:2 at any size, and\n *   a direction measured on screen is a direction in the world. `viewMargin`\n *   shows space beyond the bounds for drawing outside them (image sources).\n * - **Values.** Point values are in the plane's axis units (`x`/`y` props,\n *   defaulting to 0..width and 0..depth). A face whose parameters are\n *   normalised passes 0..1 axes and the plane still draws to scale. Every\n *   value is clamped to its axis (narrowed by the point's `limits`) and\n *   snapped to the axis `step` before it leaves the pad.\n * - **Gestures.** Pointer capture via `useSurfacePointer`; a point is grabbed\n *   where it was pressed, so it never jumps under the finger. A press in open\n *   space grabs the nearest point within `hitRadius` px, or nothing — unless\n *   `pressEmpty=\"move-selected\"` asks the XY-pad behaviour for the selected\n *   point. Each gesture reports `onPointChange(id, patch)` per move (only the\n *   fields that changed) and one `onPointChangeEnd(id, point, fields)`: the\n *   change/end bracket `useFaceParamEditGesture` expects, so\n *   `planPadParamHandlers` can bind it to parameters in one line.\n * - **Keyboard.** Every point is a focusable `role=\"slider\"` in tab order:\n *   arrows move it on both axes (plain is coarse, Shift is fine, Home/End park\n *   at the corners — the `XYPadHeadless` convention, from the same\n *   `lib/plane-geometry`), `[`/`]` turn it. A direction handle and an\n *   elevation marker are their own sliders. Each key press is one gesture.\n * - **Drawing.** `underlay` and `overlay` render into an SVG whose user units\n *   are the world units (y down, origin at the plane's low-x/high-y corner),\n *   with `plane.toSvg` to place axis values. Neither takes pointer events.\n *\n * Controlled only: the pad draws `points` and reports edits. While a gesture\n * is in flight (and briefly after, until the host echoes it) the pad shows\n * its own latest values, so a slow host round trip cannot pull a point back.\n */\n\nimport {\n  Fragment,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\"\nimport type {\n  CSSProperties,\n  KeyboardEvent,\n  PointerEvent as ReactPointerEvent,\n  ReactNode,\n} from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  defaultFormat,\n  type ValueFormatter,\n} from \"maruaudio/ui/shared/format\"\nimport { useSurfacePointer } from \"maruaudio/ui/shared/use-surface-pointer\"\nimport { useElementSize } from \"maruaudio/ui/shared/use-element-size\"\nimport {\n  limitPlaneAxis,\n  nearestPlanePoint,\n  planeAngleDegrees,\n  planeAxisFraction,\n  planeAxisKeyStep,\n  planeAxisValue,\n  planeDirectionKeyDelta,\n  planeDirectionKeyStep,\n  planeFractionAtPointer,\n  planeGridFractions,\n  planeKeyStep,\n  polarPatternPath,\n  sanitizePlaneAxis,\n  sanitizePlaneDirection,\n  PLANE_DIRECTION_BOUNDS,\n  type PlaneAxisBounds,\n  type PlaneDirectionBounds,\n  type PlaneHitCandidate,\n} from \"maruaudio/ui/shared/plane-geometry\"\n\n/* ------------------------------------------------------------------ types -- */\n\n/** The values a point can carry. */\nexport type PlanPadField = \"x\" | \"y\" | \"z\" | \"direction\"\n\nexport const PLAN_PAD_FIELDS: ReadonlyArray<PlanPadField> = [\n  \"x\",\n  \"y\",\n  \"z\",\n  \"direction\",\n]\n\nexport type PlanPadShape = \"circle\" | \"square\" | \"diamond\" | \"triangle\"\n\nexport interface PlanPadAxis {\n  min?: number\n  max?: number\n  /** Snap grid. 0 or undefined means continuous. */\n  step?: number\n  /** Spoken in each point's value text and drawn as an axis caption. */\n  label?: string\n  unit?: string\n  format?: ValueFormatter\n}\n\nexport interface PlanPadDirectionConfig {\n  /** Degrees. Defaults to -180..180, which wraps. */\n  min?: number\n  max?: number\n  step?: number\n  format?: ValueFormatter\n}\n\nexport interface PlanPadPoint {\n  /** Stable identity, reported back with every edit. */\n  id: string\n  /** Accessible name and caption. */\n  label: string\n  x: number\n  y: number\n  /** Height. Edited on the elevation strip when `elevation` is set. */\n  z?: number\n  /**\n   * Heading in degrees: 0 along +x, counter-clockwise positive. When present\n   * the point gets a direction ray and a rotate handle.\n   */\n  direction?: number\n  /**\n   * First-order polar pattern drawn around the point when it has a direction:\n   * 1 omni, 0.5 cardioid, 0.25 hypercardioid, 0 figure-8.\n   */\n  directivity?: number\n  /** One or two characters drawn inside the marker. Defaults to the label's first letter. */\n  mark?: string\n  /** Marker colour. Defaults to the accent. */\n  color?: string\n  shape?: PlanPadShape\n  disabled?: boolean\n  /** Narrow this point's travel inside the axes, e.g. to keep it off the walls. */\n  limits?: Partial<\n    Record<\"x\" | \"y\" | \"z\", { min?: number; max?: number }>\n  >\n  /** Double-click reset targets. A field with no default is left alone. */\n  defaults?: Partial<Record<PlanPadField, number>>\n}\n\n/** The fields an edit changed, already clamped and snapped. */\nexport type PlanPadPatch = Partial<Record<PlanPadField, number>>\n\nexport interface PlanPadElevationConfig {\n  /** Vertical extent in the same world units as width and depth. */\n  height: number\n  /** Value axis for z. Defaults to 0..height. */\n  z?: PlanPadAxis\n  /** Accessible name of the strip. Defaults to \"Elevation\". */\n  label?: string\n  /** Pixel bounds on the strip's height; between them it keeps to scale. */\n  minHeight?: number\n  maxHeight?: number\n}\n\n/** What `underlay` and `overlay` are handed. */\nexport interface PlanPadPlane {\n  /** The plane's extent in world units (the bounds, not counting the margin). */\n  width: number\n  depth: number\n  /** World units drawn beyond the bounds on every side. */\n  margin: number\n  /** Rendered plane size in CSS px, 0 until measured. */\n  pixelWidth: number\n  pixelHeight: number\n  /** World units per CSS pixel, 0 until measured: for pixel-sized marks. */\n  unitsPerPixel: number\n  /** Axis values to SVG user units (world units, y down). */\n  toSvg: (x: number, y: number) => { x: number; y: number }\n  /** The points as drawn, including any in-flight edit. */\n  points: ReadonlyArray<PlanPadPoint>\n  selectedId: string | null\n  /** The point being dragged, if any. */\n  activeId: string | null\n}\n\nexport interface PlanPadProps {\n  /** Plane extent along x in world units. Sets the aspect ratio. */\n  width: number\n  /** Plane extent along y in world units. */\n  depth: number\n  points: ReadonlyArray<PlanPadPoint>\n  /** Fires on every change with only the fields that moved. */\n  onPointChange?: (id: string, patch: PlanPadPatch, next: PlanPadPoint) => void\n  /** Fires once per gesture with the final point and every field it touched. */\n  onPointChangeEnd?: (\n    id: string,\n    point: PlanPadPoint,\n    fields: ReadonlyArray<PlanPadField>,\n  ) => void\n\n  /** Horizontal value axis. Defaults to 0..width. */\n  x?: PlanPadAxis\n  /** Vertical value axis, reading bottom-up. Defaults to 0..depth. */\n  y?: PlanPadAxis\n  direction?: PlanPadDirectionConfig\n  /** Adds an elevation strip under the plan for every point with a z. */\n  elevation?: PlanPadElevationConfig\n  /**\n   * Last word on a proposed point, after clamping: keep two mics apart, keep a\n   * source off a mic. Whatever it returns is clamped and snapped again.\n   */\n  constrain?: (\n    id: string,\n    next: PlanPadPoint,\n    current: PlanPadPoint,\n  ) => PlanPadPoint\n\n  selectedId?: string | null\n  defaultSelectedId?: string | null\n  onSelectedChange?: (id: string) => void\n\n  /** World units shown beyond the bounds on every side. */\n  viewMargin?: number\n  /** Grid divisions per axis when `gridSpacing` is not set. 0 hides it. */\n  grid?: number\n  /** Grid spacing in world units (1 for a metre grid). Wins over `grid`. */\n  gridSpacing?: number\n  /** Cap on the rendered width. Defaults to 420 (px); null removes it. */\n  maxWidth?: number | string | null\n  /** Cap on the plan's height in px; the width shrinks to keep the aspect. */\n  maxHeight?: number\n  /** Marker diameter in px. */\n  pointSize?: number\n  /** Distance from a point's centre to its rotate handle, in px. */\n  handleRadius?: number\n  /** How far from a point a press in open space still grabs it, in px. */\n  hitRadius?: number\n  /** What a press in open space does when no point is within reach. */\n  pressEmpty?: \"none\" | \"move-selected\"\n  showLabels?: boolean\n  /** The selected point's values in the corner. */\n  showReadout?: boolean\n\n  underlay?: (plane: PlanPadPlane) => ReactNode\n  overlay?: (plane: PlanPadPlane) => ReactNode\n\n  disabled?: boolean\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/* -------------------------------------------------------------- bindings -- */\n\n/** The change/end pair `useFaceParamEditGesture` returns, structurally. */\nexport interface PlanPadParamEdits {\n  change: (path: string, value: number) => void\n  end: (path: string, value: number) => void\n}\n\n/** Parameter paths per point id and field. Unlisted fields are not bound. */\nexport type PlanPadParamPaths = Readonly<\n  Record<string, Partial<Record<PlanPadField, string>>>\n>\n\n/**\n * Bind a pad's edits to parameter paths. With a face's edit gesture this is\n * the whole binding: each moved field becomes `edit.change(path, value)`, and\n * the gesture's end closes every path it opened with `edit.end`.\n *\n *   const edit = useFaceParamEditGesture(face)\n *   <PlanPad {...planPadParamHandlers(PATHS, edit)} points={...} />\n */\nexport function planPadParamHandlers(\n  paths: PlanPadParamPaths,\n  edit: PlanPadParamEdits,\n): Required<Pick<PlanPadProps, \"onPointChange\" | \"onPointChangeEnd\">> {\n  return {\n    onPointChange: (id, patch) => {\n      const bound = paths[id]\n      if (!bound) return\n      for (const field of PLAN_PAD_FIELDS) {\n        const value = patch[field]\n        const path = bound[field]\n        if (value !== undefined && path) edit.change(path, value)\n      }\n    },\n    onPointChangeEnd: (id, point, fields) => {\n      const bound = paths[id]\n      if (!bound) return\n      for (const field of fields) {\n        const value = point[field]\n        const path = bound[field]\n        if (value !== undefined && path) edit.end(path, value)\n      }\n    },\n  }\n}\n\n/* ---------------------------------------------------------------- helpers -- */\n\ninterface Gesture {\n  id: string\n  kind: \"move\" | \"rotate\" | \"z\"\n  /**\n   * Where the press landed (view fractions, y up) and the point's values\n   * then. A drag applies the pointer's travel to those values, so a grab\n   * never jumps, and an axis the pointer has not moved along keeps its exact\n   * value instead of picking up float noise from a round trip.\n   */\n  anchor: { at: { x: number; y: number }; x: number; y: number; z: number }\n  fields: Set<PlanPadField>\n}\n\ntype Pending = Record<string, PlanPadPatch>\n\n/** How long a released value waits for the host to echo it. */\nconst PENDING_ACK_MS = 1000\n\nfunction axisBounds(\n  config: PlanPadAxis | undefined,\n  fallbackMax: number,\n): PlaneAxisBounds {\n  return {\n    min: config?.min ?? 0,\n    max: config?.max ?? fallbackMax,\n    step: config?.step ?? 0,\n  }\n}\n\nfunction axisFormat(config: PlanPadAxis | undefined, bounds: PlaneAxisBounds) {\n  return (\n    config?.format ??\n    defaultFormat((bounds.step ?? 0) > 0 ? (bounds.step as number) : 0.01, config?.unit)\n  )\n}\n\nconst degrees: ValueFormatter = (value) => `${Math.round(value)}°`\n\n/** Inner (bounds) fraction to outer (view) fraction, and back. */\nfunction toOuter(fraction: number, extent: number, margin: number): number {\n  const total = extent + 2 * margin\n  return total > 0 ? (margin + fraction * extent) / total : fraction\n}\n\nfunction toInner(fraction: number, extent: number, margin: number): number {\n  return extent > 0\n    ? (fraction * (extent + 2 * margin) - margin) / extent\n    : fraction\n}\n\nconst pct = (fraction: number) => `${fraction * 100}%`\n\n/* -------------------------------------------------------------- component -- */\n\nexport function PlanPad({\n  width,\n  depth,\n  points,\n  onPointChange,\n  onPointChangeEnd,\n  x: xConfig,\n  y: yConfig,\n  direction: directionConfig,\n  elevation,\n  constrain,\n  selectedId: selectedProp,\n  defaultSelectedId = null,\n  onSelectedChange,\n  viewMargin = 0,\n  grid = 4,\n  gridSpacing,\n  maxWidth = 420,\n  maxHeight = 360,\n  pointSize = 22,\n  handleRadius = 28,\n  hitRadius = 18,\n  pressEmpty = \"none\",\n  showLabels = true,\n  showReadout = true,\n  underlay,\n  overlay,\n  disabled = false,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: PlanPadProps) {\n  const W = width > 0 ? width : 1\n  const D = depth > 0 ? depth : 1\n  const margin = Math.max(0, viewMargin)\n  const ax = useMemo(() => axisBounds(xConfig, W), [xConfig, W])\n  const ay = useMemo(() => axisBounds(yConfig, D), [yConfig, D])\n  const az = useMemo(\n    () => axisBounds(elevation?.z, elevation?.height ?? 1),\n    [elevation?.z, elevation?.height],\n  )\n  const dirBounds = useMemo<PlaneDirectionBounds>(\n    () => ({\n      min: directionConfig?.min ?? PLANE_DIRECTION_BOUNDS.min,\n      max: directionConfig?.max ?? PLANE_DIRECTION_BOUNDS.max,\n      step: directionConfig?.step ?? 0,\n    }),\n    [directionConfig?.min, directionConfig?.max, directionConfig?.step],\n  )\n  const fx = useMemo(() => axisFormat(xConfig, ax), [xConfig, ax])\n  const fy = useMemo(() => axisFormat(yConfig, ay), [yConfig, ay])\n  const fz = useMemo(() => axisFormat(elevation?.z, az), [elevation?.z, az])\n  const fd = directionConfig?.format ?? degrees\n  const readoutId = useId()\n\n  /* ---- selection ---- */\n  const [selectedInternal, setSelectedInternal] = useState<string | null>(\n    defaultSelectedId,\n  )\n  const selectedId =\n    selectedProp !== undefined ? selectedProp : selectedInternal\n  // A grab selects and then focuses, and focus selects again: read the latest\n  // selection so the host hears about one change, not two.\n  const selectedRef = useRef(selectedId)\n  selectedRef.current = selectedId\n  const select = useCallback(\n    (id: string) => {\n      if (id === selectedRef.current) return\n      selectedRef.current = id\n      if (selectedProp === undefined) setSelectedInternal(id)\n      onSelectedChange?.(id)\n    },\n    [selectedProp, onSelectedChange],\n  )\n\n  /* ---- values: props, sanitised, with in-flight edits on top ---- */\n  const sanitize = useCallback(\n    (point: PlanPadPoint): PlanPadPoint => {\n      const next: PlanPadPoint = {\n        ...point,\n        x: sanitizePlaneAxis(point.x, limitPlaneAxis(ax, point.limits?.x)),\n        y: sanitizePlaneAxis(point.y, limitPlaneAxis(ay, point.limits?.y)),\n      }\n      if (point.z !== undefined && elevation)\n        next.z = sanitizePlaneAxis(point.z, limitPlaneAxis(az, point.limits?.z))\n      if (point.direction !== undefined)\n        next.direction = sanitizePlaneDirection(point.direction, dirBounds)\n      return next\n    },\n    [ax, ay, az, dirBounds, elevation],\n  )\n\n  const pendingRef = useRef<Pending>({})\n  const [pendingVersion, setPendingVersion] = useState(0)\n  const bumpPending = useCallback(() => setPendingVersion((v) => v + 1), [])\n\n  const drawn = useMemo(\n    () =>\n      points.map((point) => {\n        const clean = sanitize(point)\n        const pending = pendingRef.current[point.id]\n        return pending ? { ...clean, ...pending } : clean\n      }),\n    // pendingVersion is the signal that pendingRef changed.\n    [points, sanitize, pendingVersion],\n  )\n  const drawnRef = useRef(drawn)\n  drawnRef.current = drawn\n  const pointById = useCallback(\n    (id: string) => drawnRef.current.find((point) => point.id === id),\n    [],\n  )\n\n  /* ---- gestures ---- */\n  const gestureRef = useRef<Gesture | null>(null)\n  const [activeId, setActiveId] = useState<string | null>(null)\n\n  const commit = useCallback(\n    (id: string, patch: PlanPadPatch) => {\n      const current = pointById(id)\n      if (!current) return\n      let next = sanitize({ ...current, ...patch })\n      if (constrain) next = sanitize({ ...constrain(id, next, current), id })\n      const changed: PlanPadPatch = {}\n      for (const field of PLAN_PAD_FIELDS) {\n        const value = next[field]\n        if (value !== undefined && value !== current[field]) changed[field] = value\n      }\n      if (Object.keys(changed).length === 0) return\n      pendingRef.current = {\n        ...pendingRef.current,\n        [id]: { ...pendingRef.current[id], ...changed },\n      }\n      // Later reads in the same gesture see the new value before React renders.\n      drawnRef.current = drawnRef.current.map((point) =>\n        point.id === id ? next : point,\n      )\n      for (const field of Object.keys(changed) as PlanPadField[])\n        gestureRef.current?.fields.add(field)\n      bumpPending()\n      onPointChange?.(id, changed, next)\n    },\n    [pointById, sanitize, constrain, bumpPending, onPointChange],\n  )\n\n  const finish = useCallback(() => {\n    const gesture = gestureRef.current\n    gestureRef.current = null\n    setActiveId(null)\n    if (!gesture || gesture.fields.size === 0) return\n    const point = pointById(gesture.id)\n    if (point) onPointChangeEnd?.(gesture.id, point, [...gesture.fields])\n  }, [pointById, onPointChangeEnd])\n\n  /** One keyboard or double-click edit: a whole gesture in one go. */\n  const editOnce = useCallback(\n    (id: string, kind: Gesture[\"kind\"], patch: PlanPadPatch) => {\n      if (gestureRef.current) return\n      gestureRef.current = {\n        id,\n        kind,\n        anchor: { at: { x: 0, y: 0 }, x: 0, y: 0, z: 0 },\n        fields: new Set(),\n      }\n      commit(id, patch)\n      finish()\n    },\n    [commit, finish],\n  )\n\n  // Drop in-flight values once the host echoes them, or give up after a\n  // second so a host that clamps or refuses an edit becomes authoritative.\n  useEffect(() => {\n    if (activeId !== null) return\n    const pending = pendingRef.current\n    const ids = Object.keys(pending)\n    if (ids.length === 0) return\n    let changed = false\n    const kept: Pending = {}\n    for (const id of ids) {\n      const prop = points.find((point) => point.id === id)\n      if (!prop) {\n        changed = true\n        continue\n      }\n      const clean = sanitize(prop)\n      const left: PlanPadPatch = {}\n      for (const [field, value] of Object.entries(pending[id]!) as Array<\n        [PlanPadField, number]\n      >) {\n        if (clean[field] === value) changed = true\n        else left[field] = value\n      }\n      if (Object.keys(left).length > 0) kept[id] = left\n    }\n    if (changed) {\n      pendingRef.current = kept\n      bumpPending()\n    }\n    if (Object.keys(kept).length === 0) return\n    const timer = setTimeout(() => {\n      pendingRef.current = {}\n      bumpPending()\n    }, PENDING_ACK_MS)\n    return () => clearTimeout(timer)\n  }, [points, activeId, pendingVersion, sanitize, bumpPending])\n\n  /* ---- geometry ---- */\n  const [planSizeRef, planSize, planElement] = useElementSize<HTMLDivElement>()\n  const elevationElementRef = useRef<HTMLDivElement | null>(null)\n  const focusRefs = useRef(new Map<string, HTMLElement>())\n  const focusKey = (kind: Gesture[\"kind\"], id: string) => `${kind}:${id}`\n\n  const outerX = useCallback(\n    (value: number) => toOuter(planeAxisFraction(value, ax), W, margin),\n    [ax, W, margin],\n  )\n  const outerY = useCallback(\n    (value: number) => toOuter(planeAxisFraction(value, ay), D, margin),\n    [ay, D, margin],\n  )\n  const plane: PlanPadPlane = {\n    width: W,\n    depth: D,\n    margin,\n    pixelWidth: planSize.width,\n    pixelHeight: planSize.height,\n    unitsPerPixel: planSize.width > 0 ? (W + 2 * margin) / planSize.width : 0,\n    toSvg: (x, y) => ({\n      x: planeAxisFraction(x, ax) * W,\n      y: (1 - planeAxisFraction(y, ay)) * D,\n    }),\n    points: drawn,\n    selectedId,\n    activeId,\n  }\n\n  /* ---- pointer on the plan ---- */\n  const planDrag = useSurfacePointer(\n    (state) => {\n      const gesture = gestureRef.current\n      if (!gesture || gesture.kind === \"z\" || !planElement) return\n      const point = pointById(gesture.id)\n      if (!point) return\n      const at = planeFractionAtPointer(\n        planElement.getBoundingClientRect(),\n        state.clientX,\n        state.clientY,\n      )\n      if (!at) return\n      if (gesture.kind === \"rotate\") {\n        const rect = planElement.getBoundingClientRect()\n        const angle = planeAngleDegrees(\n          (at.x - outerX(point.x)) * rect.width,\n          (at.y - outerY(point.y)) * rect.height,\n        )\n        if (angle !== null) commit(gesture.id, { direction: angle })\n        return\n      }\n      // View fractions to value travel: the view is (extent + 2·margin) wide\n      // and the axis spans the extent.\n      const { anchor } = gesture\n      commit(gesture.id, {\n        x: anchor.x + (at.x - anchor.at.x) * ((W + 2 * margin) / W) * (ax.max - ax.min),\n        y: anchor.y + (at.y - anchor.at.y) * ((D + 2 * margin) / D) * (ay.max - ay.min),\n      })\n    },\n    { disabled, onEnd: finish },\n  )\n\n  const startPlanGesture = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (disabled || event.button !== 0 || gestureRef.current || !planElement)\n      return\n    const rect = planElement.getBoundingClientRect()\n    const target = (event.target as Element | null)?.closest?.(\n      \"[data-plan-pad-point]\",\n    )\n    let id = target?.getAttribute(\"data-plan-pad-point\") ?? null\n    let kind: Gesture[\"kind\"] =\n      target?.getAttribute(\"data-plan-pad-handle\") === \"rotate\" ? \"rotate\" : \"move\"\n    let jump = false\n    if (!id) {\n      const candidates: PlaneHitCandidate[] = drawnRef.current\n        .filter((point) => !point.disabled)\n        .map((point) => ({\n          id: point.id,\n          clientX: rect.left + outerX(point.x) * rect.width,\n          clientY: rect.top + (1 - outerY(point.y)) * rect.height,\n        }))\n      id = nearestPlanePoint(candidates, event.clientX, event.clientY, hitRadius)\n      kind = \"move\"\n      if (!id && pressEmpty === \"move-selected\" && selectedId) {\n        id = selectedId\n        jump = true\n      }\n    }\n    const point = id ? pointById(id) : undefined\n    if (!id || !point || point.disabled) return\n    const at = planeFractionAtPointer(rect, event.clientX, event.clientY)\n    if (!at) return\n    gestureRef.current = {\n      id,\n      kind,\n      // A jump anchors on the pressed spot itself, so the point lands there.\n      anchor: jump\n        ? {\n            at,\n            x: planeAxisValue(toInner(at.x, W, margin), ax),\n            y: planeAxisValue(toInner(at.y, D, margin), ay),\n            z: point.z ?? 0,\n          }\n        : { at, x: point.x, y: point.y, z: point.z ?? 0 },\n      fields: new Set(),\n    }\n    setActiveId(id)\n    select(id)\n    focusRefs.current\n      .get(focusKey(kind, id))\n      ?.focus({ preventScroll: true })\n    planDrag.onPointerDown(event)\n  }\n\n  /* ---- pointer on the elevation strip ---- */\n  const elevationDrag = useSurfacePointer(\n    (state) => {\n      const gesture = gestureRef.current\n      const strip = elevationElementRef.current\n      if (!gesture || gesture.kind !== \"z\" || !strip) return\n      const at = planeFractionAtPointer(\n        strip.getBoundingClientRect(),\n        state.clientX,\n        state.clientY,\n      )\n      if (!at) return\n      const { anchor } = gesture\n      commit(gesture.id, { z: anchor.z + (at.y - anchor.at.y) * (az.max - az.min) })\n    },\n    { disabled, onEnd: finish },\n  )\n\n  const startElevationGesture = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const strip = elevationElementRef.current\n    if (disabled || event.button !== 0 || gestureRef.current || !strip) return\n    const rect = strip.getBoundingClientRect()\n    const target = (event.target as Element | null)?.closest?.(\n      \"[data-plan-pad-elevation]\",\n    )\n    let id = target?.getAttribute(\"data-plan-pad-elevation\") ?? null\n    let jump = false\n    const withZ = drawnRef.current.filter(\n      (point) => point.z !== undefined && !point.disabled,\n    )\n    if (!id) {\n      id = nearestPlanePoint(\n        withZ.map((point) => ({\n          id: point.id,\n          clientX: rect.left + outerX(point.x) * rect.width,\n          clientY:\n            rect.top + (1 - planeAxisFraction(point.z as number, az)) * rect.height,\n        })),\n        event.clientX,\n        event.clientY,\n        hitRadius,\n      )\n      if (\n        !id &&\n        pressEmpty === \"move-selected\" &&\n        selectedId &&\n        withZ.some((point) => point.id === selectedId)\n      ) {\n        id = selectedId\n        jump = true\n      }\n    }\n    const point = id ? pointById(id) : undefined\n    if (!id || !point || point.z === undefined || point.disabled) return\n    const at = planeFractionAtPointer(rect, event.clientX, event.clientY)\n    if (!at) return\n    gestureRef.current = {\n      id,\n      kind: \"z\",\n      anchor: {\n        at,\n        x: point.x,\n        y: point.y,\n        z: jump ? planeAxisValue(at.y, az) : point.z,\n      },\n      fields: new Set(),\n    }\n    setActiveId(id)\n    select(id)\n    focusRefs.current.get(focusKey(\"z\", id))?.focus({ preventScroll: true })\n    elevationDrag.onPointerDown(event)\n  }\n\n  /* ---- keyboard ---- */\n  const limitedAxes = (point: PlanPadPoint) => ({\n    x: limitPlaneAxis(ax, point.limits?.x),\n    y: limitPlaneAxis(ay, point.limits?.y),\n    z: limitPlaneAxis(az, point.limits?.z),\n  })\n\n  const onPointKeyDown = (id: string) => (event: KeyboardEvent<HTMLElement>) => {\n    const point = pointById(id)\n    if (disabled || !point || point.disabled) return\n    let patch: PlanPadPatch | null = null\n    if ((event.key === \"[\" || event.key === \"]\") && point.direction !== undefined) {\n      const turn = planeDirectionKeyDelta(dirBounds, event.shiftKey)\n      patch = { direction: point.direction + (event.key === \"]\" ? turn : -turn) }\n    } else {\n      const axes = limitedAxes(point)\n      const next = planeKeyStep(event.key, event.shiftKey, point, axes.x, axes.y)\n      if (next) patch = next\n    }\n    if (!patch) return\n    event.preventDefault()\n    select(id)\n    editOnce(id, \"move\", patch)\n  }\n\n  const onDirectionKeyDown =\n    (id: string) => (event: KeyboardEvent<HTMLElement>) => {\n      const point = pointById(id)\n      if (disabled || !point || point.disabled || point.direction === undefined)\n        return\n      const next = planeDirectionKeyStep(\n        event.key,\n        event.shiftKey,\n        point.direction,\n        dirBounds,\n      )\n      if (next === null) return\n      event.preventDefault()\n      select(id)\n      editOnce(id, \"rotate\", { direction: next })\n    }\n\n  const onElevationKeyDown =\n    (id: string) => (event: KeyboardEvent<HTMLElement>) => {\n      const point = pointById(id)\n      if (disabled || !point || point.disabled || point.z === undefined) return\n      const next = planeAxisKeyStep(\n        event.key,\n        event.shiftKey,\n        point.z,\n        limitedAxes(point).z,\n      )\n      if (next === null) return\n      event.preventDefault()\n      select(id)\n      editOnce(id, \"z\", { z: next })\n    }\n\n  const resetFields = (id: string, fields: ReadonlyArray<PlanPadField>) => {\n    const point = pointById(id)\n    if (disabled || !point || point.disabled || !point.defaults) return\n    const patch: PlanPadPatch = {}\n    for (const field of fields) {\n      const value = point.defaults[field]\n      if (value !== undefined && point[field] !== undefined) patch[field] = value\n    }\n    if (Object.keys(patch).length > 0) editOnce(id, \"move\", patch)\n  }\n\n  /* ---- drawing ---- */\n  const gridX = planeGridFractions(W, { spacing: gridSpacing, divisions: grid })\n  const gridY = planeGridFractions(D, { spacing: gridSpacing, divisions: grid })\n  const outerW = W + 2 * margin\n  const outerD = D + 2 * margin\n  const aspect = outerW / outerD\n  const fitWidth = `min(100%, ${Math.round(maxHeight * aspect)}px)`\n  const selected = drawn.find((point) => point.id === selectedId)\n  const zLabel = elevation?.z?.label ?? \"height\"\n\n  const valueText = (point: PlanPadPoint) => {\n    const parts = [\n      `${xConfig?.label ?? \"x\"} ${fx(point.x)}`,\n      `${yConfig?.label ?? \"y\"} ${fy(point.y)}`,\n    ]\n    if (elevation && point.z !== undefined) parts.push(`${zLabel} ${fz(point.z)}`)\n    if (point.direction !== undefined) parts.push(`facing ${fd(point.direction)}`)\n    return parts.join(\", \")\n  }\n\n  const registerFocus =\n    (kind: Gesture[\"kind\"], id: string) => (node: HTMLElement | null) => {\n      if (node) focusRefs.current.set(focusKey(kind, id), node)\n      else focusRefs.current.delete(focusKey(kind, id))\n    }\n\n  const glyphSize = handleRadius * 2 + 8\n\n  return (\n    <div\n      className={className}\n      data-plan-pad=\"\"\n      style={{\n        display: \"flex\",\n        flexDirection: \"column\",\n        gap: 6,\n        width: fitWidth,\n        ...(maxWidth == null ? {} : { maxWidth }),\n        fontFamily: token.font,\n        ...style,\n      }}\n    >\n      <div\n        ref={planSizeRef}\n        role=\"group\"\n        aria-label={ariaLabel ?? \"Plan\"}\n        aria-describedby={showReadout && selected ? readoutId : undefined}\n        aria-disabled={disabled || undefined}\n        data-plan-pad-plane=\"\"\n        onPointerDown={startPlanGesture}\n        style={{\n          position: \"relative\",\n          width: \"100%\",\n          aspectRatio: `${outerW} / ${outerD}`,\n          borderRadius: token.radius,\n          background: token.surface,\n          border: `1px solid ${token.border}`,\n          overflow: \"hidden\",\n          touchAction: \"none\",\n          userSelect: \"none\",\n          cursor: disabled ? \"not-allowed\" : \"default\",\n        }}\n      >\n        <svg\n          aria-hidden\n          viewBox={`${-margin} ${-margin} ${outerW} ${outerD}`}\n          preserveAspectRatio=\"none\"\n          style={layerStyle}\n        >\n          {gridX.map((fraction) => (\n            <line\n              key={`gx${fraction}`}\n              x1={fraction * W}\n              x2={fraction * W}\n              y1={0}\n              y2={D}\n              stroke={token.border}\n              strokeOpacity={0.45}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          ))}\n          {gridY.map((fraction) => (\n            <line\n              key={`gy${fraction}`}\n              x1={0}\n              x2={W}\n              y1={(1 - fraction) * D}\n              y2={(1 - fraction) * D}\n              stroke={token.border}\n              strokeOpacity={0.45}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          ))}\n          {margin > 0 && (\n            <rect\n              data-plan-pad-bounds=\"\"\n              x={0}\n              y={0}\n              width={W}\n              height={D}\n              fill=\"none\"\n              stroke={token.textMuted}\n              strokeWidth={1.5}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          )}\n          {underlay?.(plane)}\n        </svg>\n\n        {/* Direction rays and polar glyphs, pixel-sized around each point. */}\n        {drawn.map((point) =>\n          point.direction === undefined ? null : (\n            <svg\n              key={`glyph-${point.id}`}\n              aria-hidden\n              data-plan-pad-glyph={point.id}\n              width={glyphSize}\n              height={glyphSize}\n              viewBox={`${-glyphSize / 2} ${-glyphSize / 2} ${glyphSize} ${glyphSize}`}\n              style={{\n                position: \"absolute\",\n                left: pct(outerX(point.x)),\n                top: pct(1 - outerY(point.y)),\n                transform: \"translate(-50%, -50%)\",\n                overflow: \"visible\",\n                pointerEvents: \"none\",\n              }}\n            >\n              {point.directivity !== undefined && (\n                <path\n                  d={polarPatternPath(\n                    point.directivity,\n                    handleRadius - 4,\n                    point.direction,\n                  )}\n                  fill={`color-mix(in srgb, ${point.color ?? token.accent} 20%, transparent)`}\n                  stroke={point.color ?? token.accent}\n                  strokeOpacity={0.9}\n                  strokeWidth={1}\n                />\n              )}\n              <line\n                x1={0}\n                y1={0}\n                x2={handleRadius * Math.cos((point.direction * Math.PI) / 180)}\n                y2={-handleRadius * Math.sin((point.direction * Math.PI) / 180)}\n                stroke={point.color ?? token.accent}\n                strokeWidth={1.5}\n              />\n            </svg>\n          ),\n        )}\n\n        {drawn.map((point) => {\n          const left = outerX(point.x)\n          const top = 1 - outerY(point.y)\n          const color = point.color ?? token.accent\n          const active = activeId === point.id || selectedId === point.id\n          const pointDisabled = disabled || point.disabled\n          return (\n            <Fragment key={point.id}>\n              {showLabels && (\n                <span\n                  aria-hidden\n                  style={{\n                    position: \"absolute\",\n                    left: pct(left),\n                    top: `calc(${pct(top)} - ${pointSize / 2 + 13}px)`,\n                    transform: \"translateX(-50%)\",\n                    fontSize: 9,\n                    fontWeight: 600,\n                    letterSpacing: \"0.06em\",\n                    textTransform: \"uppercase\",\n                    whiteSpace: \"nowrap\",\n                    color: active ? token.text : token.textMuted,\n                    pointerEvents: \"none\",\n                  }}\n                >\n                  {point.label}\n                </span>\n              )}\n              <div\n                ref={registerFocus(\"move\", point.id)}\n                role=\"slider\"\n                tabIndex={pointDisabled ? -1 : 0}\n                className=\"uikit-control\"\n                aria-label={point.label}\n                aria-roledescription=\"position\"\n                aria-valuemin={ay.min}\n                aria-valuemax={ay.max}\n                aria-valuenow={point.y}\n                aria-valuetext={valueText(point)}\n                aria-disabled={pointDisabled || undefined}\n                data-plan-pad-point={point.id}\n                data-plan-pad-handle=\"move\"\n                data-active={active || undefined}\n                onKeyDown={onPointKeyDown(point.id)}\n                onFocus={() => select(point.id)}\n                onDoubleClick={() => resetFields(point.id, [\"x\", \"y\"])}\n                style={{\n                  position: \"absolute\",\n                  left: pct(left),\n                  top: pct(top),\n                  width: pointSize,\n                  height: pointSize,\n                  transform: \"translate(-50%, -50%)\",\n                  zIndex: active ? 3 : 2,\n                  cursor: pointDisabled\n                    ? \"not-allowed\"\n                    : activeId === point.id\n                      ? \"grabbing\"\n                      : \"grab\",\n                  opacity: point.disabled ? 0.45 : 1,\n                }}\n              >\n                <PointMark\n                  shape={point.shape ?? \"circle\"}\n                  color={color}\n                  active={active}\n                  dragging={activeId === point.id}\n                  mark={point.mark ?? point.label.slice(0, 1)}\n                />\n              </div>\n              {point.direction !== undefined && (\n                <div\n                  ref={registerFocus(\"rotate\", point.id)}\n                  role=\"slider\"\n                  tabIndex={pointDisabled ? -1 : 0}\n                  className=\"uikit-control\"\n                  aria-label={`${point.label} direction`}\n                  aria-valuemin={dirBounds.min}\n                  aria-valuemax={dirBounds.max}\n                  aria-valuenow={point.direction}\n                  aria-valuetext={fd(point.direction)}\n                  aria-disabled={pointDisabled || undefined}\n                  data-plan-pad-point={point.id}\n                  data-plan-pad-handle=\"rotate\"\n                  onKeyDown={onDirectionKeyDown(point.id)}\n                  onFocus={() => select(point.id)}\n                  onDoubleClick={() => resetFields(point.id, [\"direction\"])}\n                  style={{\n                    position: \"absolute\",\n                    left: `calc(${pct(left)} + ${handleRadius * Math.cos((point.direction * Math.PI) / 180)}px)`,\n                    top: `calc(${pct(top)} - ${handleRadius * Math.sin((point.direction * Math.PI) / 180)}px)`,\n                    width: 12,\n                    height: 12,\n                    transform: \"translate(-50%, -50%)\",\n                    borderRadius: 999,\n                    background: token.surface,\n                    border: `2px solid ${color}`,\n                    boxSizing: \"border-box\",\n                    zIndex: 4,\n                    cursor: pointDisabled ? \"not-allowed\" : \"alias\",\n                  }}\n                />\n              )}\n            </Fragment>\n          )\n        })}\n\n        {overlay && (\n          <svg\n            aria-hidden\n            viewBox={`${-margin} ${-margin} ${outerW} ${outerD}`}\n            preserveAspectRatio=\"none\"\n            style={{ ...layerStyle, zIndex: 5 }}\n          >\n            {overlay(plane)}\n          </svg>\n        )}\n\n        {(xConfig?.label || yConfig?.label) && (\n          <>\n            {xConfig?.label && (\n              <span aria-hidden style={{ ...axisCaption, right: 8, bottom: 6 }}>\n                {xConfig.label} →\n              </span>\n            )}\n            {yConfig?.label && (\n              <span\n                aria-hidden\n                style={{\n                  ...axisCaption,\n                  left: 6,\n                  top: 8,\n                  writingMode: \"vertical-rl\",\n                  transform: \"rotate(180deg)\",\n                }}\n              >\n                {yConfig.label} →\n              </span>\n            )}\n          </>\n        )}\n\n        {showReadout && selected && (\n          <span\n            id={readoutId}\n            data-plan-pad-readout=\"\"\n            style={{\n              position: \"absolute\",\n              top: 6,\n              right: 6,\n              zIndex: 6,\n              fontFamily: token.fontMono,\n              fontSize: 10,\n              fontVariantNumeric: \"tabular-nums\",\n              color: token.textMuted,\n              background: \"rgba(0,0,0,0.25)\",\n              borderRadius: 4,\n              padding: \"2px 6px\",\n              // Clear of the vertical axis caption, and one line at any width.\n              maxWidth: \"calc(100% - 34px)\",\n              whiteSpace: \"nowrap\",\n              overflow: \"hidden\",\n              textOverflow: \"ellipsis\",\n              pointerEvents: \"none\",\n            }}\n          >\n            {selected.label} · {fx(selected.x)} · {fy(selected.y)}\n            {elevation && selected.z !== undefined ? ` · ${fz(selected.z)}` : \"\"}\n            {selected.direction !== undefined ? ` · ${fd(selected.direction)}` : \"\"}\n          </span>\n        )}\n      </div>\n\n      {elevation && (\n        <div\n          ref={elevationElementRef}\n          role=\"group\"\n          aria-label={elevation.label ?? \"Elevation\"}\n          aria-disabled={disabled || undefined}\n          data-plan-pad-elevation-strip=\"\"\n          onPointerDown={startElevationGesture}\n          style={{\n            position: \"relative\",\n            width: \"100%\",\n            aspectRatio: `${outerW} / ${elevation.height > 0 ? elevation.height : 1}`,\n            minHeight: elevation.minHeight ?? 44,\n            maxHeight: elevation.maxHeight ?? 140,\n            borderRadius: token.radius,\n            background: token.surface,\n            border: `1px solid ${token.border}`,\n            overflow: \"hidden\",\n            touchAction: \"none\",\n            userSelect: \"none\",\n          }}\n        >\n          {/* The bounds' footprint on the floor line. */}\n          <span\n            aria-hidden\n            style={{\n              position: \"absolute\",\n              left: pct(toOuter(0, W, margin)),\n              right: pct(1 - toOuter(1, W, margin)),\n              top: 0,\n              bottom: 0,\n              borderLeft: margin > 0 ? `1px solid ${token.textMuted}` : undefined,\n              borderRight: margin > 0 ? `1px solid ${token.textMuted}` : undefined,\n              pointerEvents: \"none\",\n            }}\n          />\n          {drawn.map((point) => {\n            if (point.z === undefined) return null\n            const color = point.color ?? token.accent\n            const active = activeId === point.id || selectedId === point.id\n            const pointDisabled = disabled || point.disabled\n            const left = pct(outerX(point.x))\n            const bottom = pct(planeAxisFraction(point.z, az))\n            return (\n              <Fragment key={point.id}>\n                <span\n                  aria-hidden\n                  style={{\n                    position: \"absolute\",\n                    left,\n                    bottom: 0,\n                    height: bottom,\n                    width: 1,\n                    background: color,\n                    opacity: 0.45,\n                    pointerEvents: \"none\",\n                  }}\n                />\n                <div\n                  ref={registerFocus(\"z\", point.id)}\n                  role=\"slider\"\n                  tabIndex={pointDisabled ? -1 : 0}\n                  className=\"uikit-control\"\n                  aria-label={`${point.label} ${zLabel}`}\n                  aria-orientation=\"vertical\"\n                  aria-valuemin={az.min}\n                  aria-valuemax={az.max}\n                  aria-valuenow={point.z}\n                  aria-valuetext={fz(point.z)}\n                  aria-disabled={pointDisabled || undefined}\n                  data-plan-pad-elevation={point.id}\n                  data-active={active || undefined}\n                  onKeyDown={onElevationKeyDown(point.id)}\n                  onFocus={() => select(point.id)}\n                  onDoubleClick={() => resetFields(point.id, [\"z\"])}\n                  style={{\n                    position: \"absolute\",\n                    left,\n                    bottom,\n                    width: pointSize * 0.75,\n                    height: pointSize * 0.75,\n                    transform: \"translate(-50%, 50%)\",\n                    zIndex: active ? 3 : 2,\n                    cursor: pointDisabled ? \"not-allowed\" : \"ns-resize\",\n                    opacity: point.disabled ? 0.45 : 1,\n                  }}\n                >\n                  <PointMark\n                    shape={point.shape ?? \"circle\"}\n                    color={color}\n                    active={active}\n                    dragging={activeId === point.id}\n                    mark=\"\"\n                  />\n                </div>\n              </Fragment>\n            )\n          })}\n          <span aria-hidden style={{ ...axisCaption, left: 6, top: 5 }}>\n            {elevation.z?.label ?? elevation.label ?? \"Height\"} ↑\n          </span>\n        </div>\n      )}\n    </div>\n  )\n}\n\n/* ------------------------------------------------------------------ marks -- */\n\nconst SHAPE_CLIP: Record<PlanPadShape, string | undefined> = {\n  circle: undefined,\n  square: undefined,\n  diamond: \"polygon(50% 0, 100% 50%, 50% 100%, 0 50%)\",\n  triangle: \"polygon(50% 4%, 100% 92%, 0 92%)\",\n}\n\nfunction PointMark({\n  shape,\n  color,\n  active,\n  dragging,\n  mark,\n}: {\n  shape: PlanPadShape\n  color: string\n  active: boolean\n  dragging: boolean\n  mark: string\n}) {\n  const clip = SHAPE_CLIP[shape]\n  return (\n    <span\n      aria-hidden\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        display: \"grid\",\n        placeItems: \"center\",\n        // A halo cannot follow a clip-path, so clipped shapes glow through a\n        // drop shadow on the wrapper instead of a box shadow on the face.\n        filter: dragging\n          ? `drop-shadow(0 0 6px ${color})`\n          : active\n            ? `drop-shadow(0 0 3px ${color})`\n            : undefined,\n        pointerEvents: \"none\",\n      }}\n    >\n      <span\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          background: color,\n          borderRadius: shape === \"circle\" ? 999 : shape === \"square\" ? 3 : 0,\n          clipPath: clip,\n          border: clip ? undefined : \"1px solid rgba(255,255,255,0.55)\",\n          boxSizing: \"border-box\",\n        }}\n      />\n      {mark && (\n        <span\n          style={{\n            position: \"relative\",\n            fontFamily: token.fontMono,\n            fontSize: 9,\n            fontWeight: 700,\n            lineHeight: 1,\n            color: \"rgba(0,0,0,0.78)\",\n            marginTop: shape === \"triangle\" ? 4 : 0,\n          }}\n        >\n          {mark}\n        </span>\n      )}\n    </span>\n  )\n}\n\nconst layerStyle: CSSProperties = {\n  position: \"absolute\",\n  inset: 0,\n  width: \"100%\",\n  height: \"100%\",\n  pointerEvents: \"none\",\n}\n\nconst axisCaption: CSSProperties = {\n  position: \"absolute\",\n  fontSize: 9,\n  fontWeight: 600,\n  letterSpacing: \"0.08em\",\n  textTransform: \"uppercase\",\n  color: token.textMuted,\n  pointerEvents: \"none\",\n}\n",
      "type": "registry:component",
      "target": "@ui/plan-pad.tsx"
    }
  ]
}
