{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sweep-scope","type":"registry:ui","title":"Sweep Scope","description":"A phosphor radar scope: a rotating sweep with a fading trail, range rings, bearing ticks and contacts that ping as the beam passes. WebGL.","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/ui/sweep-scope.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\nexport interface SweepScopeProps {\n  /** Phosphor color. */\n  color?: string;\n  /** Sweep speed multiplier. */\n  speed?: number;\n  /** Number of range rings inside the scope. */\n  rings?: number;\n  /** Length of the fading sweep trail. */\n  trail?: number;\n  /** Number of contacts, 0-16. */\n  blips?: number;\n  /** Scope size, as a fraction of the container height. */\n  size?: number;\n  className?: string;\n}\n\nconst FRAG = `\nprecision highp float;\nuniform vec2 uRes;\nuniform float uTime;\nuniform vec3 uColor;\nuniform float uRings;\nuniform float uTrail;\nuniform float uBlips;\nuniform float uSize;\n\nconst float TAU = 6.2831853;\n\nfloat hash(float n) { return fract(sin(n * 12.9898) * 43758.5453); }\n\nvoid main() {\n  float asp = uRes.x / uRes.y;\n  vec2 p = gl_FragCoord.xy / uRes.y - vec2(0.5 * asp, 0.5);\n  float px = 1.0 / uRes.y;\n  float R = 0.5 * uSize;\n  float r = length(p);\n  float a = atan(p.y, p.x);\n  float t = uTime;\n\n  // clockwise sweep; delta = how long ago (in radians) the beam passed this angle\n  float sweep = mod(-t * 1.1, TAU);\n  float delta = mod(a - sweep, TAU);\n  float scope = 1.0 - smoothstep(R, R * 2.4, r) * 0.85;\n\n  float I = 0.0;\n  // beam + trail\n  float beam = exp(-delta * 2.4 / uTrail);\n  float inScope = mix(0.2, 1.0, 1.0 - smoothstep(R - px, R + px, r));\n  float edge = min(delta, TAU - delta) * r;\n  I += beam * 0.55 * scope * inScope;\n  I += (1.0 - smoothstep(0.0, 2.5 * px, edge)) * 0.9 * scope * inScope;\n\n  // range rings (continue faintly past the scope rim)\n  float sp = R / uRings;\n  float rd = abs(fract(r / sp + 0.5) - 0.5) * sp;\n  I += (1.0 - smoothstep(0.0, 1.4 * px, rd)) * (0.18 + 0.35 * beam) * scope;\n\n  // spokes every 30 degrees, stronger on the axes\n  float seg = TAU / 12.0;\n  float sd = abs(fract(a / seg + 0.5) - 0.5) * seg * r;\n  float axis = step(abs(fract(a / (TAU / 4.0) + 0.5) - 0.5), 0.01);\n  I += (1.0 - smoothstep(0.0, 1.1 * px, sd)) * (0.07 + 0.1 * axis) * step(r, R);\n\n  // rim with bearing ticks\n  float rim = abs(r - R);\n  I += (1.0 - smoothstep(0.0, 1.8 * px, rim)) * 0.6;\n  float tick5 = abs(fract(a / (TAU / 72.0) + 0.5) - 0.5) * (TAU / 72.0) * r;\n  float tick30 = abs(fract(a / seg + 0.5) - 0.5) * seg * r;\n  float inBand = step(R - 0.012, r) * step(r, R);\n  float inBandL = step(R - 0.028, r) * step(r, R);\n  I += (1.0 - smoothstep(0.0, 1.2 * px, tick5)) * inBand * 0.35;\n  I += (1.0 - smoothstep(0.0, 1.4 * px, tick30)) * inBandL * 0.55;\n\n  // contacts: light up when the beam passes, then fade with an expanding ping\n  for (int i = 0; i < 16; i++) {\n    if (float(i) >= uBlips) break;\n    float fi = float(i);\n    float ba = hash(fi + 1.3) * TAU + t * 0.02 * (hash(fi + 7.7) - 0.5);\n    float br = (0.15 + 0.8 * hash(fi + 4.1)) * R;\n    vec2 bp = br * vec2(cos(ba), sin(ba));\n    float bd = mod(ba - sweep, TAU);\n    float glow = 0.12 + exp(-bd * 0.6);\n    float dist = length(p - bp);\n    I += glow * (exp(-dist * dist / (2.2 * px * px * 9.0)) * 1.4 + exp(-dist / (8.0 * px)) * 0.35);\n    float ping = bd * 0.035;\n    I += exp(-abs(dist - ping) / (1.2 * px)) * exp(-bd * 2.2) * 0.6;\n  }\n\n  // phosphor background + scanlines\n  I += 0.05 * (1.0 - smoothstep(0.0, R, r)) * step(r, R);\n  I *= 0.92 + 0.08 * sin(gl_FragCoord.y * 1.6);\n\n  vec3 col = uColor * I + vec3(1.0) * pow(max(I - 0.8, 0.0), 2.0) * 0.4;\n  col = 1.0 - exp(-col * 1.4);\n  gl_FragColor = vec4(col, max(col.r, max(col.g, col.b)));\n}\n`;\n\nconst VERT = \"attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}\";\n\ntype Ptr = { x: number; y: number; hover: number };\ntype Uni = (name: string) => WebGLUniformLocation | null;\n\n/** Full-screen shader canvas inside `host`: DPR-capped resize, offscreen pause, reduced motion, eased pointer. */\nfunction runShader(host: HTMLElement, frag: string, speed: () => number, update: (gl: WebGLRenderingContext, u: Uni) => void) {\n  const canvas = document.createElement(\"canvas\");\n  canvas.style.cssText = \"position:absolute;inset:0;width:100%;height:100%;display:block\";\n  const gl = canvas.getContext(\"webgl\", { antialias: false });\n  if (!gl) return null;\n  const prog = gl.createProgram()!;\n  for (const [type, src] of [\n    [gl.VERTEX_SHADER, VERT],\n    [gl.FRAGMENT_SHADER, frag],\n  ] as const) {\n    const sh = gl.createShader(type)!;\n    gl.shaderSource(sh, src);\n    gl.compileShader(sh);\n    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) console.warn(gl.getShaderInfoLog(sh));\n    gl.attachShader(prog, sh);\n  }\n  gl.bindAttribLocation(prog, 0, \"p\");\n  gl.linkProgram(prog);\n  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {\n    gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n    return null;\n  }\n  gl.useProgram(prog);\n  gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());\n  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);\n  gl.enableVertexAttribArray(0);\n  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);\n  host.appendChild(canvas);\n\n  const locs = new Map<string, WebGLUniformLocation | null>();\n  const u: Uni = (n) => {\n    if (!locs.has(n)) locs.set(n, gl.getUniformLocation(prog, n));\n    return locs.get(n) ?? null;\n  };\n  const zone = host.parentElement ?? host;\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  const ptr: Ptr = { x: 0.5, y: 0.5, hover: 0 };\n  const goal: Ptr = { ...ptr };\n  let raf = 0;\n  let last = 0;\n  let time = 0;\n\n  const draw = () => {\n    gl.uniform2f(u(\"uRes\"), canvas.width, canvas.height);\n    gl.uniform1f(u(\"uTime\"), time);\n    gl.uniform2f(u(\"uMouse\"), ptr.x, ptr.y);\n    gl.uniform1f(u(\"uHover\"), ptr.hover);\n    update(gl, u);\n    gl.drawArrays(gl.TRIANGLES, 0, 3);\n  };\n  const loop = (now: number) => {\n    const dt = Math.min((now - last) / 1000, 0.1);\n    last = now;\n    const k = 1 - Math.exp(-dt * 4);\n    for (const key of [\"x\", \"y\", \"hover\"] as const) ptr[key] += (goal[key] - ptr[key]) * k;\n    time += dt * speed();\n    draw();\n    raf = requestAnimationFrame(loop);\n  };\n  const run = (on: boolean) => {\n    if (on && !raf && !reduced) {\n      last = performance.now();\n      raf = requestAnimationFrame(loop);\n    } else if (!on && raf) {\n      cancelAnimationFrame(raf);\n      raf = 0;\n    }\n  };\n  const resize = () => {\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    canvas.width = Math.max(1, Math.round(host.clientWidth * dpr));\n    canvas.height = Math.max(1, Math.round(host.clientHeight * dpr));\n    gl.viewport(0, 0, canvas.width, canvas.height);\n    draw();\n  };\n  const move = (e: PointerEvent) => {\n    const r = host.getBoundingClientRect();\n    goal.x = (e.clientX - r.left) / r.width;\n    goal.y = 1 - (e.clientY - r.top) / r.height;\n    goal.hover = 1;\n  };\n  const leave = () => {\n    goal.hover = 0;\n  };\n  const ro = new ResizeObserver(resize);\n  const io = new IntersectionObserver(([e]) => run(e.isIntersecting));\n  ro.observe(host);\n  io.observe(host);\n  zone.addEventListener(\"pointermove\", move);\n  zone.addEventListener(\"pointerleave\", leave);\n  return {\n    redraw: () => {\n      if (!raf) draw();\n    },\n    dispose: () => {\n      run(false);\n      ro.disconnect();\n      io.disconnect();\n      zone.removeEventListener(\"pointermove\", move);\n      zone.removeEventListener(\"pointerleave\", leave);\n      gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n      canvas.remove();\n    },\n  };\n}\n\nconst rgb = (hex: string) => {\n  const n = parseInt(hex.replace(\"#\", \"\").padEnd(6, \"0\").slice(0, 6), 16);\n  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];\n};\n\nexport function SweepScope({\n  color = \"#34d399\",\n  speed = 1,\n  rings = 4,\n  trail = 1,\n  blips = 9,\n  size = 0.85,\n  className,\n}: SweepScopeProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const opts = useRef({ color, speed, rings, trail, blips, size });\n  const redraw = useRef<(() => void) | undefined>(undefined);\n\n  useEffect(() => {\n    opts.current = { color, speed, rings, trail, blips, size };\n    redraw.current?.();\n  }, [color, speed, rings, trail, blips, size]);\n\n  useEffect(() => {\n    const r = runShader(\n      ref.current!,\n      FRAG,\n      () => opts.current.speed,\n      (gl, u) => {\n        const o = opts.current;\n        gl.uniform3fv(u(\"uColor\"), rgb(o.color));\n        gl.uniform1f(u(\"uRings\"), Math.max(1, Math.round(o.rings)));\n        gl.uniform1f(u(\"uTrail\"), Math.max(0.1, o.trail));\n        gl.uniform1f(u(\"uBlips\"), Math.round(o.blips));\n        gl.uniform1f(u(\"uSize\"), Math.max(0.1, o.size));\n      },\n    );\n    redraw.current = r?.redraw;\n    return () => r?.dispose();\n  }, []);\n\n  return (\n    <div\n      ref={ref}\n      aria-hidden\n      className={className}\n      style={{ position: \"absolute\", inset: 0, overflow: \"hidden\", pointerEvents: \"none\" }}\n    />\n  );\n}\n"}]}