CREATORJD's picture
MASSFRONT v1.33.81 - interface pass: sparing frame art, no plate over content, full resource rail, loader no longer overlaps live UGA home (part 4)
8d3bef6 verified
Raw History Blame Contribute Delete
34.6 kB
/* --------------------------------------------------------------------------
MASSFRONT — 3D GALAXY MAP ENGINE
Macro-scale star chart with system nodes, cluster nebulae, animated
mass-relay hyperlanes, and an orbit/zoom camera. Drives the "GALAXY MAP"
modal so the player can pick a destination and initiate a relay jump.
Coordinate space: arbitrary "cluster units" — the same as GALAXY_LAYOUT.
Camera lives at a few hundred units so all 4 systems fit in the frustum.
-------------------------------------------------------------------------- */
import { SHOWCASE_LAYOUT, SHOWCASE_SYSTEMS } from '../systems/showcase_systems.js';
import { createSeededRandom } from '../core/seeded_random.js';
import { registerRuntimeRenderer } from '../core/gltf_runtime_loader.js';
const GALAXY_EXPOSURE = 1.12;
const GALAXY_PORTRAIT_ASPECT = 0.78;
export function clampProjectedLabelX(projectedX, viewportWidth, labelWidth, startClearance = 10, endClearance = 10) {
const width = Math.max(0, Number(viewportWidth) || 0);
const start = Math.min(width, Math.max(0, Number(startClearance) || 0));
const end = Math.min(width - start, Math.max(0, Number(endClearance) || 0));
const visibleWidth = width - start - end;
const measuredWidth = Math.max(0, Number(labelWidth) || 0);
if (measuredWidth >= visibleWidth) return start + visibleWidth * 0.5;
const halfWidth = measuredWidth * 0.5;
return Math.min(width - end - halfWidth, Math.max(start + halfWidth, projectedX));
}
function appendExtrudedPolygon(vertices, indices, polygon, depth) {
const base = vertices.length / 3;
const count = polygon.length;
for (const [x, y] of polygon) vertices.push(x, y, depth * 0.5);
for (const [x, y] of polygon) vertices.push(x, y, -depth * 0.5);
for (let i = 1; i < count - 1; i++) indices.push(base, base + i, base + i + 1);
for (let i = 1; i < count - 1; i++) indices.push(base + count, base + count + i + 1, base + count + i);
for (let i = 0; i < count; i++) {
const next = (i + 1) % count;
indices.push(base + i, base + count + i, base + count + next);
indices.push(base + i, base + count + next, base + next);
}
}
function radialPoint(angle, radius, tangent = 0) {
const c = Math.cos(angle), s = Math.sin(angle);
return [c * radius - s * tangent, s * radius + c * tangent];
}
function createStellarPointMaterial(pointScale, size, opacity, maxSize, additive = false) {
return new THREE.ShaderMaterial({
uniforms: {
uPointScale: { value: pointScale },
uSize: { value: size },
uOpacity: { value: opacity },
uMaxSize: { value: maxSize }
},
vertexShader: `
attribute vec3 color;
varying vec3 vColor;
uniform float uPointScale;
uniform float uSize;
uniform float uMaxSize;
void main() {
vColor = color;
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = clamp(uSize * uPointScale / max(1.0, -viewPosition.z), 0.65, uMaxSize);
gl_Position = projectionMatrix * viewPosition;
}
`,
fragmentShader: `
varying vec3 vColor;
uniform float uOpacity;
void main() {
float radius = length(gl_PointCoord - vec2(0.5));
float core = 1.0 - smoothstep(0.08, 0.49, radius);
float fringe = 1.0 - smoothstep(0.34, 0.5, radius);
float alpha = (core * 0.78 + fringe * 0.22) * uOpacity;
if (alpha < 0.01) discard;
gl_FragColor = vec4(vColor * (0.88 + core * 0.32), alpha);
}
`,
transparent: true,
depthWrite: false,
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending
});
}
// These are modeled navigation beacons, not spheres with a torus marker. Each
// theater has a separate silhouette assembled from authored extruded profiles.
function createSystemBeaconGeometry(systemId) {
const vertices = [];
const indices = [];
const polygon = (points, depth = 2.4) => appendExtrudedPolygon(vertices, indices, points, depth);
if (systemId === 'aelos') {
polygon(Array.from({ length: 9 }, (_, i) => radialPoint(i * Math.PI * 2 / 9, i % 2 ? 5.2 : 6.8)), 3.4);
for (let arm = 0; arm < 3; arm++) {
const a = arm * Math.PI * 2 / 3 - Math.PI / 2;
polygon([
radialPoint(a, 5.6, -2.0), radialPoint(a, 13.0, -3.6), radialPoint(a, 18.8, -1.4),
radialPoint(a, 21.0, 0), radialPoint(a, 18.8, 1.4), radialPoint(a, 13.0, 3.6), radialPoint(a, 5.6, 2.0)
], 2.1);
polygon([
radialPoint(a + 0.22, 10.0, -0.7), radialPoint(a + 0.22, 16.2, -0.7),
radialPoint(a + 0.22, 17.6, 0), radialPoint(a + 0.22, 16.2, 0.7), radialPoint(a + 0.22, 10.0, 0.7)
], 3.0);
}
} else if (systemId === 'veyra') {
const arcs = [[-2.86, -1.78], [-1.36, -0.32], [0.08, 1.03], [1.48, 2.43]];
for (const [start, end] of arcs) {
const points = [];
const segments = 6;
for (let i = 0; i <= segments; i++) points.push(radialPoint(start + (end - start) * i / segments, 18.8));
for (let i = segments; i >= 0; i--) points.push(radialPoint(start + (end - start) * i / segments, 14.5));
polygon(points, 2.0);
}
polygon([[-10.5, -1.4], [-3.0, -3.6], [11.5, 2.0], [5.2, 3.8]], 3.4);
polygon([[-3.6, 10.8], [-1.5, 4.0], [4.4, -11.8], [1.0, -6.2]], 2.7);
polygon([[-3.4, -3.0], [0, -5.2], [4.4, -1.2], [3.2, 3.8], [-1.8, 5.0], [-5.0, 1.4]], 4.1);
} else {
const core = [[-6.8, -3.8], [-2.4, -7.0], [3.8, -5.8], [7.2, -0.8], [4.4, 5.8], [-1.6, 7.5], [-7.5, 2.0]];
polygon(core, 4.0);
const arms = [-1.72, -0.52, 0.62, 1.86, 2.82];
arms.forEach((a, i) => polygon([
radialPoint(a, 5.6, -1.7), radialPoint(a + (i % 2 ? -0.12 : 0.1), 14.0, -2.2),
radialPoint(a + (i % 2 ? 0.08 : -0.16), 22.0, -0.7), radialPoint(a, 24.0, 0),
radialPoint(a + (i % 2 ? -0.08 : 0.14), 21.5, 0.8), radialPoint(a, 12.8, 2.3), radialPoint(a, 5.6, 1.6)
], 1.9 + (i % 2) * 0.8));
polygon([[-13.8, -9.2], [-8.0, -10.7], [15.2, 8.4], [10.2, 10.8]], 2.8);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
geometry.name = `${systemId}_AuthoredNavigationBeacon`;
return geometry;
}
function createSelectionReticleGeometry() {
const vertices = [];
const indices = [];
for (let i = 0; i < 4; i++) {
const a = i * Math.PI * 0.5 + Math.PI * 0.25;
appendExtrudedPolygon(vertices, indices, [
radialPoint(a, 23.0, -4.0), radialPoint(a, 28.0, -6.3), radialPoint(a, 31.0, -2.0),
radialPoint(a, 29.0, 0), radialPoint(a, 31.0, 2.0), radialPoint(a, 28.0, 6.3), radialPoint(a, 23.0, 4.0),
radialPoint(a, 25.5, 0)
], 1.2);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
geometry.name = 'AuthoredSystemSelectionReticle';
return geometry;
}
// A phase corridor is a real indexed surface with twin luminous rails and a
// restrained central navigation field. The previous THREE.Line depended on a
// UV attribute that setFromPoints() never created, so its endpoint fade was
// zero along the entire route on hardware WebGL.
function createPhaseCorridorGeometry(curve, name) {
const segments = 80;
const acrossSteps = [-1, -0.72, -0.46, 0, 0.46, 0.72, 1];
const positions = [];
const routeU = [];
const across = [];
const indices = [];
const up = new THREE.Vector3(0, 1, 0);
const fallback = new THREE.Vector3(1, 0, 0);
const side = new THREE.Vector3();
for (let i = 0; i <= segments; i++) {
const u = i / segments;
const point = curve.getPoint(u);
const tangent = curve.getTangent(u).normalize();
side.crossVectors(tangent, up);
if (side.lengthSq() < 0.0001) side.copy(fallback);
else side.normalize();
for (const lane of acrossSteps) {
positions.push(
point.x + side.x * lane * 7.5,
point.y + side.y * lane * 7.5,
point.z + side.z * lane * 7.5
);
routeU.push(u);
across.push(lane);
}
}
for (let i = 0; i < segments; i++) {
const row = i * acrossSteps.length;
const next = row + acrossSteps.length;
for (let lane = 0; lane < acrossSteps.length - 1; lane++) {
indices.push(row + lane, next + lane, next + lane + 1);
indices.push(row + lane, next + lane + 1, row + lane + 1);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('aRouteU', new THREE.Float32BufferAttribute(routeU, 1));
geometry.setAttribute('aAcross', new THREE.Float32BufferAttribute(across, 1));
geometry.setIndex(indices);
geometry.computeBoundingSphere();
geometry.name = `${name}_AuthoredPhaseCorridor`;
return geometry;
}
export class GalaxyMapEngine {
constructor(mountElement, labelsLayer, options = {}) {
this.mount = mountElement;
this.labelsLayer = labelsLayer;
this.data = options.data || SHOWCASE_SYSTEMS;
this.layout = options.layout || SHOWCASE_LAYOUT;
this.onSystemClick = options.onSystemClick || null;
this.onSystemHover = options.onSystemHover || null;
this.currentSystemId = options.currentSystemId || 'aelos';
this.systemStatuses = options.systemStatuses || {};
this.seed = String(options.seed == null ? 'massfront-space-showcase' : options.seed);
this.selectedId = this.currentSystemId;
this._running = true;
this._disposed = false;
this._externalLoop = options.externalLoop === true;
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x000208);
// Light fog so distant systems fade out instead of clipping hard.
this.scene.fog = new THREE.Fog(0x000208, 700, 1800);
this._ownsRenderer = !options.renderer;
this.renderer = options.renderer || new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' });
/* KTX2 needs a live context to probe which compressed formats this GPU
actually supports; until a renderer registers, Basis textures stay off. */
registerRuntimeRenderer(this.renderer);
if (this._ownsRenderer) {
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.renderer.setSize(this.mount.clientWidth || 400, this.mount.clientHeight || 500);
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = GALAXY_EXPOSURE;
this.renderer.outputEncoding = THREE.sRGBEncoding;
this.mount.appendChild(this.renderer.domElement);
this.renderer.domElement.style.cssText = 'width:100%;height:100%;display:block;';
}
this.inputElement = options.inputElement || this.renderer.domElement;
this.camera = new THREE.PerspectiveCamera(45, (this.mount.clientWidth || 400) / (this.mount.clientHeight || 500), 0.1, 50000);
this.camera.position.set(0, 250, 600);
this.camera.lookAt(0, 0, 0);
// Low-emission modeled beacons need directional form lighting. This keeps
// their extruded silhouettes and side walls legible instead of clipping
// every faction color to white through emissive-only ACES tonemapping.
this.scene.add(new THREE.HemisphereLight(0x2a6687, 0x03050a, 0.82));
const chartKey = new THREE.DirectionalLight(0xd7efff, 1.55);
chartKey.position.set(240, 380, 520);
this.scene.add(chartKey);
const chartRim = new THREE.DirectionalLight(0xff865d, 0.58);
chartRim.position.set(-420, -90, -260);
this.scene.add(chartRim);
// System node registry: id -> authored beacon record.
this.systemNodes = {};
this.hyperlaneMaterials = [];
this.routeMeshes = [];
this.pointMaterials = [];
this.shaderMats = [];
this._buildStarfield();
// Cluster identity comes from authored system light, route color, and
// labels. Full-screen haze sprites washed out the actual 3D star chart.
this._buildHyperlanes();
this._buildSystemNodes();
this._buildActiveRing();
this._buildLabels();
// Camera state
this._framingProfile = '';
this._camYaw = 0;
this._camPitch = 0;
this._camDist = 600;
this._camTarget = new THREE.Vector3();
this._applyDefaultFraming(this.mount.clientWidth || 400, this.mount.clientHeight || 500, true);
// Smoothing for orbit/zoom (so dragging feels weighty).
this._camYawTarget = this._camYaw;
this._camPitchTarget = this._camPitch;
this._camDistTarget = this._camDist;
this._isDragging = false;
this._dragMode = 'orbit';
this._lastX = 0;
this._lastY = 0;
this._raycaster = new THREE.Raycaster();
this._pointer = new THREE.Vector2();
this._bindInput();
this._lastTime = performance.now();
this._loop = this._loop.bind(this);
if (!this._externalLoop) this._raf = requestAnimationFrame(this._loop);
}
// -------------------------------------------------------------
// SCENE BUILDERS
// -------------------------------------------------------------
_buildStarfield() {
const random = createSeededRandom(this.seed, 'galaxy-starfield');
const count = 5200;
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(count * 3);
const col = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const u = random(), v = random();
const theta = u * 2 * Math.PI, phi = Math.acos(2 * v - 1);
const r = 4000 + random() * 4000;
const sinPhi = Math.sin(phi);
pos[i * 3 + 0] = r * sinPhi * Math.cos(theta);
pos[i * 3 + 1] = r * Math.cos(phi);
pos[i * 3 + 2] = r * sinPhi * Math.sin(theta);
const cp = random();
if (cp > 0.7) { col[i * 3] = 0.7; col[i * 3 + 1] = 0.88; col[i * 3 + 2] = 1.0; }
else if (cp > 0.4) { col[i * 3] = 1.0; col[i * 3 + 1] = 0.85; col[i * 3 + 2] = 0.6; }
else { col[i * 3] = 0.95; col[i * 3 + 1] = 0.95; col[i * 3 + 2] = 1.0; }
}
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
const pointScale = Math.max(1, this.renderer.domElement.height) * 0.5;
const mat = createStellarPointMaterial(pointScale, 5.2, 0.82, 2.6);
this.pointMaterials.push(mat);
this.starPoints = new THREE.Points(geo, mat);
this.scene.add(this.starPoints);
// A deterministic, volumetric stellar disc supplies scale and parallax.
// It is supporting density only; the three authored system nodes remain
// the interactive foreground. One Points draw keeps the phone budget flat.
const dustCount = 18000;
const dustGeo = new THREE.BufferGeometry();
const dustPos = new Float32Array(dustCount * 3);
const dustCol = new Float32Array(dustCount * 3);
for (let i = 0; i < dustCount; i++) {
// Most navigational dust belongs near the active three-system volume;
// a smaller outer population carries the arms into the distance.
const inner = random() < 0.72;
const radial = Math.pow(random(), inner ? 1.42 : 0.72) * (inner ? 980 : 1850);
const arm = Math.floor(random() * 4);
const angle = arm * Math.PI * 0.5 + radial * 0.0062 + (random() - 0.5) * (0.34 + radial / 2300);
const bulge = radial < 420 ? 1 - radial / 420 : 0;
const vertical = (random() + random() + random() - 1.5) * (36 + radial * 0.035 + bulge * 145);
dustPos[i * 3] = Math.cos(angle) * radial;
dustPos[i * 3 + 1] = vertical;
dustPos[i * 3 + 2] = Math.sin(angle) * radial;
const core = 1 - Math.min(1, radial / 1850);
dustCol[i * 3] = 0.42 + core * 0.48;
dustCol[i * 3 + 1] = 0.58 + core * 0.30;
dustCol[i * 3 + 2] = 0.82 + core * 0.16;
}
dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPos, 3));
dustGeo.setAttribute('color', new THREE.BufferAttribute(dustCol, 3));
const dustMat = createStellarPointMaterial(pointScale, 1.26, 0.38, 3.2, true);
this.pointMaterials.push(dustMat);
this.galacticDisc = new THREE.Points(dustGeo, dustMat);
this.galacticDisc.rotation.x = -0.13;
this.scene.add(this.galacticDisc);
}
_buildHyperlanes() {
// Each pair (A, B) is drawn once. Use sorted id-tuple as the dedup key.
const drawn = new Set();
for (const [id, sys] of Object.entries(this.layout.systems)) {
for (const otherId of sys.relays) {
if (!this.layout.systems[otherId]) continue;
const key = [id, otherId].sort().join('::');
if (drawn.has(key)) continue;
drawn.add(key);
this._createHyperlane(sys.coord, this.layout.systems[otherId].coord, id, otherId);
}
}
}
_createHyperlane(a, b, idA, idB) {
// Quadratic Bézier with a small arch so the corridor doesn't cut through
// the cluster nebula plane.
const len = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, z: (a.z + b.z) / 2 };
mid.y += len * 0.04;
const curve = new THREE.QuadraticBezierCurve3(
new THREE.Vector3(a.x, a.y, a.z),
new THREE.Vector3(mid.x, mid.y, mid.z),
new THREE.Vector3(b.x, b.y, b.z)
);
const routeName = [idA, idB].sort().map(id => id[0].toUpperCase() + id.slice(1)).join('_');
const geo = createPhaseCorridorGeometry(curve, routeName);
const aColor = this.layout.clusters[this.data[idA]?.cluster]?.color || '#42d8ff';
const bColor = this.layout.clusters[this.data[idB]?.cluster]?.color || '#ffae45';
const routeColor = new THREE.Color(aColor).lerp(new THREE.Color(bColor), 0.36);
const mat = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uColor: { value: routeColor }
},
vertexShader: `
attribute float aRouteU;
attribute float aAcross;
varying float vU;
varying float vAcross;
void main() {
vU = aRouteU;
vAcross = aAcross;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform vec3 uColor;
varying float vU;
varying float vAcross;
void main() {
float edgeRail = pow(smoothstep(0.48, 1.0, abs(vAcross)), 2.4);
float guide = pow(max(0.0, 1.0 - abs(vAcross)), 4.0) * 0.28;
float packet = pow(sin(vU * 76.0 - uTime * 3.4) * 0.5 + 0.5, 11.0);
float secondary = pow(sin(vU * 31.0 + uTime * 1.7) * 0.5 + 0.5, 15.0);
float intensity = 0.08 + guide + edgeRail * 0.62 + packet * (0.28 + edgeRail * 0.62) + secondary * 0.18;
float endFade = smoothstep(0.025, 0.105, vU) * smoothstep(0.025, 0.105, 1.0 - vU);
intensity *= endFade;
gl_FragColor = vec4(uColor * intensity * 2.3, min(0.86, intensity));
}
`,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide
});
const corridor = new THREE.Mesh(geo, mat);
corridor.name = routeName;
corridor.renderOrder = 1;
this.scene.add(corridor);
this.routeMeshes.push(corridor);
this.hyperlaneMaterials.push(mat);
this.shaderMats.push(mat);
}
_buildSystemNodes() {
this.systemGroup = new THREE.Group();
this.systemGroup.name = 'AuthoredNavigationBeacons';
this.scene.add(this.systemGroup);
for (const [id, sys] of Object.entries(this.layout.systems)) {
const data = this.data[id];
if (!data) continue;
const cluster = this.layout.clusters[data.cluster];
const color = cluster ? new THREE.Color(cluster.color) : new THREE.Color(0x00f0ff);
const coreGeo = createSystemBeaconGeometry(id);
const coreMat = new THREE.MeshStandardMaterial({
color: color.clone().multiplyScalar(0.32),
emissive: color.clone().multiplyScalar(0.62),
emissiveIntensity: id === 'karak' ? 1.05 : 0.82,
roughness: 0.3,
metalness: 0.78,
transparent: true,
opacity: 0.96
});
const core = new THREE.Mesh(coreGeo, coreMat);
core.position.set(sys.coord.x, sys.coord.y, sys.coord.z);
core.scale.setScalar(1.12);
core.userData.systemId = id;
core.userData.beaconPhase = id === 'aelos' ? 0 : (id === 'veyra' ? 2.1 : 4.3);
core.renderOrder = 2;
this.systemGroup.add(core);
// A second authored profile provides physical depth and a readable
// interior light layer without a billboard or canvas-generated halo.
const accentGeo = createSystemBeaconGeometry(id);
accentGeo.scale(0.62, 0.62, 1.45);
const accentMat = new THREE.MeshBasicMaterial({
color: color.clone().lerp(new THREE.Color(0xffffff), 0.22),
transparent: true,
opacity: 0.34,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide
});
const accent = new THREE.Mesh(accentGeo, accentMat);
accent.position.copy(core.position);
accent.scale.setScalar(1.12);
accent.userData.systemId = id;
accent.userData.pickProxy = true;
accent.renderOrder = 3;
this.systemGroup.add(accent);
this.systemNodes[id] = { core, accent, color, data };
}
}
_buildActiveRing() {
// Four modeled targeting brackets replace the old flat RingGeometry.
const geo = createSelectionReticleGeometry();
const mat = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uColor: { value: new THREE.Color(0x00f0ff) }
},
vertexShader: `
varying float vDepth;
void main() {
vDepth = position.z;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform vec3 uColor;
varying float vDepth;
void main() {
float t = uTime * 1.6;
float pulse = sin(t * 2.0) * 0.5 + 0.5;
float edge = 0.78 + abs(vDepth) * 0.16;
float intensity = (0.45 + pulse * 0.55) * edge;
gl_FragColor = vec4(uColor * intensity * 2.2, intensity);
}
`,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide
});
this.activeRing = new THREE.Mesh(geo, mat);
this.scene.add(this.activeRing);
this.shaderMats.push(mat);
this._updateActiveRingPosition();
}
_updateActiveRingPosition() {
if (!this.activeRing || !this.selectedId) return;
const sys = this.layout.systems[this.selectedId];
if (sys) {
this.activeRing.position.set(sys.coord.x, sys.coord.y - 1, sys.coord.z);
// Face the modeled brackets toward the camera each frame.
this.activeRing.lookAt(this.camera.position);
}
}
_buildLabels() {
this._labelElements = new Map();
if (!this.labelsLayer) return;
this.labelsLayer.replaceChildren();
for (const [id, sys] of Object.entries(this.layout.systems)) {
const data = this.data[id];
if (!data || !sys) continue;
const label = document.createElement('div');
label.className = 'galaxy-system-label';
label.dataset.id = id;
const title = document.createElement('b');
title.textContent = data.name.toUpperCase();
const cluster = document.createElement('span');
cluster.textContent = data.cluster;
const status = document.createElement('em');
label.append(title, cluster, status);
this.labelsLayer.appendChild(label);
this._labelElements.set(id, { label, status });
}
}
_applyDefaultFraming(w, h, force = false) {
const portrait = w / Math.max(1, h) < GALAXY_PORTRAIT_ASPECT;
const profile = portrait ? 'portrait' : 'landscape';
if (!force && profile === this._framingProfile) return;
this._framingProfile = profile;
if (portrait) {
this.camera.fov = 50;
this._camYaw = 2.16;
this._camPitch = 0.22;
this._camDist = 1000;
this._camTarget.set(0, -90, 20);
} else {
this.camera.fov = 45;
this._camYaw = 1.05;
this._camPitch = 0.25;
this._camDist = 580;
this._camTarget.set(60, 0, 20);
}
this._camYawTarget = this._camYaw;
this._camPitchTarget = this._camPitch;
this._camDistTarget = this._camDist;
this.camera.updateProjectionMatrix();
}
// -------------------------------------------------------------
// PUBLIC API
// -------------------------------------------------------------
setSelected(id) {
if (!this.layout.systems[id]) return;
this.selectedId = id;
this._updateActiveRingPosition();
}
flyToSystem(id) {
// Smoothly recenter the camera target on the system, then trigger the
// entry point's transit flow via the click callback.
const sys = this.layout.systems[id];
if (sys) {
this._camTarget.set(sys.coord.x, sys.coord.y, sys.coord.z);
}
}
setCurrentSystem(id) {
this.currentSystemId = id;
this.selectedId = id;
this._updateActiveRingPosition();
}
setSystemStatuses(statuses) {
this.systemStatuses = statuses || {};
this._updateLabels();
}
resize(w, h) {
if (this._disposed) return;
this.camera.aspect = w / h;
this._applyDefaultFraming(w, h);
this.camera.updateProjectionMatrix();
if (this._ownsRenderer) this.renderer.setSize(w, h);
const pointScale = Math.max(1, this.renderer.domElement.height) * 0.5;
for (const material of this.pointMaterials) material.uniforms.uPointScale.value = pointScale;
}
pause() {
this._running = false;
if (this._raf) cancelAnimationFrame(this._raf);
this._raf = 0;
}
resume() {
if (this._disposed || this._running) return;
this._running = true;
this._lastTime = performance.now();
if (!this._externalLoop) this._raf = requestAnimationFrame(this._loop);
}
dispose() {
if (this._disposed) return;
this.pause();
this._disposed = true;
const dom = this.inputElement;
dom.removeEventListener('pointerdown', this._onDown);
dom.removeEventListener('wheel', this._onWheel);
dom.removeEventListener('pointermove', this._onHover);
window.removeEventListener('pointermove', this._onMove);
window.removeEventListener('pointerup', this._onUp);
window.removeEventListener('pointercancel', this._onUp);
if (this._ownsRenderer && this.renderer.domElement.parentNode) {
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
}
if (this._ownsRenderer) this.renderer.dispose();
this.scene.traverse(obj => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose());
else obj.material.dispose();
}
});
if (this.labelsLayer) this.labelsLayer.replaceChildren();
this._labelElements?.clear();
this.systemNodes = {};
this.routeMeshes.length = 0;
this.pointMaterials.length = 0;
this.shaderMats.length = 0;
}
// -------------------------------------------------------------
// INPUT
// -------------------------------------------------------------
_bindInput() {
const dom = this.inputElement;
this._onDown = (ev) => {
ev.preventDefault();
this._lastX = ev.clientX;
this._lastY = ev.clientY;
if (ev.button === 0) {
// Left button: pick first
const hit = this._pickSystem(ev);
if (hit) {
this.setSelected(hit);
if (this.onSystemClick) this.onSystemClick(hit);
this._isDragging = false;
return;
}
this._dragMode = 'orbit';
} else if (ev.button === 1 || ev.button === 2) {
// Middle/right: pan
this._dragMode = 'pan';
}
this._isDragging = true;
};
dom.addEventListener('pointerdown', this._onDown);
this._onMove = (ev) => {
if (!this._isDragging) return;
const dx = (ev.clientX - this._lastX) / 200;
const dy = (ev.clientY - this._lastY) / 200;
this._lastX = ev.clientX;
this._lastY = ev.clientY;
if (this._dragMode === 'orbit') {
this._camYawTarget += dx;
this._camPitchTarget = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, this._camPitchTarget + dy));
} else if (this._dragMode === 'pan') {
const right = new THREE.Vector3();
const up = new THREE.Vector3();
this.camera.getWorldDirection(right);
right.cross(this.camera.up).normalize();
up.copy(this.camera.up);
const panSpeed = this._camDist * 0.0015;
this._camTarget.addScaledVector(right, -dx * panSpeed);
this._camTarget.addScaledVector(up, dy * panSpeed);
}
};
this._onUp = () => { this._isDragging = false; };
window.addEventListener('pointermove', this._onMove);
window.addEventListener('pointerup', this._onUp);
window.addEventListener('pointercancel', this._onUp);
// Wheel zoom
this._onWheel = (ev) => {
ev.preventDefault();
const factor = ev.deltaY > 0 ? 1.12 : (1 / 1.12);
this._camDistTarget = Math.max(150, Math.min(1800, this._camDistTarget * factor));
};
dom.addEventListener('wheel', this._onWheel, { passive: false });
// Hover for label highlighting
this._onHover = (ev) => {
if (this._isDragging) return;
const id = this._pickSystem(ev);
if (this.onSystemHover) this.onSystemHover(id);
};
dom.addEventListener('pointermove', this._onHover);
}
_pickSystem(ev) {
const rect = this.inputElement.getBoundingClientRect();
this._pointer.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
this._pointer.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
this._raycaster.setFromCamera(this._pointer, this.camera);
const hits = this._raycaster.intersectObjects(this.systemGroup.children, true);
if (hits.length > 0) {
return hits[0].object.userData.systemId || null;
}
return null;
}
// -------------------------------------------------------------
// LABELS (HTML overlay positioned by projection)
// -------------------------------------------------------------
_updateLabels() {
if (!this.labelsLayer || !this._labelElements) return;
const w = this.renderer.domElement.clientWidth;
const h = this.renderer.domElement.clientHeight;
const compact = w <= 640 || (w <= 900 && h > w);
let startClearance = 10;
let endClearance = 10;
if (compact && window.visualViewport) {
const layerRect = this.labelsLayer.getBoundingClientRect();
const visibleLeft = window.visualViewport.offsetLeft;
const visibleRight = visibleLeft + window.visualViewport.width;
startClearance += Math.max(0, visibleLeft - layerRect.left);
endClearance += Math.max(0, layerRect.right - visibleRight);
}
for (const [id, sys] of Object.entries(this.layout.systems)) {
const data = this.data[id];
if (!data) continue;
const record = this._labelElements.get(id);
if (!record) continue;
const v = new THREE.Vector3(sys.coord.x, sys.coord.y, sys.coord.z);
v.project(this.camera);
const sx = (v.x * 0.5 + 0.5) * w;
const sy = (-(v.y * 0.5) + 0.5) * h;
const visible = v.z > -1 && v.z < 1 && sx > -80 && sx < w + 80 && sy > -80 && sy < h + 80;
record.label.style.display = visible ? 'grid' : 'none';
if (!visible) continue;
const isCurrent = id === this.currentSystemId;
const isSelected = id === this.selectedId;
const front = this.systemStatuses[id] || {};
record.label.className = `galaxy-system-label${isCurrent ? ' current' : ''}${isSelected ? ' selected' : ''}`;
record.label.dataset.frontState = front.state || 'unknown';
record.status.textContent = [isCurrent ? 'CURRENT' : '', front.shortLabel || ''].filter(Boolean).join(' · ');
// Projection still owns the beacon relationship. On compact viewports only,
// move the translated label center far enough inward to keep its live text
// inside the visible/safe-area gutter; the WebGL pick coordinate is untouched.
const labelX = compact
? clampProjectedLabelX(sx, w, record.label.offsetWidth, startClearance, endClearance)
: sx;
record.label.style.left = `${labelX}px`;
record.label.style.top = `${sy + 34}px`;
}
}
// -------------------------------------------------------------
// CAMERA + RENDER LOOP
// -------------------------------------------------------------
_updateCamera() {
// Smooth toward targets
this._camYaw += (this._camYawTarget - this._camYaw) * 0.18;
this._camPitch += (this._camPitchTarget - this._camPitch) * 0.18;
this._camDist += (this._camDistTarget - this._camDist) * 0.18;
const sinY = Math.sin(this._camYaw), cosY = Math.cos(this._camYaw);
const sinP = Math.sin(this._camPitch), cosP = Math.cos(this._camPitch);
this.camera.position.set(
this._camTarget.x + sinY * cosP * this._camDist,
this._camTarget.y + sinP * this._camDist,
this._camTarget.z + cosY * cosP * this._camDist
);
this.camera.lookAt(this._camTarget);
}
_loop(now) {
if (!this._running || this._disposed) return;
this.renderFrame(now);
if (this._running && !this._externalLoop) this._raf = requestAnimationFrame(this._loop);
}
renderFrame(now = performance.now()) {
if (!this._running || this._disposed) return;
this._lastTime = now;
for (const m of this.shaderMats) {
if (m.uniforms && m.uniforms.uTime) m.uniforms.uTime.value = now * 0.001;
}
for (const node of Object.values(this.systemNodes)) {
const phase = node.core.userData.beaconPhase || 0;
node.core.rotation.z = now * 0.00008 * (node.data.id === 'karak' ? -1 : 1) + phase;
node.accent.rotation.z = -now * 0.00013 + phase * 0.5;
node.accent.material.opacity = 0.23 + (Math.sin(now * 0.0018 + phase) * 0.5 + 0.5) * 0.17;
}
this._updateCamera();
this._updateActiveRingPosition();
if (this.activeRing) {
const pulse = 1 + Math.sin(now * 0.0024) * 0.045;
this.activeRing.scale.setScalar(pulse);
this.activeRing.rotation.z += 0.0018;
}
this._updateLabels();
this.renderer.toneMappingExposure = GALAXY_EXPOSURE;
this.renderer.render(this.scene, this.camera);
}
}