コンテンツにスキップ

Earth Environment Visualizer

このコンテンツはまだ日本語訳がありません。

Interactive web app for visualizing tobari’s Earth environment models. All computation runs in WebAssembly — no server required.

Features:

  • 3D globe with volumetric atmosphere shells and magnetic field lines
  • 2D equirectangular heatmaps (magnetic field, atmospheric density)
  • Altitude profile chart comparing 3 atmosphere models
  • Playback controls with Earth rotation
  • Web Worker for non-blocking computation
tobari/examples/web/src/App.tsx
import { useCallback, useEffect, useRef, useState } from "react";
import { Controls } from "./controls/Controls.js";
import { PlaybackBar } from "./controls/PlaybackBar.js";
import { AtmosphereMap } from "./panels/AtmosphereMap.js";
import { AtmosphereProfile } from "./panels/AtmosphereProfile.js";
import { GlobeView } from "./panels/GlobeView.js";
import { MagneticFieldMap } from "./panels/MagneticFieldMap.js";
import { SpaceWeatherChart } from "./panels/SpaceWeatherChart.js";
import { dateToJd, type ViewerParams } from "./types.js";
import { earthRotationAngle, initArika } from "./wasm/arikaInit.js";
import { initWorker, onSpaceWeatherReady } from "./wasm/workerClient.js";
type TabId =
| "globe-mag"
| "globe-atmo"
| "magnetic-map"
| "atmosphere-profile"
| "atmosphere-map"
| "space-weather";
const BASE_TABS: { id: TabId; label: string }[] = [
{ id: "globe-mag", label: "Globe (Magnetic)" },
{ id: "globe-atmo", label: "Globe (Atmosphere)" },
{ id: "magnetic-map", label: "Magnetic Field Map" },
{ id: "atmosphere-profile", label: "Atmosphere Profile" },
{ id: "atmosphere-map", label: "Atmosphere Map" },
];
const DEFAULT_PARAMS: ViewerParams = {
epochJd: dateToJd(new Date("2025-01-01T00:00:00Z")),
altitudeKm: 400,
f107: 150,
ap: 15,
fieldComponent: "total",
atmoModel: "harris-priester",
magModel: "igrf",
nLat: 90,
spaceWeatherMode: "constant",
};
const styles = {
app: {
display: "flex",
flexDirection: "column" as const,
height: "100vh",
background: "#0a0a0f",
color: "#e0e0e0",
},
header: {
display: "flex",
alignItems: "center",
gap: "24px",
padding: "8px 16px",
background: "#10101a",
borderBottom: "1px solid #2a2a35",
},
title: {
fontSize: "16px",
fontWeight: 700,
color: "#8ab4f8",
letterSpacing: "0.5px",
},
tabs: {
display: "flex",
gap: "2px",
},
tab: (active: boolean) => ({
padding: "6px 14px",
fontSize: "13px",
background: active ? "#2a2a45" : "transparent",
color: active ? "#e0e0e0" : "#888",
border: "none",
borderBottom: active ? "2px solid #6688cc" : "2px solid transparent",
cursor: "pointer",
borderRadius: "4px 4px 0 0",
}),
content: {
flex: 1,
overflow: "hidden",
},
loading: {
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
fontSize: "18px",
color: "#888",
},
};
function controlsTab(tab: TabId): string {
if (tab === "globe-mag") return "magnetic-map";
if (tab === "globe-atmo") return "atmosphere-map";
return tab;
}
export function App() {
const [ready, setReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabId>("globe-mag");
const [params, setParams] = useState<ViewerParams>(DEFAULT_PARAMS);
const [playing, setPlaying] = useState(false);
const [speedDaysPerSec, setSpeedDaysPerSec] = useState(30);
const [showRotation, setShowRotation] = useState(false);
const [globeDisplayMode, setGlobeDisplayMode] = useState<"single" | "volume">("volume");
const [swRange, setSwRange] = useState<{ jdFirst: number; jdLast: number } | null>(null);
const lastFrameRef = useRef<number>(0);
useEffect(() => {
onSpaceWeatherReady((range) => {
setSwRange(range);
});
Promise.all([initArika(), initWorker()])
.then(() => setReady(true))
.catch((e) => setError(String(e)));
}, []);
// Playback loop: advance epoch
useEffect(() => {
if (!playing) return;
let rafId: number;
lastFrameRef.current = performance.now();
const tick = (now: number) => {
const dt = (now - lastFrameRef.current) / 1000;
lastFrameRef.current = now;
setParams((prev) => ({
...prev,
epochJd: prev.epochJd + dt * speedDaysPerSec,
}));
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [playing, speedDaysPerSec]);
const handleParamsChange = useCallback((newParams: ViewerParams) => {
setParams(newParams);
}, []);
if (error) {
return <div style={styles.loading}>Failed to load WASM: {error}</div>;
}
if (!ready) {
return <div style={styles.loading}>Loading tobari WASM...</div>;
}
// Compute Earth rotation via arika WASM (only when enabled)
const rotation = showRotation ? earthRotationAngle(params.epochJd) : 0;
// Show Space Weather tab only when data is available
const tabs = swRange
? [...BASE_TABS, { id: "space-weather" as TabId, label: "Space Weather" }]
: BASE_TABS;
const isGlobe = activeTab === "globe-mag" || activeTab === "globe-atmo";
return (
<div style={styles.app}>
<div style={styles.header}>
<span style={styles.title}>tobari</span>
<div style={styles.tabs}>
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
style={styles.tab(activeTab === tab.id)}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
</div>
<Controls
params={params}
onChange={handleParamsChange}
activeTab={controlsTab(activeTab)}
swAvailable={swRange !== null}
isGlobe={isGlobe}
globeDisplayMode={globeDisplayMode}
onDisplayModeChange={setGlobeDisplayMode}
/>
<PlaybackBar
playing={playing}
onTogglePlay={() => setPlaying((p) => !p)}
speed={speedDaysPerSec}
onSpeedChange={setSpeedDaysPerSec}
epochJd={params.epochJd}
>
{isGlobe && (
<label
style={{
display: "flex",
alignItems: "center",
gap: "4px",
color: "#888",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={showRotation}
onChange={(e) => setShowRotation(e.target.checked)}
/>
Earth rotation
</label>
)}
</PlaybackBar>
<div style={styles.content}>
{activeTab === "globe-mag" && (
<GlobeView
params={params}
layer="magnetic"
earthRotation={rotation}
displayMode={globeDisplayMode}
/>
)}
{activeTab === "globe-atmo" && (
<GlobeView
params={params}
layer="atmosphere"
earthRotation={rotation}
displayMode={globeDisplayMode}
/>
)}
{activeTab === "magnetic-map" && <MagneticFieldMap params={params} />}
{activeTab === "atmosphere-profile" && <AtmosphereProfile params={params} />}
{activeTab === "atmosphere-map" && <AtmosphereMap params={params} />}
{activeTab === "space-weather" && <SpaceWeatherChart epochJd={params.epochJd} />}
</div>
</div>
);
}
tobari/examples/web/src/panels/GlobeView.tsx
import { OrbitControls } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import { useDebouncedValue } from "../hooks/useDebouncedValue.js";
import { overlayFrag, overlayVert } from "../shaders/fieldOverlay.js";
import type { ViewerParams } from "../types.js";
import { earthRotationAngle, isArikaReady } from "../wasm/arikaInit.js";
import {
atmosphereLatlonMapAsync,
atmosphereLatlonMapSwAsync,
atmosphereVolumeAsync,
atmosphereVolumeSwAsync,
magneticFieldLatlonMapAsync,
magneticFieldLinesAsync,
magneticFieldVolumeAsync,
} from "../wasm/workerClient.js";
interface Props {
params: ViewerParams;
layer: "magnetic" | "atmosphere";
}
const EARTH_RADIUS = 1.0; // normalized
const EARTH_RADIUS_KM = 6371.0;
const N_SHELLS = 12;
// GMST is computed by arika WASM via the caller (App) and passed as a prop.
/**
* Rotation to align Three.js SphereGeometry (pole along Y) with ECI (pole along Z).
* +π/2 around X maps: local +Y → local +Z.
* Same as viewer's POLE_ALIGNMENT_ROTATION.
*/
const POLE_ALIGN: [number, number, number] = [Math.PI / 2, 0, 0];
/**
* Rotation to map ECI (Z-up) to Three.js world (Y-up).
* -π/2 around X maps: Z → Y.
* Applied to the outer group containing everything.
*/
const ECI_TO_THREEJS: [number, number, number] = [-Math.PI / 2, 0, 0];
// ---------------------------------------------------------------------------
// Shell mesh: one altitude layer (semi-transparent data overlay)
// ---------------------------------------------------------------------------
function ShellMesh({
dataTexture,
radius,
dataMin,
dataMax,
useLogScale,
opacity,
}: {
dataTexture: THREE.DataTexture;
radius: number;
dataMin: number;
dataMax: number;
useLogScale: boolean;
opacity: number;
}) {
const material = useMemo(
() =>
new THREE.ShaderMaterial({
uniforms: {
dataMap: { value: dataTexture },
dataMin: { value: dataMin },
dataMax: { value: dataMax },
opacity: { value: opacity },
useLogScale: { value: useLogScale },
},
vertexShader: overlayVert,
fragmentShader: overlayFrag,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
blending: THREE.NormalBlending,
}),
[],
);
useEffect(() => {
material.uniforms.dataMap.value = dataTexture;
material.uniforms.dataMin.value = dataMin;
material.uniforms.dataMax.value = dataMax;
material.uniforms.opacity.value = opacity;
material.uniforms.useLogScale.value = useLogScale;
material.needsUpdate = true;
}, [material, dataTexture, dataMin, dataMax, opacity, useLogScale]);
// Pole alignment: SphereGeometry Y-pole → Z-pole (ECI)
return (
<group rotation={POLE_ALIGN}>
<mesh material={material} renderOrder={radius}>
<sphereGeometry args={[radius, 64, 32]} />
</mesh>
</group>
);
}
// ---------------------------------------------------------------------------
// Multi-shell atmosphere
// ---------------------------------------------------------------------------
interface ShellData {
texture: THREE.DataTexture;
min: number;
max: number;
}
function AtmosphereShells({ params }: { params: ViewerParams }) {
const [shells, setShells] = useState<ShellData[]>([]);
// Fix shell ranges across epochs so density bulge movement is visible.
// Only recalculate when model/altitude/space-weather-mode changes.
const shellRangesRef = useRef<{ min: number; max: number }[] | null>(null);
const prevRangeKeyRef = useRef("");
const nLat = Math.min(params.nLat, 45);
const nLon = nLat * 2;
useEffect(() => {
let cancelled = false;
const fetchVol =
params.spaceWeatherMode === "real"
? atmosphereVolumeSwAsync(params.atmoModel, 100, 1000, N_SHELLS, params.epochJd, nLat, nLon)
: atmosphereVolumeAsync(
params.atmoModel,
100,
1000,
N_SHELLS,
params.epochJd,
nLat,
nLon,
params.f107,
params.ap,
);
fetchVol.then((vol) => {
if (cancelled || !vol) return;
const sliceSize = nLat * nLon;
const rangeKey = `${params.atmoModel}:${params.spaceWeatherMode}:${params.f107}:${params.ap}`;
const needNewRanges = !shellRangesRef.current || rangeKey !== prevRangeKeyRef.current;
const newShells: ShellData[] = [];
for (let i = 0; i < N_SHELLS; i++) {
const slice = vol.data.slice(i * sliceSize, (i + 1) * sliceSize);
let sMin: number;
let sMax: number;
if (needNewRanges) {
sMin = Infinity;
sMax = -Infinity;
for (let j = 0; j < slice.length; j++) {
const v = slice[j];
if (v > 0 && v < sMin) sMin = v;
if (v > sMax) sMax = v;
}
} else {
sMin = shellRangesRef.current![i].min;
sMax = shellRangesRef.current![i].max;
}
const tex = new THREE.DataTexture(slice, nLon, nLat, THREE.RedFormat, THREE.FloatType);
tex.needsUpdate = true;
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
newShells.push({ texture: tex, min: sMin, max: sMax });
}
if (needNewRanges) {
shellRangesRef.current = newShells.map((s) => ({ min: s.min, max: s.max }));
prevRangeKeyRef.current = rangeKey;
}
setShells((prev) => {
for (const s of prev) s.texture.dispose();
return newShells;
});
});
return () => {
cancelled = true;
};
}, [
params.atmoModel,
params.epochJd,
params.f107,
params.ap,
params.spaceWeatherMode,
nLat,
nLon,
]);
if (shells.length === 0) return null;
return (
<>
{shells.map((shell, i) => {
const alt = 100 + (900 * i) / (N_SHELLS - 1);
const radius = EARTH_RADIUS * (1 + alt / EARTH_RADIUS_KM);
const opacity = 0.06 + i * 0.01;
return (
<ShellMesh
// biome-ignore lint/suspicious/noArrayIndexKey: shells have no stable ID
key={i}
dataTexture={shell.texture}
radius={radius}
dataMin={shell.min}
dataMax={shell.max}
useLogScale={true}
opacity={Math.max(0.02, opacity)}
/>
);
})}
</>
);
}
// ---------------------------------------------------------------------------
// Single shell (one altitude, for both atmosphere and magnetic)
// ---------------------------------------------------------------------------
function SingleShell({
params,
layer,
}: {
params: ViewerParams;
layer: "atmosphere" | "magnetic";
}) {
const [dataTexture, setDataTexture] = useState<THREE.DataTexture | null>(null);
const [dataRange, setDataRange] = useState({ min: 0, max: 1 });
const rangeRef = useRef<{ min: number; max: number } | null>(null);
const prevKeyRef = useRef("");
// Debounce epoch for magnetic layer (IGRF barely changes with epoch)
const debouncedEpoch = useDebouncedValue(params.epochJd, layer === "magnetic" ? 1000 : 0);
const effectEpoch = layer === "magnetic" ? debouncedEpoch : params.epochJd;
useEffect(() => {
let cancelled = false;
const nLat = params.nLat;
const nLon = nLat * 2;
const fetchData =
layer === "atmosphere"
? params.spaceWeatherMode === "real"
? atmosphereLatlonMapSwAsync(params.atmoModel, params.altitudeKm, effectEpoch, nLat, nLon)
: atmosphereLatlonMapAsync(
params.atmoModel,
params.altitudeKm,
effectEpoch,
nLat,
nLon,
params.f107,
params.ap,
)
: magneticFieldLatlonMapAsync(
params.magModel,
params.fieldComponent,
params.altitudeKm,
effectEpoch,
nLat,
nLon,
);
fetchData.then((data) => {
if (cancelled || !data) return;
const floats = new Float32Array(data.length);
for (let i = 0; i < data.length; i++) {
floats[i] = data[i];
}
// Fixed range per model/component, not per epoch
const key =
layer === "atmosphere"
? `atmo:${params.atmoModel}:${params.altitudeKm}:${params.spaceWeatherMode}:${params.f107}:${params.ap}:${nLat}`
: `mag:${params.magModel}:${params.fieldComponent}:${params.altitudeKm}:${nLat}`;
if (!rangeRef.current || key !== prevKeyRef.current) {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (let i = 0; i < data.length; i++) {
if (Number.isFinite(data[i])) {
if (data[i] < min) min = data[i];
if (data[i] > max) max = data[i];
}
}
rangeRef.current = { min, max };
prevKeyRef.current = key;
}
setDataRange(rangeRef.current);
const tex = new THREE.DataTexture(floats, nLon, nLat, THREE.RedFormat, THREE.FloatType);
tex.needsUpdate = true;
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
setDataTexture((prev) => {
prev?.dispose();
return tex;
});
});
return () => {
cancelled = true;
};
}, [
layer,
params.atmoModel,
params.magModel,
params.fieldComponent,
params.altitudeKm,
params.nLat,
params.f107,
params.ap,
params.spaceWeatherMode,
effectEpoch,
]);
// Cleanup on unmount via ref (setState during unmount is unreliable)
const texRef = useRef<THREE.DataTexture | null>(null);
useEffect(() => {
texRef.current = dataTexture;
}, [dataTexture]);
useEffect(() => {
return () => {
texRef.current?.dispose();
};
}, []);
if (!dataTexture) return null;
const radius = EARTH_RADIUS * (1 + params.altitudeKm / EARTH_RADIUS_KM);
return (
<ShellMesh
dataTexture={dataTexture}
radius={radius}
dataMin={dataRange.min}
dataMax={dataRange.max}
useLogScale={layer === "atmosphere"}
opacity={0.7}
/>
);
}
// ---------------------------------------------------------------------------
// Magnetic volume shells
// ---------------------------------------------------------------------------
function MagneticShells({ params }: { params: ViewerParams }) {
const [shells, setShells] = useState<ShellData[]>([]);
const shellRangesRef = useRef<{ min: number; max: number }[] | null>(null);
const prevRangeKeyRef = useRef("");
const nLat = Math.min(params.nLat, 45);
const nLon = nLat * 2;
const debouncedEpoch = useDebouncedValue(params.epochJd, 1000);
useEffect(() => {
let cancelled = false;
magneticFieldVolumeAsync(
params.magModel,
params.fieldComponent,
100,
1000,
N_SHELLS,
debouncedEpoch,
nLat,
nLon,
).then((vol) => {
if (cancelled || !vol) return;
const sliceSize = nLat * nLon;
const rangeKey = `${params.magModel}:${params.fieldComponent}`;
const needNewRanges = !shellRangesRef.current || rangeKey !== prevRangeKeyRef.current;
const newShells: ShellData[] = [];
for (let i = 0; i < N_SHELLS; i++) {
const slice = vol.data.slice(i * sliceSize, (i + 1) * sliceSize);
let sMin: number;
let sMax: number;
if (needNewRanges) {
sMin = Infinity;
sMax = -Infinity;
for (let j = 0; j < slice.length; j++) {
const v = slice[j];
if (Number.isFinite(v)) {
if (v < sMin) sMin = v;
if (v > sMax) sMax = v;
}
}
} else {
sMin = shellRangesRef.current![i].min;
sMax = shellRangesRef.current![i].max;
}
const tex = new THREE.DataTexture(slice, nLon, nLat, THREE.RedFormat, THREE.FloatType);
tex.needsUpdate = true;
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
newShells.push({ texture: tex, min: sMin, max: sMax });
}
if (needNewRanges) {
shellRangesRef.current = newShells.map((s) => ({ min: s.min, max: s.max }));
prevRangeKeyRef.current = rangeKey;
}
setShells((prev) => {
for (const s of prev) s.texture.dispose();
return newShells;
});
});
return () => {
cancelled = true;
};
}, [params.magModel, params.fieldComponent, debouncedEpoch, nLat, nLon]);
// Cleanup on unmount via ref
const shellsRef = useRef<ShellData[]>([]);
useEffect(() => {
shellsRef.current = shells;
}, [shells]);
useEffect(() => {
return () => {
for (const s of shellsRef.current) s.texture.dispose();
};
}, []);
if (shells.length === 0) return null;
return (
<>
{shells.map((shell, i) => {
const alt = 100 + (900 * i) / (N_SHELLS - 1);
const radius = EARTH_RADIUS * (1 + alt / EARTH_RADIUS_KM);
const opacity = 0.06 + i * 0.01;
return (
<ShellMesh
// biome-ignore lint/suspicious/noArrayIndexKey: shells have no stable ID
key={i}
dataTexture={shell.texture}
radius={radius}
dataMin={shell.min}
dataMax={shell.max}
useLogScale={false}
opacity={Math.max(0.02, opacity)}
/>
);
})}
</>
);
}
// ---------------------------------------------------------------------------
// Field lines (already in ECI coordinates — no pole alignment needed)
// ---------------------------------------------------------------------------
function FieldLines({
params,
earthRotation = 0,
seedAltitude,
}: {
params: ViewerParams;
earthRotation?: number;
seedAltitude?: number;
}) {
const [lines, setLines] = useState<{ vertices: Float32Array; nPoints: number }[]>([]);
// GMST at the time field lines were computed (ECI reference frame)
const [computedGmst, setComputedGmst] = useState(0);
const debouncedEpoch = useDebouncedValue(params.epochJd, 1000);
useEffect(() => {
let cancelled = false;
const seedLats: number[] = [];
const seedLons: number[] = [];
for (let lat = -75; lat <= 75; lat += 15) {
for (let lon = -180; lon < 180; lon += 30) {
seedLats.push(lat);
seedLons.push(lon);
}
}
const alt = seedAltitude ?? params.altitudeKm;
magneticFieldLinesAsync(
new Float64Array(seedLats),
new Float64Array(seedLons),
alt,
debouncedEpoch,
params.magModel,
500,
50,
).then((raw) => {
if (cancelled || !raw) return;
const nLines = raw[0];
const parsed: { vertices: Float32Array; nPoints: number }[] = [];
let offset = 1;
for (let i = 0; i < nLines; i++) {
const nPts = raw[offset];
offset++;
const verts = raw.slice(offset, offset + nPts * 3);
offset += nPts * 3;
parsed.push({ vertices: verts, nPoints: nPts });
}
setLines(parsed);
if (isArikaReady()) {
setComputedGmst(earthRotationAngle(debouncedEpoch));
}
});
return () => {
cancelled = true;
};
}, [params.magModel, seedAltitude, params.altitudeKm, debouncedEpoch]);
const lineObjects = useMemo(() => {
return lines
.filter((line) => line.nPoints >= 2)
.map((line) => {
const points: THREE.Vector3[] = [];
for (let j = 0; j < line.nPoints; j++) {
// ECI coordinates (Z-up), already in Earth radii
points.push(
new THREE.Vector3(
line.vertices[j * 3],
line.vertices[j * 3 + 1],
line.vertices[j * 3 + 2],
),
);
}
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: 0x66aaff,
transparent: true,
opacity: 0.4,
});
return new THREE.Line(geometry, material);
});
}, [lines]);
// Differential rotation: compensate ECI→ECEF at computation time,
// then apply current Earth rotation. This makes field lines rotate with Earth.
const deltaRotation = earthRotation - computedGmst;
return (
<group rotation={[0, 0, deltaRotation]}>
{lineObjects.map((obj, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: field lines have no stable ID
<primitive key={i} object={obj} />
))}
</group>
);
}
// ---------------------------------------------------------------------------
// Textured Earth sphere
// ---------------------------------------------------------------------------
function EarthSphere() {
const [texture, setTexture] = useState<THREE.Texture | null>(null);
useEffect(() => {
const loader = new THREE.TextureLoader();
loader.load(`${import.meta.env.BASE_URL}textures/earth_2k.jpg`, (tex) => {
tex.colorSpace = THREE.SRGBColorSpace;
setTexture(tex);
});
}, []);
return (
<group rotation={POLE_ALIGN}>
<mesh renderOrder={0}>
<sphereGeometry args={[EARTH_RADIUS, 64, 32]} />
{texture ? (
<meshStandardMaterial map={texture} roughness={0.9} metalness={0} />
) : (
<meshPhongMaterial color={0x2244aa} emissive={0x112244} shininess={25} />
)}
</mesh>
</group>
);
}
// ---------------------------------------------------------------------------
// Main GlobeView
// ---------------------------------------------------------------------------
export function GlobeView({
params,
layer,
earthRotation = 0,
displayMode = "volume",
}: Props & { earthRotation?: number; displayMode?: "single" | "volume" }) {
return (
<div style={{ width: "100%", height: "100%" }}>
<Canvas camera={{ position: [3, 1.5, 2.5], fov: 45 }} gl={{ alpha: false }}>
<color attach="background" args={["#060610"]} />
<ambientLight intensity={0.4} />
<directionalLight position={[5, 3, 5]} intensity={0.8} />
{/* ECI (Z-up) → Three.js (Y-up) */}
<group rotation={ECI_TO_THREEJS}>
{/* Earth-fixed elements rotate together (ECEF frame) */}
<group rotation={[0, 0, earthRotation]}>
<EarthSphere />
{layer === "atmosphere" && displayMode === "volume" && (
<AtmosphereShells params={params} />
)}
{layer === "atmosphere" && displayMode === "single" && (
<SingleShell params={params} layer="atmosphere" />
)}
{layer === "magnetic" && displayMode === "volume" && <MagneticShells params={params} />}
{layer === "magnetic" && displayMode === "single" && (
<SingleShell params={params} layer="magnetic" />
)}
</group>
{/* Field lines in ECI with differential rotation to follow Earth */}
{layer === "magnetic" && (
<FieldLines
params={params}
earthRotation={earthRotation}
seedAltitude={displayMode === "volume" ? 400 : params.altitudeKm}
/>
)}
</group>
<OrbitControls enableDamping dampingFactor={0.1} />
</Canvas>
</div>
);
}