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:
parent
1e4ccfb1f1
commit
ed7e07a9ba
2 changed files with 211 additions and 3 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import Globe from 'globe.gl';
|
import Globe from 'globe.gl';
|
||||||
|
import { CanvasTexture, LinearFilter } from 'three';
|
||||||
|
import { TileTextureManager } from './TileTextureManager';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
interface RadioPlace {
|
interface RadioPlace {
|
||||||
|
|
@ -59,6 +61,8 @@ export default function RadioTab({ data }: { data: any }) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const globeRef = useRef<any>(null);
|
const globeRef = useRef<any>(null);
|
||||||
const rotationResumeRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
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 [theme, setTheme] = useState(() => localStorage.getItem('radio-theme') || 'default');
|
||||||
const [places, setPlaces] = useState<RadioPlace[]>([]);
|
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 initRgb = initStyle.getPropertyValue('--accent-rgb').trim() || '230, 126, 34';
|
||||||
const initAccent = initStyle.getPropertyValue('--accent').trim() || '#e67e22';
|
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)
|
const globe = new Globe(containerRef.current)
|
||||||
.globeImageUrl('/earth-night.jpg')
|
|
||||||
.backgroundColor('rgba(0,0,0,0)')
|
.backgroundColor('rgba(0,0,0,0)')
|
||||||
.atmosphereColor(`rgba(${initRgb}, 0.25)`)
|
.atmosphereColor(`rgba(${initRgb}, 0.25)`)
|
||||||
.atmosphereAltitude(0.12)
|
.atmosphereAltitude(0.12)
|
||||||
|
|
@ -200,6 +209,18 @@ export default function RadioTab({ data }: { data: any }) {
|
||||||
.width(containerRef.current.clientWidth)
|
.width(containerRef.current.clientWidth)
|
||||||
.height(containerRef.current.clientHeight);
|
.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
|
// Sharp rendering on HiDPI/Retina displays
|
||||||
globe.renderer().setPixelRatio(window.devicePixelRatio);
|
globe.renderer().setPixelRatio(window.devicePixelRatio);
|
||||||
|
|
||||||
|
|
@ -213,11 +234,12 @@ export default function RadioTab({ data }: { data: any }) {
|
||||||
controls.autoRotateSpeed = 0.3;
|
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;
|
let lastAlt = 2.0;
|
||||||
const BASE_ALT = 2.0;
|
const BASE_ALT = 2.0;
|
||||||
const onControlsChange = () => {
|
const onControlsChange = () => {
|
||||||
const alt = globe.pointOfView().altitude;
|
const pov = globe.pointOfView();
|
||||||
|
const alt = pov.altitude;
|
||||||
// Only recalc when altitude changed significantly (>5%)
|
// Only recalc when altitude changed significantly (>5%)
|
||||||
if (Math.abs(alt - lastAlt) / lastAlt < 0.05) return;
|
if (Math.abs(alt - lastAlt) / lastAlt < 0.05) return;
|
||||||
lastAlt = alt;
|
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));
|
const base = Math.max(0.12, Math.min(0.45, 0.06 + (d.size ?? 1) * 0.005));
|
||||||
return base * scale;
|
return base * scale;
|
||||||
});
|
});
|
||||||
|
// Load higher-res tiles for visible area
|
||||||
|
tileManagerRef.current?.loadViewport(pov.lat, pov.lng, alt);
|
||||||
};
|
};
|
||||||
controls.addEventListener('change', onControlsChange);
|
controls.addEventListener('change', onControlsChange);
|
||||||
|
|
||||||
|
|
@ -253,6 +277,7 @@ export default function RadioTab({ data }: { data: any }) {
|
||||||
el.removeEventListener('touchstart', onInteract);
|
el.removeEventListener('touchstart', onInteract);
|
||||||
el.removeEventListener('wheel', onInteract);
|
el.removeEventListener('wheel', onInteract);
|
||||||
window.removeEventListener('resize', onResize);
|
window.removeEventListener('resize', onResize);
|
||||||
|
tileManagerRef.current?.destroy();
|
||||||
};
|
};
|
||||||
}, [places, pauseRotation]);
|
}, [places, pauseRotation]);
|
||||||
|
|
||||||
|
|
|
||||||
183
web/src/plugins/radio/TileTextureManager.ts
Normal file
183
web/src/plugins/radio/TileTextureManager.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue