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]);