KrishnaGarg's picture
Deploy Asterism Relay — MiniCPM on Modal + Three.js cosmos
d012783 verified
Raw
History Blame Contribute Delete
23.6 kB
import * as THREE from "three";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
// ---------------------------------------------------------------- DOM
const canvas = document.querySelector("#universe");
const hud = {
sector: document.querySelector("#sector"),
status: document.querySelector("#status"),
authored: document.querySelector("#authored"),
discovered: document.querySelector("#discovered"),
card: document.querySelector("#hud"),
phenomenon: document.querySelector("#phenomenon"),
distance: document.querySelector("#distance"),
name: document.querySelector("#body-name"),
science: document.querySelector("#science"),
transWrap: document.querySelector("#transmission-wrap"),
civ: document.querySelector("#civ"),
transmission: document.querySelector("#transmission"),
log: document.querySelector("#log"),
logList: document.querySelector("#log-list"),
toast: document.querySelector("#event-toast"),
};
const overlay = document.querySelector("#overlay");
const engage = document.querySelector("#engage");
const introEl = document.querySelector("#intro");
// ---------------------------------------------------------------- renderer
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x01030a, 0.000045);
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 60000);
const yaw = new THREE.Object3D();
const pitch = new THREE.Object3D();
yaw.add(pitch);
pitch.add(camera);
scene.add(yaw);
yaw.position.set(0, 0, 1200);
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.9, 0.55, 0.0);
composer.addPass(bloom);
scene.add(new THREE.AmbientLight(0x4a5e88, 0.5));
// ---------------------------------------------------------------- state
const clock = new THREE.Clock();
const keys = new Set();
const velocity = new THREE.Vector3();
const move = new THREE.Vector3();
let flying = false;
const bodies = []; // { data, group, anchor, mesh, glow, effects, payload, requested, revealed, animate }
const cache = new Map();
let discoveredCount = 0;
let nearest = null;
let lastEvent = 0;
// ---------------------------------------------------------------- textures
function radialTexture(stops) {
const c = document.createElement("canvas");
c.width = c.height = 128;
const ctx = c.getContext("2d");
const g = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
for (const [o, col] of stops) g.addColorStop(o, col);
ctx.fillStyle = g;
ctx.fillRect(0, 0, 128, 128);
return new THREE.CanvasTexture(c);
}
const glowTex = radialTexture([
[0, "rgba(255,255,255,1)"], [0.25, "rgba(255,255,255,0.8)"],
[0.5, "rgba(255,255,255,0.28)"], [1, "rgba(255,255,255,0)"],
]);
const softTex = radialTexture([
[0, "rgba(255,255,255,0.7)"], [0.4, "rgba(255,255,255,0.25)"], [1, "rgba(255,255,255,0)"],
]);
function glowSprite(color, scale) {
const s = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTex, color: new THREE.Color(color), transparent: true,
blending: THREE.AdditiveBlending, depthWrite: false, opacity: 0.95,
}));
s.scale.setScalar(scale);
return s;
}
// ---------------------------------------------------------------- shaders
const NOISE = `
float hash(vec3 p){ return fract(sin(dot(p, vec3(127.1,311.7,74.7)))*43758.5453); }
float noise(vec3 p){
vec3 i=floor(p), f=fract(p); f=f*f*(3.0-2.0*f);
return mix(mix(mix(hash(i),hash(i+vec3(1,0,0)),f.x),mix(hash(i+vec3(0,1,0)),hash(i+vec3(1,1,0)),f.x),f.y),
mix(mix(hash(i+vec3(0,0,1)),hash(i+vec3(1,0,1)),f.x),mix(hash(i+vec3(0,1,1)),hash(i+vec3(1,1,1)),f.x),f.y),f.z);
}
float fbm(vec3 p){ float v=0.0,a=0.5; for(int i=0;i<5;i++){ v+=a*noise(p); p*=2.03; a*=0.5; } return v; }
`;
function starMaterial(shader) {
return new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 }, primary: { value: new THREE.Color(shader.primary) },
secondary: { value: new THREE.Color(shader.secondary) }, emissive: { value: shader.emissive },
noiseScale: { value: shader.noise_scale },
},
vertexShader: `varying vec3 vp; void main(){ vp=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
fragmentShader: `
uniform float time; uniform vec3 primary; uniform vec3 secondary; uniform float emissive; uniform float noiseScale;
varying vec3 vp; ${NOISE}
void main(){
vec3 p = normalize(vp)*noiseScale;
float n = fbm(p + vec3(0.0, time*0.15, time*0.05));
float gran = fbm(p*3.5 - time*0.2);
float h = pow(n*0.7 + gran*0.4, 1.4);
vec3 col = mix(primary, secondary, smoothstep(0.25,0.95,h));
col += secondary * pow(gran, 3.0) * 0.6;
gl_FragColor = vec4(col*(0.7+emissive*0.7), 1.0);
}`,
});
}
function planetMaterial(shader) {
return new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 }, primary: { value: new THREE.Color(shader.primary) },
secondary: { value: new THREE.Color(shader.secondary) }, noiseScale: { value: shader.noise_scale },
lightDir: { value: new THREE.Vector3(0.6, 0.4, 0.7).normalize() },
},
vertexShader: `varying vec3 vp; varying vec3 vn;
void main(){ vp=position; vn=normalize(normalMatrix*normal); gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
fragmentShader: `
uniform float time; uniform vec3 primary; uniform vec3 secondary; uniform float noiseScale; uniform vec3 lightDir;
varying vec3 vp; varying vec3 vn; ${NOISE}
void main(){
vec3 p = normalize(vp);
float bands = fbm(vec3(p.x*1.2, p.y*noiseScale*1.4, p.z*1.2) + vec3(time*0.04,0.0,0.0));
float swirl = fbm(p*4.0 + vec3(time*0.08, 0.0, 0.0));
float t = smoothstep(0.2, 0.85, bands*0.7 + swirl*0.35);
vec3 col = mix(primary, secondary, t);
float light = clamp(dot(vn, lightDir), 0.0, 1.0)*0.9 + 0.12;
float rim = pow(1.0 - max(dot(vn, vec3(0.0,0.0,1.0)),0.0), 3.0);
gl_FragColor = vec4(col*light + secondary*rim*0.6, 1.0);
}`,
});
}
// ---------------------------------------------------------------- body builders
function buildBody(data) {
const group = new THREE.Group();
const p = data.position;
group.position.set(p.x, p.y, p.z);
scene.add(group);
const shader = {
primary: data.visual.primary_color, secondary: data.visual.secondary_color,
emissive: data.visual.emissive, noise_scale: 2.4, atmosphere: 0.4,
};
const r = data.visual.radius;
const render = data.visual.render;
const entry = { data, group, animate: [], glow: null, far: glowSprite(data.visual.primary_color, r * 7) };
group.add(entry.far);
if (render === "star" || render === "pulsar") {
const star = new THREE.Mesh(new THREE.IcosahedronGeometry(r, 5), starMaterial(shader));
group.add(star);
entry.animate.push((t) => { star.material.uniforms.time.value = t; star.rotation.y = t * 0.04; });
group.add(glowSprite(data.visual.primary_color, r * 5.2));
if (data.visual.corona) {
const corona = glowSprite(data.visual.secondary_color, r * 8.5);
corona.material.opacity = 0.4;
group.add(corona);
entry.animate.push((t) => corona.scale.setScalar(r * (8.0 + Math.sin(t * 1.3) * 0.6)));
}
if (render === "pulsar" || data.visual.beam) addBeams(group, entry, r, data.visual.primary_color);
} else if (render === "planet") {
const planet = new THREE.Mesh(new THREE.SphereGeometry(r, 96, 64), planetMaterial(shader));
group.add(planet);
entry.animate.push((t) => { planet.material.uniforms.time.value = t; planet.rotation.y = t * 0.06; });
const atmo = new THREE.Mesh(new THREE.SphereGeometry(r * 1.06, 48, 32), new THREE.MeshBasicMaterial({
color: new THREE.Color(data.visual.secondary_color), transparent: true, opacity: 0.18,
blending: THREE.AdditiveBlending, side: THREE.BackSide, depthWrite: false,
}));
group.add(atmo);
if (data.visual.rings) addRings(group, r, data.visual.particles, 1.4, 2.3, false);
} else if (render === "blackhole") {
const core = new THREE.Mesh(new THREE.SphereGeometry(r, 48, 32), new THREE.MeshBasicMaterial({ color: 0x000000 }));
group.add(core);
addRings(group, r, data.visual.primary_color, 1.5, 3.4, true, data.visual.secondary_color);
const photon = glowSprite(data.visual.secondary_color, r * 4.0);
photon.material.opacity = 0.5;
group.add(photon);
if (data.visual.jets) addBeams(group, entry, r, data.visual.secondary_color, true);
entry.animate.push((t) => { group.children.forEach((c) => { if (c.userData.disk) c.rotation.z = t * 0.5; }); });
} else if (render === "nebula") {
addNebula(group, r, data.visual.primary_color, data.visual.secondary_color, entry);
} else if (render === "remnant") {
addRemnant(group, r, data.visual.primary_color, data.visual.secondary_color, entry);
}
return entry;
}
function addBeams(group, entry, r, color, vertical = false) {
const mat = new THREE.MeshBasicMaterial({
color: new THREE.Color(color), transparent: true, opacity: 0.32,
blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide,
});
const len = r * (vertical ? 30 : 22);
const geo = new THREE.CylinderGeometry(r * 0.05, r * 0.5, len, 24, 1, true);
const a = new THREE.Mesh(geo, mat);
const b = new THREE.Mesh(geo, mat.clone());
a.position.y = len / 2; b.position.y = -len / 2; b.rotation.z = Math.PI;
const pivot = new THREE.Group();
pivot.add(a, b);
if (!vertical) pivot.rotation.z = Math.PI / 2.6;
group.add(pivot);
entry.animate.push((t) => { if (!vertical) pivot.rotation.y = t * 1.4; mat.opacity = 0.22 + Math.abs(Math.sin(t * 2.0)) * 0.28; });
}
function addRings(group, r, color, inner, outer, hot, color2) {
const geo = new THREE.RingGeometry(r * inner, r * outer, 128);
const mat = new THREE.ShaderMaterial({
uniforms: { time: { value: 0 }, c1: { value: new THREE.Color(color) }, c2: { value: new THREE.Color(color2 || color) } },
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide,
vertexShader: `varying vec2 vu; void main(){ vu=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);} `,
fragmentShader: `uniform float time; uniform vec3 c1; uniform vec3 c2; varying vec2 vu;
void main(){ float a=atan(vu.y-0.5, vu.x-0.5); float r=length(vu-0.5);
float band=0.5+0.5*sin(a*${hot ? "18.0" : "40.0"}+time*${hot ? "3.0" : "0.0"});
float glow=smoothstep(0.5,0.18,abs(r-0.34));
vec3 col=mix(c2,c1,band); gl_FragColor=vec4(col, glow*${hot ? "0.9" : "0.6"}); }`,
});
const ring = new THREE.Mesh(geo, mat);
ring.rotation.x = Math.PI / 2 + (hot ? 0.32 : 0.5);
ring.userData.disk = hot;
group.add(ring);
group.userData.ringMat = mat;
}
function addNebula(group, r, c1, c2, entry) {
const colors = [c1, c2, c1];
for (let i = 0; i < 18; i++) {
const col = colors[i % 3];
const s = new THREE.Sprite(new THREE.SpriteMaterial({
map: softTex, color: new THREE.Color(col), transparent: true, opacity: 0.16,
blending: THREE.AdditiveBlending, depthWrite: false,
}));
s.position.set((Math.random() - 0.5) * r * 1.6, (Math.random() - 0.5) * r * 1.1, (Math.random() - 0.5) * r * 1.6);
s.scale.setScalar(r * (0.7 + Math.random() * 0.9));
group.add(s);
}
group.add(glowSprite(c2, r * 0.8));
entry.animate.push((t) => { group.rotation.y = t * 0.012; });
}
function addRemnant(group, r, c1, c2, entry) {
const count = 2200;
const pos = new Float32Array(count * 3);
const col = new Float32Array(count * 3);
const a = new THREE.Color(c1), b = new THREE.Color(c2);
for (let i = 0; i < count; i++) {
const rad = r * (0.85 + Math.random() * 0.2);
const th = Math.random() * Math.PI * 2, ph = Math.acos(Math.random() * 2 - 1);
pos[i * 3] = rad * Math.sin(ph) * Math.cos(th);
pos[i * 3 + 1] = rad * Math.cos(ph);
pos[i * 3 + 2] = rad * Math.sin(ph) * Math.sin(th);
const c = Math.random() < 0.5 ? a : b;
col[i * 3] = c.r; col[i * 3 + 1] = c.g; col[i * 3 + 2] = c.b;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute("color", new THREE.Float32BufferAttribute(col, 3));
const pts = new THREE.Points(geo, new THREE.PointsMaterial({
size: r * 0.03, vertexColors: true, transparent: true, opacity: 0.8,
blending: THREE.AdditiveBlending, depthWrite: false, map: softTex,
}));
group.add(pts);
group.add(glowSprite(c1, r * 0.6));
entry.animate.push((t) => { const s = 1 + Math.sin(t * 0.25) * 0.04; group.scale.setScalar(s); group.rotation.y = t * 0.02; });
}
// ---------------------------------------------------------------- starfield + nebulae backdrop
function backgroundStars(positions, colors, size) {
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
const pts = new THREE.Points(geo, new THREE.PointsMaterial({
size, sizeAttenuation: false, vertexColors: true, transparent: true,
opacity: 0.95, depthWrite: false, map: glowTex, alphaTest: 0.02,
}));
scene.add(pts);
}
async function loadHygStars() {
const fallback = () => {
const pos = [], col = [];
for (let i = 0; i < 2600; i++) {
const d = 30000, th = Math.random() * Math.PI * 2, ph = Math.acos(Math.random() * 2 - 1);
pos.push(d * Math.sin(ph) * Math.cos(th), d * Math.cos(ph), d * Math.sin(ph) * Math.sin(th));
const c = new THREE.Color().setHSL(0.55 + Math.random() * 0.12, 0.4, 0.7 + Math.random() * 0.25);
col.push(c.r, c.g, c.b);
}
backgroundStars(pos, col, 1.6);
};
try {
const res = await fetch("/data/hyg_sample.csv");
if (!res.ok) return fallback();
const rows = (await res.text()).trim().split(/\r?\n/).slice(1);
const pos = [], col = [];
for (const row of rows) {
const [, , ra, dec, dist, mag, spect] = row.split(",");
const R = 28000;
const a = (Number(ra) / 24) * Math.PI * 2, d = THREE.MathUtils.degToRad(Number(dec));
pos.push(R * Math.cos(d) * Math.cos(a), R * Math.sin(d), R * Math.cos(d) * Math.sin(a));
const c = spectralColor(spect, Number(mag));
col.push(c.r, c.g, c.b);
}
if (pos.length) backgroundStars(pos, col, 2.2);
fallback(); // add a faint deep field behind the real catalog
} catch { fallback(); }
}
function spectralColor(spect = "G", mag = 1) {
const palette = { O: 0x9bb0ff, B: 0xaabfff, A: 0xdce6ff, F: 0xfff4ea, G: 0xfff2cc, K: 0xffd2a1, M: 0xffb38a };
const c = new THREE.Color(palette[(spect.trim()[0] || "G").toUpperCase()] || 0xffffff);
return c.multiplyScalar(THREE.MathUtils.clamp(1.5 - (mag + 1.5) * 0.12, 0.5, 1.5));
}
function backdropNebulae() {
const spots = [
[-9000, 2000, -12000, 5200, 0x3a2f6e], [11000, -3000, 9000, 6000, 0x123b4a],
[-4000, -6000, 14000, 5000, 0x4a1d3a], [8000, 5000, -16000, 7000, 0x1c3a2e],
];
for (const [x, y, z, s, c] of spots) {
const sp = new THREE.Sprite(new THREE.SpriteMaterial({
map: softTex, color: new THREE.Color(c), transparent: true, opacity: 0.5,
blending: THREE.AdditiveBlending, depthWrite: false,
}));
sp.position.set(x, y, z); sp.scale.setScalar(s); scene.add(sp);
}
}
// ---------------------------------------------------------------- universe load
async function loadUniverse() {
const manifest = await fetch("/api/universe").then((r) => r.json());
introEl.textContent = manifest.intro;
for (const data of manifest.bodies) bodies.push(buildBody(data));
hud.discovered.textContent = `relics 0 / ${bodies.length}`;
hud.status.textContent = "STANDBY";
}
// ---------------------------------------------------------------- sector fetch + reveal
async function requestSector(entry) {
if (entry.requested) return;
entry.requested = true;
if (cache.has(entry.data.id)) { entry.payload = cache.get(entry.data.id); return; }
try {
const r = await fetch(`/api/sector/${entry.data.id}`);
const payload = await r.json();
entry.payload = payload;
cache.set(entry.data.id, payload);
} catch {
entry.requested = false; // allow retry
}
}
function showCard(entry) {
const p = entry.payload;
hud.card.classList.remove("hidden");
hud.sector.textContent = `SECTOR ${entry.data.id.toUpperCase()}`;
hud.phenomenon.textContent = (p ? p.body.phenomenon : entry.data.name).toUpperCase();
hud.name.textContent = p ? p.body.name : entry.data.name;
hud.authored.textContent = `author: ${p ? p.authored_by : "…"}`;
if (p) {
hud.science.textContent = p.fact_layer.explanation;
} else {
hud.science.textContent = "Authoring sector… approaching telemetry.";
}
hud.transWrap.classList.add("hidden");
}
function revealTransmission(entry) {
const p = entry.payload;
if (!p || entry.revealed) return;
entry.revealed = true;
hud.civ.textContent = p.fiction_layer.civilization;
hud.transmission.textContent = `“${p.fiction_layer.transmission}”`;
hud.transWrap.classList.remove("hidden");
hud.status.textContent = "TRANSMISSION LOCK";
discoveredCount += 1;
hud.discovered.textContent = `relics ${discoveredCount} / ${bodies.length}`;
addLog(p);
}
function addLog(p) {
hud.log.classList.remove("hidden");
const li = document.createElement("li");
li.innerHTML = `<b>${p.body.name}</b> — ${p.fiction_layer.transmission}`;
hud.logList.prepend(li);
while (hud.logList.children.length > 8) hud.logList.lastChild.remove();
}
// ---------------------------------------------------------------- cosmic events
function maybeEvent(t) {
if (t - lastEvent < 22 || Math.random() > 0.004) return;
lastEvent = t;
Math.random() < 0.5 ? supernova() : comet();
}
function supernova() {
const far = bodies.filter((b) => b.group.position.distanceTo(yaw.position) > 1500);
if (!far.length) return;
const target = far[Math.floor(Math.random() * far.length)];
const flash = glowSprite(0xfff0d0, 40);
flash.position.copy(target.group.position);
scene.add(flash);
toast("◎ SUPERNOVA DETECTED — a star is seeding the void with heavy elements");
let s = 0;
const grow = () => {
s += 0.04;
flash.scale.setScalar(40 + s * 2600);
flash.material.opacity = Math.max(0, 1 - s);
if (s < 1) requestAnimationFrame(grow); else scene.remove(flash);
};
grow();
}
function comet() {
const start = new THREE.Vector3().copy(yaw.position).add(new THREE.Vector3((Math.random() - 0.5) * 3000, 800, -2500));
const head = glowSprite(0xbfe9ff, 60);
head.position.copy(start);
scene.add(head);
const vel = new THREE.Vector3((Math.random() - 0.5) * 60, -8, 120);
toast("☄ COMET passing — primordial ice from the outer dark");
let life = 0;
const fly = () => {
life += 1; head.position.add(vel); head.material.opacity = Math.max(0, 1 - life / 120);
if (life < 120) requestAnimationFrame(fly); else scene.remove(head);
};
fly();
}
let toastTimer = 0;
function toast(msg) {
hud.toast.textContent = msg;
hud.toast.classList.remove("hidden");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => hud.toast.classList.add("hidden"), 6000);
}
// ---------------------------------------------------------------- controls
engage.addEventListener("click", () => {
overlay.style.opacity = "0";
setTimeout(() => overlay.classList.add("hidden"), 600);
canvas.requestPointerLock?.();
flying = true;
hud.status.textContent = "FLIGHT ONLINE";
});
document.addEventListener("pointerlockchange", () => {
if (document.pointerLockElement !== canvas && flying) hud.status.textContent = "CURSOR FREE · CLICK TO RESUME";
});
canvas.addEventListener("click", () => { if (flying) canvas.requestPointerLock?.(); });
document.addEventListener("mousemove", (e) => {
if (document.pointerLockElement !== canvas) return;
yaw.rotation.y -= e.movementX * 0.0021;
pitch.rotation.x = THREE.MathUtils.clamp(pitch.rotation.x - e.movementY * 0.0021, -1.45, 1.45);
});
document.addEventListener("keydown", (e) => keys.add(e.code));
document.addEventListener("keyup", (e) => keys.delete(e.code));
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
function updateFlight(dt) {
move.set(0, 0, 0);
if (keys.has("KeyW") || keys.has("ArrowUp")) move.z -= 1;
if (keys.has("KeyS") || keys.has("ArrowDown")) move.z += 1;
if (keys.has("KeyA") || keys.has("ArrowLeft")) move.x -= 1;
if (keys.has("KeyD") || keys.has("ArrowRight")) move.x += 1;
if (keys.has("Space")) move.y += 1;
if (keys.has("ShiftLeft") && keys.has("KeyW")) move.z -= 2.2; // boost
if (keys.has("ControlLeft") || keys.has("KeyC")) move.y -= 1;
const boost = keys.has("ShiftLeft") ? 2.4 : 1;
const speed = 420 * boost;
const q = new THREE.Quaternion();
camera.getWorldQuaternion(q);
move.applyQuaternion(q);
velocity.lerp(move.multiplyScalar(speed), 0.06);
yaw.position.addScaledVector(velocity, dt);
}
// ---------------------------------------------------------------- main loop
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
if (flying) updateFlight(dt);
// nearest body + prefetch + reveal
let best = null, bestD = Infinity;
for (const entry of bodies) {
const d = entry.group.position.distanceTo(yaw.position);
for (const fn of entry.animate) fn(t);
if (entry.group.userData.ringMat) entry.group.userData.ringMat.uniforms.time.value = t;
entry.far.material.opacity = THREE.MathUtils.clamp((d - 600) / 4000, 0.0, 0.95);
const trigger = entry.data.visual.radius * 26 + 900;
if (d < trigger) requestSector(entry);
if (d < bestD) { bestD = d; best = entry; }
}
if (best) {
const approach = best.data.visual.radius * 14 + 600;
if (bestD < approach) {
if (nearest !== best) { nearest = best; best.revealed = false; showCard(best); }
if (!best.payload && best.requested) requestSector(best);
if (best.payload && hud.science.textContent.startsWith("Authoring")) showCard(best);
hud.distance.textContent = `${Math.round(bestD)} u`;
hud.status.textContent = bestD < best.data.visual.radius * 5 + 200 ? "ARRIVAL" : "APPROACH";
if (bestD < best.data.visual.radius * 6 + 260) revealTransmission(best);
} else if (nearest && bestD > approach * 1.4) {
nearest = null;
hud.card.classList.add("hidden");
if (flying) hud.status.textContent = "CRUISING";
}
}
if (flying) maybeEvent(t);
composer.render();
}
// ---------------------------------------------------------------- boot
loadHygStars();
backdropNebulae();
loadUniverse();
animate();