Render radio stations as sprite particles

This commit is contained in:
Daniel 2026-03-06 13:58:50 +01:00
parent 693f719abc
commit d55aaf71b1

View file

@ -62,6 +62,26 @@ const THEMES = [
{ id: 'cherry', color: '#e74c6f', label: 'Cherry' }, { id: 'cherry', color: '#e74c6f', label: 'Cherry' },
]; ];
function createParticleTexture(color: string): string {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext('2d');
if (!ctx) return '';
const gradient = ctx.createRadialGradient(32, 32, 3, 32, 32, 32);
gradient.addColorStop(0, 'rgba(255,255,255,1)');
gradient.addColorStop(0.35, color);
gradient.addColorStop(0.7, color.replace('rgb(', 'rgba(').replace(')', ',0.42)'));
gradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 64, 64);
return canvas.toDataURL('image/png');
}
// ── Component ── // ── Component ──
export default function RadioTab({ data }: { data: any }) { export default function RadioTab({ data }: { data: any }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -154,8 +174,10 @@ export default function RadioTab({ data }: { data: any }) {
if (globeRef.current && containerRef.current) { if (globeRef.current && containerRef.current) {
const style = getComputedStyle(containerRef.current.parentElement!); const style = getComputedStyle(containerRef.current.parentElement!);
const accentRgb = style.getPropertyValue('--accent-rgb').trim(); const accentRgb = style.getPropertyValue('--accent-rgb').trim();
const particleTexture = createParticleTexture(`rgb(${accentRgb})`);
globeRef.current globeRef.current
.pointColor(() => `rgba(${accentRgb}, 0.85)`) .particlesColor(`rgb(${accentRgb})`)
.particlesTexture(particleTexture)
.atmosphereColor(`rgb(${accentRgb})`); .atmosphereColor(`rgb(${accentRgb})`);
} }
}, [theme]); }, [theme]);
@ -194,31 +216,33 @@ export default function RadioTab({ data }: { data: any }) {
if (!containerRef.current || places.length === 0) return; if (!containerRef.current || places.length === 0) return;
if (globeRef.current) { if (globeRef.current) {
globeRef.current.pointsData(places); globeRef.current.particlesData([places]);
return; return;
} }
// Read accent color from theme // Read accent color from theme
const initStyle = getComputedStyle(containerRef.current.parentElement!); const initStyle = getComputedStyle(containerRef.current.parentElement!);
const initRgb = initStyle.getPropertyValue('--accent-rgb').trim() || '230, 126, 34'; const initRgb = initStyle.getPropertyValue('--accent-rgb').trim() || '230, 126, 34';
const particleTexture = createParticleTexture(`rgb(${initRgb})`);
const globe = new Globe(containerRef.current) const globe = new Globe(containerRef.current)
.backgroundColor('rgba(0,0,0,0)') .backgroundColor('rgba(0,0,0,0)')
.atmosphereColor(`rgb(${initRgb})`) .atmosphereColor(`rgb(${initRgb})`)
.atmosphereAltitude(0.12) .atmosphereAltitude(0.12)
.globeImageUrl('/nasa-blue-marble.jpg') .globeImageUrl('/nasa-blue-marble.jpg')
.pointsData(places) .particlesData([places] as any)
// Radio Garden geo format: [lng, lat] .particlesList((d: any) => d)
.pointLat((d: any) => d.geo[1]) .particleLat((d: any) => d.geo[1])
.pointLng((d: any) => d.geo[0]) .particleLng((d: any) => d.geo[0])
.pointColor(() => `rgba(${initRgb}, 0.85)`) .particleAltitude(0.0008)
.pointRadius((d: any) => Math.max(0.12, Math.min(0.45, 0.06 + (d.size ?? 1) * 0.005))) .particlesSize(6)
.pointAltitude(0.003) .particlesSizeAttenuation(false)
.pointResolution(6) .particlesColor(`rgb(${initRgb})`)
.pointLabel((d: any) => .particlesTexture(particleTexture)
.particleLabel((d: any) =>
`<div style="font-family:system-ui;font-size:13px;color:#fff;background:rgba(30,31,34,0.92);padding:6px 10px;border-radius:6px;border:1px solid rgba(${initRgb},0.3);pointer-events:none">` + `<div style="font-family:system-ui;font-size:13px;color:#fff;background:rgba(30,31,34,0.92);padding:6px 10px;border-radius:6px;border:1px solid rgba(${initRgb},0.3);pointer-events:none">` +
`<b>${d.title}</b><br/><span style="color:#949ba4;font-size:11px">${d.country}</span></div>` `<b>${d.title}</b><br/><span style="color:#949ba4;font-size:11px">${d.country}</span></div>`
) )
.onPointClick((d: any) => handlePointClickRef.current?.(d)) .onParticleClick((d: any) => handlePointClickRef.current?.(d))
.width(containerRef.current.clientWidth) .width(containerRef.current.clientWidth)
.height(containerRef.current.clientHeight); .height(containerRef.current.clientHeight);
@ -235,23 +259,6 @@ export default function RadioTab({ data }: { data: any }) {
controls.autoRotateSpeed = 0.3; controls.autoRotateSpeed = 0.3;
} }
// Scale points + load detail tiles on zoom change
let lastAlt = 2.0;
const BASE_ALT = 2.0;
const onControlsChange = () => {
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;
const scale = Math.max(0.1, alt / BASE_ALT);
globe.pointRadius((d: any) => {
const base = Math.max(0.12, Math.min(0.45, 0.06 + (d.size ?? 1) * 0.005));
return base * scale;
});
};
controls.addEventListener('change', onControlsChange);
globeRef.current = globe; globeRef.current = globe;
// Pause rotation on any globe interaction (drag, scroll, touch) // Pause rotation on any globe interaction (drag, scroll, touch)
@ -271,7 +278,6 @@ export default function RadioTab({ data }: { data: any }) {
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
return () => { return () => {
controls.removeEventListener('change', onControlsChange);
el.removeEventListener('mousedown', onInteract); el.removeEventListener('mousedown', onInteract);
el.removeEventListener('touchstart', onInteract); el.removeEventListener('touchstart', onInteract);
el.removeEventListener('wheel', onInteract); el.removeEventListener('wheel', onInteract);