{
  "name": "compass",
  "title": "Compass",
  "type": "registry:component",
  "description": "A screen-fixed HUD compass: a glassy dial in a corner of the canvas whose rose card swings with the camera heading, with a fixed lubber line and an optional wind + bearing readout — works with any traversal that drives the camera (on foot or at a vehicle's chase-cam).",
  "dependencies": [
    "@react-three/drei@^10.7.7",
    "@react-three/fiber@^9.6.1",
    "@runek/core@^0.12.0",
    "three@^0.184.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "Compass.tsx",
      "content": "import { Html } from '@react-three/drei'\nimport { useFrame, useThree } from '@react-three/fiber'\nimport { useWorld, type WorldComponentProps } from '@runek/core'\nimport { useRef } from 'react'\nimport { type Group, Vector3 } from 'three'\n\nexport interface CompassProps extends WorldComponentProps {\n  /** Screen corner the dial sits in. */\n  corner?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'\n  /** Dial diameter, in CSS px (shrinks ~22% on narrow viewports). */\n  size?: number\n  /** Inset from the corner edges, in CSS px `[x, y]`. */\n  inset?: [number, number]\n  /** Show the wind + bearing readout pill under the dial. */\n  readout?: boolean\n  /**\n   * World yaw (radians) the dial reads as north, using the same convention as `Player`/`Helm`\n   * yaw: 0 faces +Z, so the default makes +Z north (and -X east).\n   */\n  north?: number\n  /** North needle + north letter color. Compass-north red; no palette slot fits. */\n  accentColor?: string\n}\n\n// The dial is drawn in a fixed 200x200 viewBox and scaled by the `size` prop.\nconst CX = 100\nconst CY = 100\n\n/** Point at radius r, angle deg (0 at twelve o'clock, clockwise). */\nfunction xy(r: number, deg: number): [number, number] {\n  const a = ((deg - 90) * Math.PI) / 180\n  return [+(CX + r * Math.cos(a)).toFixed(2), +(CY + r * Math.sin(a)).toFixed(2)]\n}\n\nfunction pt(r: number, deg: number): string {\n  const [x, y] = xy(r, deg)\n  return `${x},${y}`\n}\n\n/** A rose point split into its classic shaded/lit halves. */\nfunction rosePoint(deg: number, len: number, w: number) {\n  const tip = pt(len, deg)\n  const center = `${CX},${CY}`\n  return {\n    dark: `${center} ${pt(w, deg - 90)} ${tip}`,\n    light: `${center} ${pt(w, deg + 90)} ${tip}`,\n  }\n}\n\nconst CARDINALS = [0, 90, 180, 270].map((deg) => ({ deg, ...rosePoint(deg, 56, 8) }))\nconst INTERCARDINALS = [45, 135, 225, 315].map((deg) => ({ deg, ...rosePoint(deg, 38, 5.5) }))\nconst TICKS = Array.from({ length: 24 }, (_, i) => i * 15)\nconst LETTER_ANGLES = [0, 90, 180, 270] as const\nconst LETTER_CHARS = ['N', 'E', 'S', 'W'] as const\nconst WINDS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']\n\nconst DIR = new Vector3()\n\nconst CORNER_INSETS: Record<NonNullable<CompassProps['corner']>, [string, string]> = {\n  'top-left': ['top', 'left'],\n  'top-right': ['top', 'right'],\n  'bottom-left': ['bottom', 'left'],\n  'bottom-right': ['bottom', 'right'],\n}\n\n/**\n * A screen-fixed HUD compass: a glassy dial (translucent ring, classic split-half rose\n * needles, cardinal markers, a fixed lubber line at twelve o'clock) floating in a corner of\n * the canvas, with an optional wind + bearing readout pill. Like a ship's binnacle compass,\n * the rose card swings while the ring and lubber line stay fixed — whatever cardinal sits\n * under the pointer is the way you're facing.\n *\n * The heading is read from the camera every frame, which covers any traversal scheme that\n * drives the camera (an ecctrl `Player` on foot, a chase-cam vehicle). The card rotates by\n * direct DOM mutation, so per-frame updates never re-render React. Rendered via drei `Html`\n * into the canvas wrapper — purely informational, it never intercepts pointer events.\n */\nexport function Compass({\n  corner = 'bottom-left',\n  size = 108,\n  inset = [16, 16],\n  readout = true,\n  north = 0,\n  accentColor = '#c0392b',\n}: CompassProps) {\n  const { fonts } = useWorld()\n  const camera = useThree((s) => s.camera)\n\n  const anchor = useRef<Group>(null)\n  const card = useRef<SVGGElement>(null)\n  const readoutEl = useRef<HTMLSpanElement>(null)\n  const shown = useRef(-1)\n\n  useFrame(() => {\n    camera.getWorldDirection(DIR)\n    // Keep the Html anchor just ahead of the camera so drei's behind-camera check (which\n    // runs if the projected position ever shifts, e.g. on a resize) can never hide the dial.\n    if (anchor.current) {\n      anchor.current.position.copy(camera.position).addScaledVector(DIR, 1)\n    }\n    // drei's Html renders its content in its own DOM root, so the refs attach a beat after\n    // the first frames — don't consume a heading until the card exists to receive it.\n    if (!card.current) return\n    const heading = north - Math.atan2(DIR.x, DIR.z)\n    const deg = ((((heading * 180) / Math.PI) % 360) + 360) % 360\n    if (Math.abs(deg - shown.current) < 0.02) return\n    shown.current = deg\n    // The card counter-rotates so the cardinal you face lands under the lubber line.\n    card.current.setAttribute('transform', `rotate(${(-deg).toFixed(2)} ${CX} ${CY})`)\n    if (readoutEl.current) {\n      const text = `${WINDS[Math.round(deg / 45) % 8]} ${String(Math.round(deg) % 360).padStart(3, '0')}°`\n      if (readoutEl.current.textContent !== text) readoutEl.current.textContent = text\n    }\n  })\n\n  const [vSide, hSide] = CORNER_INSETS[corner]\n  const dialClass = `runek-compass--${corner}`\n\n  return (\n    <group ref={anchor}>\n      <Html\n        // Pin the wrapper to the canvas center; with `fullscreen` the inner div then covers\n        // the canvas exactly, and the constant position keeps drei's per-frame work dormant.\n        calculatePosition={(_el, _camera, htmlSize) => [htmlSize.width / 2, htmlSize.height / 2]}\n        fullscreen\n        // A HUD is never occluded: absorb drei's behind-camera callback so it can't latch\n        // `display: none` during the first frames while the camera controller settles.\n        onOcclude={() => null}\n        style={{ pointerEvents: 'none' }}\n        zIndexRange={[10, 0]}\n      >\n        <style>{`\n          @font-face {\n            font-family: 'RunekCompass';\n            src: url('${fonts.display}');\n            font-display: swap;\n          }\n          .${dialClass} svg {\n            width: ${size}px;\n            height: ${size}px;\n            filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));\n          }\n          @media (max-width: 640px) {\n            .${dialClass} svg {\n              width: ${Math.round(size * 0.78)}px;\n              height: ${Math.round(size * 0.78)}px;\n            }\n          }\n        `}</style>\n        <div\n          className={dialClass}\n          aria-hidden=\"true\"\n          style={{\n            position: 'absolute',\n            [vSide]: inset[1],\n            [hSide]: inset[0],\n            display: 'flex',\n            flexDirection: 'column',\n            alignItems: 'center',\n            gap: 6,\n            pointerEvents: 'none',\n            userSelect: 'none',\n          }}\n        >\n          <svg viewBox=\"0 0 200 200\" role=\"presentation\">\n            {/* Glass ring: a translucent brass band between two white hairlines. */}\n            <circle\n              cx={CX}\n              cy={CY}\n              r=\"91\"\n              fill=\"none\"\n              stroke=\"rgba(238, 205, 120, 0.28)\"\n              strokeWidth=\"11\"\n            />\n            <circle\n              cx={CX}\n              cy={CY}\n              r=\"96.5\"\n              fill=\"none\"\n              stroke=\"rgba(255, 255, 255, 0.55)\"\n              strokeWidth=\"1.2\"\n            />\n            <circle\n              cx={CX}\n              cy={CY}\n              r=\"85.5\"\n              fill=\"none\"\n              stroke=\"rgba(255, 255, 255, 0.4)\"\n              strokeWidth=\"1\"\n            />\n\n            {/* The rose card — everything in this group swings with the heading. */}\n            <g ref={card}>\n              {TICKS.map((deg) => {\n                const major = deg % 45 === 0\n                const [x1, y1] = xy(major ? 76 : 79, deg)\n                const [x2, y2] = xy(83, deg)\n                return (\n                  <line\n                    key={deg}\n                    x1={x1}\n                    y1={y1}\n                    x2={x2}\n                    y2={y2}\n                    stroke=\"rgba(255, 255, 255, 0.7)\"\n                    strokeWidth={major ? 2 : 1}\n                    opacity={major ? 0.9 : 0.5}\n                  />\n                )\n              })}\n              <circle\n                cx={CX}\n                cy={CY}\n                r=\"72\"\n                fill=\"none\"\n                stroke=\"rgba(255, 255, 255, 0.25)\"\n                strokeWidth=\"0.8\"\n              />\n\n              {INTERCARDINALS.map(({ deg, dark, light }) => (\n                <g key={deg} stroke=\"rgba(255, 255, 255, 0.35)\" strokeWidth=\"0.5\">\n                  <polygon points={dark} fill=\"rgba(16, 28, 44, 0.5)\" />\n                  <polygon points={light} fill=\"rgba(255, 255, 255, 0.5)\" />\n                </g>\n              ))}\n              {CARDINALS.map(({ deg, dark, light }) => (\n                <g key={deg} stroke=\"rgba(255, 255, 255, 0.35)\" strokeWidth=\"0.5\">\n                  <polygon\n                    points={dark}\n                    fill={deg === 0 ? accentColor : 'rgba(16, 28, 44, 0.5)'}\n                    fillOpacity={deg === 0 ? 0.85 : 1}\n                  />\n                  <polygon\n                    points={light}\n                    fill={deg === 0 ? accentColor : 'rgba(255, 255, 255, 0.5)'}\n                    fillOpacity={deg === 0 ? 0.5 : 1}\n                  />\n                </g>\n              ))}\n\n              {LETTER_ANGLES.map((deg, i) => {\n                const [x, y] = xy(66, deg)\n                return (\n                  <text\n                    key={LETTER_CHARS[i]}\n                    x={x}\n                    y={y}\n                    fill={deg === 0 ? accentColor : 'rgba(255, 255, 255, 0.92)'}\n                    stroke=\"rgba(8, 14, 24, 0.45)\"\n                    strokeWidth=\"2.2\"\n                    paintOrder=\"stroke\"\n                    fontSize=\"15\"\n                    fontFamily=\"'RunekCompass', Georgia, serif\"\n                    textAnchor=\"middle\"\n                    dominantBaseline=\"central\"\n                  >\n                    {LETTER_CHARS[i]}\n                  </text>\n                )\n              })}\n            </g>\n\n            {/* Fixed pivot pin and the lubber line marking your heading. */}\n            <circle\n              cx={CX}\n              cy={CY}\n              r=\"4.5\"\n              fill=\"rgba(255, 255, 255, 0.65)\"\n              stroke=\"rgba(16, 28, 44, 0.5)\"\n              strokeWidth=\"1\"\n            />\n            <circle cx={CX} cy={CY} r=\"1.5\" fill=\"rgba(16, 28, 44, 0.7)\" />\n            <polygon\n              points=\"93,2.5 107,2.5 100,20\"\n              fill=\"rgba(238, 205, 120, 0.75)\"\n              stroke=\"rgba(255, 255, 255, 0.6)\"\n              strokeWidth=\"1\"\n            />\n          </svg>\n\n          {readout && (\n            <span\n              ref={readoutEl}\n              style={{\n                padding: '2px 9px',\n                border: '1px solid rgba(255, 255, 255, 0.35)',\n                borderRadius: 999,\n                background: 'rgba(255, 255, 255, 0.12)',\n                backdropFilter: 'blur(10px)',\n                WebkitBackdropFilter: 'blur(10px)',\n                color: 'rgba(255, 255, 255, 0.92)',\n                textShadow: '0 1px 2px rgba(0, 0, 0, 0.4)',\n                font: \"600 0.62rem 'RunekCompass', ui-monospace, monospace\",\n                letterSpacing: '0.06em',\n                boxShadow: '0 2px 6px rgba(0, 0, 0, 0.2)',\n              }}\n            >\n              N 000°\n            </span>\n          )}\n        </div>\n      </Html>\n    </group>\n  )\n}\n",
      "type": "registry:component"
    }
  ]
}
