Backgrounds
Wireframe globe
A network sphere of evenly spread points joined to their nearest neighbours. Turns slowly and tilts toward the cursor.
Code 3 files
src/gallery/globe/Globe.astroAstro25 lines
---
// Wireframe globe that fills its parent. Three.js loads only once the frame
// scrolls into view, so it never competes with first paint.
---
<canvas class="globe" data-globe aria-hidden="true"></canvas>
<style>
.globe {
position: absolute; left: 50%; top: 50%;
height: 112%; width: auto; max-width: none; aspect-ratio: 1;
transform: translate(-50%, -50%);
pointer-events: none; opacity: 0.8;
}
</style>
<script>
document.querySelectorAll<HTMLCanvasElement>('[data-globe]').forEach((canvas) => {
const io = new IntersectionObserver(([e]) => {
if (!e?.isIntersecting) return;
io.disconnect();
import('./globe').then((m) => m.mountGlobe(canvas));
}, { rootMargin: '200px' });
io.observe(canvas);
});
</script>src/gallery/globe/globe.tsTypeScript89 lines
// A wireframe network globe. Points spread evenly on a sphere (Fibonacci),
// each joined to its nearest neighbours. Turns slowly, tilts toward the cursor,
// and brightens on the starfield's heartbeat.
import * as THREE from 'three';
import { createStage, heartbeat, trackPointer } from '../../lib/stage';
export function mountGlobe(canvas: HTMLCanvasElement) {
const stage = createStage(canvas, 38);
if (!stage) return;
const { scene, camera, run } = stage;
camera.position.set(0, 0, 6.2);
const COUNT = canvas.clientWidth < 700 ? 150 : 230;
const pts: THREE.Vector3[] = [];
const golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < COUNT; i++) {
const y = 1 - (i / (COUNT - 1)) * 2;
const r = Math.sqrt(1 - y * y);
const th = golden * i;
// A little jitter so it reads as a network, not a perfect lattice.
const j = 1 + (Math.random() - 0.5) * 0.12;
pts.push(new THREE.Vector3(Math.cos(th) * r * j, y * j, Math.sin(th) * r * j).multiplyScalar(1.9));
}
// Join each point to its 3 nearest neighbours (deduplicated).
const pairs = new Set<string>();
const linePos: number[] = [];
for (let i = 0; i < pts.length; i++) {
const near = pts
.map((p, k) => ({ k, d: p.distanceToSquared(pts[i]) }))
.filter((n) => n.k !== i)
.sort((a, b) => a.d - b.d)
.slice(0, 3);
for (const { k } of near) {
const key = i < k ? `${i}-${k}` : `${k}-${i}`;
if (pairs.has(key)) continue;
pairs.add(key);
linePos.push(pts[i].x, pts[i].y, pts[i].z, pts[k].x, pts[k].y, pts[k].z);
}
}
const group = new THREE.Group();
scene.add(group);
const lineGeo = new THREE.BufferGeometry();
lineGeo.setAttribute('position', new THREE.Float32BufferAttribute(linePos, 3));
const lineMat = new THREE.LineBasicMaterial({ color: 0x9fd09c, transparent: true, opacity: 0.32, depthWrite: false });
group.add(new THREE.LineSegments(lineGeo, lineMat));
// Round, soft points from a tiny canvas sprite.
const sprite = document.createElement('canvas');
sprite.width = sprite.height = 64;
const g = sprite.getContext('2d')!;
const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, 'rgba(244,239,228,1)');
grad.addColorStop(0.35, 'rgba(244,239,228,0.6)');
grad.addColorStop(1, 'rgba(244,239,228,0)');
g.fillStyle = grad;
g.fillRect(0, 0, 64, 64);
const pointGeo = new THREE.BufferGeometry().setFromPoints(pts);
const pointMat = new THREE.PointsMaterial({
size: 0.09, map: new THREE.CanvasTexture(sprite), transparent: true, depthWrite: false, opacity: 0.9,
});
group.add(new THREE.Points(pointGeo, pointMat));
// Faint inner sphere wireframe for depth.
const shell = new THREE.Mesh(
new THREE.IcosahedronGeometry(1.2, 2),
new THREE.MeshBasicMaterial({ color: 0xf1f1ef, wireframe: true, transparent: true, opacity: 0.05 })
);
group.add(shell);
const pointer = trackPointer();
let tx = 0, ty = 0;
group.rotation.x = 0.35;
run((t) => {
tx += (pointer.y * 0.35 - tx) * 0.04;
ty += (pointer.x * 0.5 - ty) * 0.04;
group.rotation.y = t * 0.00009 + ty;
group.rotation.x = 0.35 + tx;
shell.rotation.y = -t * 0.00015;
const b = heartbeat();
lineMat.opacity = 0.26 + b * 0.22;
pointMat.size = 0.085 + b * 0.035;
const s = 1 + b * 0.015;
group.scale.set(s, s, s);
});
}src/lib/stage.tsTypeScript58 lines
// Shared Three.js plumbing: renderer on a given canvas, sizing, and a loop that
// only runs while the canvas is on screen and the tab is visible.
import * as THREE from 'three';
export const reduceMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
// Heartbeat level published by Starfield.astro (0 at rest, ~1 at the peak).
export const heartbeat = () => (window as unknown as { __beat?: number }).__beat ?? 0;
export function createStage(canvas: HTMLCanvasElement, fov = 40) {
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true, powerPreference: 'low-power' });
} catch {
canvas.remove();
return null;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
renderer.setClearColor(0x000000, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(fov, 1, 0.1, 100);
const resize = () => {
const w = canvas.clientWidth, h = canvas.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
};
resize();
new ResizeObserver(resize).observe(canvas);
const run = (frame: (t: number) => void) => {
if (reduceMotion()) { frame(0); renderer.render(scene, camera); return; }
let raf = 0, visible = true;
const loop = (t: number) => { frame(t); renderer.render(scene, camera); raf = requestAnimationFrame(loop); };
const sync = () => {
cancelAnimationFrame(raf);
if (visible && !document.hidden) raf = requestAnimationFrame(loop);
};
new IntersectionObserver(([e]) => { visible = e.isIntersecting; sync(); }).observe(canvas);
document.addEventListener('visibilitychange', sync);
sync();
};
return { renderer, scene, camera, run };
}
// Pointer position in -1..1, smoothed by the caller.
export function trackPointer() {
const p = { x: 0, y: 0 };
window.addEventListener('pointermove', (e) => {
if (e.pointerType !== 'mouse') return;
p.x = (e.clientX / innerWidth) * 2 - 1;
p.y = (e.clientY / innerHeight) * 2 - 1;
}, { passive: true });
return p;
}