Skip to content
Brok UI

Loading…

No results

Globe

Open source

An interactive rotating 3D globe drawn on a canvas as a dotted graticule, with plotted lat/long markers and faint great-circle arcs. Drag to spin; release to resume auto-rotation. Pure canvas + 3D math (rotation matrix + orthographic projection), semantic token colours, DPR aware and reduced-motion safe (static frame).

Version
v1.1.3
Stability
stable
License
MIT
Related
Map
Blob
Mesh Gradient

Preview

Variant
previews.components.globe.default.blade.php Blade
<div class="mx-auto h-72 w-72 text-muted-foreground">
    <x-ui.globe accent="ring" />
</div>
Dots Current
Lines Current
Glow Current

Installation

terminal
php artisan ui:add globe

Note

This component ships an Alpine behavior module at resources/js/ui/globe.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './globe.js';

Registry contract

php artisan ui:add globe writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/globe.blade.php
  • js resources/js/ui/globe.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

globe.md
# Brok UI: Globe (`globe`)

An interactive rotating 3D globe drawn on a canvas as a dotted graticule, with plotted lat/long markers and faint great-circle arcs. Drag to spin; release to resume auto-rotation. Pure canvas + 3D math (rotation matrix + orthographic projection), semantic token colours, DPR aware and reduced-motion safe (static frame).

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add globe
```

## Usage

```blade
<div class="mx-auto h-72 w-72 text-muted-foreground">
    <x-ui.globe accent="ring" />
</div>
```

## Props

- `markers` (mixed|null, default `null`) — Array of {lat, lng, label} points plotted on the surface; defaults to a handful of world cities.
- `arcs` (bool, default `true`) — Draws faint arcs between consecutive markers.
- `autoRotate` (bool, default `true`) — Slowly rotates the globe when idle; suppressed under reduced motion.
- `color` (string, default `muted`) — Semantic token for the dot and graticule color, such as muted or primary.
- `accent` (string, default `primary`) — Semantic token for the marker and arc accent color.
- `variant` (dots|lines|glow, default `dots`) — Rendering style: dots (sampled graticule), lines (wireframe), or glow (dots on a shaded disc with an atmosphere rim).
- `labels` (bool, default `false`) — Draws each marker's label beside it.
- `pulse` (bool, default `false`) — Animates markers breathing and a bead traveling each arc.
- `density` (int, default `12`) — Graticule density in degrees; smaller values draw more dots or lines.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Visualizing a set of geographic points, such as offices, users, or routes, as an interactive, draggable rotating globe.
- Choosing dots, lines, or glow rendering, or drawing arcs between consecutive markers for a route-like overlay.

## Avoid when

- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Precise geographic detail, zooming, or panning is required; use a real map component instead of a decorative globe. Use blocks/maps-heatmap or a real mapping library instead.

## Anti-patterns

- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus

## Rules

- Use the `<brok:globe>` tag (or `<x-ui.globe>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/globe
- Registry JSON (files, props, contract): https://brokui.dev/r/open/globe.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

offices.blade.php Blade
{{-- A "where we are" globe: labelled offices on a wireframe, no arcs. --}}
<div class="mx-auto flex max-w-2xl flex-col items-center gap-6 sm:flex-row">
    <div class="h-64 w-64 shrink-0">
        <x-ui.globe
            variant="lines"
            labels
            :arcs="false"
            accent="chart-1"
            :markers="[
                ['lat' => 52.37, 'lng' => 4.9, 'label' => 'Amsterdam'],
                ['lat' => 40.71, 'lng' => -74.01, 'label' => 'New York'],
                ['lat' => 1.35, 'lng' => 103.82, 'label' => 'Singapore'],
            ]"
        />
    </div>
    <div class="space-y-2 text-center sm:text-start">
        <p class="text-lg font-semibold text-foreground">{{ __('Three offices, one team') }}</p>
        <p class="text-sm text-muted-foreground">{{ __('Amsterdam, New York and Singapore cover every working hour. Drag the globe to find yours.') }}</p>
    </div>
</div>
traffic.blade.php Blade
{{-- A live-traffic hero: a glowing globe on a dark field, pulsing edge
     locations joined by animated arcs from the origin. --}}
<div class="relative mx-auto flex h-80 w-full max-w-2xl items-center justify-center overflow-hidden rounded-lg bg-foreground">
    <div class="h-72 w-72 text-background/70">
        <x-ui.globe
            variant="glow"
            pulse
            accent="ring"
            :density="10"
            :markers="[
                ['lat' => 50.1, 'lng' => 8.68, 'label' => 'Frankfurt'],
                ['lat' => 37.77, 'lng' => -122.42, 'label' => 'San Francisco'],
                ['lat' => -33.87, 'lng' => 151.21, 'label' => 'Sydney'],
                ['lat' => 35.68, 'lng' => 139.69, 'label' => 'Tokyo'],
                ['lat' => -23.55, 'lng' => -46.63, 'label' => 'São Paulo'],
                ['lat' => 50.1, 'lng' => 8.68, 'label' => 'Frankfurt'],
            ]"
        />
    </div>
    <p class="absolute inset-x-0 bottom-4 text-center text-xs text-background/60">{{ __('Requests served from six regions') }}</p>
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
markers mixed | null null Array of {lat, lng, label} points plotted on the surface; defaults to a handful of world cities.
arcs bool true Draws faint arcs between consecutive markers.
autoRotate bool true Slowly rotates the globe when idle; suppressed under reduced motion.
color string muted Semantic token for the dot and graticule color, such as muted or primary.
accent string primary Semantic token for the marker and arc accent color.
variant dots | lines | glow dots Rendering style: dots (sampled graticule), lines (wireframe), or glow (dots on a shaded disc with an atmosphere rim).
labels bool false Draws each marker's label beside it.
pulse bool false Animates markers breathing and a bead traveling each arc.
density int 12 Graticule density in degrees; smaller values draw more dots or lines.

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

globe

Behavior

  • Auto-rotates slowly when idle; dragging spins the globe and releases back into auto-rotation.
  • Dot and accent colors are read at runtime from this element's computed color and from a hidden accent probe, so a text-* utility on the host re-skins the globe.
  • pulse makes markers breathe and a bead travel each arc; reduced motion renders one static frame with no rotation or pulsing.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.

Guidance

Expressive and showcase effect

Support brand expression or a showcase moment.

Use when

  • Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
  • Visualizing a set of geographic points, such as offices, users, or routes, as an interactive, draggable rotating globe.
  • Choosing dots, lines, or glow rendering, or drawing arcs between consecutive markers for a route-like overlay.

Avoid when

  • Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
  • Precise geographic detail, zooming, or panning is required; use a real map component instead of a decorative globe. Use blocks/maps-heatmap or a real mapping library instead.

Use instead

  • Static content-first presentation

Anti-patterns

  • Heavy effects in forms or task-dense workflows
  • Motion that interferes with reading or focus
Anatomy
root
Theming hooks
globe

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The root carries role=img and a localized aria-label ('Interactive globe'), since the markers convey meaningful information rather than pure decoration.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<div wire:ignore>
    <div class="mx-auto h-72 w-72 text-muted-foreground">
        <x-ui.globe accent="ring" />
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/globe.blade.php Blade
@props([
    // Plotted points on the surface: {lat, lng, label?}. Neutral default of a
    // handful of well-known world cities (lat/lng only — no PII).
    'markers' => null,
    // Draw faint arcs between consecutive markers (a simple route-like overlay).
    'arcs' => true,
    // Slow auto-rotation when idle (suppressed under reduced motion).
    'autoRotate' => true,
    // Dot/graticule colour: a text-* token read at runtime from computed `color`.
    'color' => 'muted',
    // Marker/arc accent: a second text-* token probed from a child element.
    'accent' => 'primary',
    // dots (a sampled graticule) | lines (a wireframe) | glow (dots on a shaded
    // disc with an atmosphere rim in the accent hue).
    'variant' => 'dots',
    // Draw each marker's label beside it.
    'labels' => false,
    // Markers breathe and a bead travels each arc (still under reduced motion).
    'pulse' => false,
    // Graticule density in degrees (smaller = more dots / lines).
    'density' => 12,
])

@php
    // An interactive rotating globe rendered on a <canvas>. The sphere is drawn
    // as a graticule of dots (a lat/long grid sampled on the unit sphere),
    // auto-rotating slowly; markers sit on the surface at given lat/lng and faint
    // arcs trace the shortest path between consecutive markers. Drag to spin;
    // release to resume auto-rotation.
    //
    // Colour is SEMANTIC. The dot colour is read from this element's computed
    // `color` (a text-* token); the marker/arc accent is read from a hidden
    // child carrying a text-* accent token. Nothing is hard-coded — theming or a
    // text-* utility on the host re-skins the globe. DPR-aware and reduced-motion
    // safe (a single static frame, no spin).
    $colorClasses = [
        'foreground' => 'text-foreground',
        'muted' => 'text-muted-foreground',
        'primary' => 'text-primary',
        'secondary' => 'text-secondary-foreground',
        'accent' => 'text-accent-foreground',
        'ring' => 'text-ring',
        'chart-1' => 'text-chart-1',
        'chart-2' => 'text-chart-2',
        'chart-3' => 'text-chart-3',
        'chart-4' => 'text-chart-4',
        'chart-5' => 'text-chart-5',
        'background' => 'text-background',
    ];
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);
    $variant = in_array($variant, ['dots', 'lines', 'glow'], true) ? $variant : 'dots';
    $dotClass = $colorClasses[$color] ?? 'text-muted-foreground';
    $accentClass = $colorClasses[$accent] ?? 'text-primary';

    // Neutral default markers — major world cities (lat/lng only).
    $defaultMarkers = [
        ['lat' => 40.71, 'lng' => -74.01, 'label' => 'New York'],
        ['lat' => 51.51, 'lng' => -0.13, 'label' => 'London'],
        ['lat' => 35.68, 'lng' => 139.69, 'label' => 'Tokyo'],
        ['lat' => -33.87, 'lng' => 151.21, 'label' => 'Sydney'],
        ['lat' => -23.55, 'lng' => -46.63, 'label' => 'São Paulo'],
    ];

    // Sanitise markers down to plain {lat, lng, label} numbers/strings.
    $rawMarkers = is_array($markers) ? $markers : $defaultMarkers;
    $cleanMarkers = [];
    foreach ($rawMarkers as $m) {
        if (! is_array($m) || ! isset($m['lat'], $m['lng'])) {
            continue;
        }
        $cleanMarkers[] = [
            'lat' => max(-90, min(90, (float) $m['lat'])),
            'lng' => max(-180, min(180, (float) $m['lng'])),
            'label' => isset($m['label']) ? (string) $m['label'] : '',
        ];
    }

    $config = [
        'markers' => $cleanMarkers,
        'arcs' => (bool) $arcs,
        'autoRotate' => (bool) $autoRotate,
        // Graticule density: latitude step in degrees (smaller = more dots).
        'latStep' => max(4, (int) $density),
        'lngStep' => max(4, (int) $density),
        'variant' => $variant,
        'labels' => (bool) $labels,
        'pulse' => (bool) $pulse,
    ];
@endphp

{{-- The globe carries markers/labels, so it is a meaningful image rather than
     pure decoration: role="img" + a localized label. The canvas inherits its dot
     colour from this element's computed `color`. --}}
<div
    data-slot="globe"
    data-variant="{{ $variant }}"
    x-data="uiGlobe(@js($config))"
    x-ref="root"
    role="img"
    aria-label="{{ __('Interactive globe') }}"
    {{ $attributes->merge(['class' => $dotClass.' relative block aspect-square w-full select-none overflow-hidden']) }}
>
    {{-- Hidden accent probe: the JS reads this element's computed `color` to get
         the marker/arc accent token (kept off-screen, never rendered visually). --}}
    <span x-ref="accent" class="{{ $accentClass }} pointer-events-none absolute h-0 w-0 opacity-0" aria-hidden="true"></span>

    <canvas x-ref="canvas" class="block h-full w-full cursor-grab touch-none"></canvas>
</div>
resources/js/ui/globe.js JS
/**
 * Globe behavior (interactive rotating 3D globe on a <canvas>).
 *
 * The sphere is drawn as a GRATICULE of dots: we step latitude and longitude in
 * fixed degree increments, convert each (lat,lng) to a unit xyz on the sphere,
 * rotate it by the current yaw (about Y) plus a small fixed tilt (about X), then
 * ORTHOGRAPHICALLY project — screen x = cx + X*R, y = cy - Y*R. Only points with
 * Z >= 0 (facing the viewer) are painted, and their alpha fades with Z so the
 * far side reads as depth. Markers are bigger dots plotted the same way; arcs are
 * a series of interpolated points along the shortest (great-circle-ish) path
 * between consecutive markers, also depth-culled.
 *
 * Interaction: pointerdown captures, pointermove adds the drag delta to yaw (and
 * a clamped pitch); pointerup resumes auto-rotation. Each frame applies a slow
 * idle yaw when not dragging.
 *
 * Colour is SEMANTIC. The dot colour is read from the host's computed `color`
 * (a text-* token); the marker/arc accent is read from a hidden child's computed
 * `color`. Nothing is hard-coded — theming or a text-* utility re-skins the globe.
 *
 * Sizing: the backing store is scaled for devicePixelRatio and a ResizeObserver
 * recomputes geometry on resize. Reduced motion: a single STATIC frame is drawn
 * (globe at rotation 0, markers shown) and no rAF spin runs — drag still redraws
 * on demand, but there is no continuous animation loop.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiGlobe', (config = {}) => ({
        markers: Array.isArray(config.markers) ? config.markers : [],
        arcs: config.arcs ?? true,
        autoRotate: config.autoRotate ?? true,
        latStep: Math.max(4, config.latStep ?? 12),
        lngStep: Math.max(4, config.lngStep ?? 12),
        // dots (a sampled graticule) | lines (a wireframe of parallels and
        // meridians) | glow (dots on a shaded disc with an atmosphere rim).
        variant: config.variant ?? 'dots',
        // Marker labels drawn beside visible markers.
        labels: config.labels ?? false,
        // Markers breathe; arcs carry a travelling pulse (both off under reduced motion).
        pulse: config.pulse ?? false,
        _t: 0,

        _ctx: null,
        _dpr: 1,
        _cssW: 0,
        _cssH: 0,
        _cx: 0,
        _cy: 0,
        _radius: 0,

        // Sphere-surface samples for the graticule, precomputed as unit vectors.
        _grid: [],
        // Marker unit vectors (parallel to this.markers).
        _markerVecs: [],

        // Resolved token colours; the placeholders are never painted.
        _dotColor: 'transparent',
        _accentColor: 'transparent',

        // Rotation state (radians). Pitch is a small fixed tilt that drag can nudge.
        _yaw: 0,
        _pitch: 0.4,
        _idleYaw: 0.0025,

        // Drag state.
        _dragging: false,
        _lastX: 0,
        _lastY: 0,

        _raf: null,
        _ro: null,
        _onPointerDown: null,
        _onPointerMove: null,
        _onPointerUp: null,
        _reduced: false,
        _viewObserver: null,
        _offscreen: false,

        init() {
            const canvas = this.$refs.canvas;
            this._ctx = canvas.getContext('2d');
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

            this.buildGrid();
            this.buildMarkers();
            this.layout();

            // Pointer drag-to-rotate works in every mode (just no idle spin under
            // reduced motion). Listeners are bound to the canvas and cleaned up.
            this._onPointerDown = (e) => this.onDown(e);
            this._onPointerMove = (e) => this.onMove(e);
            this._onPointerUp = (e) => this.onUp(e);
            canvas.addEventListener('pointerdown', this._onPointerDown);
            canvas.addEventListener('pointermove', this._onPointerMove);
            canvas.addEventListener('pointerup', this._onPointerUp);
            canvas.addEventListener('pointercancel', this._onPointerUp);

            let pending = false;
            this._ro = new ResizeObserver(() => {
                if (pending) return;
                pending = true;
                requestAnimationFrame(() => {
                    pending = false;
                    this.layout();
                    if (this._reduced) this.draw();
                });
            });
            this._ro.observe(this.$refs.root);

            if (this._reduced) {
                // Static frame only: globe at rotation 0 with markers shown.
                this._yaw = 0;
                this._idleYaw = 0;
                this.draw();
                return;
            }

            this._raf = requestAnimationFrame(() => this.loop());

            // Pause the rAF loop while fully scrolled out of view (same-tab;
            // backgrounded tabs are already throttled by the browser) so
            // off-screen instances don't burn GPU/CPU. Resume on re-entry.
            // Static/reduced-motion renders never set _raf, so the `&& this._raf`
            // guard leaves them untouched.
            if (typeof IntersectionObserver !== 'undefined') {
                this._viewObserver = new IntersectionObserver((entries) => {
                    const visible = entries.some((e) => e.isIntersecting);
                    if (!visible && this._raf) {
                        cancelAnimationFrame(this._raf);
                        this._raf = null;
                        this._offscreen = true;
                    } else if (visible && this._offscreen) {
                        this._offscreen = false;
                        this._raf = requestAnimationFrame(() => this.loop());
                    }
                });
                this._viewObserver.observe(this.$refs.root);
            }
        },

        // ---- Geometry -------------------------------------------------------

        // (lat,lng) in degrees → unit vector on the sphere (y up).
        toVec(lat, lng) {
            const phi = (lat * Math.PI) / 180;
            const lambda = (lng * Math.PI) / 180;
            const cosPhi = Math.cos(phi);
            return {
                x: cosPhi * Math.sin(lambda),
                y: Math.sin(phi),
                z: cosPhi * Math.cos(lambda),
            };
        },

        // Sample the sphere as a lat/long dot grid.
        buildGrid() {
            this._grid = [];
            for (let lat = -90; lat <= 90; lat += this.latStep) {
                // Pack dots roughly evenly: fewer per ring near the poles.
                const ringCos = Math.max(0.08, Math.cos((lat * Math.PI) / 180));
                const step = this.lngStep / ringCos;
                for (let lng = -180; lng < 180; lng += step) {
                    this._grid.push(this.toVec(lat, lng));
                }
            }
        },

        buildMarkers() {
            this._markerVecs = this.markers.map((m) => this.toVec(m.lat, m.lng));
        },

        // Rotate a unit vector by yaw (about Y) then pitch (about X).
        rotate(v) {
            const cy = Math.cos(this._yaw);
            const sy = Math.sin(this._yaw);
            // Yaw about Y.
            const x1 = v.x * cy + v.z * sy;
            const z1 = -v.x * sy + v.z * cy;
            const y1 = v.y;
            // Pitch about X.
            const cp = Math.cos(this._pitch);
            const sp = Math.sin(this._pitch);
            const y2 = y1 * cp - z1 * sp;
            const z2 = y1 * sp + z1 * cp;
            return { x: x1, y: y2, z: z2 };
        },

        // Orthographic projection of a rotated vector to canvas pixels.
        project(r) {
            return {
                sx: this._cx + r.x * this._radius,
                sy: this._cy - r.y * this._radius,
                z: r.z, // >= 0 faces the viewer.
            };
        },

        // ---- Colour ---------------------------------------------------------

        resolveColors() {
            const root = getComputedStyle(this.$refs.root).color;
            this._dotColor = root && root !== '' ? root : getComputedStyle(document.body).color;
            const accentEl = this.$refs.accent;
            const a = accentEl ? getComputedStyle(accentEl).color : '';
            this._accentColor = a && a !== '' ? a : this._dotColor;
        },

        parseRgb(str) {
            const m = String(str).match(/rgba?\(([^)]+)\)/);
            if (!m) return { r: 0, g: 0, b: 0 };
            const parts = m[1].split(',').map((p) => parseFloat(p.trim()));
            return { r: parts[0] || 0, g: parts[1] || 0, b: parts[2] || 0 };
        },

        // ---- Layout ---------------------------------------------------------

        layout() {
            const canvas = this.$refs.canvas;
            const root = this.$refs.root;
            this._dpr = Math.max(1, window.devicePixelRatio || 1);
            this.resolveColors();

            this._cssW = Math.max(1, root.clientWidth || 1);
            this._cssH = Math.max(1, root.clientHeight || 1);

            canvas.width = Math.round(this._cssW * this._dpr);
            canvas.height = Math.round(this._cssH * this._dpr);
            this._ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);

            this._cx = this._cssW / 2;
            this._cy = this._cssH / 2;
            // Leave a small margin so markers near the limb are not clipped.
            this._radius = Math.min(this._cssW, this._cssH) / 2 - 4;
        },

        // ---- Drawing --------------------------------------------------------

        draw() {
            const ctx = this._ctx;
            if (!ctx) return;
            ctx.clearRect(0, 0, this._cssW, this._cssH);

            const dot = this.parseRgb(this._dotColor);
            const acc = this.parseRgb(this._accentColor);
            this._t = this._reduced ? 0 : performance.now() / 1000;

            if (this.variant === 'glow') this.drawDisc(dot, acc);
            if (this.variant === 'lines') this.drawWireframe(dot);

            // Graticule dots, depth-culled and depth-faded.
            for (let i = 0; this.variant !== 'lines' && i < this._grid.length; i++) {
                const r = this.rotate(this._grid[i]);
                if (r.z < 0) continue;
                const p = this.project(r);
                // Alpha ramps from ~0.12 at the limb to ~0.6 at the near pole.
                const alpha = 0.12 + r.z * 0.48;
                const size = 0.6 + r.z * 0.9;
                ctx.beginPath();
                ctx.arc(p.sx, p.sy, size, 0, Math.PI * 2);
                ctx.fillStyle = `rgba(${dot.r}, ${dot.g}, ${dot.b}, ${alpha})`;
                ctx.fill();
            }

            // Arcs between consecutive markers (drawn under the markers).
            if (this.arcs && this._markerVecs.length > 1) {
                for (let i = 0; i < this._markerVecs.length - 1; i++) {
                    this.drawArc(this._markerVecs[i], this._markerVecs[i + 1], acc);
                }
            }

            // Markers: larger accent dots with a soft halo, depth-culled; the
            // halo breathes when `pulse` is on.
            for (let i = 0; i < this._markerVecs.length; i++) {
                const r = this.rotate(this._markerVecs[i]);
                if (r.z < 0) continue;
                const p = this.project(r);
                const alpha = 0.35 + r.z * 0.65;
                const breath = this.pulse ? 0.5 + 0.5 * Math.sin(this._t * 2.2 + i * 1.3) : 0;
                // Halo.
                ctx.beginPath();
                ctx.arc(p.sx, p.sy, 5 + breath * 7, 0, Math.PI * 2);
                ctx.fillStyle = `rgba(${acc.r}, ${acc.g}, ${acc.b}, ${alpha * (0.25 - breath * 0.15)})`;
                ctx.fill();
                // Core.
                ctx.beginPath();
                ctx.arc(p.sx, p.sy, 2.2, 0, Math.PI * 2);
                ctx.fillStyle = `rgba(${acc.r}, ${acc.g}, ${acc.b}, ${alpha})`;
                ctx.fill();
                // Label beside the marker, in the dot colour.
                const label = this.markers[i]?.label;
                if (this.labels && label) {
                    ctx.font = '500 11px system-ui, sans-serif';
                    ctx.textBaseline = 'middle';
                    ctx.textAlign = p.sx > this._cx ? 'left' : 'right';
                    ctx.fillStyle = `rgba(${dot.r}, ${dot.g}, ${dot.b}, ${Math.min(1, alpha + 0.2)})`;
                    ctx.fillText(label, p.sx + (p.sx > this._cx ? 8 : -8), p.sy);
                }
            }
        },

        // `glow`: a shaded disc behind the dots — lit from the upper left,
        // darker towards the limb — and an atmosphere rim in the accent hue.
        drawDisc(dot, acc) {
            const ctx = this._ctx;
            const g = ctx.createRadialGradient(this._cx - this._radius * 0.35, this._cy - this._radius * 0.35, this._radius * 0.1, this._cx, this._cy, this._radius);
            g.addColorStop(0, `rgba(${dot.r}, ${dot.g}, ${dot.b}, 0.04)`);
            g.addColorStop(1, `rgba(${dot.r}, ${dot.g}, ${dot.b}, 0.18)`);
            ctx.beginPath();
            ctx.arc(this._cx, this._cy, this._radius, 0, Math.PI * 2);
            ctx.fillStyle = g;
            ctx.fill();
            ctx.save();
            ctx.shadowColor = `rgba(${acc.r}, ${acc.g}, ${acc.b}, 0.55)`;
            ctx.shadowBlur = 24;
            ctx.lineWidth = 1.5;
            ctx.strokeStyle = `rgba(${acc.r}, ${acc.g}, ${acc.b}, 0.45)`;
            ctx.stroke();
            ctx.restore();
        },

        // `lines`: parallels every latStep and meridians every lngStep, each
        // drawn as a polyline of visible samples so it clips at the limb.
        drawWireframe(dot) {
            const ctx = this._ctx;
            ctx.lineWidth = 1;
            const stroke = (points) => {
                let open = false;
                ctx.beginPath();
                for (const v of points) {
                    const r = this.rotate(v);
                    const p = this.project(r);
                    if (r.z < 0) {
                        open = false;
                        continue;
                    }
                    open ? ctx.lineTo(p.sx, p.sy) : ctx.moveTo(p.sx, p.sy);
                    open = true;
                }
                ctx.stroke();
            };
            ctx.strokeStyle = `rgba(${dot.r}, ${dot.g}, ${dot.b}, 0.28)`;
            for (let lat = -90 + this.latStep; lat < 90; lat += this.latStep) {
                const ring = [];
                for (let lng = -180; lng <= 180; lng += 4) ring.push(this.toVec(lat, lng));
                stroke(ring);
            }
            for (let lng = -180; lng < 180; lng += this.lngStep) {
                const arc = [];
                for (let lat = -90; lat <= 90; lat += 4) arc.push(this.toVec(lat, lng));
                stroke(arc);
            }
            // The limb.
            ctx.beginPath();
            ctx.arc(this._cx, this._cy, this._radius, 0, Math.PI * 2);
            ctx.strokeStyle = `rgba(${dot.r}, ${dot.g}, ${dot.b}, 0.4)`;
            ctx.stroke();
        },

        // Interpolate along the shortest path between two unit vectors (slerp)
        // and paint each visible sample as a faint accent dot.
        drawArc(a, b, acc) {
            const ctx = this._ctx;
            const dot = Math.max(-1, Math.min(1, a.x * b.x + a.y * b.y + a.z * b.z));
            const omega = Math.acos(dot);
            const steps = Math.max(8, Math.round((omega * 180) / Math.PI / 3));
            const sinOmega = Math.sin(omega);

            for (let s = 0; s <= steps; s++) {
                const t = s / steps;
                let vx, vy, vz;
                if (sinOmega < 1e-4) {
                    // Nearly coincident — straight blend avoids a divide-by-zero.
                    vx = a.x + (b.x - a.x) * t;
                    vy = a.y + (b.y - a.y) * t;
                    vz = a.z + (b.z - a.z) * t;
                } else {
                    const k1 = Math.sin((1 - t) * omega) / sinOmega;
                    const k2 = Math.sin(t * omega) / sinOmega;
                    vx = a.x * k1 + b.x * k2;
                    vy = a.y * k1 + b.y * k2;
                    vz = a.z * k1 + b.z * k2;
                }
                const r = this.rotate({ x: vx, y: vy, z: vz });
                if (r.z < 0) continue;
                const p = this.project(r);
                // With `pulse`, a bright bead travels the arc once every ~2s.
                const head = this.pulse ? ((this._t * 0.5 + omega) % 1) : -1;
                const near = head >= 0 ? Math.max(0, 1 - Math.abs(t - head) * 10) : 0;
                const alpha = (0.1 + r.z * 0.4) * 0.8 + near * 0.5;
                ctx.beginPath();
                ctx.arc(p.sx, p.sy, 1 + near * 1.5, 0, Math.PI * 2);
                ctx.fillStyle = `rgba(${acc.r}, ${acc.g}, ${acc.b}, ${Math.min(1, alpha)})`;
                ctx.fill();
            }
        },

        // ---- Loop & interaction --------------------------------------------

        loop() {
            if (!this._dragging && this.autoRotate) {
                this._yaw += this._idleYaw;
            }
            this.draw();
            this._raf = requestAnimationFrame(() => this.loop());
        },

        onDown(e) {
            this._dragging = true;
            this._lastX = e.clientX;
            this._lastY = e.clientY;
            this.$refs.canvas.style.cursor = 'grabbing';
            if (this.$refs.canvas.setPointerCapture) {
                try {
                    this.$refs.canvas.setPointerCapture(e.pointerId);
                } catch (_) {
                    // Pointer capture is best-effort; ignore unsupported cases.
                }
            }
        },

        onMove(e) {
            if (!this._dragging) return;
            const dx = e.clientX - this._lastX;
            const dy = e.clientY - this._lastY;
            this._lastX = e.clientX;
            this._lastY = e.clientY;
            // Map pixels to radians relative to the globe radius.
            this._yaw += dx / (this._radius || 1);
            this._pitch += dy / (this._radius || 1);
            // Clamp pitch so the poles never flip past the viewer.
            const limit = Math.PI / 2 - 0.05;
            this._pitch = Math.max(-limit, Math.min(limit, this._pitch));
            // Under reduced motion there is no rAF, so redraw on demand.
            if (this._reduced) this.draw();
        },

        onUp(e) {
            this._dragging = false;
            this.$refs.canvas.style.cursor = 'grab';
            if (this.$refs.canvas.releasePointerCapture && e.pointerId != null) {
                try {
                    this.$refs.canvas.releasePointerCapture(e.pointerId);
                } catch (_) {
                    // Ignore if the pointer was never captured.
                }
            }
        },

        destroy() {
            if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
            if (this._raf) {
                cancelAnimationFrame(this._raf);
                this._raf = null;
            }
            if (this._ro) {
                this._ro.disconnect();
                this._ro = null;
            }
            const canvas = this.$refs.canvas;
            if (canvas) {
                if (this._onPointerDown) canvas.removeEventListener('pointerdown', this._onPointerDown);
                if (this._onPointerMove) canvas.removeEventListener('pointermove', this._onPointerMove);
                if (this._onPointerUp) {
                    canvas.removeEventListener('pointerup', this._onPointerUp);
                    canvas.removeEventListener('pointercancel', this._onPointerUp);
                }
            }
            this._onPointerDown = null;
            this._onPointerMove = null;
            this._onPointerUp = null;
            this._grid = [];
            this._markerVecs = [];
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.1.3
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0