component

Compass

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).

⌖ Explore the library in 3D → · View as Markdown

Tune every prop in the workshop →

Add it

npx @runek/cli add compass

Pulls @react-three/drei@^10.7.7, @react-three/fiber@^9.6.1, @runek/core@^0.15.0, three@^0.184.0.

Use it

import { Compass } from './runek/Compass'

<Compass position={[0, 0, 0]} />

Or as a node in a world file (worlds as data):

{ "type": "Compass", "props": { "position": [0, 0, 0] } }

Props

Prop Type Default Description
corner "top-left" | "top-right" | "bottom-left" | "bottom-right" "bottom-left" Screen corner the dial sits in.
size number 108 Dial diameter, in CSS px (shrinks ~22% on narrow viewports).
inset [number, number] [16, 16] Inset from the corner edges, in CSS px [x, y].
readout boolean true Show the wind + bearing readout pill under the dial.
north number 0 World yaw (radians) the dial reads as north, using the same convention as Player/Helm yaw: 0 faces +Z, so the default makes +Z north (and -X east).
accentColor color "#c0392b" North needle + north letter color. Compass-north red; no palette slot fits.
position [x, y, z] Position [x, y, z] in units (1 unit = 1 m, Y-up).
rotation [x, y, z] Euler rotation [x, y, z] in radians.
seed number Accepted for the component contract; this component has no seeded variation yet.

Registry manifest

registry manifest /r/components/compass.json Self-contained JSON: inlined source plus resolved dependencies, exactly what runek add compass fetches.

Browse the whole catalog in the gallery →.

Source (Compass.tsx)
import { Html } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { useWorld, type WorldComponentProps } from '@runek/core'
import { useRef } from 'react'
import { type Group, Vector3 } from 'three'

export interface CompassProps extends WorldComponentProps {
  /** Screen corner the dial sits in. */
  corner?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
  /** Dial diameter, in CSS px (shrinks ~22% on narrow viewports). */
  size?: number
  /** Inset from the corner edges, in CSS px `[x, y]`. */
  inset?: [number, number]
  /** Show the wind + bearing readout pill under the dial. */
  readout?: boolean
  /**
   * World yaw (radians) the dial reads as north, using the same convention as `Player`/`Helm`
   * yaw: 0 faces +Z, so the default makes +Z north (and -X east).
   */
  north?: number
  /** North needle + north letter color. Compass-north red; no palette slot fits. */
  accentColor?: string
}

// The dial is drawn in a fixed 200x200 viewBox and scaled by the `size` prop.
const CX = 100
const CY = 100

/** Point at radius r, angle deg (0 at twelve o'clock, clockwise). */
function xy(r: number, deg: number): [number, number] {
  const a = ((deg - 90) * Math.PI) / 180
  return [+(CX + r * Math.cos(a)).toFixed(2), +(CY + r * Math.sin(a)).toFixed(2)]
}

function pt(r: number, deg: number): string {
  const [x, y] = xy(r, deg)
  return `${x},${y}`
}

/** A rose point split into its classic shaded/lit halves. */
function rosePoint(deg: number, len: number, w: number) {
  const tip = pt(len, deg)
  const center = `${CX},${CY}`
  return {
    dark: `${center} ${pt(w, deg - 90)} ${tip}`,
    light: `${center} ${pt(w, deg + 90)} ${tip}`,
  }
}

const CARDINALS = [0, 90, 180, 270].map((deg) => ({ deg, ...rosePoint(deg, 56, 8) }))
const INTERCARDINALS = [45, 135, 225, 315].map((deg) => ({ deg, ...rosePoint(deg, 38, 5.5) }))
const TICKS = Array.from({ length: 24 }, (_, i) => i * 15)
const LETTER_ANGLES = [0, 90, 180, 270] as const
const LETTER_CHARS = ['N', 'E', 'S', 'W'] as const
const WINDS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']

const DIR = new Vector3()

const CORNER_INSETS: Record<NonNullable<CompassProps['corner']>, [string, string]> = {
  'top-left': ['top', 'left'],
  'top-right': ['top', 'right'],
  'bottom-left': ['bottom', 'left'],
  'bottom-right': ['bottom', 'right'],
}

/**
 * A screen-fixed HUD compass: a glassy dial (translucent ring, classic split-half rose
 * needles, cardinal markers, a fixed lubber line at twelve o'clock) floating in a corner of
 * the canvas, with an optional wind + bearing readout pill. Like a ship's binnacle compass,
 * the rose card swings while the ring and lubber line stay fixed — whatever cardinal sits
 * under the pointer is the way you're facing.
 *
 * The heading is read from the camera every frame, which covers any traversal scheme that
 * drives the camera (an ecctrl `Player` on foot, a chase-cam vehicle). The card rotates by
 * direct DOM mutation, so per-frame updates never re-render React. Rendered via drei `Html`
 * into the canvas wrapper — purely informational, it never intercepts pointer events.
 */
export function Compass({
  corner = 'bottom-left',
  size = 108,
  inset = [16, 16],
  readout = true,
  north = 0,
  accentColor = '#c0392b',
}: CompassProps) {
  const { fonts } = useWorld()
  const camera = useThree((s) => s.camera)

  const anchor = useRef<Group>(null)
  const card = useRef<SVGGElement>(null)
  const readoutEl = useRef<HTMLSpanElement>(null)
  const shown = useRef(-1)

  useFrame(() => {
    camera.getWorldDirection(DIR)
    // Keep the Html anchor just ahead of the camera so drei's behind-camera check (which
    // runs if the projected position ever shifts, e.g. on a resize) can never hide the dial.
    if (anchor.current) {
      anchor.current.position.copy(camera.position).addScaledVector(DIR, 1)
    }
    // drei's Html renders its content in its own DOM root, so the refs attach a beat after
    // the first frames — don't consume a heading until the card exists to receive it.
    if (!card.current) return
    const heading = north - Math.atan2(DIR.x, DIR.z)
    const deg = ((((heading * 180) / Math.PI) % 360) + 360) % 360
    if (Math.abs(deg - shown.current) < 0.02) return
    shown.current = deg
    // The card counter-rotates so the cardinal you face lands under the lubber line.
    card.current.setAttribute('transform', `rotate(${(-deg).toFixed(2)} ${CX} ${CY})`)
    if (readoutEl.current) {
      const text = `${WINDS[Math.round(deg / 45) % 8]} ${String(Math.round(deg) % 360).padStart(3, '0')}°`
      if (readoutEl.current.textContent !== text) readoutEl.current.textContent = text
    }
  })

  const [vSide, hSide] = CORNER_INSETS[corner]
  const dialClass = `runek-compass--${corner}`

  return (
    <group ref={anchor}>
      <Html
        // Pin the wrapper to the canvas center; with `fullscreen` the inner div then covers
        // the canvas exactly, and the constant position keeps drei's per-frame work dormant.
        calculatePosition={(_el, _camera, htmlSize) => [htmlSize.width / 2, htmlSize.height / 2]}
        fullscreen
        // A HUD is never occluded: absorb drei's behind-camera callback so it can't latch
        // `display: none` during the first frames while the camera controller settles.
        onOcclude={() => null}
        style={{ pointerEvents: 'none' }}
        zIndexRange={[10, 0]}
      >
        <style>{`
          @font-face {
            font-family: 'RunekCompass';
            src: url('${fonts.display}');
            font-display: swap;
          }
          .${dialClass} svg {
            width: ${size}px;
            height: ${size}px;
            filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
          }
          @media (max-width: 640px) {
            .${dialClass} svg {
              width: ${Math.round(size * 0.78)}px;
              height: ${Math.round(size * 0.78)}px;
            }
          }
        `}</style>
        <div
          className={dialClass}
          aria-hidden="true"
          style={{
            position: 'absolute',
            [vSide]: inset[1],
            [hSide]: inset[0],
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            gap: 6,
            pointerEvents: 'none',
            userSelect: 'none',
          }}
        >
          <svg viewBox="0 0 200 200" role="presentation">
            {/* Glass ring: a translucent brass band between two white hairlines. */}
            <circle
              cx={CX}
              cy={CY}
              r="91"
              fill="none"
              stroke="rgba(238, 205, 120, 0.28)"
              strokeWidth="11"
            />
            <circle
              cx={CX}
              cy={CY}
              r="96.5"
              fill="none"
              stroke="rgba(255, 255, 255, 0.55)"
              strokeWidth="1.2"
            />
            <circle
              cx={CX}
              cy={CY}
              r="85.5"
              fill="none"
              stroke="rgba(255, 255, 255, 0.4)"
              strokeWidth="1"
            />

            {/* The rose card — everything in this group swings with the heading. */}
            <g ref={card}>
              {TICKS.map((deg) => {
                const major = deg % 45 === 0
                const [x1, y1] = xy(major ? 76 : 79, deg)
                const [x2, y2] = xy(83, deg)
                return (
                  <line
                    key={deg}
                    x1={x1}
                    y1={y1}
                    x2={x2}
                    y2={y2}
                    stroke="rgba(255, 255, 255, 0.7)"
                    strokeWidth={major ? 2 : 1}
                    opacity={major ? 0.9 : 0.5}
                  />
                )
              })}
              <circle
                cx={CX}
                cy={CY}
                r="72"
                fill="none"
                stroke="rgba(255, 255, 255, 0.25)"
                strokeWidth="0.8"
              />

              {INTERCARDINALS.map(({ deg, dark, light }) => (
                <g key={deg} stroke="rgba(255, 255, 255, 0.35)" strokeWidth="0.5">
                  <polygon points={dark} fill="rgba(16, 28, 44, 0.5)" />
                  <polygon points={light} fill="rgba(255, 255, 255, 0.5)" />
                </g>
              ))}
              {CARDINALS.map(({ deg, dark, light }) => (
                <g key={deg} stroke="rgba(255, 255, 255, 0.35)" strokeWidth="0.5">
                  <polygon
                    points={dark}
                    fill={deg === 0 ? accentColor : 'rgba(16, 28, 44, 0.5)'}
                    fillOpacity={deg === 0 ? 0.85 : 1}
                  />
                  <polygon
                    points={light}
                    fill={deg === 0 ? accentColor : 'rgba(255, 255, 255, 0.5)'}
                    fillOpacity={deg === 0 ? 0.5 : 1}
                  />
                </g>
              ))}

              {LETTER_ANGLES.map((deg, i) => {
                const [x, y] = xy(66, deg)
                return (
                  <text
                    key={LETTER_CHARS[i]}
                    x={x}
                    y={y}
                    fill={deg === 0 ? accentColor : 'rgba(255, 255, 255, 0.92)'}
                    stroke="rgba(8, 14, 24, 0.45)"
                    strokeWidth="2.2"
                    paintOrder="stroke"
                    fontSize="15"
                    fontFamily="'RunekCompass', Georgia, serif"
                    textAnchor="middle"
                    dominantBaseline="central"
                  >
                    {LETTER_CHARS[i]}
                  </text>
                )
              })}
            </g>

            {/* Fixed pivot pin and the lubber line marking your heading. */}
            <circle
              cx={CX}
              cy={CY}
              r="4.5"
              fill="rgba(255, 255, 255, 0.65)"
              stroke="rgba(16, 28, 44, 0.5)"
              strokeWidth="1"
            />
            <circle cx={CX} cy={CY} r="1.5" fill="rgba(16, 28, 44, 0.7)" />
            <polygon
              points="93,2.5 107,2.5 100,20"
              fill="rgba(238, 205, 120, 0.75)"
              stroke="rgba(255, 255, 255, 0.6)"
              strokeWidth="1"
            />
          </svg>

          {readout && (
            <span
              ref={readoutEl}
              style={{
                padding: '2px 9px',
                border: '1px solid rgba(255, 255, 255, 0.35)',
                borderRadius: 999,
                background: 'rgba(255, 255, 255, 0.12)',
                backdropFilter: 'blur(10px)',
                WebkitBackdropFilter: 'blur(10px)',
                color: 'rgba(255, 255, 255, 0.92)',
                textShadow: '0 1px 2px rgba(0, 0, 0, 0.4)',
                font: "600 0.62rem 'RunekCompass', ui-monospace, monospace",
                letterSpacing: '0.06em',
                boxShadow: '0 2px 6px rgba(0, 0, 0, 0.2)',
              }}
            >
              N 000°
            </span>
          )}
        </div>
      </Html>
    </group>
  )
}