From ed7e07a9ba3a12cf6efdf5992eef6c41e496c8c4 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 10:43:49 +0100 Subject: [PATCH] Feat: Dynamic tile-based globe texture (like Radio Garden) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/plugins/radio/RadioTab.tsx | 31 +++- web/src/plugins/radio/TileTextureManager.ts | 183 ++++++++++++++++++++ 2 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 web/src/plugins/radio/TileTextureManager.ts diff --git a/web/src/plugins/radio/RadioTab.tsx b/web/src/plugins/radio/RadioTab.tsx index bc07ea0..671ef0b 100644 --- a/web/src/plugins/radio/RadioTab.tsx +++ b/web/src/plugins/radio/RadioTab.tsx @@ -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(null); const globeRef = useRef(null); const rotationResumeRef = useRef>(undefined); + const tileManagerRef = useRef(null); + const tileTextureRef = useRef(null); const [theme, setTheme] = useState(() => localStorage.getItem('radio-theme') || 'default'); const [places, setPlaces] = useState([]); @@ -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]); diff --git a/web/src/plugins/radio/TileTextureManager.ts b/web/src/plugins/radio/TileTextureManager.ts new file mode 100644 index 0000000..3ca20b4 --- /dev/null +++ b/web/src/plugins/radio/TileTextureManager.ts @@ -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(); + private loading = new Set(); + private drawn = new Set(); + private updatePending = false; + private onUpdate: () => void; + private vpDebounce: ReturnType | 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 { + 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 { + const n = 1 << z; + const batch: Promise[] = []; + 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 { + const k = `${z}/${x}/${y}`; + if (this.cache.has(k) || this.loading.has(k)) return Promise.resolve(); + + this.loading.add(k); + return new Promise((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(); + }); + } +}