# Globe (Moonarc)

A turning globe of dots that you can drag. The land is the same 1° bitmask DottedMap draws from, sent as a downsampled mask in a JSON block and decoded on the client into rows of dots spread evenly by latitude; each frame projects them orthographically, front side only, depth as alpha, in three batched fills. Markers pulse on the ambient token. Drag has inertia; touch-action keeps the page scrolling. An SVG globe stands in without JavaScript.

- Import: `import Globe from '@moonarc/core/Globe'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/globe.json`
- Tier C · category background · trigger always, pointer
- Readout: `<Globe markers={[…]} speed={8} draggable>`
- Browser support: widely (every browser)
- Measured cost: 2.8 kB raw JS · 1.4 kB gzip · + runtime + canvas (with dependencies 6.1 kB raw; CSS 5.1 kB raw)
- Page: https://moonarc.dev/components/globe/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `markers` | `{ lat, lng, label? }[]` | `[]` | Cities to mark; labels go into the accessible name and a hidden list. |
| `speed` | `number` | `8` | Degrees per second of auto-rotation; 0 stops it. |
| `draggable` | `boolean` | `true` | Turn with the pointer; released with inertia on the preset friction. |
| `density` | `'low' | 'medium' | 'high'` | `'medium'` | Dot spacing: 4°, 3° or 2°. Denser is more data; the JSON size is on the card. |
| `lng` | `number` | `-20` | Longitude facing the viewer at the start. |
| `tilt` | `number` | `18` | Tilt toward the viewer in degrees. |
| `color` | `string` | `ink at 70 %` | Dot colour. |
| `sea` | `string` | `ink at 7 %` | Sphere colour. |
| `accent` | `string` | `'currentColor'` | Marker colour; currentColor is the globe's text colour, inherited from around it unless you set one. |
| `label` | `string` | `'Globe'` | Accessible name; marker labels are appended. |

## Usage

```astro
<div class="relative aspect-square max-w-lg">
  <Globe
    markers={[
      { lat: 35.7, lng: 139.7, label: 'Tokyo' },
      { lat: -6.2, lng: 106.8, label: 'Jakarta' },
      { lat: 52.5, lng: 13.4, label: 'Berlin' },
    ]}
    accent="var(--action)"
  />
</div>
```

## Reduced motion

One frame is drawn and the globe does not turn on its own; it still turns while dragged (direct manipulation) and rests when released.

## With ClientRouter

Bound through the shared runtime: the loop, the observers, the pointer capture and the listeners are released before the swap; five round trips leave one loop per instance.

## Craft

- Only the mask travels to the client, a base64 string of 2–3 kB raw where the coordinates would take tens of kB. The rows are sampled with columns scaled by cos(latitude), so the dots are evenly spread on the sphere rather than crowding at the poles.
- Projection is two rotations per point and a depth test; the visible points are bucketed into three alpha levels and filled as three paths, so a frame is three fills, not five thousand.
- Drag writes the angles directly and records a velocity; release hands the velocity to a friction from the preset (snap stops short, ambient coasts) and the auto-rotation resumes underneath it.
- touch-action: pan-y on the canvas: a thumb dragging sideways turns the globe, a thumb dragging up still scrolls the page.
- The loop runs only on screen and in a visible tab; DPR is capped at 2; colours are the theme's, resolved through the canvas element's computed style and re-read when data-theme changes.
- Without JavaScript an SVG sphere with a graticule stands where the canvas will be; the canvas fades it out after its first frame.

## Replaces

- Globe (Magic UI, cobe)
- GitHubGlobe (Aceternity, three.js)
- cobe

## Source

```astro
---
/**
 * Globe — the world as dots on a sphere, turning, and yours to drag. The
 * land is the same 1° bitmask DottedMap draws from, sent here as a
 * downsampled mask in a JSON block (a few kB of HTML, printed on the card)
 * and decoded on the client into rows of dots spread evenly by latitude;
 * the script projects them orthographically each frame, front side only,
 * depth as alpha, in three batched fills. Markers pulse on the front side
 * on the ambient duration token. Drag turns it with inertia; the friction,
 * the drag gain and the tilt are custom properties, the speed a prop.
 * touch-action: pan-y keeps the page scrolling on a phone. Without
 * JavaScript an SVG globe with a graticule stands in; under reduced motion
 * one frame is drawn and it turns only while dragged. Colours are the
 * theme's, re-read when it changes. Fills its positioned parent.
 */
import type { HTMLAttributes } from 'astro/types';
import { LAND, LAND_H, LAND_W } from '../lib/land';
import { decode, downsample, type Marker } from '../lib/geo';

interface Props extends HTMLAttributes<'div'> {
  /** Cities to mark: latitude, longitude, optional label. */
  markers?: Marker[];
  /** Degrees per second of auto-rotation; 0 stops it. */
  speed?: number;
  /** Turn with the pointer. */
  draggable?: boolean;
  /** Dot spacing: low 4°, medium 3°, high 2°. */
  density?: 'low' | 'medium' | 'high';
  /** Longitude facing the viewer at the start. */
  lng?: number;
  /** Tilt toward the viewer in degrees. */
  tilt?: number;
  /** Dot colour. */
  color?: string;
  /** Sphere colour. */
  sea?: string;
  /** Marker colour. */
  accent?: string;
  /** Accessible name; the marker labels are appended. */
  label?: string;
}

const { markers = [], speed = 8, draggable = true, density = 'medium', lng = -20, tilt = 18, color = 'color-mix(in srgb, var(--ma-ink) 70%, transparent)', sea = 'color-mix(in srgb, var(--ma-ink) 7%, transparent)', accent = 'currentColor', label = 'Globe', class: className, style, ...rest } = Astro.props;
const step = density === 'low' ? 4 : density === 'high' ? 2 : 3;
const data = downsample(decode(LAND, LAND_W, LAND_H), step);
const json = JSON.stringify({ w: data.w, h: data.h, step, mask: data.b64 });
const name = markers.length ? `${label}: ${markers.map((m) => m.label ?? `${m.lat}, ${m.lng}`).join(', ')}` : label;
const inline = [`--ma-globe-speed:${speed}`, `--ma-globe-lng:${lng}`, `--ma-globe-tilt:${tilt}`, `--ma-globe-color:${color}`, `--ma-globe-sea:${sea}`, `--ma-globe-accent:${accent}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
const rings = [-60, -30, 0, 30, 60];
// the fallback's gradient, one per globe: a second globe on the page must not share the first one's id
const gradient = `ma-globe-g-${Math.random().toString(36).slice(2, 7)}`;
---

<div class:list={['ma-globe', className]} data-ma-globe data-drag={draggable ? '' : undefined} data-markers={markers.length ? JSON.stringify(markers.map((m) => [m.lat, m.lng])) : undefined} data-bytes={json.length} role="img" aria-label={name} style={inline} {...rest}>
  <svg class="ma-globe__fallback" viewBox="-1 -1 2 2" aria-hidden="true">
    <defs>
      <radialGradient id={gradient} cx="35%" cy="30%" r="80%">
        <stop offset="0" stop-color="var(--ma-globe-sea)" stop-opacity="1" />
        <stop offset="1" stop-color="var(--ma-globe-sea)" stop-opacity="0.4" />
      </radialGradient>
    </defs>
    <circle r="0.98" fill={`url(#${gradient})`} />
    <g class="ma-globe__grat">
      {rings.map((la) => {
        const y = -Math.sin((la * Math.PI) / 180) * 0.98;
        const rx = Math.cos((la * Math.PI) / 180) * 0.98;
        return <ellipse cx="0" cy={y.toFixed(3)} rx={rx.toFixed(3)} ry={(rx * 0.18).toFixed(3)} />;
      })}
      {[0, 30, 60].map((lo) => <ellipse cx="0" cy="0" rx={(Math.sin((lo * Math.PI) / 180) * 0.98).toFixed(3)} ry="0.98" />)}
      <circle r="0.98" />
    </g>
  </svg>
  <canvas aria-hidden="true"></canvas>
  <script is:inline type="application/json" set:html={json} />
  {markers.some((m) => m.label) && (
    <ul class="ma-globe__list">
      {markers.map((m) => m.label && <li>{m.label}</li>)}
    </ul>
  )}
</div>

<style is:global>
  @layer components {
    :where(.ma-globe) {
      position: absolute;
      inset: 0;
      overflow: hidden;
      contain: strict;
      /* drag gain (radians per radius of travel), friction (velocity kept per frame), the ring's reach in radii */
      --ma-globe-gain: 1.4;
      --ma-globe-friction: 0.95;
      --ma-globe-ping: 0.09;
    }
    :where([data-ma-preset='snap'] .ma-globe) {
      --ma-globe-friction: 0.9;
    }
    :where([data-ma-preset='ambient'] .ma-globe),
    :where([data-ma-preset='gentle'] .ma-globe) {
      --ma-globe-friction: 0.975;
    }
    :where(.ma-globe__fallback) {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      transition: opacity var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-globe__grat) {
      fill: none;
      stroke: var(--ma-globe-color);
      stroke-width: 0.006;
      opacity: 0.35;
    }
    :where(.ma-globe[data-live] .ma-globe__fallback) {
      opacity: 0;
    }
    :where(.ma-globe canvas) {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      touch-action: pan-y;
      /* the theme-resolved colours the script reads: sea, marker, land. The canvas keeps the globe's own color, so a
         currentColor accent (the default) is the text colour around the globe, not the land colour */
      border: 0 solid;
      border-top-color: var(--ma-globe-sea);
      border-right-color: var(--ma-globe-accent);
      border-left-color: var(--ma-globe-color);
    }
    :where(.ma-globe[data-drag] canvas) {
      cursor: grab;
    }
    :where(.ma-globe[data-dragging] canvas) {
      cursor: grabbing;
    }
    :where(.ma-globe__list) {
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-globe__fallback) {
        transition: none;
      }
    }
  }
</style>

<script>
  import { onMount, prefersReducedMotion } from '../lib/runtime';
  import { colors, fit, loop, ms, num, onTheme, visible } from '../lib/canvas';
  import { decode, landRows, toSphere } from '../lib/geo';

  onMount<HTMLElement>('[data-ma-globe]', (el, { signal }) => {
    const canvas = el.querySelector('canvas')!;
    const data = JSON.parse(el.querySelector('script')!.textContent!) as { w: number; h: number; step: number; mask: string };
    const pts = landRows(decode(data.mask, data.w, data.h), data.step).map(([la, lo]) => toSphere(la, lo));
    const marks = (JSON.parse(el.dataset.markers ?? '[]') as [number, number][]).map(([la, lo]) => toSphere(la, lo));
    const v = (n: string, f: number) => num(el, '--ma-globe-' + n, f);
    const rad = Math.PI / 180;
    let ctx: CanvasRenderingContext2D;
    let w = 0;
    let h = 0;
    let R = 0;
    let phi = -v('lng', -20) * rad;
    let theta = v('tilt', 18) * rad;
    let vphi = 0;
    let vtheta = 0;
    let drag = false;
    let land = '';
    let sea = '';
    let accent = '';
    let period = 1;
    let dotR = 1;
    const theme = () => ([, sea, accent, , land] = colors(canvas));
    const frame = (t: number, dt: number) => {
      if (!drag) {
        phi += (v('speed', 8) * rad * dt) / 1000 + vphi;
        theta = Math.max(-1.3, Math.min(1.3, theta + vtheta));
        const fr = v('friction', 0.95);
        vphi *= fr;
        vtheta *= fr;
      }
      const cx = w / 2;
      const cy = h / 2;
      const cp = Math.cos(phi);
      const sp = Math.sin(phi);
      const ct = Math.cos(theta);
      const st = Math.sin(theta);
      ctx.clearRect(0, 0, w, h);
      ctx.fillStyle = sea;
      ctx.beginPath();
      ctx.arc(cx, cy, R, 0, 7);
      ctx.fill();
      // project once, bucket by depth, three fills
      const buckets: number[][] = [[], [], []];
      for (const [x, y, z] of pts) {
        const x1 = x * cp + z * sp;
        const z1 = z * cp - x * sp;
        const y1 = y * ct - z1 * st;
        const z2 = y * st + z1 * ct;
        if (z2 > 0) buckets[Math.min(2, (z2 * 3) | 0)]!.push(cx + x1 * R, cy - y1 * R);
      }
      ctx.fillStyle = land;
      buckets.forEach((b, i) => {
        ctx.globalAlpha = 0.3 + i * 0.35;
        ctx.beginPath();
        for (let k = 0; k < b.length; k += 2) {
          ctx.moveTo(b[k]! + dotR, b[k + 1]!);
          ctx.arc(b[k]!, b[k + 1]!, dotR, 0, 7);
        }
        ctx.fill();
      });
      ctx.globalAlpha = 1;
      const ping = (t % period) / period;
      for (const [x, y, z] of marks) {
        const x1 = x * cp + z * sp;
        const z1 = z * cp - x * sp;
        const y1 = y * ct - z1 * st;
        if (y * st + z1 * ct <= 0) continue;
        const mx = cx + x1 * R;
        const my = cy - y1 * R;
        ctx.fillStyle = accent;
        ctx.beginPath();
        ctx.arc(mx, my, dotR * 1.8, 0, 7);
        ctx.fill();
        ctx.strokeStyle = accent;
        ctx.globalAlpha = 1 - ping;
        ctx.lineWidth = dotR * 0.6;
        ctx.beginPath();
        ctx.arc(mx, my, dotR * 1.8 + ping * v('ping', 0.09) * R, 0, 7);
        ctx.stroke();
        ctx.globalAlpha = 1;
      }
    };
    const run = loop(frame, signal);
    // reduced motion is read live (the page's switch stops a turning globe): one still frame, frames only while dragging
    const wake = () => (prefersReducedMotion() && !drag ? (run.sleep(), run.once()) : run.wake());
    fit(canvas, el, signal, (W, H, c) => {
      w = W;
      h = H;
      ctx = c;
      R = (Math.min(w, h) / 2) * 0.92;
      dotR = Math.max(0.6, ((R * data.step * rad) / 2) * 0.55);
      period = ms(el, '--ma-dur-ambient', 1400) * 2;
      run.once();
    });
    theme();
    let on = false;
    onTheme(signal, () => {
      theme();
      on ? wake() : run.once();
    });
    el.setAttribute('data-live', '');
    visible(el, signal, (o) => ((on = o) ? wake() : run.sleep()));
    if (el.hasAttribute('data-drag')) {
      let lx = 0;
      let ly = 0;
      let lt = 0;
      canvas.addEventListener(
        'pointerdown',
        (e) => {
          drag = true;
          el.setAttribute('data-dragging', '');
          canvas.setPointerCapture(e.pointerId);
          lx = e.clientX;
          ly = e.clientY;
          lt = e.timeStamp;
          vphi = vtheta = 0;
          run.wake();
        },
        { signal },
      );
      canvas.addEventListener(
        'pointermove',
        (e) => {
          if (!drag) return;
          const g = v('gain', 1.4) / R;
          const dp = (e.clientX - lx) * g;
          const dth = (e.clientY - ly) * g;
          phi += dp;
          theta = Math.max(-1.3, Math.min(1.3, theta + dth));
          const k = 16 / Math.max(1, e.timeStamp - lt);
          vphi = dp * k;
          vtheta = dth * k;
          lx = e.clientX;
          ly = e.clientY;
          lt = e.timeStamp;
        },
        { signal, passive: true },
      );
      const up = () => {
        drag = false;
        el.removeAttribute('data-dragging');
        if (on) wake();
      };
      canvas.addEventListener('pointerup', up, { signal });
      canvas.addEventListener('pointercancel', up, { signal });
    }
  });
</script>

```
