Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'

This commit is contained in:
ruv
2026-02-28 14:39:40 -05:00
7854 changed files with 3522914 additions and 0 deletions

View File

@@ -0,0 +1,199 @@
import * as THREE from 'three';
export interface GraphNode {
id: string;
domain: string;
x: number;
y: number;
z: number;
weight: number;
}
export interface GraphEdge {
source: string;
target: string;
weight: number;
}
const DOMAIN_COLORS: Record<string, THREE.Color> = {
transit: new THREE.Color(0x00E5FF),
flare: new THREE.Color(0xFF4D4D),
rotation: new THREE.Color(0x2ECC71),
eclipse: new THREE.Color(0x9944ff),
variability: new THREE.Color(0xFFB020),
};
const DEFAULT_COLOR = new THREE.Color(0x8B949E);
function colorForDomain(domain: string): THREE.Color {
return DOMAIN_COLORS[domain] ?? DEFAULT_COLOR;
}
export class AtlasGraph {
private nodesMesh: THREE.InstancedMesh | null = null;
private edgesLine: THREE.LineSegments | null = null;
private glowPoints: THREE.Points | null = null;
private scene: THREE.Scene;
private nodeMap: Map<string, number> = new Map();
constructor(scene: THREE.Scene) {
this.scene = scene;
}
setNodes(nodes: GraphNode[]): void {
this.disposeNodes();
// Star-like nodes using InstancedMesh with emissive material
const geometry = new THREE.SphereGeometry(0.12, 8, 6);
const material = new THREE.MeshStandardMaterial({
vertexColors: false,
emissiveIntensity: 0.8,
roughness: 0.3,
metalness: 0.1,
});
const mesh = new THREE.InstancedMesh(geometry, material, nodes.length);
const dummy = new THREE.Object3D();
const color = new THREE.Color();
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
this.nodeMap.set(node.id, i);
dummy.position.set(node.x, node.y, node.z);
const scale = 0.3 + node.weight * 0.7;
dummy.scale.set(scale, scale, scale);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
color.copy(colorForDomain(node.domain));
mesh.setColorAt(i, color);
}
mesh.instanceMatrix.needsUpdate = true;
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
this.nodesMesh = mesh;
this.scene.add(mesh);
// Additive glow halo points around each node
const glowPositions = new Float32Array(nodes.length * 3);
const glowColors = new Float32Array(nodes.length * 3);
const glowSizes = new Float32Array(nodes.length);
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
glowPositions[i * 3] = node.x;
glowPositions[i * 3 + 1] = node.y;
glowPositions[i * 3 + 2] = node.z;
const c = colorForDomain(node.domain);
glowColors[i * 3] = c.r;
glowColors[i * 3 + 1] = c.g;
glowColors[i * 3 + 2] = c.b;
glowSizes[i] = 0.8 + node.weight * 1.5;
}
const glowGeo = new THREE.BufferGeometry();
glowGeo.setAttribute('position', new THREE.Float32BufferAttribute(glowPositions, 3));
glowGeo.setAttribute('color', new THREE.Float32BufferAttribute(glowColors, 3));
const glowMat = new THREE.PointsMaterial({
size: 1.2,
vertexColors: true,
transparent: true,
opacity: 0.25,
sizeAttenuation: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
this.glowPoints = new THREE.Points(glowGeo, glowMat);
this.scene.add(this.glowPoints);
}
setEdges(edges: GraphEdge[], nodes: GraphNode[]): void {
this.disposeEdges();
const positions: number[] = [];
const colors: number[] = [];
const nodeById = new Map<string, GraphNode>();
for (const n of nodes) nodeById.set(n.id, n);
for (const edge of edges) {
const src = nodeById.get(edge.source);
const tgt = nodeById.get(edge.target);
if (!src || !tgt) continue;
positions.push(src.x, src.y, src.z);
positions.push(tgt.x, tgt.y, tgt.z);
// Cyan glow edges with weight-based opacity
const alpha = Math.max(0.05, Math.min(0.6, edge.weight * 0.5));
colors.push(0.0, 0.9, 1.0, alpha);
colors.push(0.0, 0.9, 1.0, alpha);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 4));
const material = new THREE.LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: 0.6,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
this.edgesLine = new THREE.LineSegments(geometry, material);
this.scene.add(this.edgesLine);
}
getNodeIndex(id: string): number | undefined {
return this.nodeMap.get(id);
}
/** Animate node glow pulse (0-1 range). */
setPulse(intensity: number): void {
if (this.glowPoints) {
(this.glowPoints.material as THREE.PointsMaterial).opacity = 0.15 + intensity * 0.15;
}
if (this.nodesMesh) {
const mat = this.nodesMesh.material as THREE.MeshStandardMaterial;
mat.emissiveIntensity = 0.5 + intensity * 0.5;
}
}
private disposeNodes(): void {
if (this.nodesMesh) {
this.scene.remove(this.nodesMesh);
this.nodesMesh.geometry.dispose();
(this.nodesMesh.material as THREE.Material).dispose();
this.nodesMesh = null;
}
if (this.glowPoints) {
this.scene.remove(this.glowPoints);
this.glowPoints.geometry.dispose();
(this.glowPoints.material as THREE.Material).dispose();
this.glowPoints = null;
}
this.nodeMap.clear();
}
private disposeEdges(): void {
if (this.edgesLine) {
this.scene.remove(this.edgesLine);
this.edgesLine.geometry.dispose();
(this.edgesLine.material as THREE.Material).dispose();
this.edgesLine = null;
}
}
dispose(): void {
this.disposeNodes();
this.disposeEdges();
}
}

View File

@@ -0,0 +1,248 @@
import * as THREE from 'three';
export class CoherenceSurface {
private mesh: THREE.Mesh | null = null;
private wireframe: THREE.LineSegments | null = null;
private contourLines: THREE.Group | null = null;
private gridLabels: THREE.Group | null = null;
private scene: THREE.Scene;
private gridWidth: number;
private gridHeight: number;
constructor(scene: THREE.Scene, gridWidth = 64, gridHeight = 64) {
this.scene = scene;
this.gridWidth = gridWidth;
this.gridHeight = gridHeight;
this.createMesh();
this.createGridLabels();
}
private createMesh(): void {
const geometry = new THREE.PlaneGeometry(
10, 10,
this.gridWidth - 1,
this.gridHeight - 1,
);
const vertexCount = geometry.attributes.position.count;
const colors = new Float32Array(vertexCount * 3);
for (let i = 0; i < vertexCount; i++) {
colors[i * 3] = 0.0;
colors[i * 3 + 1] = 0.3;
colors[i * 3 + 2] = 0.5;
}
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const material = new THREE.MeshPhongMaterial({
vertexColors: true,
side: THREE.DoubleSide,
shininess: 40,
specular: new THREE.Color(0x112233),
flatShading: false,
});
this.mesh = new THREE.Mesh(geometry, material);
this.mesh.rotation.x = -Math.PI / 2;
this.scene.add(this.mesh);
// Subtle grid overlay
const wireGeo = new THREE.WireframeGeometry(geometry);
const wireMat = new THREE.LineBasicMaterial({
color: 0x1C2333,
transparent: true,
opacity: 0.12,
});
this.wireframe = new THREE.LineSegments(wireGeo, wireMat);
this.wireframe.rotation.x = -Math.PI / 2;
this.scene.add(this.wireframe);
}
private createGridLabels(): void {
this.gridLabels = new THREE.Group();
// Base grid plane at y=0 with faint lines
const gridHelper = new THREE.GridHelper(10, 8, 0x1C2333, 0x131A22);
gridHelper.position.y = -0.01;
this.gridLabels.add(gridHelper);
// Axis lines
const axisMat = new THREE.LineBasicMaterial({ color: 0x2A3444, transparent: true, opacity: 0.5 });
const xAxisGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-5.5, 0, 5.5),
new THREE.Vector3(5.5, 0, 5.5),
]);
this.gridLabels.add(new THREE.Line(xAxisGeo, axisMat));
const zAxisGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-5.5, 0, 5.5),
new THREE.Vector3(-5.5, 0, -5.5),
]);
this.gridLabels.add(new THREE.Line(zAxisGeo, axisMat));
this.scene.add(this.gridLabels);
}
/** Map coherence value [0,1] to a clear multi-stop color ramp. */
private valueToColor(v: number, color: THREE.Color): void {
// 1.0 = deep stable blue, 0.85 = cyan, 0.75 = yellow warning, <0.7 = red critical
if (v > 0.85) {
// Blue -> Cyan (stable zone)
const t = (v - 0.85) / 0.15;
color.setRGB(0.0, 0.4 + t * 0.1, 0.6 + t * 0.4);
} else if (v > 0.75) {
// Cyan -> Yellow (transition)
const t = (v - 0.75) / 0.1;
color.setRGB(1.0 - t * 1.0, 0.7 + t * 0.2, t * 0.6);
} else if (v > 0.65) {
// Yellow -> Orange (warning)
const t = (v - 0.65) / 0.1;
color.setRGB(1.0, 0.5 + t * 0.2, t * 0.1);
} else {
// Orange -> Red (critical)
const t = Math.max(0, v / 0.65);
color.setRGB(0.9 + t * 0.1, 0.15 + t * 0.35, 0.1);
}
}
setValues(values: number[]): void {
if (!this.mesh) return;
const geometry = this.mesh.geometry;
const colorAttr = geometry.attributes.color;
const posAttr = geometry.attributes.position;
const count = Math.min(values.length, colorAttr.count);
const color = new THREE.Color();
for (let i = 0; i < count; i++) {
const v = Math.max(0, Math.min(1, values[i]));
this.valueToColor(v, color);
colorAttr.setXYZ(i, color.r, color.g, color.b);
// Elevation: higher coherence = flat, lower = raised (shows "pressure")
const elevation = (1 - v) * 2.5;
posAttr.setZ(i, elevation);
}
colorAttr.needsUpdate = true;
posAttr.needsUpdate = true;
geometry.computeVertexNormals();
this.updateWireframe(geometry);
this.updateContours(values);
}
private updateWireframe(geometry: THREE.PlaneGeometry): void {
if (this.wireframe) {
this.scene.remove(this.wireframe);
this.wireframe.geometry.dispose();
(this.wireframe.material as THREE.Material).dispose();
}
const wireGeo = new THREE.WireframeGeometry(geometry);
const wireMat = new THREE.LineBasicMaterial({
color: 0x1C2333,
transparent: true,
opacity: 0.12,
});
this.wireframe = new THREE.LineSegments(wireGeo, wireMat);
this.wireframe.rotation.x = -Math.PI / 2;
this.scene.add(this.wireframe);
}
/** Draw contour rings at threshold boundaries (0.8 warning, 0.7 critical). */
private updateContours(values: number[]): void {
if (this.contourLines) {
this.scene.remove(this.contourLines);
this.contourLines.traverse((obj) => {
if (obj instanceof THREE.Line) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
}
this.contourLines = new THREE.Group();
const gw = this.gridWidth;
const gh = this.gridHeight;
const halfW = 5;
const thresholds = [
{ level: 0.80, color: 0xFFB020, opacity: 0.6 }, // warning
{ level: 0.70, color: 0xFF4D4D, opacity: 0.7 }, // critical
];
for (const thresh of thresholds) {
const points: THREE.Vector3[] = [];
for (let y = 0; y < gh - 1; y++) {
for (let x = 0; x < gw - 1; x++) {
const v00 = values[y * gw + x] ?? 1;
const v10 = values[y * gw + x + 1] ?? 1;
const v01 = values[(y + 1) * gw + x] ?? 1;
// Horizontal edge crossing
if ((v00 - thresh.level) * (v10 - thresh.level) < 0) {
const t = (thresh.level - v00) / (v10 - v00);
const wx = -halfW + ((x + t) / (gw - 1)) * halfW * 2;
const wz = -halfW + (y / (gh - 1)) * halfW * 2;
const elev = (1 - thresh.level) * 2.5;
points.push(new THREE.Vector3(wx, elev + 0.02, wz));
}
// Vertical edge crossing
if ((v00 - thresh.level) * (v01 - thresh.level) < 0) {
const t = (thresh.level - v00) / (v01 - v00);
const wx = -halfW + (x / (gw - 1)) * halfW * 2;
const wz = -halfW + ((y + t) / (gh - 1)) * halfW * 2;
const elev = (1 - thresh.level) * 2.5;
points.push(new THREE.Vector3(wx, elev + 0.02, wz));
}
}
}
if (points.length > 1) {
const geo = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.PointsMaterial({
color: thresh.color,
size: 0.08,
transparent: true,
opacity: thresh.opacity,
depthWrite: false,
});
this.contourLines.add(new THREE.Points(geo, mat));
}
}
this.scene.add(this.contourLines);
}
dispose(): void {
if (this.mesh) {
this.scene.remove(this.mesh);
this.mesh.geometry.dispose();
(this.mesh.material as THREE.Material).dispose();
this.mesh = null;
}
if (this.wireframe) {
this.scene.remove(this.wireframe);
this.wireframe.geometry.dispose();
(this.wireframe.material as THREE.Material).dispose();
this.wireframe = null;
}
if (this.contourLines) {
this.scene.remove(this.contourLines);
this.contourLines.traverse((obj) => {
if (obj instanceof THREE.Line || obj instanceof THREE.Points) {
obj.geometry.dispose();
(obj.material as THREE.Material).dispose();
}
});
this.contourLines = null;
}
if (this.gridLabels) {
this.scene.remove(this.gridLabels);
this.gridLabels = null;
}
}
}

View File

@@ -0,0 +1,390 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
/**
* Interactive 3D Dyson sphere visualization with galactic context.
*
* Renders:
* - Deep-field starfield + galactic plane
* - Central star (emissive sphere, color from spectral type)
* - Partial Dyson swarm shell (coverage_fraction controls opacity mask)
* - IR waste heat glow halo
* - Orbiting collector panels as instanced quads
*
* Interaction:
* - OrbitControls: drag to rotate, scroll to zoom, right-drag to pan
* - Speed control via setSpeed()
* - Reset view via resetCamera()
*/
export interface DysonParams {
coverageFraction: number;
warmTempK: number;
spectralType: string;
w3Excess: number;
w4Excess: number;
label: string;
}
const SPECTRAL_COLORS: Record<string, number> = {
O: 0x9bb0ff, B: 0xaabfff, A: 0xcad7ff, F: 0xf8f7ff,
G: 0xfff4ea, K: 0xffd2a1, M: 0xffb56c, L: 0xff8833,
};
function starColor(spectralType: string): number {
const letter = spectralType.charAt(0).toUpperCase();
return SPECTRAL_COLORS[letter] ?? 0xffd2a1;
}
function warmColor(tempK: number): THREE.Color {
const t = Math.max(0, Math.min(1, (tempK - 100) / 400));
return new THREE.Color().setHSL(0.02 + t * 0.06, 0.9, 0.3 + t * 0.2);
}
function seededRandom(seed: number): () => number {
let s = seed;
return () => {
s = (s * 16807 + 0) % 2147483647;
return (s - 1) / 2147483646;
};
}
export class DysonSphere3D {
private scene: THREE.Scene;
private camera: THREE.PerspectiveCamera;
private renderer: THREE.WebGLRenderer;
private controls: OrbitControls;
private starMesh: THREE.Mesh | null = null;
private shellMesh: THREE.Mesh | null = null;
private glowMesh: THREE.Mesh | null = null;
private panelInstances: THREE.InstancedMesh | null = null;
private animId = 0;
private time = 0;
private speedMultiplier = 1;
private autoRotate = true;
private defaultCamPos = new THREE.Vector3(0, 1.5, 4);
private bgGroup: THREE.Group | null = null;
constructor(private container: HTMLElement) {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x020408);
const w = container.clientWidth || 400;
const h = container.clientHeight || 300;
this.camera = new THREE.PerspectiveCamera(50, w / h, 0.01, 2000);
this.camera.position.set(0, 1.5, 4);
this.camera.lookAt(0, 0, 0);
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setSize(w, h);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(this.renderer.domElement);
// OrbitControls
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.08;
this.controls.minDistance = 1;
this.controls.maxDistance = 400;
this.controls.enablePan = true;
this.controls.zoomSpeed = 1.2;
this.controls.rotateSpeed = 0.8;
this.controls.addEventListener('start', () => { this.autoRotate = false; });
this.scene.add(new THREE.AmbientLight(0x222244, 0.3));
this.buildBackground();
}
// ── Public controls ──
setSpeed(multiplier: number): void {
this.speedMultiplier = multiplier;
}
resetCamera(): void {
this.autoRotate = true;
this.camera.position.copy(this.defaultCamPos);
this.camera.lookAt(0, 0, 0);
this.controls.target.set(0, 0, 0);
this.controls.update();
}
toggleAutoRotate(): void {
this.autoRotate = !this.autoRotate;
}
getAutoRotate(): boolean {
return this.autoRotate;
}
// ── Background ──
private buildBackground(): void {
this.bgGroup = new THREE.Group();
const rand = seededRandom(77);
// Starfield
const starCount = 4000;
const positions = new Float32Array(starCount * 3);
const colors = new Float32Array(starCount * 3);
const tints = [
new THREE.Color(0xffffff), new THREE.Color(0xaaccff),
new THREE.Color(0xfff4ea), new THREE.Color(0xffd2a1),
new THREE.Color(0xffb56c), new THREE.Color(0xccddff),
];
for (let i = 0; i < starCount; i++) {
const theta = rand() * Math.PI * 2;
const phi = Math.acos(2 * rand() - 1);
const r = 300 + rand() * 500;
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
positions[i * 3 + 2] = r * Math.cos(phi);
const tint = tints[Math.floor(rand() * tints.length)];
const b = 0.4 + rand() * 0.6;
colors[i * 3] = tint.r * b;
colors[i * 3 + 1] = tint.g * b;
colors[i * 3 + 2] = tint.b * b;
}
const sGeo = new THREE.BufferGeometry();
sGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
sGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
this.bgGroup.add(new THREE.Points(sGeo, new THREE.PointsMaterial({
size: 1.5, vertexColors: true, transparent: true, opacity: 0.9,
sizeAttenuation: true, depthWrite: false,
})));
// Galactic plane
const galCount = 5000;
const gp = new Float32Array(galCount * 3);
const gc = new Float32Array(galCount * 3);
for (let i = 0; i < galCount; i++) {
const a = rand() * Math.PI * 2;
const d = Math.pow(rand(), 0.5) * 500;
const h = (rand() - 0.5) * (12 + d * 0.02);
gp[i * 3] = d * Math.cos(a);
gp[i * 3 + 1] = h;
gp[i * 3 + 2] = d * Math.sin(a);
const cp = 1 - Math.min(1, d / 500);
gc[i * 3] = 0.5 + cp * 0.35;
gc[i * 3 + 1] = 0.5 + cp * 0.25;
gc[i * 3 + 2] = 0.6 + rand() * 0.1;
}
const gGeo = new THREE.BufferGeometry();
gGeo.setAttribute('position', new THREE.BufferAttribute(gp, 3));
gGeo.setAttribute('color', new THREE.BufferAttribute(gc, 3));
const gal = new THREE.Points(gGeo, new THREE.PointsMaterial({
size: 0.8, vertexColors: true, transparent: true, opacity: 0.2,
sizeAttenuation: true, depthWrite: false,
}));
gal.rotation.x = Math.PI * 0.35;
gal.rotation.z = Math.PI * 0.15;
gal.position.set(0, 80, -180);
this.bgGroup.add(gal);
// Nebulae
const nebColors = [0x3344aa, 0xaa3355, 0x2288aa, 0x8844aa];
for (let i = 0; i < 6; i++) {
const canvas = document.createElement('canvas');
canvas.width = 128; canvas.height = 128;
const ctx = canvas.getContext('2d')!;
const grad = ctx.createRadialGradient(64, 64, 4, 64, 64, 64);
const col = new THREE.Color(nebColors[i % nebColors.length]);
grad.addColorStop(0, `rgba(${Math.floor(col.r * 255)},${Math.floor(col.g * 255)},${Math.floor(col.b * 255)},0.25)`);
grad.addColorStop(0.4, `rgba(${Math.floor(col.r * 255)},${Math.floor(col.g * 255)},${Math.floor(col.b * 255)},0.06)`);
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 128, 128);
const tex = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false });
const sp = new THREE.Sprite(mat);
const t2 = rand() * Math.PI * 2;
const p2 = (rand() - 0.5) * Math.PI * 0.5;
const r2 = 200 + rand() * 350;
sp.position.set(r2 * Math.cos(p2) * Math.cos(t2), r2 * Math.sin(p2), r2 * Math.cos(p2) * Math.sin(t2));
sp.scale.setScalar(50 + rand() * 100);
this.bgGroup.add(sp);
}
this.scene.add(this.bgGroup);
}
update(params: DysonParams): void {
this.clearSystem();
const sc = starColor(params.spectralType);
// ── Central Star ──
const starGeo = new THREE.SphereGeometry(0.5, 32, 32);
const starMat = new THREE.MeshBasicMaterial({ color: sc });
this.starMesh = new THREE.Mesh(starGeo, starMat);
this.scene.add(this.starMesh);
const starLight = new THREE.PointLight(sc, 2, 20);
starLight.position.set(0, 0, 0);
this.scene.add(starLight);
// ── Dyson Shell ──
const shellRadius = 1.5;
const shellGeo = new THREE.SphereGeometry(shellRadius, 64, 64);
const wc = warmColor(params.warmTempK);
const positions = shellGeo.attributes.position;
const vertColors = new Float32Array(positions.count * 4);
const coverage = params.coverageFraction;
for (let i = 0; i < positions.count; i++) {
const x = positions.getX(i);
const y = positions.getY(i);
const z = positions.getZ(i);
const theta = Math.atan2(Math.sqrt(x * x + z * z), y);
const phi = Math.atan2(z, x);
const pattern =
0.5 + 0.2 * Math.sin(theta * 5 + phi * 3) +
0.15 * Math.sin(theta * 8 - phi * 5) +
0.15 * Math.cos(phi * 7 + theta * 2);
const visible = pattern < coverage;
const alpha = visible ? 0.6 + coverage * 0.3 : 0.02;
vertColors[i * 4] = visible ? wc.r : 0.05;
vertColors[i * 4 + 1] = visible ? wc.g : 0.05;
vertColors[i * 4 + 2] = visible ? wc.b : 0.05;
vertColors[i * 4 + 3] = alpha;
}
shellGeo.setAttribute('color', new THREE.BufferAttribute(vertColors, 4));
const shellMat = new THREE.MeshBasicMaterial({
vertexColors: true, transparent: true, opacity: 0.7,
side: THREE.DoubleSide, depthWrite: false,
});
this.shellMesh = new THREE.Mesh(shellGeo, shellMat);
this.scene.add(this.shellMesh);
// Wireframe overlay
const wireGeo = new THREE.SphereGeometry(shellRadius + 0.01, 24, 24);
const wireMat = new THREE.MeshBasicMaterial({ color: wc, transparent: true, opacity: 0.08, wireframe: true });
this.scene.add(new THREE.Mesh(wireGeo, wireMat));
// ── IR Glow ──
const glowRadius = shellRadius + 0.3 + params.w4Excess * 0.1;
const glowGeo = new THREE.SphereGeometry(glowRadius, 32, 32);
const glowMat = new THREE.MeshBasicMaterial({
color: wc, transparent: true, opacity: 0.04 + coverage * 0.06,
side: THREE.BackSide, depthWrite: false,
});
this.glowMesh = new THREE.Mesh(glowGeo, glowMat);
this.scene.add(this.glowMesh);
// ── Collector Panels ──
const panelCount = Math.floor(coverage * 400);
if (panelCount > 0) {
const panelGeo = new THREE.PlaneGeometry(0.06, 0.06);
const panelMat = new THREE.MeshBasicMaterial({ color: wc, transparent: true, opacity: 0.9, side: THREE.DoubleSide });
this.panelInstances = new THREE.InstancedMesh(panelGeo, panelMat, panelCount);
const dummy = new THREE.Object3D();
for (let i = 0; i < panelCount; i++) {
const t = i / panelCount;
const incl = Math.acos(1 - 2 * t);
const azim = Math.PI * (1 + Math.sqrt(5)) * i;
const r = shellRadius + 0.02 + Math.random() * 0.05;
dummy.position.set(
r * Math.sin(incl) * Math.cos(azim),
r * Math.cos(incl),
r * Math.sin(incl) * Math.sin(azim),
);
dummy.lookAt(0, 0, 0);
dummy.updateMatrix();
this.panelInstances.setMatrixAt(i, dummy.matrix);
}
this.panelInstances.instanceMatrix.needsUpdate = true;
this.scene.add(this.panelInstances);
}
this.defaultCamPos.set(2.5, 1.5, 3.5);
this.camera.position.copy(this.defaultCamPos);
this.controls.target.set(0, 0, 0);
this.controls.update();
this.autoRotate = true;
this.animate();
}
private clearSystem(): void {
cancelAnimationFrame(this.animId);
const toRemove: THREE.Object3D[] = [];
this.scene.traverse((obj) => {
if (obj !== this.scene && obj !== this.bgGroup && obj.parent === this.scene && !(obj instanceof THREE.AmbientLight)) {
toRemove.push(obj);
}
});
for (const obj of toRemove) {
this.scene.remove(obj);
if ((obj as THREE.Mesh).geometry) (obj as THREE.Mesh).geometry.dispose();
}
let hasAmbient = false;
this.scene.traverse((o) => { if (o instanceof THREE.AmbientLight) hasAmbient = true; });
if (!hasAmbient) this.scene.add(new THREE.AmbientLight(0x222244, 0.3));
this.starMesh = null;
this.shellMesh = null;
this.glowMesh = null;
this.panelInstances = null;
}
private animate = (): void => {
this.animId = requestAnimationFrame(this.animate);
this.time += 0.005 * this.speedMultiplier;
if (this.shellMesh) this.shellMesh.rotation.y = this.time * 0.3;
if (this.panelInstances) this.panelInstances.rotation.y = this.time * 0.3;
// Auto-rotate camera
if (this.autoRotate) {
const camR = 4;
this.camera.position.x = camR * Math.sin(this.time * 0.15);
this.camera.position.z = camR * Math.cos(this.time * 0.15);
this.camera.position.y = 1.2 + 0.3 * Math.sin(this.time * 0.1);
this.controls.target.set(0, 0, 0);
}
if (this.starMesh) {
const scale = 1 + 0.03 * Math.sin(this.time * 3);
this.starMesh.scale.setScalar(scale);
}
if (this.glowMesh) {
const mat = this.glowMesh.material as THREE.MeshBasicMaterial;
mat.opacity = 0.04 + 0.02 * Math.sin(this.time * 2);
}
this.controls.update();
this.renderer.render(this.scene, this.camera);
};
resize(): void {
const w = this.container.clientWidth || 400;
const h = this.container.clientHeight || 300;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
destroy(): void {
cancelAnimationFrame(this.animId);
this.controls.dispose();
this.clearSystem();
if (this.bgGroup) {
this.scene.remove(this.bgGroup);
this.bgGroup.traverse((obj) => {
if ((obj as THREE.Mesh).geometry) (obj as THREE.Mesh).geometry.dispose();
});
this.bgGroup = null;
}
this.renderer.dispose();
if (this.renderer.domElement.parentElement) {
this.renderer.domElement.remove();
}
}
}

View File

@@ -0,0 +1,204 @@
import * as THREE from 'three';
export class OrbitPreview {
private line: THREE.Line | null = null;
private starMesh: THREE.Mesh | null = null;
private starGlow: THREE.Sprite | null = null;
private planetMesh: THREE.Mesh | null = null;
private hzRing: THREE.Line | null = null;
private gridHelper: THREE.GridHelper | null = null;
private scene: THREE.Scene;
private orbitPoints: THREE.Vector3[] = [];
private orbitAngle = 0;
private orbitSpeed = 0.005;
private paramOverlay: HTMLElement | null = null;
private parentEl: HTMLElement | null = null;
constructor(scene: THREE.Scene) {
this.scene = scene;
this.addStar();
this.addGrid();
}
private addStar(): void {
// Solid sphere
const geo = new THREE.SphereGeometry(0.18, 24, 16);
const mat = new THREE.MeshBasicMaterial({ color: 0xffdd44 });
this.starMesh = new THREE.Mesh(geo, mat);
this.scene.add(this.starMesh);
// Glow sprite
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext('2d');
if (ctx) {
const grad = ctx.createRadialGradient(32, 32, 2, 32, 32, 32);
grad.addColorStop(0, 'rgba(255,221,68,0.6)');
grad.addColorStop(0.4, 'rgba(255,200,50,0.15)');
grad.addColorStop(1, 'rgba(255,200,50,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 64, 64);
}
const tex = new THREE.CanvasTexture(canvas);
const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true, blending: THREE.AdditiveBlending });
this.starGlow = new THREE.Sprite(spriteMat);
this.starGlow.scale.set(1.2, 1.2, 1);
this.scene.add(this.starGlow);
}
private addGrid(): void {
this.gridHelper = new THREE.GridHelper(8, 8, 0x1C2333, 0x151B23);
this.gridHelper.position.y = -0.5;
this.scene.add(this.gridHelper);
}
setOrbit(
semiMajorAxis: number,
eccentricity: number,
inclination: number,
parentElement?: HTMLElement,
): void {
this.disposeLine();
this.disposePlanet();
this.disposeHzRing();
this.disposeOverlay();
const segments = 128;
this.orbitPoints = [];
const a = semiMajorAxis;
const e = Math.min(Math.max(eccentricity, 0), 0.99);
const incRad = (inclination * Math.PI) / 180;
for (let i = 0; i <= segments; i++) {
const theta = (i / segments) * Math.PI * 2;
const r = (a * (1 - e * e)) / (1 + e * Math.cos(theta));
const x = r * Math.cos(theta);
const z = r * Math.sin(theta) * Math.cos(incRad);
const y = r * Math.sin(theta) * Math.sin(incRad);
this.orbitPoints.push(new THREE.Vector3(x, y, z));
}
// Orbit path
const geometry = new THREE.BufferGeometry().setFromPoints(this.orbitPoints);
const material = new THREE.LineBasicMaterial({
color: 0x4488ff,
transparent: true,
opacity: 0.7,
});
this.line = new THREE.Line(geometry, material);
this.scene.add(this.line);
// Planet dot
const planetGeo = new THREE.SphereGeometry(0.08, 12, 8);
const planetMat = new THREE.MeshStandardMaterial({ color: 0x4488ff, emissive: 0x2244aa, emissiveIntensity: 0.3 });
this.planetMesh = new THREE.Mesh(planetGeo, planetMat);
this.planetMesh.position.copy(this.orbitPoints[0]);
this.scene.add(this.planetMesh);
// Habitable zone ring (0.95-1.37 AU scaled)
const hzInner = 0.95 * (a / 1.5);
const hzOuter = 1.37 * (a / 1.5);
const hzMid = (hzInner + hzOuter) / 2;
const hzPts: THREE.Vector3[] = [];
for (let i = 0; i <= 64; i++) {
const theta = (i / 64) * Math.PI * 2;
hzPts.push(new THREE.Vector3(hzMid * Math.cos(theta), -0.48, hzMid * Math.sin(theta)));
}
const hzGeo = new THREE.BufferGeometry().setFromPoints(hzPts);
const hzMat = new THREE.LineBasicMaterial({ color: 0x2ECC71, transparent: true, opacity: 0.25 });
this.hzRing = new THREE.Line(hzGeo, hzMat);
this.scene.add(this.hzRing);
// Orbit speed based on period (faster for shorter periods)
this.orbitSpeed = 0.003 + (1 / (a * 10)) * 0.02;
this.orbitAngle = 0;
// Param overlay
if (parentElement) {
this.parentEl = parentElement;
this.paramOverlay = document.createElement('div');
this.paramOverlay.style.cssText =
'position:absolute;bottom:8px;left:8px;' +
'background:rgba(11,15,20,0.85);border:1px solid var(--border);border-radius:4px;' +
'padding:6px 10px;font-family:var(--font-mono);font-size:10px;color:var(--text-secondary);' +
'line-height:1.6;z-index:10;pointer-events:none';
this.paramOverlay.innerHTML =
`<div style="color:var(--text-primary);font-weight:600;margin-bottom:2px">Orbit Parameters</div>` +
`<div>Semi-major: <span style="color:var(--accent)">${a.toFixed(2)} AU</span></div>` +
`<div>Eccentricity: <span style="color:var(--accent)">${e.toFixed(3)}</span></div>` +
`<div>Inclination: <span style="color:var(--accent)">${inclination.toFixed(1)}&deg;</span></div>` +
`<div style="margin-top:4px;color:#2ECC71;font-size:9px">&#9679; Habitable zone</div>`;
parentElement.appendChild(this.paramOverlay);
}
}
/** Call each frame to animate the planet along the orbit. */
tick(): void {
if (!this.planetMesh || this.orbitPoints.length < 2) return;
this.orbitAngle = (this.orbitAngle + this.orbitSpeed) % 1;
const idx = Math.floor(this.orbitAngle * (this.orbitPoints.length - 1));
this.planetMesh.position.copy(this.orbitPoints[idx]);
}
private disposeLine(): void {
if (this.line) {
this.scene.remove(this.line);
this.line.geometry.dispose();
(this.line.material as THREE.Material).dispose();
this.line = null;
}
}
private disposePlanet(): void {
if (this.planetMesh) {
this.scene.remove(this.planetMesh);
this.planetMesh.geometry.dispose();
(this.planetMesh.material as THREE.Material).dispose();
this.planetMesh = null;
}
}
private disposeHzRing(): void {
if (this.hzRing) {
this.scene.remove(this.hzRing);
this.hzRing.geometry.dispose();
(this.hzRing.material as THREE.Material).dispose();
this.hzRing = null;
}
}
private disposeOverlay(): void {
if (this.paramOverlay && this.parentEl) {
this.parentEl.removeChild(this.paramOverlay);
this.paramOverlay = null;
this.parentEl = null;
}
}
dispose(): void {
this.disposeLine();
this.disposePlanet();
this.disposeHzRing();
this.disposeOverlay();
if (this.starMesh) {
this.scene.remove(this.starMesh);
this.starMesh.geometry.dispose();
(this.starMesh.material as THREE.Material).dispose();
this.starMesh = null;
}
if (this.starGlow) {
this.scene.remove(this.starGlow);
this.starGlow.material.map?.dispose();
this.starGlow.material.dispose();
this.starGlow = null;
}
if (this.gridHelper) {
this.scene.remove(this.gridHelper);
this.gridHelper.geometry.dispose();
(this.gridHelper.material as THREE.Material).dispose();
this.gridHelper = null;
}
}
}

View File

@@ -0,0 +1,544 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
/**
* Interactive 3D exoplanet system visualization with galactic context.
*
* Renders:
* - Deep-field starfield (4000 background stars)
* - Milky Way galactic plane disc
* - Distant nebula patches
* - Central host star (color from effective temperature)
* - Planet on animated orbital path (size from radius_earth)
* - Habitable zone annulus (green band)
* - Orbit ellipse line
* - AU scale labels
*
* Interaction:
* - OrbitControls: drag to rotate, scroll to zoom, right-drag to pan
* - Speed control via setSpeed()
* - Reset view via resetCamera()
*/
export interface PlanetSystemParams {
label: string;
radiusEarth: number;
semiMajorAxisAU: number;
eqTempK: number;
stellarTempK: number;
stellarRadiusSolar: number;
periodDays: number;
hzMember: boolean;
esiScore: number;
transitDepth: number;
}
function starColorFromTemp(teff: number): number {
if (teff > 7500) return 0xaabfff;
if (teff > 6000) return 0xf8f7ff;
if (teff > 5200) return 0xfff4ea;
if (teff > 3700) return 0xffd2a1;
return 0xffb56c;
}
function planetColor(eqTempK: number): number {
if (eqTempK < 200) return 0x4488cc;
if (eqTempK < 260) return 0x44aa77;
if (eqTempK < 320) return 0x55bb55;
if (eqTempK < 500) return 0xddaa44;
return 0xff6644;
}
/** Deterministic pseudo-random from seed. */
function seededRandom(seed: number): () => number {
let s = seed;
return () => {
s = (s * 16807 + 0) % 2147483647;
return (s - 1) / 2147483646;
};
}
export class PlanetSystem3D {
private scene: THREE.Scene;
private camera: THREE.PerspectiveCamera;
private renderer: THREE.WebGLRenderer;
private controls: OrbitControls;
private starMesh: THREE.Mesh | null = null;
private planetMesh: THREE.Mesh | null = null;
private orbitLine: THREE.Line | null = null;
private hzInnerRing: THREE.Mesh | null = null;
private animId = 0;
private time = 0;
private orbitPoints: THREE.Vector3[] = [];
private orbitSpeed = 0.003;
private orbitAngle = 0;
private speedMultiplier = 1;
private autoRotate = true;
private defaultCamPos = new THREE.Vector3(0, 3, 6);
private bgGroup: THREE.Group | null = null;
private labelSprites: THREE.Sprite[] = [];
private currentParams: PlanetSystemParams | null = null;
constructor(private container: HTMLElement) {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x020408);
const w = container.clientWidth || 400;
const h = container.clientHeight || 300;
this.camera = new THREE.PerspectiveCamera(50, w / h, 0.01, 2000);
this.camera.position.set(0, 3, 6);
this.camera.lookAt(0, 0, 0);
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setSize(w, h);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(this.renderer.domElement);
// OrbitControls for mouse interaction
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.08;
this.controls.minDistance = 1;
this.controls.maxDistance = 500;
this.controls.enablePan = true;
this.controls.autoRotate = false; // We handle auto-rotate ourselves
this.controls.zoomSpeed = 1.2;
this.controls.rotateSpeed = 0.8;
// Stop auto-rotation when user interacts
this.controls.addEventListener('start', () => { this.autoRotate = false; });
this.scene.add(new THREE.AmbientLight(0x222244, 0.4));
// Build immutable background (stars, galaxy, nebulae)
this.buildBackground();
}
// ── Public controls ──
setSpeed(multiplier: number): void {
this.speedMultiplier = multiplier;
}
resetCamera(): void {
this.autoRotate = true;
this.camera.position.copy(this.defaultCamPos);
this.camera.lookAt(0, 0, 0);
this.controls.target.set(0, 0, 0);
this.controls.update();
}
toggleAutoRotate(): void {
this.autoRotate = !this.autoRotate;
}
getAutoRotate(): boolean {
return this.autoRotate;
}
// ── Background: starfield, galaxy, nebulae ──
private buildBackground(): void {
this.bgGroup = new THREE.Group();
// ── Starfield: 4000 background stars ──
const starCount = 4000;
const positions = new Float32Array(starCount * 3);
const colors = new Float32Array(starCount * 3);
const sizes = new Float32Array(starCount);
const rand = seededRandom(42);
const starTints = [
new THREE.Color(0xffffff), // white
new THREE.Color(0xaaccff), // blue-white
new THREE.Color(0xfff4ea), // yellow-white
new THREE.Color(0xffd2a1), // orange
new THREE.Color(0xffb56c), // red-orange
new THREE.Color(0xccddff), // pale blue
];
for (let i = 0; i < starCount; i++) {
// Distribute on a large sphere shell (300-800 units away)
const theta = rand() * Math.PI * 2;
const phi = Math.acos(2 * rand() - 1);
const r = 300 + rand() * 500;
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
positions[i * 3 + 2] = r * Math.cos(phi);
const tint = starTints[Math.floor(rand() * starTints.length)];
const brightness = 0.4 + rand() * 0.6;
colors[i * 3] = tint.r * brightness;
colors[i * 3 + 1] = tint.g * brightness;
colors[i * 3 + 2] = tint.b * brightness;
sizes[i] = 0.5 + rand() * 2.0;
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
starGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
starGeo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
const starMat = new THREE.PointsMaterial({
size: 1.5,
vertexColors: true,
transparent: true,
opacity: 0.9,
sizeAttenuation: true,
depthWrite: false,
});
this.bgGroup.add(new THREE.Points(starGeo, starMat));
// ── Milky Way galactic plane ──
// A large tilted disc with dense star concentration
const galaxyCount = 6000;
const galPos = new Float32Array(galaxyCount * 3);
const galCol = new Float32Array(galaxyCount * 3);
for (let i = 0; i < galaxyCount; i++) {
// Flat disc distribution, concentrated toward center
const angle = rand() * Math.PI * 2;
const dist = Math.pow(rand(), 0.5) * 600; // More concentrated near center
const height = (rand() - 0.5) * (15 + dist * 0.02); // Thin disc, thicker outward
galPos[i * 3] = dist * Math.cos(angle);
galPos[i * 3 + 1] = height;
galPos[i * 3 + 2] = dist * Math.sin(angle);
// Milky Way is blueish-white with warm core
const coreProx = 1 - Math.min(1, dist / 600);
const r2 = rand();
galCol[i * 3] = 0.5 + coreProx * 0.4 + r2 * 0.1;
galCol[i * 3 + 1] = 0.5 + coreProx * 0.3 + r2 * 0.1;
galCol[i * 3 + 2] = 0.6 + r2 * 0.15;
}
const galGeo = new THREE.BufferGeometry();
galGeo.setAttribute('position', new THREE.BufferAttribute(galPos, 3));
galGeo.setAttribute('color', new THREE.BufferAttribute(galCol, 3));
const galMat = new THREE.PointsMaterial({
size: 0.8,
vertexColors: true,
transparent: true,
opacity: 0.25,
sizeAttenuation: true,
depthWrite: false,
});
const galaxy = new THREE.Points(galGeo, galMat);
// Tilt the galactic plane ~60 degrees (we see the Milky Way at an angle)
galaxy.rotation.x = Math.PI * 0.35;
galaxy.rotation.z = Math.PI * 0.15;
galaxy.position.set(0, 100, -200);
this.bgGroup.add(galaxy);
// ── Galactic core glow ──
const coreGlowGeo = new THREE.SphereGeometry(40, 16, 16);
const coreGlowMat = new THREE.MeshBasicMaterial({
color: 0xeeddcc,
transparent: true,
opacity: 0.04,
side: THREE.BackSide,
depthWrite: false,
});
const coreGlow = new THREE.Mesh(coreGlowGeo, coreGlowMat);
coreGlow.position.copy(galaxy.position);
this.bgGroup.add(coreGlow);
// ── Nebula patches (colored sprite billboards) ──
const nebulaColors = [0x3344aa, 0xaa3355, 0x2288aa, 0x8844aa, 0x44aa66];
for (let i = 0; i < 8; i++) {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const ctx = canvas.getContext('2d')!;
const grad = ctx.createRadialGradient(64, 64, 4, 64, 64, 64);
const col = new THREE.Color(nebulaColors[i % nebulaColors.length]);
grad.addColorStop(0, `rgba(${Math.floor(col.r * 255)},${Math.floor(col.g * 255)},${Math.floor(col.b * 255)},0.3)`);
grad.addColorStop(0.4, `rgba(${Math.floor(col.r * 255)},${Math.floor(col.g * 255)},${Math.floor(col.b * 255)},0.08)`);
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 128, 128);
const tex = new THREE.CanvasTexture(canvas);
const spriteMat = new THREE.SpriteMaterial({
map: tex,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const sprite = new THREE.Sprite(spriteMat);
const theta2 = rand() * Math.PI * 2;
const phi2 = (rand() - 0.5) * Math.PI * 0.6;
const r2 = 200 + rand() * 400;
sprite.position.set(
r2 * Math.cos(phi2) * Math.cos(theta2),
r2 * Math.sin(phi2),
r2 * Math.cos(phi2) * Math.sin(theta2),
);
sprite.scale.setScalar(60 + rand() * 120);
this.bgGroup.add(sprite);
}
this.scene.add(this.bgGroup);
}
update(params: PlanetSystemParams): void {
this.clearSystem();
this.currentParams = params;
const sc = starColorFromTemp(params.stellarTempK);
const pc = planetColor(params.eqTempK);
const orbitRadius = Math.max(0.8, Math.min(4.0, params.semiMajorAxisAU * 2.5));
// ── Host Star ──
const starVisualRadius = 0.25 + params.stellarRadiusSolar * 0.2;
const starGeo = new THREE.SphereGeometry(starVisualRadius, 32, 32);
const starMat = new THREE.MeshBasicMaterial({ color: sc });
this.starMesh = new THREE.Mesh(starGeo, starMat);
this.scene.add(this.starMesh);
const starLight = new THREE.PointLight(sc, 2.5, 30);
starLight.position.set(0, 0, 0);
this.scene.add(starLight);
// Star corona
const glowGeo = new THREE.SphereGeometry(starVisualRadius * 2.5, 24, 24);
const glowMat = new THREE.MeshBasicMaterial({
color: sc,
transparent: true,
opacity: 0.05,
side: THREE.BackSide,
depthWrite: false,
});
this.scene.add(new THREE.Mesh(glowGeo, glowMat));
// ── Habitable Zone ──
if (params.hzMember) {
const hzInner = orbitRadius * 0.75;
const hzOuter = orbitRadius * 1.35;
const hzGeo = new THREE.RingGeometry(hzInner, hzOuter, 64);
const hzMat = new THREE.MeshBasicMaterial({
color: 0x2ecc71,
transparent: true,
opacity: 0.07,
side: THREE.DoubleSide,
depthWrite: false,
});
this.hzInnerRing = new THREE.Mesh(hzGeo, hzMat);
this.hzInnerRing.rotation.x = -Math.PI / 2;
this.hzInnerRing.position.y = -0.01;
this.scene.add(this.hzInnerRing);
const makeHzCircle = (r: number, color: number, opacity: number) => {
const pts: THREE.Vector3[] = [];
for (let i = 0; i <= 128; i++) {
const th = (i / 128) * Math.PI * 2;
pts.push(new THREE.Vector3(r * Math.cos(th), 0, r * Math.sin(th)));
}
const geo = new THREE.BufferGeometry().setFromPoints(pts);
const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity });
this.scene.add(new THREE.Line(geo, mat));
};
makeHzCircle(hzInner, 0x2ecc71, 0.25);
makeHzCircle(hzOuter, 0x2ecc71, 0.12);
}
// ── Orbit Path ──
this.orbitPoints = [];
const segments = 256;
for (let i = 0; i <= segments; i++) {
const theta = (i / segments) * Math.PI * 2;
this.orbitPoints.push(new THREE.Vector3(
orbitRadius * Math.cos(theta), 0, orbitRadius * Math.sin(theta),
));
}
const orbitGeo = new THREE.BufferGeometry().setFromPoints(this.orbitPoints);
const orbitMat = new THREE.LineBasicMaterial({ color: pc, transparent: true, opacity: 0.5 });
this.orbitLine = new THREE.Line(orbitGeo, orbitMat);
this.scene.add(this.orbitLine);
// Reference rings
const makeRefRing = (r: number) => {
const pts: THREE.Vector3[] = [];
for (let i = 0; i <= 128; i++) {
const th = (i / 128) * Math.PI * 2;
pts.push(new THREE.Vector3(r * Math.cos(th), 0, r * Math.sin(th)));
}
const geo = new THREE.BufferGeometry().setFromPoints(pts);
const mat = new THREE.LineBasicMaterial({ color: 0x1c2333, transparent: true, opacity: 0.3 });
this.scene.add(new THREE.Line(geo, mat));
};
makeRefRing(0.5 * 2.5);
makeRefRing(1.5 * 2.5);
// AU scale labels
this.addScaleLabel('0.5 AU', 0.5 * 2.5 + 0.2, 0.3, 0);
this.addScaleLabel('1.0 AU', 1.0 * 2.5 + 0.2, 0.3, 0);
this.addScaleLabel('1.5 AU', 1.5 * 2.5 + 0.2, 0.3, 0);
if (params.hzMember) {
this.addScaleLabel('HZ', orbitRadius * 1.05, 0.5, 0, '#2ecc71');
}
// ── Planet ──
const planetVisualRadius = Math.max(0.06, Math.min(0.2, params.radiusEarth * 0.1));
const planetGeo = new THREE.SphereGeometry(planetVisualRadius, 24, 24);
const planetMat = new THREE.MeshStandardMaterial({
color: pc,
emissive: pc,
emissiveIntensity: 0.2,
roughness: 0.7,
metalness: 0.1,
});
this.planetMesh = new THREE.Mesh(planetGeo, planetMat);
this.planetMesh.position.copy(this.orbitPoints[0]);
this.scene.add(this.planetMesh);
// Atmosphere halo for habitable candidates
if (params.hzMember && params.eqTempK > 180 && params.eqTempK < 350) {
const atmoGeo = new THREE.SphereGeometry(planetVisualRadius * 1.2, 24, 24);
const atmoMat = new THREE.MeshBasicMaterial({
color: 0x66ccff,
transparent: true,
opacity: 0.12,
side: THREE.BackSide,
depthWrite: false,
});
this.planetMesh.add(new THREE.Mesh(atmoGeo, atmoMat));
}
// Planet label
this.addScaleLabel(
params.label,
this.orbitPoints[0].x,
this.orbitPoints[0].y + planetVisualRadius + 0.15,
this.orbitPoints[0].z,
'#00e5ff',
);
// ── Grid ──
const gridHelper = new THREE.GridHelper(12, 12, 0x151b23, 0x0d1117);
gridHelper.position.y = -0.3;
this.scene.add(gridHelper);
// Speed and camera
this.orbitSpeed = 0.002 + (1 / Math.max(params.periodDays, 10)) * 0.8;
this.orbitAngle = 0;
const camDist = orbitRadius * 1.8 + 2;
this.defaultCamPos.set(camDist * 0.6, camDist * 0.45, camDist * 0.7);
this.camera.position.copy(this.defaultCamPos);
this.controls.target.set(0, 0, 0);
this.controls.update();
this.autoRotate = true;
this.animate();
}
private addScaleLabel(text: string, x: number, y: number, z: number, color = '#556677'): void {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 32;
const ctx = canvas.getContext('2d')!;
ctx.font = '14px monospace';
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.fillText(text, 64, 20);
const tex = new THREE.CanvasTexture(canvas);
tex.minFilter = THREE.LinearFilter;
const mat = new THREE.SpriteMaterial({
map: tex,
transparent: true,
depthWrite: false,
depthTest: false,
});
const sprite = new THREE.Sprite(mat);
sprite.position.set(x, y, z);
sprite.scale.set(1.2, 0.3, 1);
this.scene.add(sprite);
this.labelSprites.push(sprite);
}
/** Remove system objects but keep background. */
private clearSystem(): void {
cancelAnimationFrame(this.animId);
const toRemove: THREE.Object3D[] = [];
this.scene.traverse((obj) => {
if (obj !== this.scene && obj !== this.bgGroup && obj.parent === this.scene && !(obj instanceof THREE.AmbientLight)) {
toRemove.push(obj);
}
});
for (const obj of toRemove) {
this.scene.remove(obj);
if ((obj as THREE.Mesh).geometry) (obj as THREE.Mesh).geometry.dispose();
}
// Re-add ambient if missing
let hasAmbient = false;
this.scene.traverse((o) => { if (o instanceof THREE.AmbientLight) hasAmbient = true; });
if (!hasAmbient) this.scene.add(new THREE.AmbientLight(0x222244, 0.4));
this.starMesh = null;
this.planetMesh = null;
this.orbitLine = null;
this.hzInnerRing = null;
this.labelSprites = [];
}
private animate = (): void => {
this.animId = requestAnimationFrame(this.animate);
this.time += 0.005 * this.speedMultiplier;
// Planet orbit
if (this.planetMesh && this.orbitPoints.length > 1) {
this.orbitAngle = (this.orbitAngle + this.orbitSpeed * this.speedMultiplier) % 1;
const idx = Math.floor(this.orbitAngle * (this.orbitPoints.length - 1));
this.planetMesh.position.copy(this.orbitPoints[idx]);
this.planetMesh.rotation.y += 0.01 * this.speedMultiplier;
}
// Star pulse
if (this.starMesh) {
const scale = 1 + 0.02 * Math.sin(this.time * 3);
this.starMesh.scale.setScalar(scale);
}
// Auto-rotate camera (only if user hasn't grabbed controls)
if (this.autoRotate) {
const camDist = this.camera.position.length();
this.camera.position.x = camDist * 0.7 * Math.sin(this.time * 0.1);
this.camera.position.z = camDist * 0.7 * Math.cos(this.time * 0.1);
this.camera.position.y = camDist * 0.35 + 0.5 * Math.sin(this.time * 0.07);
this.controls.target.set(0, 0, 0);
}
this.controls.update();
this.renderer.render(this.scene, this.camera);
};
resize(): void {
const w = this.container.clientWidth || 400;
const h = this.container.clientHeight || 300;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
destroy(): void {
cancelAnimationFrame(this.animId);
this.controls.dispose();
this.clearSystem();
// Also clear background
if (this.bgGroup) {
this.scene.remove(this.bgGroup);
this.bgGroup.traverse((obj) => {
if ((obj as THREE.Mesh).geometry) (obj as THREE.Mesh).geometry.dispose();
});
this.bgGroup = null;
}
this.renderer.dispose();
if (this.renderer.domElement.parentElement) {
this.renderer.domElement.remove();
}
}
}