From a923463f83021090a008fdfeb830de77631591e3 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 16:30:42 +0100 Subject: [PATCH] Fix: Crisp radio station dots with zoom-scaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace sprite-based markers (objectsData + SpriteMaterial with soft 64x64 gradient texture → blurry at zoom) with optimized point meshes: - pointResolution(24): smooth 24-sided circles (no hexagons) - pointAltitude(0.001): nearly flat on surface (no cylinder effect) - sqrt-based zoom scaling: dots shrink when zooming in, grow when zooming out → visually consistent at all zoom levels - Removed three.js Sprite/SpriteMaterial/CanvasTexture imports Co-Authored-By: Claude Opus 4.6 --- web/src/plugins/radio/RadioTab.tsx | 97 +++++++++++------------------- 1 file changed, 35 insertions(+), 62 deletions(-) diff --git a/web/src/plugins/radio/RadioTab.tsx b/web/src/plugins/radio/RadioTab.tsx index 59d7d6d..a86c092 100644 --- a/web/src/plugins/radio/RadioTab.tsx +++ b/web/src/plugins/radio/RadioTab.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import Globe from 'globe.gl'; -import { CanvasTexture, Sprite, SpriteMaterial } from 'three'; // ── Types ── interface RadioPlace { @@ -63,29 +62,9 @@ const THEMES = [ { id: 'cherry', color: '#e74c6f', label: 'Cherry' }, ]; -function createMarkerTexture(color: string): CanvasTexture | null { - const canvas = document.createElement('canvas'); - canvas.width = 64; - canvas.height = 64; - - const ctx = canvas.getContext('2d'); - if (!ctx) return null; - - 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 new CanvasTexture(canvas); -} - -function markerScale(size: number): number { - return Math.max(0.55, Math.min(1.75, 0.42 + size * 0.035)); -} +// ── Zoom scaling constants ── +const BASE_ALT = 2.0; +const BASE_RADIUS = 0.18; // ── Component ── export default function RadioTab({ data }: { data: any }) { @@ -179,21 +158,9 @@ export default function RadioTab({ data }: { data: any }) { if (globeRef.current && containerRef.current) { const style = getComputedStyle(containerRef.current.parentElement!); const accentRgb = style.getPropertyValue('--accent-rgb').trim(); - const spriteTexture = createMarkerTexture(`rgb(${accentRgb})`); - globeRef.current.atmosphereColor(`rgb(${accentRgb})`); - globeRef.current.objectsData(places); - globeRef.current.objectThreeObject((d: any) => { - const material = new SpriteMaterial({ - map: spriteTexture ?? undefined, - color: `rgb(${accentRgb})`, - transparent: true, - depthWrite: false, - }); - const sprite = new Sprite(material); - const scale = markerScale(d.size ?? 1); - sprite.scale.set(scale, scale, 1); - return sprite; - }); + globeRef.current + .pointColor(() => `rgba(${accentRgb}, 0.85)`) + .atmosphereColor(`rgba(${accentRgb}, 0.25)`); } }, [theme]); @@ -231,41 +198,32 @@ export default function RadioTab({ data }: { data: any }) { if (!containerRef.current || places.length === 0) return; if (globeRef.current) { - globeRef.current.objectsData(places); + globeRef.current.pointsData(places); return; } // Read accent color from theme const initStyle = getComputedStyle(containerRef.current.parentElement!); const initRgb = initStyle.getPropertyValue('--accent-rgb').trim() || '230, 126, 34'; - const markerTexture = createMarkerTexture(`rgb(${initRgb})`); + const globe = new Globe(containerRef.current) .backgroundColor('rgba(0,0,0,0)') - .atmosphereColor(`rgb(${initRgb})`) + .atmosphereColor(`rgba(${initRgb}, 0.25)`) .atmosphereAltitude(0.12) .globeImageUrl('/nasa-blue-marble.jpg') - .objectsData(places) - .objectLat((d: any) => d.geo[1]) - .objectLng((d: any) => d.geo[0]) - .objectAltitude(0.0012) - .objectFacesSurface(false) - .objectLabel((d: any) => + .pointsData(places) + // Radio Garden geo format: [lng, lat] + .pointLat((d: any) => d.geo[1]) + .pointLng((d: any) => d.geo[0]) + .pointColor(() => `rgba(${initRgb}, 0.85)`) + .pointRadius(BASE_RADIUS) + .pointAltitude(0.001) + .pointResolution(24) + .pointLabel((d: any) => `
` + `${d.title}
${d.country}
` ) - .objectThreeObject((d: any) => { - const material = new SpriteMaterial({ - map: markerTexture ?? undefined, - color: `rgb(${initRgb})`, - transparent: true, - depthWrite: false, - }); - const sprite = new Sprite(material); - const scale = markerScale(d.size ?? 1); - sprite.scale.set(scale, scale, 1); - return sprite; - }) - .onObjectClick((d: any) => handlePointClickRef.current?.(d)) + .onPointClick((d: any) => handlePointClickRef.current?.(d)) .width(containerRef.current.clientWidth) .height(containerRef.current.clientHeight); @@ -273,7 +231,7 @@ export default function RadioTab({ data }: { data: any }) { globe.renderer().setPixelRatio(window.devicePixelRatio); // Start-Position: Europa - globe.pointOfView({ lat: 48, lng: 10, altitude: 2.0 }); + globe.pointOfView({ lat: 48, lng: 10, altitude: BASE_ALT }); // Auto-Rotation const controls = globe.controls() as any; @@ -282,6 +240,20 @@ export default function RadioTab({ data }: { data: any }) { controls.autoRotateSpeed = 0.3; } + // ── Zoom-based dot scaling ── + // Dots scale with sqrt(altitude) so they stay visually consistent: + // zoomed out (alt 2.0) → radius 0.18°, zoomed in (alt 0.4) → ~0.08° + let lastAlt = BASE_ALT; + const onControlsChange = () => { + const pov = globe.pointOfView(); + const alt = pov.altitude; + if (Math.abs(alt - lastAlt) / lastAlt < 0.05) return; + lastAlt = alt; + const scale = Math.sqrt(alt / BASE_ALT); + globe.pointRadius(() => BASE_RADIUS * Math.max(0.15, Math.min(2.5, scale))); + }; + controls.addEventListener('change', onControlsChange); + globeRef.current = globe; // Pause rotation on any globe interaction (drag, scroll, touch) @@ -301,6 +273,7 @@ export default function RadioTab({ data }: { data: any }) { window.addEventListener('resize', onResize); return () => { + controls.removeEventListener('change', onControlsChange); el.removeEventListener('mousedown', onInteract); el.removeEventListener('touchstart', onInteract); el.removeEventListener('wheel', onInteract);