Feat: Dynamic tile-based globe texture (like Radio Garden)

Replace static earth texture with tile-based rendering from CDN.
Tiles load progressively (zoom 1→2→3 base), then dynamically
load zoom 4-6 as user zooms in. Mercator→equirectangular
reprojection via strip-based canvas compositing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-06 10:43:49 +01:00
parent 1e4ccfb1f1
commit ed7e07a9ba
2 changed files with 211 additions and 3 deletions

View file

@ -1,5 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import Globe from 'globe.gl';
import { CanvasTexture, LinearFilter } from 'three';
import { TileTextureManager } from './TileTextureManager';
// ── Types ──
interface RadioPlace {
@ -59,6 +61,8 @@ export default function RadioTab({ data }: { data: any }) {
const containerRef = useRef<HTMLDivElement>(null);
const globeRef = useRef<any>(null);
const rotationResumeRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const tileManagerRef = useRef<TileTextureManager | null>(null);
const tileTextureRef = useRef<CanvasTexture | null>(null);
const [theme, setTheme] = useState(() => localStorage.getItem('radio-theme') || 'default');
const [places, setPlaces] = useState<RadioPlace[]>([]);
@ -179,8 +183,13 @@ export default function RadioTab({ data }: { data: any }) {
const initRgb = initStyle.getPropertyValue('--accent-rgb').trim() || '230, 126, 34';
const initAccent = initStyle.getPropertyValue('--accent').trim() || '#e67e22';
// ── Tile-based texture (like Radio Garden) ──
const tileMgr = new TileTextureManager(4096, 2048, () => {
if (tileTextureRef.current) tileTextureRef.current.needsUpdate = true;
});
tileManagerRef.current = tileMgr;
const globe = new Globe(containerRef.current)
.globeImageUrl('/earth-night.jpg')
.backgroundColor('rgba(0,0,0,0)')
.atmosphereColor(`rgba(${initRgb}, 0.25)`)
.atmosphereAltitude(0.12)
@ -200,6 +209,18 @@ export default function RadioTab({ data }: { data: any }) {
.width(containerRef.current.clientWidth)
.height(containerRef.current.clientHeight);
// Apply tile canvas as globe texture
const texture = new CanvasTexture(tileMgr.canvas);
texture.minFilter = LinearFilter;
texture.generateMipmaps = false;
tileTextureRef.current = texture;
const mat = globe.globeMaterial() as any;
mat.map = texture;
mat.needsUpdate = true;
// Start loading tiles (progressive: zoom 1 → 2 → 3)
tileMgr.init();
// Sharp rendering on HiDPI/Retina displays
globe.renderer().setPixelRatio(window.devicePixelRatio);
@ -213,11 +234,12 @@ export default function RadioTab({ data }: { data: any }) {
controls.autoRotateSpeed = 0.3;
}
// Scale point radius only when zoom (altitude) changes, not during rotation
// Scale points + load detail tiles on zoom change
let lastAlt = 2.0;
const BASE_ALT = 2.0;
const onControlsChange = () => {
const alt = globe.pointOfView().altitude;
const pov = globe.pointOfView();
const alt = pov.altitude;
// Only recalc when altitude changed significantly (>5%)
if (Math.abs(alt - lastAlt) / lastAlt < 0.05) return;
lastAlt = alt;
@ -226,6 +248,8 @@ export default function RadioTab({ data }: { data: any }) {
const base = Math.max(0.12, Math.min(0.45, 0.06 + (d.size ?? 1) * 0.005));
return base * scale;
});
// Load higher-res tiles for visible area
tileManagerRef.current?.loadViewport(pov.lat, pov.lng, alt);
};
controls.addEventListener('change', onControlsChange);
@ -253,6 +277,7 @@ export default function RadioTab({ data }: { data: any }) {
el.removeEventListener('touchstart', onInteract);
el.removeEventListener('wheel', onInteract);
window.removeEventListener('resize', onResize);
tileManagerRef.current?.destroy();
};
}, [places, pauseRotation]);

View file

@ -0,0 +1,183 @@
/**
* TileTextureManager loads satellite tiles from a CDN and composites
* them onto a single equirectangular canvas for use as a globe.gl texture.
*
* Tiles are standard Web Mercator (z/x/y). The manager handles the
* Mercator equirectangular reprojection when painting onto the canvas.
*/
const TILE_CDN = 'https://rg-tiles.b-cdn.net';
// ── Mercator math ──
function tileToLng(x: number, z: number): number {
return (x / (1 << z)) * 360 - 180;
}
function tileToLat(y: number, z: number): number {
const n = Math.PI - (2 * Math.PI * y) / (1 << z);
return (180 / Math.PI) * Math.atan(Math.sinh(n));
}
function latToTileY(lat: number, z: number): number {
const r = (lat * Math.PI) / 180;
return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * (1 << z);
}
// ── Manager ──
export class TileTextureManager {
readonly canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private cache = new Map<string, HTMLImageElement>();
private loading = new Set<string>();
private drawn = new Set<string>();
private updatePending = false;
private onUpdate: () => void;
private vpDebounce: ReturnType<typeof setTimeout> | undefined;
constructor(
public readonly width = 4096,
public readonly height = 2048,
onUpdate: () => void,
) {
this.canvas = document.createElement('canvas');
this.canvas.width = width;
this.canvas.height = height;
this.ctx = this.canvas.getContext('2d')!;
this.onUpdate = onUpdate;
// Ocean background
this.ctx.fillStyle = '#070b15';
this.ctx.fillRect(0, 0, width, height);
}
/** Load base layers progressively (zoom 1 → 3) */
async init(): Promise<void> {
await this.loadZoom(1); // 4 tiles, instant
await this.loadZoom(2); // 16 tiles, fast
this.loadZoom(3); // 64 tiles, background
}
/** Call on every controls-change; internally debounced. */
loadViewport(lat: number, lng: number, altitude: number): void {
if (this.vpDebounce) clearTimeout(this.vpDebounce);
this.vpDebounce = setTimeout(() => this.doLoadViewport(lat, lng, altitude), 250);
}
destroy(): void {
if (this.vpDebounce) clearTimeout(this.vpDebounce);
this.cache.clear();
this.loading.clear();
this.drawn.clear();
}
// ── Internal ──
private doLoadViewport(lat: number, lng: number, altitude: number): void {
let z: number;
if (altitude > 1.5) return; // base tiles sufficient
else if (altitude > 0.8) z = 4;
else if (altitude > 0.4) z = 5;
else z = 6;
// Estimate visible range in degrees
const latR = Math.min(85, altitude * 40);
const lngR = Math.min(180, altitude * 50);
const n = 1 << z;
const xMin = Math.max(0, Math.floor(((lng - lngR + 180) / 360) * n));
const xMax = Math.min(n - 1, Math.ceil(((lng + lngR + 180) / 360) * n));
const yMin = Math.max(0, Math.floor(latToTileY(Math.min(85, lat + latR), z)));
const yMax = Math.min(n - 1, Math.ceil(latToTileY(Math.max(-85, lat - latR), z)));
for (let x = xMin; x <= xMax; x++) {
for (let y = yMin; y <= yMax; y++) {
this.loadTile(z, x, y);
}
}
}
private async loadZoom(z: number): Promise<void> {
const n = 1 << z;
const batch: Promise<void>[] = [];
for (let x = 0; x < n; x++)
for (let y = 0; y < n; y++)
batch.push(this.loadTile(z, x, y));
await Promise.all(batch);
}
private loadTile(z: number, x: number, y: number): Promise<void> {
const k = `${z}/${x}/${y}`;
if (this.cache.has(k) || this.loading.has(k)) return Promise.resolve();
this.loading.add(k);
return new Promise<void>((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
this.cache.set(k, img);
this.loading.delete(k);
this.drawTile(z, x, y, img);
resolve();
};
img.onerror = () => {
this.loading.delete(k);
resolve();
};
img.src = `${TILE_CDN}/${k}.jpg`;
});
}
/**
* Paint a Mercator tile onto the equirectangular canvas.
* Slices into horizontal strips for correct reprojection.
*/
private drawTile(z: number, x: number, y: number, img: HTMLImageElement): void {
const k = `${z}/${x}/${y}`;
if (this.drawn.has(k)) return;
this.drawn.add(k);
const { width: W, height: H, ctx } = this;
// Horizontal (longitude) — linear, same in both projections
const lngL = tileToLng(x, z);
const lngR = tileToLng(x + 1, z);
const cx = ((lngL + 180) / 360) * W;
const cw = ((lngR - lngL) / 360) * W;
// Vertical — slice into strips for Mercator → equirectangular warp
const STRIPS = 8;
const th = img.height;
for (let s = 0; s < STRIPS; s++) {
const t0 = s / STRIPS;
const t1 = (s + 1) / STRIPS;
// Source rectangle in tile image
const sy = t0 * th;
const sh = (t1 - t0) * th;
// Latitude at strip edges (via Mercator inverse)
const lat0 = tileToLat(y + t0, z);
const lat1 = tileToLat(y + t1, z);
// Destination on equirectangular canvas
const dy = ((90 - lat0) / 180) * H;
const dh = ((90 - lat1) / 180) * H - dy;
ctx.drawImage(img, 0, sy, img.width, sh, cx, dy, cw, Math.max(1, dh));
}
this.scheduleUpdate();
}
private scheduleUpdate(): void {
if (this.updatePending) return;
this.updatePending = true;
requestAnimationFrame(() => {
this.updatePending = false;
this.onUpdate();
});
}
}