mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
BIN
public/textures/earth-blue-marble.jpg
Normal file
BIN
public/textures/earth-blue-marble.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user