feat(globe): add visual enhancements and texture selection (#1076)

- Add texture picker in Settings (Topographic / NASA Blue Marble)
- Upgrade globe material to MeshStandardMaterial (roughness 0.8, metalness 0.1, emissive glow)
- Add cyan backlight PointLight at (-10,-10,-10)
- Add dual atmosphere glow layers (outer 0x00d4ff, inner 0x00a8cc)
- Add procedural starfield (2000 stars, random spherical distribution)
- Animate glow rotation (0.0003 rad/frame) and star rotation (0.00005 rad/frame)
- Fix GPU memory leak: dispose geometries/materials on destroy
- Fix animation frame cleanup: store and cancel rAF on destroy
This commit is contained in:
Elie Habib
2026-03-06 00:44:14 +04:00
committed by GitHub
parent f2fa5858c8
commit 19e62e92fc
4 changed files with 170 additions and 16 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -21,7 +21,7 @@ import { INTEL_HOTSPOTS, CONFLICT_ZONES, MILITARY_BASES, NUCLEAR_FACILITIES, SPA
import { PIPELINES } from '@/config/pipelines';
import { t } from '@/services/i18n';
import { SITE_VARIANT } from '@/config/variant';
import { getGlobeRenderScale, resolveGlobePixelRatio, resolvePerformanceProfile, subscribeGlobeRenderScaleChange, type GlobeRenderScale, type GlobePerformanceProfile } from '@/services/globe-render-settings';
import { getGlobeRenderScale, resolveGlobePixelRatio, resolvePerformanceProfile, subscribeGlobeRenderScaleChange, getGlobeTexture, GLOBE_TEXTURE_URLS, subscribeGlobeTextureChange, type GlobeRenderScale, type GlobePerformanceProfile } from '@/services/globe-render-settings';
import { getLayersForVariant, resolveLayerLabel, type MapVariant } from '@/config/map-layer-definitions';
import { resolveTradeRouteSegments, type TradeRouteSegment } from '@/config/trade-routes';
import { GAMMA_IRRADIATORS } from '@/config/irradiators';
@@ -317,8 +317,14 @@ export class GlobeMap {
private container: HTMLElement;
private globe: GlobeInstance | null = null;
private unsubscribeGlobeQuality: (() => void) | null = null;
private unsubscribeGlobeTexture: (() => void) | null = null;
private controls: GlobeControlsLike | null = null;
private renderPaused = false;
private outerGlow: any = null;
private innerGlow: any = null;
private starField: any = null;
private cyanLight: any = null;
private extrasAnimFrameId: number | null = null;
private pendingFlushWhilePaused = false;
private controlsAutoRotateBeforePause: boolean | null = null;
private controlsDampingBeforePause: boolean | null = null;
@@ -437,9 +443,10 @@ export class GlobeMap {
const initW = this.container.clientWidth || window.innerWidth;
const initH = this.container.clientHeight || window.innerHeight;
const initialTexture = getGlobeTexture();
globe
.globeImageUrl('/textures/earth-topo-bathy.jpg')
.backgroundImageUrl('/textures/night-sky.png')
.globeImageUrl(GLOBE_TEXTURE_URLS[initialTexture])
.backgroundImageUrl('')
.atmosphereColor('#4466cc')
.atmosphereAltitude(0.18)
.width(initW)
@@ -473,26 +480,92 @@ export class GlobeMap {
attribution.innerHTML = '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> © <a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a>';
this.container.appendChild(attribution);
// Load specular/water map for ocean shimmer
// Upgrade material to MeshStandardMaterial + add scene enhancements
setTimeout(async () => {
try {
const material = globe.globeMaterial();
if (material) {
const { TextureLoader, Color } = await import('three');
new TextureLoader().load('/textures/earth-water.png', (tex: any) => {
(material as any).specularMap = tex;
(material as any).specular = new Color(2767434);
(material as any).shininess = 30;
material.needsUpdate = true;
const THREE = await import('three');
const scene = globe.scene();
// --- Material: MeshStandardMaterial with emissive glow ---
const oldMat = globe.globeMaterial();
if (oldMat) {
const stdMat = new THREE.MeshStandardMaterial({
color: 0xffffff,
roughness: 0.8,
metalness: 0.1,
emissive: new THREE.Color(0x0a1f2e),
emissiveIntensity: 0.3,
});
(material as any).bumpScale = 3;
material.needsUpdate = true;
if ((oldMat as any).map) stdMat.map = (oldMat as any).map;
(globe as any).globeMaterial(stdMat);
}
// --- Lighting: cyan backlight ---
this.cyanLight = new THREE.PointLight(0x00d4ff, 0.3);
this.cyanLight.position.set(-10, -10, -10);
scene.add(this.cyanLight);
// --- Dual atmosphere glow layers ---
const outerGeo = new THREE.SphereGeometry(2.15, 64, 64);
const outerMat = new THREE.MeshBasicMaterial({
color: 0x00d4ff,
side: THREE.BackSide,
transparent: true,
opacity: 0.15,
});
this.outerGlow = new THREE.Mesh(outerGeo, outerMat);
scene.add(this.outerGlow);
const innerGeo = new THREE.SphereGeometry(2.08, 64, 64);
const innerMat = new THREE.MeshBasicMaterial({
color: 0x00a8cc,
side: THREE.BackSide,
transparent: true,
opacity: 0.1,
});
this.innerGlow = new THREE.Mesh(innerGeo, innerMat);
scene.add(this.innerGlow);
// --- Procedural starfield ---
const starCount = 2000;
const starPositions = new Float32Array(starCount * 3);
const starColors = new Float32Array(starCount * 3);
for (let i = 0; i < starCount; i++) {
const r = 50 + Math.random() * 50;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
starPositions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
starPositions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
starPositions[i * 3 + 2] = r * Math.cos(phi);
const brightness = 0.5 + Math.random() * 0.5;
starColors[i * 3] = brightness;
starColors[i * 3 + 1] = brightness;
starColors[i * 3 + 2] = brightness;
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
starGeo.setAttribute('color', new THREE.BufferAttribute(starColors, 3));
const starMat = new THREE.PointsMaterial({ size: 0.1, vertexColors: true, transparent: true });
this.starField = new THREE.Points(starGeo, starMat);
scene.add(this.starField);
const animateExtras = () => {
if (this.destroyed) return;
if (this.outerGlow) this.outerGlow.rotation.y += 0.0003;
if (this.starField) this.starField.rotation.y += 0.00005;
this.extrasAnimFrameId = requestAnimationFrame(animateExtras);
};
animateExtras();
} catch {
// specular map is cosmetic — ignore
// enhancements are cosmetic — ignore
}
}, 800);
// Subscribe to texture changes
this.unsubscribeGlobeTexture = subscribeGlobeTextureChange((texture) => {
if (this.globe) this.globe.globeImageUrl(GLOBE_TEXTURE_URLS[texture]);
});
// Pause auto-rotate on user interaction; resume after 60 s idle (like Sentinel)
const pauseAutoRotate = () => {
if (this.renderPaused) return;
@@ -2013,8 +2086,12 @@ export class GlobeMap {
if (profile.disableAtmosphere) {
this.globe.atmosphereAltitude(0);
if (this.outerGlow) this.outerGlow.visible = false;
if (this.innerGlow) this.innerGlow.visible = false;
} else {
this.globe.atmosphereAltitude(0.18);
if (this.outerGlow) this.outerGlow.visible = true;
if (this.innerGlow) this.innerGlow.visible = true;
}
if (prevPulse !== this._pulseEnabled) {
@@ -2027,7 +2104,28 @@ export class GlobeMap {
public destroy(): void {
this.unsubscribeGlobeQuality?.();
this.unsubscribeGlobeQuality = null;
this.unsubscribeGlobeTexture?.();
this.unsubscribeGlobeTexture = null;
this.destroyed = true;
if (this.extrasAnimFrameId != null) {
cancelAnimationFrame(this.extrasAnimFrameId);
this.extrasAnimFrameId = null;
}
const scene = this.globe?.scene();
for (const obj of [this.outerGlow, this.innerGlow, this.starField, this.cyanLight]) {
if (!obj) continue;
if (scene) scene.remove(obj);
if (obj.geometry) obj.geometry.dispose();
if (obj.material) obj.material.dispose();
}
if (this.globe) {
const mat = this.globe.globeMaterial();
if (mat && (mat as any).isMeshStandardMaterial) mat.dispose();
}
this.outerGlow = null;
this.innerGlow = null;
this.starField = null;
this.cyanLight = null;
if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; }
if (this.flushMaxTimer) { clearTimeout(this.flushMaxTimer); this.flushMaxTimer = null; }
if (this.autoRotateTimer) clearTimeout(this.autoRotateTimer);

View File

@@ -3,7 +3,7 @@ import { PANEL_CATEGORY_MAP } from '@/config/panels';
import { SITE_VARIANT } from '@/config/variant';
import { LANGUAGES, changeLanguage, getCurrentLanguage, t } from '@/services/i18n';
import { getAiFlowSettings, setAiFlowSetting, getStreamQuality, setStreamQuality, STREAM_QUALITY_OPTIONS } from '@/services/ai-flow-settings';
import { getGlobeRenderScale, setGlobeRenderScale, GLOBE_RENDER_SCALE_OPTIONS, type GlobeRenderScale } from '@/services/globe-render-settings';
import { getGlobeRenderScale, setGlobeRenderScale, GLOBE_RENDER_SCALE_OPTIONS, getGlobeTexture, setGlobeTexture, GLOBE_TEXTURE_OPTIONS, type GlobeRenderScale, type GlobeTexture } from '@/services/globe-render-settings';
import { getLiveStreamsAlwaysOn, setLiveStreamsAlwaysOn } from '@/services/live-stream-settings';
import type { StreamQuality } from '@/services/ai-flow-settings';
import { escapeHtml } from '@/utils/sanitize';
@@ -204,6 +204,11 @@ export class UnifiedSettings {
return;
}
if (target.id === 'us-globe-texture') {
setGlobeTexture(target.value as GlobeTexture);
return;
}
if (target.id === 'us-live-streams-always-on') {
setLiveStreamsAlwaysOn(target.checked);
return;
@@ -394,6 +399,21 @@ export class UnifiedSettings {
}
html += `</select>`;
// Globe texture selection
const currentTexture = getGlobeTexture();
html += `<div class="ai-flow-toggle-row">
<div class="ai-flow-toggle-label-wrap">
<div class="ai-flow-toggle-label">Globe texture</div>
<div class="ai-flow-toggle-desc">Choose between topographic or NASA Blue Marble Earth imagery.</div>
</div>
</div>`;
html += `<select class="unified-settings-select" id="us-globe-texture">`;
for (const opt of GLOBE_TEXTURE_OPTIONS) {
const selected = opt.value === currentTexture ? ' selected' : '';
html += `<option value="${opt.value}"${selected}>${opt.label}</option>`;
}
html += `</select>`;
html += this.toggleRowHtml('us-map-flash', t('components.insights.mapFlashLabel'), t('components.insights.mapFlashDesc'), settings.mapNewsFlash);
// Panels section

View File

@@ -1,8 +1,12 @@
export type GlobeRenderScale = 'auto' | '1' | '1.5' | '2' | '3';
export type GlobeTexture = 'topographic' | 'blue-marble';
const STORAGE_KEY = 'wm-globe-render-scale';
const EVENT_NAME = 'wm-globe-render-scale-changed';
const TEXTURE_STORAGE_KEY = 'wm-globe-texture';
const TEXTURE_EVENT_NAME = 'wm-globe-texture-changed';
export const GLOBE_RENDER_SCALE_OPTIONS: {
value: GlobeRenderScale;
labelKey: string;
@@ -69,3 +73,35 @@ export function resolvePerformanceProfile(scale: GlobeRenderScale): GlobePerform
disableAtmosphere: isEco,
};
}
export const GLOBE_TEXTURE_OPTIONS: { value: GlobeTexture; label: string }[] = [
{ value: 'topographic', label: 'Topographic' },
{ value: 'blue-marble', label: 'Blue Marble (NASA)' },
];
export const GLOBE_TEXTURE_URLS: Record<GlobeTexture, string> = {
'topographic': '/textures/earth-topo-bathy.jpg',
'blue-marble': '/textures/earth-blue-marble.jpg',
};
export function getGlobeTexture(): GlobeTexture {
try {
const raw = localStorage.getItem(TEXTURE_STORAGE_KEY);
if (raw === 'topographic' || raw === 'blue-marble') return raw;
} catch { /* ignore */ }
return 'topographic';
}
export function setGlobeTexture(texture: GlobeTexture): void {
try { localStorage.setItem(TEXTURE_STORAGE_KEY, texture); } catch { /* ignore */ }
window.dispatchEvent(new CustomEvent(TEXTURE_EVENT_NAME, { detail: { texture } }));
}
export function subscribeGlobeTextureChange(cb: (texture: GlobeTexture) => void): () => void {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as { texture?: GlobeTexture } | undefined;
cb(detail?.texture ?? getGlobeTexture());
};
window.addEventListener(TEXTURE_EVENT_NAME, handler);
return () => window.removeEventListener(TEXTURE_EVENT_NAME, handler);
}