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).
Preview
<div class="mx-auto h-72 w-72 text-muted-foreground">
<x-ui.globe accent="ring" />
</div>
Installation
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:
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.
-
resources/views/components/ui/globe.blade.php -
resources/js/ui/globe.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: 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.
# 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
{{-- 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>
{{-- 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<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.
@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>
/**
* 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