{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "globe",
  "title": "Globe",
  "description": "A turning dotted earth on one canvas with no 3D library: land dots, a wire ball, a shaded disc or routes with a light along them, plus markers and drag to spin.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/globe.tsx",
      "content": "import type * as React from \"react\"\nimport { useEffect, useRef } from \"react\"\nimport { useBackgroundPlayback } from \"@/registry/miseui/ui/background-loop\"\nimport { createGlobePainter, type GlobePainter, type GlobeVariant } from \"@/registry/miseui/ui/globe-painter\"\nimport type { GlobePoint } from \"@/registry/miseui/ui/globe-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype GlobeEffect = \"drag\"\n\ninterface GlobeMarker extends GlobePoint {\n  /** Read out in a hidden list: a dot on a canvas says nothing to a screen reader. */\n  label: string\n}\n\ninterface GlobeArc {\n  from: GlobePoint\n  to: GlobePoint\n  /** Add it when the route means something: it joins the hidden list. */\n  label?: string\n}\n\nconst KEY_TURN_DEGREES = 20\nconst DEFAULT_CENTER: GlobePoint = { lat: 18, lng: 15 }\nconst NO_MARKERS: readonly GlobeMarker[] = []\nconst NO_ARCS: readonly GlobeArc[] = []\n\ntype GlobeProps = Omit<React.ComponentProps<\"div\">, \"ref\" | \"children\"> & {\n  /** `dots` land as dots, `wire` lines of latitude and longitude with land dots, `solid` a shaded disc with land in the accent, `arcs` dots plus routes with a light along each. */\n  variant?: GlobeVariant\n  /** `drag` spins it with inertia. The page still scrolls vertically, and Left and Right turn it while it has focus. */\n  effect?: GlobeEffect\n  markers?: readonly GlobeMarker[]\n  /** Only `arcs` draws these. */\n  arcs?: readonly GlobeArc[]\n  /** What faces the viewer at the start. `lat` is also the tilt, and stays fixed while it turns. */\n  center?: GlobePoint\n  /** Holds the slow turn. Wire it to a control: motion past five seconds needs a way to stop it. */\n  paused?: boolean\n  /** What the picture shows, for assistive tech. */\n  label?: string\n}\n\nfunction usePointerDrag(painterRef: React.RefObject<GlobePainter | null>, enabled: boolean) {\n  const lastX = useRef<number | null>(null)\n  if (!enabled) return {}\n  return {\n    onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => {\n      if (event.button !== 0) return\n      lastX.current = event.clientX\n      event.currentTarget.setPointerCapture?.(event.pointerId)\n    },\n    onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => {\n      if (lastX.current === null) return\n      painterRef.current?.dragBy(event.clientX - lastX.current, event.timeStamp)\n      lastX.current = event.clientX\n    },\n    onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => {\n      lastX.current = null\n      painterRef.current?.release(event.timeStamp)\n    },\n    // The browser took the gesture for a vertical scroll, so whatever speed was measured is not a flick.\n    onPointerCancel: () => {\n      lastX.current = null\n      painterRef.current?.release(Number.NEGATIVE_INFINITY)\n    },\n  }\n}\n\nfunction Globe({\n  variant = \"dots\",\n  effect,\n  markers = NO_MARKERS,\n  arcs = NO_ARCS,\n  center = DEFAULT_CENTER,\n  paused = false,\n  label = \"A slowly turning globe\",\n  className,\n  ...props\n}: GlobeProps) {\n  const rootRef = useRef<HTMLDivElement>(null)\n  const canvasRef = useRef<HTMLCanvasElement>(null)\n  const painterRef = useRef<GlobePainter | null>(null)\n  const { still, playing } = useBackgroundPlayback(rootRef, { paused })\n  const draggable = effect === \"drag\"\n  const dragHandlers = usePointerDrag(painterRef, draggable)\n  const { lat, lng } = center\n\n  useEffect(() => {\n    const canvas = canvasRef.current\n    if (!canvas) return\n    const painter = createGlobePainter(canvas, { variant: \"dots\", center: { lat: 0, lng: 0 }, markers: [], arcs: [] })\n    painterRef.current = painter\n    return () => {\n      painter.destroy()\n      painterRef.current = null\n    }\n  }, [])\n\n  // Declared after the painter's effect, so it finds the painter on the first run too.\n  useEffect(() => {\n    painterRef.current?.setScene({ variant, center: { lat, lng }, markers, arcs })\n  }, [variant, lat, lng, markers, arcs])\n  useEffect(() => {\n    painterRef.current?.setStill(still)\n    painterRef.current?.setAuto(playing)\n  }, [still, playing])\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLCanvasElement>) => {\n    if (event.key !== \"ArrowLeft\" && event.key !== \"ArrowRight\") return\n    event.preventDefault()\n    painterRef.current?.turnBy(event.key === \"ArrowRight\" ? KEY_TURN_DEGREES : -KEY_TURN_DEGREES)\n  }\n  const namedArcs = arcs.filter((arc) => arc.label)\n\n  return (\n    <div\n      {...props}\n      {...dragHandlers}\n      ref={rootRef}\n      data-slot=\"globe\"\n      data-variant={variant}\n      data-effect={effect}\n      data-playing={still ? undefined : String(playing)}\n      className={cn(\"relative aspect-square w-full min-w-0\", draggable && \"cursor-grab touch-pan-y select-none active:cursor-grabbing\", className)}\n    >\n      <canvas\n        ref={canvasRef}\n        role=\"img\"\n        aria-label={draggable ? `${label}. Left and Right arrow keys turn it.` : label}\n        aria-keyshortcuts={draggable ? \"ArrowLeft ArrowRight\" : undefined}\n        tabIndex={draggable ? 0 : undefined}\n        onKeyDown={draggable ? onKeyDown : undefined}\n        data-slot=\"globe-canvas\"\n        className=\"absolute inset-0 size-full rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n      />\n      {markers.length + namedArcs.length > 0 ? (\n        <ul data-slot=\"globe-places\" className=\"sr-only select-none\">\n          {markers.map((marker) => (\n            <li key={marker.label}>{marker.label}</li>\n          ))}\n          {namedArcs.map((arc) => (\n            <li key={arc.label}>{arc.label}</li>\n          ))}\n        </ul>\n      ) : null}\n    </div>\n  )\n}\n\nexport { Globe, type GlobeProps, type GlobeVariant, type GlobeEffect, type GlobeMarker, type GlobeArc }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/globe-color.ts",
      "content": "export type Rgb = readonly [number, number, number]\r\n\r\nfunction channelLuminance(channel: number) {\r\n  const unit = channel / 255\r\n  return unit <= 0.03928 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4\r\n}\r\n\r\nfunction luminance([r, g, b]: Rgb) {\r\n  return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b)\r\n}\r\n\r\nexport function contrast(a: Rgb, b: Rgb) {\r\n  const one = luminance(a) + 0.05\r\n  const two = luminance(b) + 0.05\r\n  return one > two ? one / two : two / one\r\n}\r\n\r\nexport const mixRgb = (from: Rgb, to: Rgb, amount: number): Rgb => [\r\n  from[0] + (to[0] - from[0]) * amount,\r\n  from[1] + (to[1] - from[1]) * amount,\r\n  from[2] + (to[2] - from[2]) * amount,\r\n]\r\n\r\n/** A near-black accent on a dark page would vanish, and a pale one on a light page. Walks it toward the foreground only as far as it takes. */\r\nexport function legibleAccent(accent: Rgb, surface: Rgb, foreground: Rgb, minimum = 2.2): Rgb {\r\n  for (let amount = 0; amount <= 1; amount += 0.1) {\r\n    const candidate = mixRgb(accent, foreground, amount)\r\n    if (contrast(candidate, surface) >= minimum) return candidate\r\n  }\r\n  return foreground\r\n}\r\n\r\n/** Land covers a third of the disc, and a full accent over that much area shouts. Dark takes less, because a warm hue mixed into a dark ocean turns brown. */\r\nexport function softLand(accent: Rgb, ocean: Rgb, foreground: Rgb): Rgb {\r\n  const share = luminance(ocean) > 0.5 ? 0.35 : 0.15\r\n  return legibleAccent(mixRgb(accent, ocean, share), ocean, foreground, 1.5)\r\n}\r\n\r\nexport interface Ink {\r\n  foreground: Rgb\r\n  surface: Rgb\r\n  ocean: Rgb\r\n  accent: Rgb\r\n  land: Rgb\r\n}\r\n\r\n/** Canvas parses any CSS colour the theme uses, oklch included, so asking it is shorter than writing a parser. */\r\nexport function readInk(canvas: HTMLCanvasElement): Ink {\r\n  const styles = getComputedStyle(canvas)\r\n  const probe = document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true })\r\n  const resolve = (name: string, fallback: Rgb): Rgb => {\r\n    const value = styles.getPropertyValue(name).trim()\r\n    if (!probe || !value) return fallback\r\n    probe.clearRect(0, 0, 1, 1)\r\n    probe.fillStyle = value\r\n    probe.fillRect(0, 0, 1, 1)\r\n    const [r = 0, g = 0, b = 0] = probe.getImageData(0, 0, 1, 1).data\r\n    return [r, g, b]\r\n  }\r\n  const foreground = resolve(\"--foreground\", [32, 32, 36])\r\n  const surface = resolve(\"--background\", [255, 255, 255])\r\n  const ocean = resolve(\"--secondary\", [238, 238, 241])\r\n  const mise = resolve(\"--mise\", [136, 136, 136])\r\n  return { foreground, surface, ocean, accent: legibleAccent(mise, surface, foreground), land: softLand(mise, ocean, foreground) }\r\n}\r\n\r\nexport function watchTheme(onChange: () => void) {\r\n  const observer = new MutationObserver(onChange)\r\n  observer.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\"] })\r\n  const media = window.matchMedia(\"(prefers-color-scheme: dark)\")\r\n  media.addEventListener?.(\"change\", onChange)\r\n  return () => {\r\n    observer.disconnect()\r\n    media.removeEventListener?.(\"change\", onChange)\r\n  }\r\n}\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/globe-draw.ts",
      "content": "import { isArcPointVisible, projectDots, rotatePoint, toUnitVector, type GlobePoint, type GlobeView } from \"@/registry/miseui/ui/globe-math\"\r\nimport type { Rgb } from \"@/registry/miseui/ui/globe-color\"\r\n\r\nexport interface Disc {\r\n  cx: number\r\n  cy: number\r\n  radius: number\r\n  /** Backing pixels per CSS pixel, so marks keep their size on any screen. */\r\n  ratio: number\r\n}\r\n\r\nconst css = ([r, g, b]: Rgb, alpha: number) => `rgb(${Math.round(r)} ${Math.round(g)} ${Math.round(b)} / ${alpha})`\r\n\r\n/**\r\n * Depth under an orthographic view depends only on the distance from the centre, so one radial gradient as the fill of\r\n * one path fades every dot toward the rim. Buckets of dots per opacity would need a path each and show as rings.\r\n */\r\nexport function limbFill(ctx: CanvasRenderingContext2D, { cx, cy, radius }: Disc, color: Rgb, peak: number) {\r\n  const fill = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius)\r\n  fill.addColorStop(0, css(color, peak))\r\n  fill.addColorStop(0.7, css(color, peak))\r\n  fill.addColorStop(0.9, css(color, peak * 0.72))\r\n  fill.addColorStop(1, css(color, peak * 0.18))\r\n  return fill\r\n}\r\n\r\n/** The rim is a little denser than the middle, as on any ball seen against a flat page. It is what turns a cloud of dots into a sphere. */\r\nexport function drawHaze(ctx: CanvasRenderingContext2D, { cx, cy, radius }: Disc, color: Rgb) {\r\n  const fill = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius)\r\n  fill.addColorStop(0, css(color, 0))\r\n  fill.addColorStop(0.5, css(color, 0.008))\r\n  fill.addColorStop(0.8, css(color, 0.036))\r\n  fill.addColorStop(1, css(color, 0.078))\r\n  ctx.fillStyle = fill\r\n  ctx.beginPath()\r\n  ctx.arc(cx, cy, radius, 0, Math.PI * 2)\r\n  ctx.fill()\r\n}\r\n\r\nexport function drawDots(ctx: CanvasRenderingContext2D, units: Float32Array, scratch: Float32Array, view: GlobeView, disc: Disc, dotRadius: number, fill: CanvasGradient) {\r\n  const count = projectDots(units, view, scratch)\r\n  ctx.fillStyle = fill\r\n  ctx.beginPath()\r\n  for (let index = 0; index < count; index += 1) {\r\n    const x = disc.cx + (scratch[index * 3] as number) * disc.radius\r\n    const y = disc.cy - (scratch[index * 3 + 1] as number) * disc.radius\r\n    // Round dots seen edge-on get narrower. Shrinking them is the cheap stand-in for an ellipse per dot.\r\n    const size = dotRadius * (0.55 + 0.45 * (scratch[index * 3 + 2] as number))\r\n    ctx.moveTo(x + size, y)\r\n    ctx.arc(x, y, size, 0, Math.PI * 2)\r\n  }\r\n  ctx.fill()\r\n  return count\r\n}\r\n\r\nconst GRATICULE_STEP = 30\r\nconst LINE_SEGMENTS = 48\r\n\r\n/** Flat x, y, z polylines. The poles get no parallel: a circle of radius zero only draws a dot. */\r\nexport function buildGraticule() {\r\n  const lines: Float32Array[] = []\r\n  const trace = (at: (share: number) => GlobePoint) => {\r\n    const line = new Float32Array((LINE_SEGMENTS + 1) * 3)\r\n    for (let step = 0; step <= LINE_SEGMENTS; step += 1) line.set(toUnitVector(at(step / LINE_SEGMENTS)), step * 3)\r\n    lines.push(line)\r\n  }\r\n  for (let lng = -180; lng < 180; lng += GRATICULE_STEP) trace((share) => ({ lat: share * 180 - 90, lng }))\r\n  for (let lat = -60; lat <= 60; lat += GRATICULE_STEP) trace((share) => ({ lat, lng: share * 360 - 180 }))\r\n  return lines\r\n}\r\n\r\n/** Adds the kept stretches of a polyline to the current path. A gap breaks the line rather than joining across the disc. */\r\nfunction tracePolyline(ctx: CanvasRenderingContext2D, line: Float32Array, view: GlobeView, disc: Disc, keep: (x: number, y: number, z: number) => boolean) {\r\n  let drawing = false\r\n  for (let index = 0; index < line.length; index += 3) {\r\n    const [x, y, z] = rotatePoint(line[index] as number, line[index + 1] as number, line[index + 2] as number, view)\r\n    if (!keep(x, y, z)) {\r\n      drawing = false\r\n      continue\r\n    }\r\n    const screenX = disc.cx + x * disc.radius\r\n    const screenY = disc.cy - y * disc.radius\r\n    if (drawing) ctx.lineTo(screenX, screenY)\r\n    else ctx.moveTo(screenX, screenY)\r\n    drawing = true\r\n  }\r\n}\r\n\r\nconst isNear = (_x: number, _y: number, z: number) => z > 0\r\nconst isFar = (_x: number, _y: number, z: number) => z <= 0\r\n\r\n/** The far side shows through very faintly: that is the whole difference between a wire ball and a printed circle. */\r\nexport function drawGraticule(ctx: CanvasRenderingContext2D, lines: readonly Float32Array[], view: GlobeView, disc: Disc, color: Rgb) {\r\n  ctx.lineWidth = disc.ratio * 0.75\r\n  ctx.strokeStyle = css(color, 0.06)\r\n  ctx.beginPath()\r\n  lines.forEach((line) => tracePolyline(ctx, line, view, disc, isFar))\r\n  ctx.stroke()\r\n  ctx.strokeStyle = css(color, 0.15)\r\n  ctx.beginPath()\r\n  lines.forEach((line) => tracePolyline(ctx, line, view, disc, isNear))\r\n  ctx.stroke()\r\n  ctx.strokeStyle = css(color, 0.22)\r\n  ctx.beginPath()\r\n  ctx.arc(disc.cx, disc.cy, disc.radius, 0, Math.PI * 2)\r\n  ctx.stroke()\r\n}\r\n\r\nconst MARKER_FADE_DEPTH = 0.22\r\n\r\nexport function drawMarkers(ctx: CanvasRenderingContext2D, markers: readonly GlobePoint[], view: GlobeView, disc: Disc, accent: Rgb, surface: Rgb) {\r\n  markers.forEach((marker) => {\r\n    const unit = toUnitVector(marker)\r\n    const [x, y, z] = rotatePoint(unit[0], unit[1], unit[2], view)\r\n    if (z <= 0) return\r\n    const alpha = Math.min(z / MARKER_FADE_DEPTH, 1)\r\n    const px = disc.cx + x * disc.radius\r\n    const py = disc.cy - y * disc.radius\r\n    const circle = (radius: number, color: string) => {\r\n      ctx.fillStyle = color\r\n      ctx.beginPath()\r\n      ctx.arc(px, py, radius * disc.ratio, 0, Math.PI * 2)\r\n      ctx.fill()\r\n    }\r\n    circle(8, css(accent, 0.22 * alpha))\r\n    circle(4.5, css(surface, alpha))\r\n    circle(3, css(accent, alpha))\r\n  })\r\n}\r\n\r\nconst isArcVisible = (x: number, y: number, z: number) => isArcPointVisible(x, y, z)\r\nconst LIGHT_TAIL = 0.24\r\nconst LIGHT_SHARE = 0.72\r\n\r\n/** Stays inside one stroke: a tail of short segments in rising alpha would show a bead at every joint. */\r\nfunction drawLight(ctx: CanvasRenderingContext2D, line: Float32Array, phase: number, view: GlobeView, disc: Disc, accent: Rgb) {\r\n  if (phase > LIGHT_SHARE) return\r\n  const pointCount = line.length / 3\r\n  const travel = phase / LIGHT_SHARE\r\n  const head = Math.round(travel * (1 + LIGHT_TAIL) * (pointCount - 1))\r\n  const tail = Math.max(head - Math.round(LIGHT_TAIL * (pointCount - 1)), 0)\r\n  const end = Math.min(head, pointCount - 1)\r\n  if (end <= tail) return\r\n  const screen = (at: number) => {\r\n    const [x, y, z] = rotatePoint(line[at * 3] as number, line[at * 3 + 1] as number, line[at * 3 + 2] as number, view)\r\n    return { x: disc.cx + x * disc.radius, y: disc.cy - y * disc.radius, shown: isArcPointVisible(x, y, z) }\r\n  }\r\n  const from = screen(tail)\r\n  const to = screen(end)\r\n  const fill = ctx.createLinearGradient(from.x, from.y, to.x, to.y)\r\n  fill.addColorStop(0, css(accent, 0))\r\n  fill.addColorStop(1, css(accent, 1))\r\n  ctx.strokeStyle = fill\r\n  ctx.lineWidth = disc.ratio * 1.75\r\n  ctx.lineCap = \"round\"\r\n  ctx.beginPath()\r\n  tracePolyline(ctx, line.subarray(tail * 3, (end + 1) * 3), view, disc, isArcVisible)\r\n  ctx.stroke()\r\n  if (!to.shown || head > pointCount - 1) return\r\n  ctx.fillStyle = css(accent, 1)\r\n  ctx.beginPath()\r\n  ctx.arc(to.x, to.y, disc.ratio * 1.75, 0, Math.PI * 2)\r\n  ctx.fill()\r\n}\r\n\r\ninterface ArcPaint {\r\n  lines: readonly Float32Array[]\r\n  /** Seconds of light travel, or null for a still frame: the arc alone says where it runs. */\r\n  lightSeconds: number | null\r\n}\r\n\r\nconst LIGHT_PERIOD_SECONDS = 4.2\r\n\r\nexport function drawArcs(ctx: CanvasRenderingContext2D, { lines, lightSeconds }: ArcPaint, view: GlobeView, disc: Disc, accent: Rgb) {\r\n  ctx.lineWidth = disc.ratio\r\n  ctx.strokeStyle = css(accent, lightSeconds === null ? 0.85 : 0.38)\r\n  ctx.beginPath()\r\n  lines.forEach((line) => tracePolyline(ctx, line, view, disc, isArcVisible))\r\n  ctx.stroke()\r\n  if (lightSeconds === null) return\r\n  lines.forEach((line, index) => {\r\n    const phase = (lightSeconds / LIGHT_PERIOD_SECONDS + index / Math.max(lines.length, 1)) % 1\r\n    drawLight(ctx, line, phase, view, disc, accent)\r\n  })\r\n}\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/globe-frame.ts",
      "content": "import type { Ink } from \"@/registry/miseui/ui/globe-color\"\r\nimport { drawDots, drawGraticule, drawHaze, limbFill, type Disc } from \"@/registry/miseui/ui/globe-draw\"\r\nimport { dotStep, sampleLandDots, type GlobeView } from \"@/registry/miseui/ui/globe-math\"\r\nimport { buildSolidFrame, type SolidFrame } from \"@/registry/miseui/ui/globe-solid\"\r\n\r\nexport type GlobeVariant = \"dots\" | \"wire\" | \"solid\" | \"arcs\"\r\n\r\nconst DOT_PEAK: Record<GlobeVariant, number> = { dots: 0.66, wire: 0.5, solid: 0, arcs: 0.42 }\r\n\r\nexport class DotCache {\r\n  private step = -1\r\n  units = new Float32Array(0)\r\n  scratch = new Float32Array(0)\r\n  use(step: number) {\r\n    if (step === this.step) return\r\n    this.step = step\r\n    this.units = sampleLandDots(step)\r\n    this.scratch = new Float32Array(this.units.length)\r\n  }\r\n}\r\n\r\nexport interface SolidBuffer {\r\n  key: string\r\n  frame: SolidFrame\r\n  image: ImageData\r\n  target: Uint32Array\r\n}\r\n\r\nexport function solidBuffer(ctx: CanvasRenderingContext2D, previous: SolidBuffer | null, pixels: number, tilt: number, ink: Ink): SolidBuffer {\r\n  const key = `${pixels}|${tilt}|${ink.ocean.join()}|${ink.land.join()}`\r\n  if (previous?.key === key) return previous\r\n  const image = ctx.createImageData(pixels, pixels)\r\n  return { key, image, frame: buildSolidFrame(pixels, tilt, { ocean: ink.ocean, land: ink.land }), target: new Uint32Array(image.data.buffer) }\r\n}\r\n\r\nexport function paintVector(ctx: CanvasRenderingContext2D, variant: GlobeVariant, view: GlobeView, disc: Disc, ink: Ink, dots: DotCache, graticule: readonly Float32Array[]) {\r\n  ctx.clearRect(0, 0, disc.cx * 2, disc.cy * 2)\r\n  if (variant === \"wire\") drawGraticule(ctx, graticule, view, disc, ink.foreground)\r\n  else drawHaze(ctx, disc, ink.foreground)\r\n  dots.use(dotStep(disc.radius / disc.ratio, variant === \"wire\" ? 7 : 5.6))\r\n  const dotRadius = disc.ratio * (variant === \"wire\" ? 0.8 : disc.radius / disc.ratio > 180 ? 1.2 : 1)\r\n  drawDots(ctx, dots.units, dots.scratch, view, disc, dotRadius, limbFill(ctx, disc, ink.foreground, DOT_PEAK[variant]))\r\n}\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/globe-land.ts",
      "content": "/**\n * Land as a bit mask, 144 columns by 72 rows from the north pole and 180 degrees west, one bit per 2.5 degree cell.\n * Rasterized once from Natural Earth 1:110m land, which is in the public domain. It is data, so it lives in a string\n * rather than a dependency, and it is coarse on purpose: a globe drawn in dots has nothing finer to show.\n */\nexport const LAND_COLUMNS = 144\nexport const LAND_ROWS = 72\nexport const LAND_MASK =\n  \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANgD8AAAAAAAAAAAAAAAAAAPz///8DAAAMAGAAAAAAAAAAAPj7//8A4AAAAMABAAAAAAAAh/vx//8AQACAAfAfgAMAAADAIVUB/v8AAABAAP9/BAAAAAfAPvcP/H8AAAwg2P////8Awf//f08e/D8AwP/g////////7////398+IMD4P//////////gP///186eAAD+P////////9/wP///wcOcAAAfP////////8/gAf//wd+AAAAfPz//////ycBgAD4/x/+AABAKP//////f8ABAADw/3//AQDw/P///////8EAAADg////AwDQ/////////wMAAADA//8/BwDA/////////wIAAADA////AACA////////fwAAAADA//8fAADg3wf/////PwMAAADA//8PAADwaff/////DwEAAACA//8DAADwAP////9/BAEAAACA//8DAABgH/j/////zQAAAAAA//8BAADgD8D/////cAAAAAAA/v8AAADwf///////AQAAAAAA/IMAAAD4///3////AQAAAAAA8IEAAAD8/7+P////AAAAAAAA4AEAAAD8/79/+P9/AQAAAAAAwBEDAAD+/3//8OcfAAAAAAAAwB8IAAD+/39/4OMHAQAAAAAAAD8AAAD+//8e4MEHAQAAAAAAAHgAAAD+//8HwIAPAAAAAAAAAGAMAAD8//8dwIAGAAAAAAAAAAD/AAD4//8PAAEABgAAAAAAAAD+AwDw//8PAABBAAAAAAAAAAD+DwAA8P8HAIBjAAAAAAAAAAD/DwAA8P8DAIBxAwAAAAAAAAD/PwAA+P8BAABzIAAAAAAAAAD//wEA8P8AAAAm6RMAAAAAAAD//wMA4P8AAAAcgEcAAAAAAAD//wMA4P8AAAAwgAkAAAAAAAD+/wEA4P8AAAAAAAAAAAAAAAD+/wEA4P8IAAAAcAMAAAAAAAD8/wEA8P8MAAAAfgMAAAAAAADw/wAA4D8MAAAA/gcAAAAAAADw/wAA4D8GAADA/w8AAAAAAADwPwAAwD8GAADg/x8AAAAAAAD4HwAAwB8AAADg/x8AAAAAAAD4DwAAwB8AAADA/z8AAAAAAAD4DwAAgA8AAADA/x8AAAAAAAD4BwAAgAcAAADAwR8AAAAAAAD4AQAAAAAAAAAAAA8AAAAAAAD8AQAAAAAAAAAAAAZAAAAAAAB8AAAAAAAAAAAAAAQAAAAAAAA8AAAAAAAAAAAAAAAYAAAAAAAcAAAAAAAAAAAAAAAIAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAcAQAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgAAAAAABgAABzfgMAAAAAAABgAAAAAGD/j////z8AAAAAgAB8AADw////5/////8PAAAAhv//AAD8//////////8PAOD///8fAOD///////////8DgP7///8DMPz///////////8DAPj/////uP////////////8BG/j///////////////////9/////////////////////////////////////////////////\"\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/globe-math.ts",
      "content": "import { LAND_COLUMNS, LAND_MASK, LAND_ROWS } from \"@/registry/miseui/ui/globe-land\"\r\n\r\nexport interface GlobePoint {\r\n  lat: number\r\n  lng: number\r\n}\r\n\r\nconst BASE64 = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\"\r\nconst CELL_DEGREES = 360 / LAND_COLUMNS\r\nconst RADIANS = Math.PI / 180\r\n\r\n/** Written out because Buffer does not exist in a browser and atob does not exist in every server runtime. */\r\nexport function decodeBase64(text: string) {\r\n  const clean = text.replace(/=+$/, \"\")\r\n  const bytes = new Uint8Array(Math.floor((clean.length * 3) / 4))\r\n  let buffer = 0\r\n  let bits = 0\r\n  let written = 0\r\n  for (const letter of clean) {\r\n    buffer = (buffer << 6) | BASE64.indexOf(letter)\r\n    bits += 6\r\n    if (bits >= 8) {\r\n      bits -= 8\r\n      bytes[written] = (buffer >> bits) & 255\r\n      written += 1\r\n      buffer &= (1 << bits) - 1\r\n    }\r\n  }\r\n  return bytes\r\n}\r\n\r\nlet decodedMask: Uint8Array | null = null\r\n\r\nexport function landMask() {\r\n  decodedMask ??= decodeBase64(LAND_MASK)\r\n  return decodedMask\r\n}\r\n\r\nfunction cellIsLand(mask: Uint8Array, row: number, column: number) {\r\n  const clampedRow = Math.min(Math.max(row, 0), LAND_ROWS - 1)\r\n  const wrappedColumn = ((column % LAND_COLUMNS) + LAND_COLUMNS) % LAND_COLUMNS\r\n  const bit = clampedRow * LAND_COLUMNS + wrappedColumn\r\n  return (((mask[bit >> 3] as number) >> (bit & 7)) & 1) as 0 | 1\r\n}\r\n\r\nexport function isLand(lat: number, lng: number, mask = landMask()) {\r\n  const row = Math.floor((90 - lat) / CELL_DEGREES)\r\n  const column = Math.floor((lng + 180) / CELL_DEGREES)\r\n  return cellIsLand(mask, row, column) === 1\r\n}\r\n\r\n/**\r\n * How much land surrounds a point, 0 to 1, read between cell centres. Taking the nearest cell instead draws the 2.5\r\n * degree staircase on every coast as soon as anything is sampled finer than the mask.\r\n */\r\nexport function landShare(lat: number, lng: number, mask = landMask()) {\r\n  const rowAt = (90 - lat) / CELL_DEGREES - 0.5\r\n  const columnAt = (lng + 180) / CELL_DEGREES - 0.5\r\n  const row = Math.floor(rowAt)\r\n  const column = Math.floor(columnAt)\r\n  const down = rowAt - row\r\n  const across = columnAt - column\r\n  const top = cellIsLand(mask, row, column) * (1 - across) + cellIsLand(mask, row, column + 1) * across\r\n  const bottom = cellIsLand(mask, row + 1, column) * (1 - across) + cellIsLand(mask, row + 1, column + 1) * across\r\n  return top * (1 - down) + bottom * down\r\n}\r\n\r\n/** A row near a pole is a short circle, so it gets fewer dots. A plain grid bunches them into a ring at each pole. */\r\nexport function rowDotCount(lat: number, stepDegrees: number) {\r\n  return Math.max(1, Math.round((360 * Math.cos(lat * RADIANS)) / stepDegrees))\r\n}\r\n\r\nexport function toUnitVector({ lat, lng }: GlobePoint): readonly [number, number, number] {\r\n  const phi = lat * RADIANS\r\n  const lambda = lng * RADIANS\r\n  return [Math.cos(phi) * Math.sin(lambda), Math.sin(phi), Math.cos(phi) * Math.cos(lambda)]\r\n}\r\n\r\nconst LAND_THRESHOLD = 0.42\r\n\r\n/** Unit vectors of the land dots as flat x, y, z. Every other row is shifted half a step, so no straight lines of dots run across a coast. */\r\nexport function sampleLandDots(stepDegrees: number, mask = landMask()) {\r\n  const found: number[] = []\r\n  const rows = Math.round(180 / stepDegrees)\r\n  for (let row = 0; row < rows; row += 1) {\r\n    const lat = 90 - ((row + 0.5) * 180) / rows\r\n    const count = rowDotCount(lat, stepDegrees)\r\n    for (let index = 0; index < count; index += 1) {\r\n      const lng = -180 + ((index + (row % 2 ? 0.5 : 0.25)) * 360) / count\r\n      if (landShare(lat, lng, mask) >= LAND_THRESHOLD) found.push(...toUnitVector({ lat, lng }))\r\n    }\r\n  }\r\n  return Float32Array.from(found)\r\n}\r\n\r\nexport interface GlobeView {\r\n  sinLng: number\r\n  cosLng: number\r\n  sinTilt: number\r\n  cosTilt: number\r\n}\r\n\r\nexport function createView(lng: number, tilt: number): GlobeView {\r\n  return { sinLng: Math.sin(lng * RADIANS), cosLng: Math.cos(lng * RADIANS), sinTilt: Math.sin(tilt * RADIANS), cosTilt: Math.cos(tilt * RADIANS) }\r\n}\r\n\r\n/** Turns the point so the view's centre lies at x 0, y 0, z 1. Orthographic: x and y are already the screen, z is depth. */\r\nexport function rotatePoint(x: number, y: number, z: number, view: GlobeView): readonly [number, number, number] {\r\n  const turnedX = x * view.cosLng - z * view.sinLng\r\n  const turnedZ = x * view.sinLng + z * view.cosLng\r\n  return [turnedX, y * view.cosTilt - turnedZ * view.sinTilt, y * view.sinTilt + turnedZ * view.cosTilt]\r\n}\r\n\r\n/** Writes the near side into `out` as flat x, y, z and returns how many fit. `out` is reused each frame, so a turning globe allocates nothing. */\r\nexport function projectDots(units: Float32Array, view: GlobeView, out: Float32Array) {\r\n  const { sinLng, cosLng, sinTilt, cosTilt } = view\r\n  let visible = 0\r\n  for (let index = 0; index < units.length; index += 3) {\r\n    const x = units[index] as number\r\n    const y = units[index + 1] as number\r\n    const z = units[index + 2] as number\r\n    const turnedZ = x * sinLng + z * cosLng\r\n    const depth = y * sinTilt + turnedZ * cosTilt\r\n    if (depth <= 0) continue\r\n    out[visible] = x * cosLng - z * sinLng\r\n    out[visible + 1] = y * cosTilt - turnedZ * sinTilt\r\n    out[visible + 2] = depth\r\n    visible += 3\r\n  }\r\n  return visible / 3\r\n}\r\n\r\n/** An arc lifted off the surface: still a great circle in plan, and lifted so it reads as a path rather than a scratch on the sphere. */\r\nexport function greatCirclePoints(from: GlobePoint, to: GlobePoint, segments: number, lift = 0) {\r\n  const a = toUnitVector(from)\r\n  const b = toUnitVector(to)\r\n  const dot = Math.min(Math.max(a[0] * b[0] + a[1] * b[1] + a[2] * b[2], -1), 1)\r\n  const omega = Math.acos(dot)\r\n  const sinOmega = Math.sin(omega)\r\n  const points = new Float32Array((segments + 1) * 3)\r\n  for (let step = 0; step <= segments; step += 1) {\r\n    const share = step / segments\r\n    // Identical or opposite ends have no single great circle; a straight blend at least never divides by zero.\r\n    const fromWeight = sinOmega < 1e-5 ? 1 - share : Math.sin((1 - share) * omega) / sinOmega\r\n    const toWeight = sinOmega < 1e-5 ? share : Math.sin(share * omega) / sinOmega\r\n    const height = 1 + lift * Math.sin(share * Math.PI)\r\n    points.set([0, 1, 2].map((axis) => (fromWeight * (a[axis] as number) + toWeight * (b[axis] as number)) * height), step * 3)\r\n  }\r\n  return points\r\n}\r\n\r\n/** A point lifted past the rim shows beside the disc even with negative depth; only a point over the disc can be behind it. */\r\nexport function isArcPointVisible(x: number, y: number, z: number) {\r\n  return z >= 0 || x * x + y * y >= 1\r\n}\r\n\r\nexport const SPIN_DEGREES_PER_SECOND = 6\r\nconst SPIN_EASE_SECONDS = 0.7\r\n\r\nexport interface Spin {\r\n  angle: number\r\n  velocity: number\r\n}\r\n\r\n/** Velocity relaxes toward its target, so a flick decays into the slow turn with no seam, and pausing eases out instead of stopping dead. */\r\nexport function stepSpin(spin: Spin, targetVelocity: number, seconds: number): Spin {\r\n  const velocity = targetVelocity + (spin.velocity - targetVelocity) * Math.exp(-seconds / SPIN_EASE_SECONDS)\r\n  return { angle: normalizeDegrees(spin.angle + velocity * seconds), velocity }\r\n}\r\n\r\nexport function normalizeDegrees(degrees: number) {\r\n  return ((((degrees + 180) % 360) + 360) % 360) - 180\r\n}\r\n\r\n/** Degrees that keep the land at the centre of the disc under the finger. */\r\nexport function dragDegrees(pixels: number, radius: number) {\r\n  return radius > 0 ? ((-pixels / radius) * 180) / Math.PI : 0\r\n}\r\n\r\n/** Dots stay about the same distance apart at any size. A large globe gets more of them, up to where the mask has nothing finer to show. */\r\nexport function dotStep(radius: number, spacingPx = 5.6) {\r\n  if (radius <= 0) return 2.5\r\n  return Math.min(Math.max(((spacingPx / radius) * 180) / Math.PI, 1.5), 2.8)\r\n}\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/globe-painter.ts",
      "content": "import { readInk, watchTheme, type Ink } from \"@/registry/miseui/ui/globe-color\"\r\nimport { buildGraticule, drawArcs, drawMarkers, type Disc } from \"@/registry/miseui/ui/globe-draw\"\r\nimport { DotCache, paintVector, solidBuffer, type GlobeVariant, type SolidBuffer } from \"@/registry/miseui/ui/globe-frame\"\r\nimport { createView, dragDegrees, greatCirclePoints, normalizeDegrees, SPIN_DEGREES_PER_SECOND, stepSpin, type GlobePoint, type Spin } from \"@/registry/miseui/ui/globe-math\"\r\nimport { paintSolidFrame } from \"@/registry/miseui/ui/globe-solid\"\r\n\r\nexport type { GlobeVariant }\r\n\r\nexport interface GlobeScene {\r\n  variant: GlobeVariant\r\n  center: GlobePoint\r\n  markers: readonly GlobePoint[]\r\n  arcs: readonly { from: GlobePoint; to: GlobePoint }[]\r\n}\r\n\r\nconst MAX_PIXEL_RATIO = 2\r\n/** Per-pixel shading scales with area, and a smooth ball loses nothing when the browser enlarges the last step. */\r\nconst MAX_SOLID_PIXELS = 720\r\nconst ARC_SEGMENTS = 64\r\nconst ARC_LIFT = 0.12\r\n/** Lifted arcs need room past the rim, or the canvas edge would cut them straight. */\r\nconst ARCS_DISC_SHARE = 0.88\r\nconst IDLE_VELOCITY = 0.05\r\nconst FLICK_STALE_MS = 80\r\nconst MAX_FLICK = 360\r\nconst KEY_EASE_SECONDS = 0.12\r\n/** The slow turn moves under half a pixel per step at this rate, so painting it at 60 Hz would only spend battery. */\r\nconst AUTO_FRAME_MS = 32\r\n\r\nexport interface GlobePainter {\r\n  setScene: (scene: GlobeScene) => void\r\n  /** Whether the slow turn runs. False off screen, in a hidden tab, when paused and under reduced motion. */\r\n  setAuto: (auto: boolean) => void\r\n  /** Under reduced motion a drag still turns the globe, one to one, and a key press lands at once. */\r\n  setStill: (still: boolean) => void\r\n  dragBy: (pixels: number, timeMs: number) => void\r\n  release: (timeMs: number) => void\r\n  turnBy: (degrees: number) => void\r\n  destroy: () => void\r\n}\r\n\r\nconst NO_PAINTER: GlobePainter = { setScene: () => {}, setAuto: () => {}, setStill: () => {}, dragBy: () => {}, release: () => {}, turnBy: () => {}, destroy: () => {} }\r\n\r\n/**\r\n * Paints on demand. Frames run only while something moves: the slow turn, a flick dying away, or a key press landing.\r\n * Spin lives here rather than in React, because a pointer stream must not re-render anything.\r\n */\r\nexport function createGlobePainter(canvas: HTMLCanvasElement, firstScene: GlobeScene): GlobePainter {\r\n  const ctx = canvas.getContext(\"2d\")\r\n  if (!ctx) return NO_PAINTER\r\n  const dots = new DotCache()\r\n  const graticule = buildGraticule()\r\n  let scene = firstScene\r\n  let spin: Spin = { angle: firstScene.center.lng, velocity: 0 }\r\n  let arcLines = firstScene.arcs.map(({ from, to }) => greatCirclePoints(from, to, ARC_SEGMENTS, ARC_LIFT))\r\n  let ink: Ink | null = null\r\n  let solid: SolidBuffer | null = null\r\n  let auto = false\r\n  let still = false\r\n  let dragging = false\r\n  let lastMoveAt = 0\r\n  let keyTurn = 0\r\n  let lightSeconds = 0\r\n  let frame = 0\r\n  let lastStep = 0\r\n  let lastPaint = 0\r\n\r\n  const paint = () => {\r\n    const cssSize = canvas.clientWidth\r\n    if (cssSize <= 0) return\r\n    const wanted = Math.round(cssSize * Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO))\r\n    const pixels = scene.variant === \"solid\" ? Math.min(wanted, MAX_SOLID_PIXELS) : wanted\r\n    if (canvas.width !== pixels) canvas.width = pixels\r\n    if (canvas.height !== pixels) canvas.height = pixels\r\n    ink ??= readInk(canvas)\r\n    const disc: Disc = { cx: pixels / 2, cy: pixels / 2, radius: (pixels / 2 - 1) * (scene.variant === \"arcs\" ? ARCS_DISC_SHARE : 1), ratio: pixels / cssSize }\r\n    const view = createView(spin.angle, scene.center.lat)\r\n    if (scene.variant === \"solid\") {\r\n      solid = solidBuffer(ctx, solid, pixels, scene.center.lat, ink)\r\n      paintSolidFrame(solid.frame, spin.angle, solid.target)\r\n      ctx.putImageData(solid.image, 0, 0)\r\n    } else paintVector(ctx, scene.variant, view, disc, ink, dots, graticule)\r\n    if (scene.variant === \"arcs\") drawArcs(ctx, { lines: arcLines, lightSeconds: still ? null : lightSeconds }, view, disc, ink.accent)\r\n    drawMarkers(ctx, scene.markers, view, disc, ink.accent, scene.variant === \"solid\" ? ink.ocean : ink.surface)\r\n  }\r\n\r\n  const isSettling = () => Math.abs(spin.velocity - (auto ? -SPIN_DEGREES_PER_SECOND : 0)) > IDLE_VELOCITY || Math.abs(keyTurn) > IDLE_VELOCITY\r\n  const schedule = () => {\r\n    if (frame === 0) frame = requestAnimationFrame(tick)\r\n  }\r\n  const advance = (seconds: number) => {\r\n    if (keyTurn !== 0) {\r\n      const moved = Math.abs(keyTurn) <= IDLE_VELOCITY ? keyTurn : keyTurn * (1 - Math.exp(-seconds / KEY_EASE_SECONDS))\r\n      spin = { angle: normalizeDegrees(spin.angle + moved), velocity: 0 }\r\n      keyTurn -= moved\r\n      return\r\n    }\r\n    if (dragging) return\r\n    spin = stepSpin(spin, auto ? -SPIN_DEGREES_PER_SECOND : 0, seconds)\r\n    if (auto) lightSeconds += seconds\r\n  }\r\n  function tick(now: number) {\r\n    frame = 0\r\n    const settling = isSettling() || dragging\r\n    if (!settling && auto && now - lastPaint < AUTO_FRAME_MS) {\r\n      schedule()\r\n      return\r\n    }\r\n    const seconds = lastStep === 0 ? 0 : Math.min((now - lastStep) / 1000, 0.05)\r\n    lastStep = now\r\n    lastPaint = now\r\n    advance(seconds)\r\n    paint()\r\n    // A held drag paints from its own pointer events. Looping here would only redraw the same frame.\r\n    if (!dragging && (auto || isSettling())) schedule()\r\n    else lastStep = 0\r\n  }\r\n\r\n  const resize = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(schedule)\r\n  resize?.observe(canvas)\r\n  const stopTheme = watchTheme(() => {\r\n    ink = null\r\n    schedule()\r\n  })\r\n  schedule()\r\n\r\n  return {\r\n    setScene: (next) => {\r\n      if (next.center.lat !== scene.center.lat || next.center.lng !== scene.center.lng) spin = { angle: next.center.lng, velocity: spin.velocity }\r\n      scene = next\r\n      arcLines = next.arcs.map(({ from, to }) => greatCirclePoints(from, to, ARC_SEGMENTS, ARC_LIFT))\r\n      schedule()\r\n    },\r\n    setAuto: (next) => {\r\n      auto = next\r\n      schedule()\r\n    },\r\n    setStill: (next) => {\r\n      still = next\r\n      schedule()\r\n    },\r\n    dragBy: (pixels, timeMs) => {\r\n      const degrees = dragDegrees(pixels, canvas.clientWidth / 2)\r\n      const elapsed = Math.max(timeMs - lastMoveAt, 1) / 1000\r\n      dragging = true\r\n      keyTurn = 0\r\n      lastMoveAt = timeMs\r\n      spin = { angle: normalizeDegrees(spin.angle + degrees), velocity: spin.velocity * 0.6 + (degrees / elapsed) * 0.4 }\r\n      schedule()\r\n    },\r\n    release: (timeMs) => {\r\n      dragging = false\r\n      const fresh = timeMs - lastMoveAt < FLICK_STALE_MS && !still\r\n      spin = { angle: spin.angle, velocity: fresh ? Math.min(Math.max(spin.velocity, -MAX_FLICK), MAX_FLICK) : 0 }\r\n      schedule()\r\n    },\r\n    turnBy: (degrees) => {\r\n      if (still) spin = { angle: normalizeDegrees(spin.angle + degrees), velocity: 0 }\r\n      else keyTurn += degrees\r\n      schedule()\r\n    },\r\n    destroy: () => {\r\n      cancelAnimationFrame(frame)\r\n      resize?.disconnect()\r\n      stopTheme()\r\n    },\r\n  }\r\n}\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/globe-solid.ts",
      "content": "import type { Rgb } from \"@/registry/miseui/ui/globe-color\"\nimport { LAND_COLUMNS, LAND_ROWS } from \"@/registry/miseui/ui/globe-land\"\nimport { landMask } from \"@/registry/miseui/ui/globe-math\"\n\nexport const TEXTURE_WIDTH = 1024\nexport const TEXTURE_HEIGHT = 512\nconst EDGE_FROM = 0.44\nconst EDGE_WIDTH = 0.08\nconst RADIANS = Math.PI / 180\n/** From the upper left, as on most things people have seen lit. */\nconst LIGHT_X = -0.42\nconst LIGHT_Y = 0.5\nconst LIGHT_Z = 0.76\nconst OCEAN_FLOOR = 0.82\nconst LAND_FLOOR = 0.84\nconst SHEEN = 0.1\n\n/** Weights chosen so a one-cell island still clears the edge threshold: it stays on the map, only its corners go. */\nfunction softenedCells(mask: Uint8Array) {\n  const bits = new Uint8Array(LAND_COLUMNS * LAND_ROWS)\n  for (let bit = 0; bit < bits.length; bit += 1) bits[bit] = ((mask[bit >> 3] as number) >> (bit & 7)) & 1\n  const at = (row: number, column: number) => bits[Math.min(Math.max(row, 0), LAND_ROWS - 1) * LAND_COLUMNS + ((column + LAND_COLUMNS) % LAND_COLUMNS)] as number\n  const cells = new Float32Array(bits.length)\n  for (let row = 0; row < LAND_ROWS; row += 1) {\n    for (let column = 0; column < LAND_COLUMNS; column += 1) {\n      const sides = at(row - 1, column) + at(row + 1, column) + at(row, column - 1) + at(row, column + 1)\n      const corners = at(row - 1, column - 1) + at(row - 1, column + 1) + at(row + 1, column - 1) + at(row + 1, column + 1)\n      cells[row * LAND_COLUMNS + column] = (at(row, column) * 8 + sides + corners / 2) / 14\n    }\n  }\n  return cells\n}\n\nlet texture: Uint8Array | null = null\n\n/** Land as coverage, 0 to 255. The coarse mask is softened first, so a coast reads as a shape and not as 2.5 degree steps. Built once per page. */\nexport function landTexture() {\n  if (texture) return texture\n  const built = new Uint8Array(TEXTURE_WIDTH * TEXTURE_HEIGHT)\n  const cells = softenedCells(landMask())\n  const left = new Int32Array(TEXTURE_WIDTH)\n  const right = new Int32Array(TEXTURE_WIDTH)\n  const across = new Float32Array(TEXTURE_WIDTH)\n  for (let column = 0; column < TEXTURE_WIDTH; column += 1) {\n    const at = ((column + 0.5) / TEXTURE_WIDTH) * LAND_COLUMNS - 0.5\n    const floor = Math.floor(at)\n    left[column] = (floor + LAND_COLUMNS) % LAND_COLUMNS\n    right[column] = (floor + 1) % LAND_COLUMNS\n    across[column] = at - floor\n  }\n  for (let row = 0; row < TEXTURE_HEIGHT; row += 1) {\n    const at = ((row + 0.5) / TEXTURE_HEIGHT) * LAND_ROWS - 0.5\n    const floor = Math.floor(at)\n    const down = at - floor\n    const top = Math.max(floor, 0) * LAND_COLUMNS\n    const bottom = Math.min(floor + 1, LAND_ROWS - 1) * LAND_COLUMNS\n    for (let column = 0; column < TEXTURE_WIDTH; column += 1) {\n      const a = left[column] as number\n      const b = right[column] as number\n      const share = across[column] as number\n      const upper = (cells[top + a] as number) * (1 - share) + (cells[top + b] as number) * share\n      const lower = (cells[bottom + a] as number) * (1 - share) + (cells[bottom + b] as number) * share\n      const edge = (upper * (1 - down) + lower * down - EDGE_FROM) / EDGE_WIDTH\n      built[row * TEXTURE_WIDTH + column] = edge <= 0 ? 0 : edge >= 1 ? 255 : edge * 255\n    }\n  }\n  texture = built\n  return built\n}\n\n/** What a pixel always sees for one size and tilt. Changing theme recolours from this, without any trigonometry. */\ninterface Geometry {\n  key: string\n  pixels: Int32Array\n  rows: Int32Array\n  columns: Int32Array\n  facing: Float32Array\n  cover: Uint8Array\n}\n\n/**\n * Tilt never changes while the globe turns, so a pixel always sees the same latitude and the same longitude offset.\n * All of it is worked out once, and turning only adds to the column: the look of per-pixel shading, without per-frame trigonometry.\n */\nfunction buildGeometry(size: number, tiltDegrees: number): Geometry {\n  const radius = size / 2 - 0.5\n  const centre = size / 2\n  const sinTilt = Math.sin(tiltDegrees * RADIANS)\n  const cosTilt = Math.cos(tiltDegrees * RADIANS)\n  const capacity = Math.ceil(Math.PI * (radius + 2) ** 2)\n  const pixels = new Int32Array(capacity)\n  const rows = new Int32Array(capacity)\n  const columns = new Int32Array(capacity)\n  const facing = new Float32Array(capacity)\n  const cover = new Uint8Array(capacity)\n  const inner = (1 - 0.5 / radius) ** 2\n  const outer = (1 + 0.5 / radius) ** 2\n  let count = 0\n  for (let y = 0; y < size; y += 1) {\n    const ny = (centre - y - 0.5) / radius\n    for (let x = 0; x < size; x += 1) {\n      const nx = (x + 0.5 - centre) / radius\n      const squared = nx * nx + ny * ny\n      if (squared >= outer || count >= capacity) continue\n      const nz = squared >= 1 ? 0 : Math.sqrt(1 - squared)\n      const worldZ = nz * cosTilt - ny * sinTilt\n      const worldY = ny * cosTilt + nz * sinTilt\n      const lat = Math.asin(worldY > 1 ? 1 : worldY < -1 ? -1 : worldY) / RADIANS\n      const lng = Math.atan2(nx, worldZ) / RADIANS\n      pixels[count] = y * size + x\n      rows[count] = Math.min(Math.floor(((90 - lat) / 180) * TEXTURE_HEIGHT), TEXTURE_HEIGHT - 1) * TEXTURE_WIDTH\n      columns[count] = Math.floor(((lng + 360) / 360) * TEXTURE_WIDTH)\n      const light = nx * LIGHT_X + ny * LIGHT_Y + nz * LIGHT_Z\n      facing[count] = light > 0 ? light : 0\n      cover[count] = squared <= inner ? 255 : Math.max(Math.min((1 - Math.sqrt(squared)) * radius + 0.5, 1), 0) * 255\n      count += 1\n    }\n  }\n  return { key: `${size}|${tiltDegrees}`, pixels: pixels.subarray(0, count), rows: rows.subarray(0, count), columns: columns.subarray(0, count), facing: facing.subarray(0, count), cover: cover.subarray(0, count) }\n}\n\nexport interface SolidColors {\n  ocean: Rgb\n  land: Rgb\n}\n\nexport interface SolidFrame {\n  size: number\n  pixels: Int32Array\n  rows: Int32Array\n  columns: Int32Array\n  ocean: Uint32Array\n  land: Uint32Array\n}\n\n/** ImageData bytes run red, green, blue, alpha, which on any little-endian machine packs alpha into the top byte. */\nfunction pack(r: number, g: number, b: number, a: number) {\n  return ((a << 24) | (b << 16) | (g << 8) | r) >>> 0\n}\n\nfunction shadeInto(target: Uint32Array, { facing, cover }: Geometry, color: Rgb, floor: number) {\n  const [r, g, b] = color\n  for (let index = 0; index < target.length; index += 1) {\n    const light = facing[index] as number\n    const squared = light * light\n    const fourth = squared * squared\n    const sheen = SHEEN * fourth * fourth\n    const body = floor + (1 - floor) * light\n    const keep = body * (1 - sheen)\n    const add = 255 * sheen\n    target[index] = pack(r * keep + add, g * keep + add, b * keep + add, cover[index] as number)\n  }\n}\n\nlet lastGeometry: Geometry | null = null\n\nexport function buildSolidFrame(size: number, tiltDegrees: number, { ocean, land }: SolidColors): SolidFrame {\n  const key = `${size}|${tiltDegrees}`\n  const geometry = lastGeometry?.key === key ? lastGeometry : buildGeometry(size, tiltDegrees)\n  lastGeometry = geometry\n  const oceanPixels = new Uint32Array(geometry.pixels.length)\n  const landPixels = new Uint32Array(geometry.pixels.length)\n  shadeInto(oceanPixels, geometry, ocean, OCEAN_FLOOR)\n  shadeInto(landPixels, geometry, land, LAND_FLOOR)\n  return { size, pixels: geometry.pixels, rows: geometry.rows, columns: geometry.columns, ocean: oceanPixels, land: landPixels }\n}\n\nfunction blendPacked(ocean: number, land: number, coverage: number) {\n  const inverse = 255 - coverage\n  const red = ((ocean & 255) * inverse + (land & 255) * coverage) / 255\n  const green = (((ocean >>> 8) & 255) * inverse + ((land >>> 8) & 255) * coverage) / 255\n  const blue = (((ocean >>> 16) & 255) * inverse + ((land >>> 16) & 255) * coverage) / 255\n  return pack(red, green, blue, ocean >>> 24)\n}\n\n/** Writes into the canvas's own pixel buffer: this runs every frame, and an allocation here would be garbage at 60 Hz. */\nexport function paintSolidFrame(frame: SolidFrame, angle: number, target: Uint32Array, landCoverage = landTexture()) {\n  const wrap = TEXTURE_WIDTH - 1\n  const shift = Math.round(((angle + 180) / 360) * TEXTURE_WIDTH)\n  const { pixels, rows, columns, ocean, land } = frame\n  for (let index = 0; index < pixels.length; index += 1) {\n    const coverage = landCoverage[(rows[index] as number) + (((columns[index] as number) + shift) & wrap)] as number\n    if (coverage === 0) target[pixels[index] as number] = ocean[index] as number\n    else if (coverage === 255) target[pixels[index] as number] = land[index] as number\n    else target[pixels[index] as number] = blendPacked(ocean[index] as number, land[index] as number, coverage)\n  }\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/background-loop.ts",
      "content": "import type * as React from \"react\"\nimport { useEffect, useState, useSyncExternalStore } from \"react\"\nimport { useReducedMotion } from \"motion/react\"\n\n/**\n * The accent as a light source, held in a lightness band per theme. A near-black accent would read as smoke on a light\n * page and a dark blue would pull muted text under 4.5:1 where a beam crosses it, so the band's floor is 0.6.\n * Older browsers keep plain --mise, which is only a stronger tint.\n */\nexport const LIGHT_TOKEN_CLASS = [\n  \"[--mise-light:var(--mise)]\",\n  \"supports-[color:oklch(from_red_l_c_h)]:[--mise-light:oklch(from_var(--mise)_clamp(0.6,l,0.76)_c_h)]\",\n  \"dark:supports-[color:oklch(from_red_l_c_h)]:[--mise-light:oklch(from_var(--mise)_clamp(0.62,l,0.82)_c_h)]\",\n].join(\" \")\n\n/** Before hydration the hook cannot know the preference yet. A paused animation holds its seeded frame. */\nexport const LOOP_CLASS = \"motion-reduce:[animation-play-state:paused]!\"\n\nconst subscribeToVisibility = (onChange: () => void) => {\n  document.addEventListener(\"visibilitychange\", onChange)\n  return () => document.removeEventListener(\"visibilitychange\", onChange)\n}\n\nfunction useTabVisible() {\n  return useSyncExternalStore(\n    subscribeToVisibility,\n    () => !document.hidden,\n    () => true,\n  )\n}\n\nfunction useOnScreen(rootRef: React.RefObject<HTMLElement | null>, watching: boolean) {\n  const [onScreen, setOnScreen] = useState(true)\n  useEffect(() => {\n    const root = rootRef.current\n    if (!watching || !root || typeof IntersectionObserver === \"undefined\") return\n    const view = new IntersectionObserver(([entry]) => setOnScreen(entry?.isIntersecting ?? true))\n    view.observe(root)\n    return () => view.disconnect()\n  }, [rootRef, watching])\n  return onScreen\n}\n\ninterface PlaybackOptions {\n  /** The owner's stop control. */\n  paused?: boolean\n  /** False when this look never moves, so nothing is watched at all. */\n  loops?: boolean\n}\n\n/** `still` draws one finished frame. `playing` is only true on screen, in a visible tab and not paused. */\nexport function useBackgroundPlayback(rootRef: React.RefObject<HTMLElement | null>, { paused = false, loops = true }: PlaybackOptions) {\n  const reduceMotion = useReducedMotion() ?? false\n  const moving = loops && !reduceMotion\n  const onScreen = useOnScreen(rootRef, moving)\n  const tabVisible = useTabVisible()\n  return { still: !moving, playing: moving && !paused && onScreen && tabVisible }\n}\n\n/** Longhands, because the shorthand would reset the play state each element reads from the layer. */\nexport function loopStyle(name: string, seconds: number, delay: number, timing = \"linear\", direction = \"normal\"): React.CSSProperties {\n  return {\n    animationName: name,\n    animationDuration: `${seconds}s`,\n    animationDelay: `${delay}s`,\n    animationTimingFunction: timing,\n    animationDirection: direction,\n    animationIterationCount: \"infinite\",\n    animationPlayState: \"var(--mise-bg-play)\",\n  }\n}\n\n/** Set once on the layer, so pausing writes one property however many elements are moving. */\nexport function playStyle(playing: boolean) {\n  return { \"--mise-bg-play\": playing ? \"running\" : \"paused\" } as React.CSSProperties\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "interactions"
  ],
  "type": "registry:ui"
}