{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "compass",
  "title": "Compass",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Bússola em camadas: a agulha vai até o ponteiro, passa e volta, e o mostrador inclina em direção à mão.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "@visant/use-idle"
  ],
  "files": [
    {
      "path": "registry/visant/ui/compass.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useMotionValue, useMotionValueEvent, useReducedMotion, useSpring, useTransform } from \"motion/react\";\n\nimport { useIdle } from \"@/hooks/use-idle\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CompassProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  /** Para onde a agulha aponta, em graus (0 = norte, sentido horário). Passando esta prop, quem manda é você. */\n  bearing?: number;\n  /** Onde a agulha nasce quando a peça é livre. */\n  defaultBearing?: number;\n  /**\n   * Quanto o mostrador inclina em direção ao ponteiro, em graus.\n   * @min 0\n   * @max 30\n   * @step 1\n   */\n  depth?: number;\n  /** A letra do topo do mostrador. */\n  north?: string;\n  /** Sozinha na tela, a agulha procura o norte de tempos em tempos. */\n  idle?: boolean;\n  /** Nome do controle para leitor de tela. */\n  label?: string;\n  onBearingChange?: (bearing: number) => void;\n}\n\nconst MARCAS = Array.from({ length: 36 }, (_, i) => i * 10);\nconst INCLINA = { stiffness: 120, damping: 16 };\n// A inclinação com que nasce, em fração de `depth`: a foto já mostra as camadas.\nconst NASCE = { x: -0.8, y: -0.65 };\n\n/**\n * Bússola: a agulha vai até o ponteiro como uma de verdade vai ao norte, passa, volta e\n * para. O mostrador inclina em direção à mão, e a distância entre as camadas é que dá a\n * profundidade.\n *\n * - **Ângulo desenrolado.** O alvo é a menor volta a partir de onde a agulha está. Sem\n *   isso, cruzar de 179° para -179° dá uma volta inteira para trás.\n * - **Mola subamortecida de propósito.** A passada do ponto é o magnetismo.\n * - **Camadas de verdade em `preserve-3d`.** Sombra, base, marcas, agulha, eixo e vidro\n *   ficam em alturas diferentes e se deslocam entre si ao inclinar. Por isso a peça nasce\n *   inclinada: profundidade não pode depender de hover.\n * - **`touch-none` só no mostrador.** Gesto circular não tem eixo para ceder à rolagem, e\n *   as camadas não pegam ponteiro: em 3D elas passam da borda, e o dedo que começa fora do\n *   mostrador tem de rolar a página. O mouse segue a peça inteira.\n * - **Movimento reduzido:** a agulha só aponta, e a inclinação fica na pose de nascença.\n * - **Marca em forma, nunca em texto.** A marca vai no eixo; a letra usa `--foreground`.\n */\nexport function Compass({\n  bearing,\n  defaultBearing = -62,\n  depth = 18,\n  north = \"N\",\n  idle = true,\n  label = \"Direção\",\n  onBearingChange,\n  className,\n  ...rest\n}: CompassProps) {\n  const reduz = useReducedMotion();\n  const caixa = React.useRef<HTMLDivElement>(null);\n  const disco = React.useRef<HTMLDivElement>(null);\n  const preso = React.useRef(false);\n  const alvo = React.useRef(bearing ?? defaultBearing);\n  const angulo = useSpring(alvo.current, { stiffness: 120, damping: 7 });\n  const nx = useMotionValue(NASCE.x);\n  const ny = useMotionValue(NASCE.y);\n  const sx = useSpring(nx, INCLINA);\n  const sy = useSpring(ny, INCLINA);\n  const px = reduz ? nx : sx;\n  const py = reduz ? ny : sy;\n  const rotateY = useTransform(px, (v) => v * depth);\n  const rotateX = useTransform(py, (v) => -v * depth);\n  // A sombra foge da mão e o brilho vai atrás dela: é o que convence de que há altura.\n  const sombraX = useTransform(px, (v) => `${-v * 9}%`);\n  const sombraY = useTransform(py, (v) => `${5 - v * 9}%`);\n  const brilhoX = useTransform(px, (v) => `${v * 26}%`);\n  const brilhoY = useTransform(py, (v) => `${v * 26}%`);\n  const [grau, setGrau] = React.useState(() => Math.round(((alvo.current % 360) + 360) % 360));\n\n  useMotionValueEvent(angulo, \"change\", (a) => setGrau(Math.round(((a % 360) + 360) % 360)));\n\n  const apontar = React.useCallback(\n    (deg: number, avisar = true) => {\n      alvo.current += ((((deg - alvo.current + 540) % 360) + 360) % 360) - 180;\n      if (reduz) angulo.jump(alvo.current);\n      else angulo.set(alvo.current);\n      if (avisar) onBearingChange?.(((deg % 360) + 360) % 360);\n    },\n    [reduz, angulo, onBearingChange],\n  );\n\n  React.useEffect(() => {\n    if (bearing !== undefined) apontar(bearing, false);\n  }, [bearing, apontar]);\n\n  const livre = bearing === undefined;\n  const poke = useIdle(\n    caixa,\n    () => {\n      apontar((Math.random() - 0.5) * 120, false);\n      nx.set((Math.random() - 0.5) * 1.6);\n      ny.set((Math.random() - 0.5) * 1.6);\n    },\n    2800,\n    idle && livre,\n  );\n\n  const seguir = (e: React.PointerEvent) => {\n    if (e.pointerType !== \"mouse\" && !preso.current) return;\n    const r = disco.current?.getBoundingClientRect();\n    if (!r) return;\n    poke();\n    const dx = e.clientX - (r.left + r.width / 2);\n    const dy = e.clientY - (r.top + r.height / 2);\n    const raio = r.width / 2 || 1;\n    nx.set(Math.max(-1, Math.min(1, dx / raio)));\n    ny.set(Math.max(-1, Math.min(1, dy / raio)));\n    if (livre && Math.hypot(dx, dy) >= 6) apontar((Math.atan2(dy, dx) * 180) / Math.PI + 90); // centro não tem direção\n  };\n  const aoSair = () => {\n    preso.current = false;\n    nx.set(NASCE.x);\n    ny.set(NASCE.y);\n  };\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const passo = ({ ArrowRight: 5, ArrowUp: 5, ArrowLeft: -5, ArrowDown: -5, PageUp: 45, PageDown: -45 } as Record<string, number>)[e.key];\n    if (!passo || !livre) return;\n    e.preventDefault();\n    poke();\n    // A seta parte do que está na tela, não de onde a mola ainda ia chegar.\n    alvo.current = angulo.get();\n    apontar(alvo.current + passo);\n  };\n\n  const camada = (z: number): React.CSSProperties => ({ transform: `translateZ(${z}px)` });\n\n  return (\n    <div\n      ref={caixa}\n      onPointerMove={seguir}\n      onPointerLeave={(e) => {\n        if (e.pointerType === \"mouse\") aoSair();\n      }}\n      className={cn(\"grid w-full select-none place-items-center [perspective:56rem]\", className)}\n      {...rest}\n    >\n      <motion.div\n        ref={disco}\n        role=\"slider\"\n        tabIndex={0}\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={359}\n        aria-valuenow={grau}\n        aria-valuetext={`${grau} graus`}\n        onPointerDown={(e) => {\n          e.currentTarget.setPointerCapture(e.pointerId);\n          preso.current = true;\n          seguir(e);\n        }}\n        onPointerUp={(e) => {\n          preso.current = false;\n          if (e.pointerType !== \"mouse\") aoSair();\n        }}\n        onPointerCancel={aoSair}\n        onKeyDown={onKeyDown}\n        style={{ rotateX, rotateY, transformStyle: \"preserve-3d\" }}\n        className=\"relative aspect-square w-[min(100%,20rem)] cursor-grab touch-none rounded-full outline-none [container-type:inline-size] focus-visible:[box-shadow:var(--focus)] active:cursor-grabbing\"\n      >\n        <motion.span\n          aria-hidden\n          style={{ x: sombraX, y: sombraY, z: -36 }}\n          className=\"pointer-events-none absolute inset-[2%] rounded-full blur-xl [background:color-mix(in_srgb,black_30%,transparent)]\"\n        />\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 rounded-full border border-border [background:radial-gradient(circle_at_50%_38%,var(--card),color-mix(in_srgb,var(--foreground)_7%,var(--card)))]\"\n        />\n        <span aria-hidden className=\"pointer-events-none absolute inset-[3%] rounded-full border border-border\" style={camada(6)} />\n\n        <span aria-hidden className=\"pointer-events-none absolute inset-[5%]\" style={camada(12)}>\n          {MARCAS.map((t) => (\n            <span key={t} style={{ transform: `rotate(${t}deg)` }} className=\"absolute inset-0 flex justify-center\">\n              <span className={t % 90 === 0 ? \"h-[6cqw] w-0.5 bg-foreground\" : \"h-[3cqw] w-px bg-muted-foreground\"} />\n            </span>\n          ))}\n          <span className=\"absolute left-1/2 top-[8cqw] -translate-x-1/2 text-[8cqw] font-medium leading-none text-foreground [font-family:var(--font-display,inherit)]\">\n            {north}\n          </span>\n        </span>\n\n        {/* A sombra da agulha gira junto, mais embaixo: é ela que descola a agulha do mostrador. */}\n        <motion.span\n          aria-hidden\n          style={{ rotate: angulo, z: 18, x: \"1.5cqw\", y: \"2.5cqw\" }}\n          className=\"pointer-events-none absolute inset-[15%] flex flex-col items-center opacity-25 blur-[2px]\"\n        >\n          <span className=\"h-1/2 w-[5cqw] bg-black [clip-path:polygon(50%_0,100%_100%,0_100%)]\" />\n          <span className=\"h-1/2 w-[5cqw] bg-black [clip-path:polygon(0_0,100%_0,50%_100%)]\" />\n        </motion.span>\n        <motion.span aria-hidden style={{ rotate: angulo, z: 34 }} className=\"pointer-events-none absolute inset-[15%] flex flex-col items-center\">\n          <span className=\"h-1/2 w-[5cqw] bg-foreground [clip-path:polygon(50%_0,100%_100%,0_100%)]\" />\n          <span className=\"h-1/2 w-[5cqw] bg-muted-foreground [clip-path:polygon(0_0,100%_0,50%_100%)]\" />\n        </motion.span>\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute left-1/2 top-1/2 -ml-[3.5cqw] -mt-[3.5cqw] size-[7cqw] rounded-full border-2 border-foreground bg-[var(--brand)]\"\n          style={camada(40)}\n        />\n\n        {/* O vidro fica dentro do aro: quem escorrega é o reflexo, senão ele sai pela borda ao inclinar. */}\n        <span aria-hidden className=\"pointer-events-none absolute inset-[3%] overflow-hidden rounded-full\" style={camada(8)}>\n          <motion.span\n            style={{ x: brilhoX, y: brilhoY }}\n            className=\"absolute -inset-1/4 [background:radial-gradient(closest-side_at_50%_50%,color-mix(in_srgb,white_28%,transparent),transparent_70%)]\"\n          />\n        </span>\n      </motion.div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Mostrar ou escolher uma direção, ou dar um objeto vivo a uma página que fala de rumo",
      "evite": "Alinhar com um alvo: sensor-dial; escolher uma hora: time-dial"
    }
  },
  "categories": [
    "controles",
    "bussola",
    "mostrador",
    "3d",
    "mola",
    "direcao"
  ],
  "type": "registry:ui"
}