- Restructure flat static prototype into pnpm workspace monorepo - apps/game: playable shell with R3F 3D scene, HUD, SpacetimeDB connection - apps/docs: design docs and prototypes - apps/site: landing page - packages/ui: shared Button and Panel primitives - services/spacetimedb: backend module (9 tables, 11 reducers) - Archive legacy static files to archive/legacy-static/ - Game loop: connect, undock, target, approach, dock, mine, sell - Add pnpm-workspace.yaml, tsconfig.base.json, spacetime.json
1045 lines
56 KiB
JavaScript
1045 lines
56 KiB
JavaScript
window.GDD = window.GDD || {};
|
|
|
|
const { useState, useEffect, useRef, useCallback, useMemo } = React;
|
|
const TH = window.GDD.THREE;
|
|
|
|
/* ── Scale constants ── */
|
|
const SCALE = 0.5; // world position scale
|
|
const ORBIT_SCALE = 0.22; // orbital radius scale
|
|
const PLANET_SCALE = 0.7; // planet mesh scale
|
|
|
|
function StarMapDemo() {
|
|
const containerRef = useRef(null);
|
|
const sceneRef = useRef(null);
|
|
const [systems, setSystems] = useState([]);
|
|
const [connections, setConnections] = useState([]);
|
|
const [selected, setSelected] = useState(null);
|
|
const [destination, setDestination] = useState(null);
|
|
const [hovered, setHovered] = useState(null);
|
|
const [waypoints, setWaypoints] = useState([]);
|
|
const [route, setRoute] = useState([]);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [systemFilter, setSystemFilter] = useState('all');
|
|
const [contextMenu, setContextMenu] = useState({ visible: false, x: 0, y: 0, target: null });
|
|
const systemMeshesRef = useRef([]);
|
|
const routeLineRef = useRef(null);
|
|
const connLinesRef = useRef([]);
|
|
const animIdRef = useRef(null);
|
|
const cameraCtrlRef = useRef(null);
|
|
const minimapCanvasRef = useRef(null);
|
|
const focusTargetRef = useRef(null);
|
|
const orbitalBodiesRef = useRef([]);
|
|
const asteroidBeltsRef = useRef([]);
|
|
|
|
/* ── Celestial data ── */
|
|
const celestialData = useMemo(() => {
|
|
return window.GDD.CONSTANTS.CELESTIAL_BODIES || {};
|
|
}, []);
|
|
|
|
const selectedCelestial = useMemo(() => {
|
|
if (!selected) return null;
|
|
return celestialData[selected.id] || null;
|
|
}, [selected, celestialData]);
|
|
|
|
/* ── Load data ── */
|
|
useEffect(() => {
|
|
window.GDD.api.getSystems().then(s => setSystems(s));
|
|
window.GDD.api.getConnections().then(c => setConnections(c));
|
|
}, []);
|
|
|
|
/* ── Camera fly-to on selection ── */
|
|
useEffect(() => {
|
|
if (!focusTargetRef.current || !cameraCtrlRef.current) return;
|
|
cameraCtrlRef.current.flyTo(focusTargetRef.current.x, focusTargetRef.current.y, focusTargetRef.current.z || 0);
|
|
focusTargetRef.current = null;
|
|
}, [selected]);
|
|
|
|
/* ── Context menu close on click / Escape / scroll ── */
|
|
useEffect(() => {
|
|
const close = () => setContextMenu(prev => ({ ...prev, visible: false }));
|
|
const onKey = (e) => { if (e.key === 'Escape') close(); };
|
|
window.addEventListener('click', close);
|
|
window.addEventListener('keydown', onKey);
|
|
return () => { window.removeEventListener('click', close); window.removeEventListener('keydown', onKey); };
|
|
}, []);
|
|
|
|
/* ── Build 3D scene ── */
|
|
useEffect(() => {
|
|
if (systems.length === 0) return;
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
// Cleanup previous
|
|
if (sceneRef.current) {
|
|
sceneRef.current.scene.traverse(child => {
|
|
if (child.geometry) child.geometry.dispose();
|
|
if (child.material) { if (child.material.map) child.material.map.dispose(); child.material.dispose(); }
|
|
});
|
|
sceneRef.current.renderer.dispose();
|
|
if (sceneRef.current.renderer.domElement.parentNode) sceneRef.current.renderer.domElement.parentNode.removeChild(sceneRef.current.renderer.domElement);
|
|
if (animIdRef.current) cancelAnimationFrame(animIdRef.current);
|
|
if (cameraCtrlRef.current) cameraCtrlRef.current.dispose();
|
|
}
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.fog = new THREE.FogExp2(0x040810, 0.0003);
|
|
|
|
const w = container.clientWidth;
|
|
const h = container.clientHeight;
|
|
const camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 5000);
|
|
camera.position.set(0, 180, 220);
|
|
camera.lookAt(0, 0, 0);
|
|
|
|
const renderer = TH.createRenderer(container, { clearColor: 0x040810 });
|
|
renderer.setSize(w, h);
|
|
TH.handleResize(renderer, camera, container);
|
|
|
|
// Star field
|
|
const stars = TH.createStarField(4000, 2500);
|
|
scene.add(stars);
|
|
|
|
// Nebulae
|
|
TH.addNebula(scene, 0x22d3ee, [-100, 50, -300], 400);
|
|
TH.addNebula(scene, 0xa78bfa, [200, -80, -200], 300);
|
|
TH.addNebula(scene, 0xf0a030, [0, 100, -250], 200);
|
|
|
|
// Grid
|
|
const grid = new THREE.GridHelper(600, 30, 0x0d1520, 0x0d1520);
|
|
grid.material.transparent = true;
|
|
grid.material.opacity = 0.12;
|
|
scene.add(grid);
|
|
|
|
TH.setupSpaceLighting(scene);
|
|
|
|
// Build system meshes
|
|
systemMeshesRef.current = [];
|
|
orbitalBodiesRef.current = [];
|
|
asteroidBeltsRef.current = [];
|
|
|
|
systems.forEach(sys => {
|
|
const group = TH.createStarSystem(sys, SCALE);
|
|
scene.add(group);
|
|
systemMeshesRef.current.push(group);
|
|
|
|
// Create celestial bodies for this system
|
|
const cData = celestialData[sys.id];
|
|
if (!cData) return;
|
|
const sysPos = new THREE.Vector3(sys.x * SCALE, sys.y * SCALE, 0);
|
|
|
|
cData.bodies.forEach(body => {
|
|
if (body.type === 'belt') {
|
|
// Asteroid belt
|
|
const belt = TH.createAsteroidBelt(
|
|
body.innerOrbit * ORBIT_SCALE,
|
|
body.outerOrbit * ORBIT_SCALE,
|
|
body.count,
|
|
{ basePeriod: 20, maxInclination: 0.12 }
|
|
);
|
|
belt.position.copy(sysPos);
|
|
scene.add(belt);
|
|
asteroidBeltsRef.current.push({ belt, parentPos: sysPos });
|
|
return;
|
|
}
|
|
|
|
// Planet
|
|
const orbitR = body.orbit * ORBIT_SCALE;
|
|
const planetSize = body.size * PLANET_SCALE;
|
|
const planetMesh = TH.createPlanetMesh(planetSize, body.color, body.atmosphere);
|
|
scene.add(planetMesh);
|
|
|
|
// Orbit trail
|
|
const trail = TH.createOrbitTrail(orbitR, body.ecc, body.inc, 0x1a3050, 0.18);
|
|
trail.position.copy(sysPos);
|
|
scene.add(trail);
|
|
|
|
// Orbital body
|
|
const orbBody = new TH.OrbitalBody({
|
|
mesh: planetMesh,
|
|
parentPos: sysPos,
|
|
orbitRadius: orbitR,
|
|
eccentricity: body.ecc,
|
|
inclination: body.inc,
|
|
period: body.period,
|
|
});
|
|
|
|
// Rings
|
|
if (body.hasRings) {
|
|
const rings = TH.createRingSystem(planetSize * 1.3, planetSize * 2.1, body.color, 0.25);
|
|
planetMesh.add(rings);
|
|
}
|
|
|
|
// Moons
|
|
if (body.moons) {
|
|
body.moons.forEach(moon => {
|
|
const moonSize = moon.size * PLANET_SCALE * 0.8;
|
|
const moonMesh = TH.createPlanetMesh(moonSize, moon.color);
|
|
scene.add(moonMesh);
|
|
|
|
const moonOrbitR = moon.orbit * ORBIT_SCALE * 0.5;
|
|
const moonTrail = TH.createOrbitTrail(moonOrbitR, 0, 0, 0x0d1a2a, 0.08);
|
|
scene.add(moonTrail);
|
|
|
|
const moonBody = new TH.OrbitalBody({
|
|
mesh: moonMesh,
|
|
orbitRadius: moonOrbitR,
|
|
period: moon.period,
|
|
});
|
|
orbBody.children.push(moonBody);
|
|
});
|
|
}
|
|
|
|
// Stations in orbit around planets
|
|
if (sys.stations && body.type === 'terrestrial') {
|
|
sys.stations.forEach((stn, si) => {
|
|
const stnMesh = TH.createOrbitalStation(planetSize * 0.25, 0x22d3ee);
|
|
scene.add(stnMesh);
|
|
const stnOrbitR = planetSize * 2.5 + si * 1.5;
|
|
const stnTrail = TH.createOrbitTrail(stnOrbitR, 0, 0, 0x22d3ee, 0.06);
|
|
scene.add(stnTrail);
|
|
const stnBody = new TH.OrbitalBody({
|
|
mesh: stnMesh,
|
|
orbitRadius: stnOrbitR,
|
|
period: 3 + si,
|
|
phase: si * Math.PI * 0.7,
|
|
});
|
|
orbBody.children.push(stnBody);
|
|
});
|
|
}
|
|
|
|
orbitalBodiesRef.current.push(orbBody);
|
|
});
|
|
});
|
|
|
|
// Connection lines
|
|
connLinesRef.current = [];
|
|
connections.forEach(([a, b]) => {
|
|
const sa = systems.find(s => s.id === a);
|
|
const sb = systems.find(s => s.id === b);
|
|
if (!sa || !sb) return;
|
|
const line = TH.createConnectionLine(
|
|
{ x: sa.x * SCALE, y: sa.y * SCALE, z: 0 },
|
|
{ x: sb.x * SCALE, y: sb.y * SCALE, z: 0 },
|
|
0x1c2a3f, 0.4
|
|
);
|
|
scene.add(line);
|
|
connLinesRef.current.push({ line, a, b });
|
|
});
|
|
|
|
// Camera controller
|
|
const ctrl = new TH.OrbitController(camera, renderer.domElement, new THREE.Vector3(0, 0, 0));
|
|
ctrl.minDistance = 8;
|
|
ctrl.maxDistance = 400;
|
|
ctrl.dampingFactor = 0.08;
|
|
ctrl.rotateSpeed = 0.4;
|
|
ctrl.zoomSpeed = 0.8;
|
|
ctrl.panSpeed = 0.5;
|
|
ctrl.enablePan = true;
|
|
ctrl.panButton = 2;
|
|
cameraCtrlRef.current = ctrl;
|
|
|
|
sceneRef.current = { scene, camera, renderer, stars };
|
|
|
|
// Animation loop
|
|
const clock = new THREE.Clock();
|
|
const animate = () => {
|
|
animIdRef.current = requestAnimationFrame(animate);
|
|
const dt = clock.getDelta();
|
|
const t = clock.elapsedTime;
|
|
ctrl.update(dt);
|
|
|
|
stars.rotation.y = t * 0.001;
|
|
|
|
// Pulse system glows
|
|
systemMeshesRef.current.forEach(group => {
|
|
const glow = group.children[1];
|
|
if (glow) {
|
|
const base = 6 * SCALE;
|
|
glow.scale.setScalar(base + Math.sin(t * 2 + group.position.x) * 0.5);
|
|
}
|
|
});
|
|
|
|
// Update orbital bodies (Keplerian physics)
|
|
orbitalBodiesRef.current.forEach(ob => ob.update(dt));
|
|
|
|
// Update asteroid belts
|
|
asteroidBeltsRef.current.forEach(ab => TH.updateAsteroidBelt(ab.belt, dt, ab.parentPos));
|
|
|
|
renderer.render(scene, camera);
|
|
};
|
|
animate();
|
|
|
|
const onResize = () => TH.handleResize(renderer, camera, container);
|
|
window.addEventListener('resize', onResize);
|
|
|
|
return () => {
|
|
if (animIdRef.current) cancelAnimationFrame(animIdRef.current);
|
|
window.removeEventListener('resize', onResize);
|
|
if (cameraCtrlRef.current) cameraCtrlRef.current.dispose();
|
|
};
|
|
}, [systems, celestialData]);
|
|
|
|
/* ── Draw minimap ── */
|
|
useEffect(() => {
|
|
const canvas = minimapCanvasRef.current;
|
|
if (!canvas || systems.length === 0) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const W = canvas.width;
|
|
const H = canvas.height;
|
|
const xs = systems.map(s => s.x);
|
|
const ys = systems.map(s => s.y);
|
|
const minX = Math.min(...xs) - 40;
|
|
const maxX = Math.max(...xs) + 40;
|
|
const minY = Math.min(...ys) - 40;
|
|
const maxY = Math.max(...ys) + 40;
|
|
const scaleX = (W - 20) / (maxX - minX);
|
|
const scaleY = (H - 20) / (maxY - minY);
|
|
const sc = Math.min(scaleX, scaleY);
|
|
const mapX = (x) => 10 + (x - minX) * sc;
|
|
const mapY = (y) => H - 10 - (y - minY) * sc;
|
|
|
|
ctx.clearRect(0, 0, W, H);
|
|
ctx.fillStyle = 'rgba(8,12,20,0.95)';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
ctx.strokeStyle = 'rgba(28,42,63,0.6)';
|
|
ctx.lineWidth = 0.5;
|
|
connections.forEach(([a, b]) => {
|
|
const sa = systems.find(s => s.id === a);
|
|
const sb = systems.find(s => s.id === b);
|
|
if (!sa || !sb) return;
|
|
ctx.beginPath(); ctx.moveTo(mapX(sa.x), mapY(sa.y)); ctx.lineTo(mapX(sb.x), mapY(sb.y)); ctx.stroke();
|
|
});
|
|
|
|
if (route.length > 1) {
|
|
ctx.strokeStyle = '#22d3ee'; ctx.lineWidth = 1.5; ctx.setLineDash([3, 2]);
|
|
ctx.beginPath();
|
|
route.forEach((s, i) => { if (i === 0) ctx.moveTo(mapX(s.x), mapY(s.y)); else ctx.lineTo(mapX(s.x), mapY(s.y)); });
|
|
ctx.stroke(); ctx.setLineDash([]);
|
|
}
|
|
|
|
systems.forEach(sys => {
|
|
const x = mapX(sys.x), y = mapY(sys.y);
|
|
const isSel = selected && selected.id === sys.id;
|
|
const isDest = destination === sys.id;
|
|
const isHov = hovered && hovered.id === sys.id;
|
|
let col = '#5a6b82';
|
|
if (sys.security >= 0.5) col = '#22c55e';
|
|
else if (sys.security >= 0.2) col = '#f0a030';
|
|
else col = '#ef4444';
|
|
if (isSel) { ctx.fillStyle = '#f0a030'; ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill(); }
|
|
if (isDest) { ctx.strokeStyle = '#22d3ee'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(x, y, 6, 0, Math.PI * 2); ctx.stroke(); }
|
|
const r = isHov ? 3 : isSel ? 4 : 2.5;
|
|
ctx.fillStyle = col; ctx.globalAlpha = isHov || isSel ? 1 : 0.7;
|
|
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1;
|
|
if (isSel || isHov || isDest) {
|
|
ctx.font = '8px JetBrains Mono, monospace';
|
|
ctx.fillStyle = isSel ? '#f1f5f9' : '#94a3b8';
|
|
ctx.textAlign = 'center'; ctx.fillText(sys.name, x, y - 8);
|
|
}
|
|
});
|
|
}, [systems, connections, selected, hovered, destination, route]);
|
|
|
|
/* ── Update route 3D line ── */
|
|
useEffect(() => {
|
|
if (!sceneRef.current) return;
|
|
const { scene } = sceneRef.current;
|
|
if (routeLineRef.current) {
|
|
scene.remove(routeLineRef.current);
|
|
if (routeLineRef.current.geometry) routeLineRef.current.geometry.dispose();
|
|
if (routeLineRef.current.material) routeLineRef.current.material.dispose();
|
|
routeLineRef.current = null;
|
|
}
|
|
if (route.length > 1) {
|
|
const line = TH.createRouteLine(route.map(s => ({ x: s.x * SCALE, y: s.y * SCALE, z: 1 })), 0x22d3ee);
|
|
scene.add(line);
|
|
routeLineRef.current = line;
|
|
}
|
|
}, [route]);
|
|
|
|
/* ── Update selection visuals ── */
|
|
useEffect(() => {
|
|
systemMeshesRef.current.forEach(group => {
|
|
const sys = group.userData;
|
|
const isSel = selected && selected.id === sys.id;
|
|
const isHov = hovered && hovered.id === sys.id;
|
|
const isDest = destination === sys.id;
|
|
const isWP = waypoints.some(w => w.id === sys.id);
|
|
|
|
const core = group.children[0];
|
|
if (core) core.scale.setScalar(isSel ? 2.0 : isHov ? 1.5 : isDest ? 1.6 : isWP ? 1.3 : 1);
|
|
|
|
const glow = group.children[1];
|
|
if (glow) {
|
|
let col = new THREE.Color(sys.color);
|
|
if (isDest) col = new THREE.Color(0x22d3ee);
|
|
else if (isSel) col = new THREE.Color(0xf0a030);
|
|
else if (isWP) col = new THREE.Color(0xa78bfa);
|
|
glow.material.color.copy(col);
|
|
}
|
|
|
|
const label = group.children[2];
|
|
if (label) {
|
|
let color = '#5a6b82', size = 18;
|
|
if (isSel) { color = '#f1f5f9'; size = 22; }
|
|
else if (isHov) { color = '#d4dce8'; size = 20; }
|
|
else if (isDest) { color = '#22d3ee'; size = 20; }
|
|
else if (isWP) { color = '#a78bfa'; size = 19; }
|
|
const prefix = isDest ? '⊕ ' : isWP ? '◇ ' : '';
|
|
TH.updateLabelText(label, prefix + sys.name, color, size);
|
|
}
|
|
});
|
|
}, [selected, hovered, destination, waypoints]);
|
|
|
|
/* ── Highlight connections ── */
|
|
useEffect(() => {
|
|
connLinesRef.current.forEach(({ line, a, b }) => {
|
|
const isOnRoute = route.some((s, i) => {
|
|
const next = route[i + 1];
|
|
return next && ((a === s.id && b === next.id) || (b === s.id && a === next.id));
|
|
});
|
|
const isActive = selected && (selected.id === a || selected.id === b);
|
|
if (isOnRoute) { line.material.color.set(0x22d3ee); line.material.opacity = 0.5; }
|
|
else if (isActive) { line.material.color.set(0xf0a030); line.material.opacity = 0.5; }
|
|
else { line.material.color.set(0x1c2a3f); line.material.opacity = 0.25; }
|
|
});
|
|
}, [selected, route]);
|
|
|
|
/* ── Raycast helper ── */
|
|
const raycastSystems = useCallback((e) => {
|
|
if (!sceneRef.current) return null;
|
|
const { camera } = sceneRef.current;
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const mouse = new THREE.Vector2(
|
|
((e.clientX - rect.left) / rect.width) * 2 - 1,
|
|
-((e.clientY - rect.top) / rect.height) * 2 + 1
|
|
);
|
|
const cores = systemMeshesRef.current.map(g => g.children[0]).filter(Boolean);
|
|
const hits = TH.raycast(mouse, camera, cores);
|
|
if (hits.length > 0) return hits[0].object.parent;
|
|
return null;
|
|
}, []);
|
|
|
|
/* ── Click ── */
|
|
const handleClick = useCallback((e) => {
|
|
const group = raycastSystems(e);
|
|
if (group) setSelected(group.userData);
|
|
else setSelected(null);
|
|
}, [raycastSystems]);
|
|
|
|
/* ── Double-click ── */
|
|
const handleDoubleClick = useCallback((e) => {
|
|
const group = raycastSystems(e);
|
|
if (group) {
|
|
const sys = group.userData;
|
|
setSelected(sys);
|
|
focusTargetRef.current = { x: sys.x * SCALE, y: sys.y * SCALE, z: 0 };
|
|
}
|
|
}, [raycastSystems]);
|
|
|
|
/* ── Hover ── */
|
|
const handleMove = useCallback((e) => {
|
|
const group = raycastSystems(e);
|
|
if (group) { setHovered(group.userData); containerRef.current.style.cursor = 'pointer'; }
|
|
else { setHovered(null); containerRef.current.style.cursor = 'grab'; }
|
|
}, [raycastSystems]);
|
|
|
|
/* ── Right-click context menu ── */
|
|
const handleContextMenu = useCallback((e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const group = raycastSystems(e);
|
|
setContextMenu({
|
|
visible: true,
|
|
x: Math.min(e.clientX, window.innerWidth - 200),
|
|
y: Math.min(e.clientY, window.innerHeight - 250),
|
|
target: group ? group.userData : null,
|
|
});
|
|
}, [raycastSystems]);
|
|
|
|
/* ── Actions ── */
|
|
const setDestFor = useCallback((sys) => {
|
|
setDestination(sys.id);
|
|
const sol = systems.find(s => s.id === 'sol');
|
|
setRoute(sol && sys.id !== 'sol' ? [sol, sys] : [sys]);
|
|
}, [systems]);
|
|
|
|
const addWaypointFor = useCallback((sys) => {
|
|
setWaypoints(prev => {
|
|
if (prev.some(w => w.id === sys.id)) return prev;
|
|
const next = [...prev, sys];
|
|
const sol = systems.find(s => s.id === 'sol');
|
|
setRoute(sol ? [sol, ...next] : next);
|
|
return next;
|
|
});
|
|
}, [systems]);
|
|
|
|
const handleSetDestination = useCallback(() => { if (selected) setDestFor(selected); }, [selected, setDestFor]);
|
|
const handleAddWaypoint = useCallback(() => { if (selected) addWaypointFor(selected); }, [selected, addWaypointFor]);
|
|
|
|
const handleClearRoute = useCallback(() => { setDestination(null); setWaypoints([]); setRoute([]); }, []);
|
|
const handleZoomTo = useCallback((level) => { if (cameraCtrlRef.current) cameraCtrlRef.current.distance = level; }, []);
|
|
const handleResetView = useCallback(() => {
|
|
const ctrl = cameraCtrlRef.current;
|
|
if (!ctrl) return;
|
|
ctrl.target.set(0, 0, 0); ctrl.distance = 200; ctrl.theta = Math.PI / 4; ctrl.phi = Math.PI / 3;
|
|
}, []);
|
|
|
|
/* ── Filtered systems ── */
|
|
const filteredSystems = useMemo(() => {
|
|
let list = systems;
|
|
if (searchQuery) { const q = searchQuery.toLowerCase(); list = list.filter(s => s.name.toLowerCase().includes(q)); }
|
|
if (systemFilter !== 'all') {
|
|
if (systemFilter === 'highsec') list = list.filter(s => s.security >= 0.5);
|
|
else if (systemFilter === 'lowsec') list = list.filter(s => s.security >= 0.2 && s.security < 0.5);
|
|
else if (systemFilter === 'nullsec') list = list.filter(s => s.security < 0.2);
|
|
}
|
|
return list;
|
|
}, [systems, searchQuery, systemFilter]);
|
|
|
|
const currentSystem = systems.find(s => s.id === 'sol');
|
|
const destSystem = destination ? systems.find(s => s.id === destination) : null;
|
|
|
|
const secColor = (sec) => { if (sec >= 0.5) return 'var(--green)'; if (sec >= 0.2) return 'var(--accent)'; return 'var(--red)'; };
|
|
const secBg = (sec) => { if (sec >= 0.5) return 'var(--green-bg)'; if (sec >= 0.2) return 'var(--accent-bg)'; return 'var(--red-bg)'; };
|
|
const planetIcon = (t) => { if (t === 'gas') return '⛽'; if (t === 'terrestrial') return '🌍'; return '●'; };
|
|
|
|
/* ══════════════════════════════════════
|
|
RENDER
|
|
══════════════════════════════════════ */
|
|
return (
|
|
<div style={{ position: 'relative', width: '100%', height: '100vh', background: '#040810', overflow: 'hidden' }}>
|
|
|
|
{/* 3D Canvas */}
|
|
<div ref={containerRef} style={{ position: 'absolute', inset: 0, zIndex: 0, cursor: 'grab' }}
|
|
onClick={handleClick} onDoubleClick={handleDoubleClick} onMouseMove={handleMove} onContextMenu={handleContextMenu} />
|
|
|
|
{/* ═══ Custom Context Menu ═══ */}
|
|
{contextMenu.visible && (
|
|
<div onClick={(e) => e.stopPropagation()} style={{
|
|
position: 'fixed', left: contextMenu.x, top: contextMenu.y, zIndex: 9999, minWidth: '200px',
|
|
background: 'rgba(10,16,28,0.97)', border: '1px solid var(--border-light)',
|
|
borderRadius: 'var(--radius-lg)', backdropFilter: 'blur(16px)',
|
|
boxShadow: '0 8px 40px rgba(0,0,0,0.7), 0 0 1px rgba(34,211,238,0.15)', overflow: 'hidden',
|
|
fontFamily: 'var(--font-mono)', fontSize: '11px',
|
|
}}>
|
|
{contextMenu.target ? (
|
|
<>
|
|
{/* System header */}
|
|
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '8px', background: 'rgba(15,22,35,0.6)' }}>
|
|
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: contextMenu.target.color, boxShadow: `0 0 6px ${contextMenu.target.color}` }} />
|
|
<span style={{ flex: 1, color: 'var(--fg-bright)', fontWeight: 600, fontSize: '12px' }}>{contextMenu.target.name}</span>
|
|
<span style={{ fontSize: '9px', padding: '1px 5px', borderRadius: 'var(--radius-pill)', background: secBg(contextMenu.target.security), color: secColor(contextMenu.target.security) }}>
|
|
{contextMenu.target.security.toFixed(1)}
|
|
</span>
|
|
</div>
|
|
{/* Quick info */}
|
|
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: '3px' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ color: 'var(--muted)', fontSize: '9px' }}>TYPE</span>
|
|
<span style={{ color: 'var(--fg-dim)', fontSize: '9px' }}>{contextMenu.target.type}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ color: 'var(--muted)', fontSize: '9px' }}>PLANETS</span>
|
|
<span style={{ color: 'var(--fg-dim)', fontSize: '9px' }}>{contextMenu.target.planets}</span>
|
|
</div>
|
|
{celestialData[contextMenu.target.id] && (
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ color: 'var(--muted)', fontSize: '9px' }}>FACTION</span>
|
|
<span style={{ color: 'var(--fg-dim)', fontSize: '9px' }}>{celestialData[contextMenu.target.id].faction}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Actions */}
|
|
<div style={{ padding: '4px' }}>
|
|
{[
|
|
{ icon: '✦', label: 'Show Details', action: () => { setSelected(contextMenu.target); focusTargetRef.current = { x: contextMenu.target.x * SCALE, y: contextMenu.target.y * SCALE, z: 0 }; setContextMenu(prev => ({...prev, visible: false})); } },
|
|
{ icon: '⊕', label: 'Set Destination', action: () => { setDestFor(contextMenu.target); setContextMenu(prev => ({...prev, visible: false})); }, highlight: 'var(--cyan)' },
|
|
{ icon: '◇', label: 'Add Waypoint', action: () => { addWaypointFor(contextMenu.target); setContextMenu(prev => ({...prev, visible: false})); } },
|
|
{ icon: '◎', label: 'Focus Camera', action: () => { focusTargetRef.current = { x: contextMenu.target.x * SCALE, y: contextMenu.target.y * SCALE, z: 0 }; setContextMenu(prev => ({...prev, visible: false})); } },
|
|
{ icon: '⚐', label: 'Bookmark', action: () => setContextMenu(prev => ({...prev, visible: false})) },
|
|
].map((item, i) => (
|
|
<div key={i} onClick={item.action} style={{
|
|
padding: '7px 10px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '10px',
|
|
color: 'var(--fg-dim)', borderRadius: 'var(--radius-sm)', transition: 'all 0.1s',
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--surface-raised)'; e.currentTarget.style.color = 'var(--fg-bright)'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--fg-dim)'; }}>
|
|
<span style={{ width: '16px', textAlign: 'center', color: item.highlight || (i === 0 ? 'var(--cyan)' : 'var(--muted)') }}>{item.icon}</span>
|
|
{item.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', color: 'var(--muted)', fontSize: '9px', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
|
Star Map Controls
|
|
</div>
|
|
<div style={{ padding: '4px' }}>
|
|
{[
|
|
{ icon: '⟳', label: 'Reset View', action: () => { handleResetView(); setContextMenu(prev => ({...prev, visible: false})); } },
|
|
{ icon: '⊞', label: 'Zoom — Region', action: () => { handleZoomTo(120); setContextMenu(prev => ({...prev, visible: false})); } },
|
|
{ icon: '⊡', label: 'Zoom — Show All', action: () => { handleZoomTo(280); setContextMenu(prev => ({...prev, visible: false})); } },
|
|
{ icon: '◎', label: 'Zoom — System', action: () => { handleZoomTo(30); setContextMenu(prev => ({...prev, visible: false})); } },
|
|
].map((item, i) => (
|
|
<div key={i} onClick={item.action} style={{
|
|
padding: '7px 10px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '10px',
|
|
color: 'var(--fg-dim)', borderRadius: 'var(--radius-sm)', transition: 'all 0.1s',
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--surface-raised)'; e.currentTarget.style.color = 'var(--fg-bright)'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--fg-dim)'; }}>
|
|
<span style={{ width: '16px', textAlign: 'center', color: 'var(--muted)' }}>{item.icon}</span>
|
|
{item.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ═══ HUD Overlay ═══ */}
|
|
<div style={{ position: 'absolute', inset: 0, zIndex: 1, display: 'flex', flexDirection: 'column', pointerEvents: 'none' }}>
|
|
|
|
{/* ═══ TOP BAR ═══ */}
|
|
<div style={{
|
|
height: '40px', display: 'flex', alignItems: 'center', padding: '0 14px', gap: '12px',
|
|
background: 'linear-gradient(180deg, rgba(8,12,20,0.94) 0%, rgba(8,12,20,0.6) 80%, transparent 100%)',
|
|
fontFamily: 'var(--font-mono)', fontSize: '11px', pointerEvents: 'auto', flexShrink: 0,
|
|
}}>
|
|
<button onClick={() => window.history.back()} title="Back to Docs" style={{ background: 'rgba(255,255,255,0.06)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', color: 'var(--muted)', cursor: 'pointer', padding: '2px 8px', fontSize: '11px', display: 'flex', alignItems: 'center', gap: '4px', transition: 'all 0.15s' }}
|
|
onMouseEnter={e => { e.currentTarget.style.color = 'var(--fg-bright)'; e.currentTarget.style.borderColor = 'var(--border-light)'; }}
|
|
onMouseLeave={e => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--border)'; }}
|
|
>
|
|
<span style={{ fontSize: '9px' }}>◀</span> DOCS
|
|
</button>
|
|
<div style={{ width: 1, height: 18, background: 'var(--border-light)' }} />
|
|
<span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--fg-bright)', fontFamily: 'var(--font-display)' }}>STAR MAP</span>
|
|
<span style={{ fontSize: '9px', padding: '2px 8px', borderRadius: 'var(--radius-pill)', fontWeight: 600, background: secBg(currentSystem?.security || 1), color: secColor(currentSystem?.security || 1), border: `1px solid ${secColor(currentSystem?.security || 1)}33` }}>
|
|
{currentSystem?.name || 'Sol'} · {currentSystem?.security.toFixed(1) || '1.0'}
|
|
</span>
|
|
<div style={{ width: 1, height: 18, background: 'var(--border-light)' }} />
|
|
<span style={{ color: 'var(--muted)', fontSize: '9px', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Systems</span>
|
|
<span style={{ color: 'var(--fg-bright)', fontWeight: 600 }}>{systems.length}</span>
|
|
<div style={{ width: 1, height: 18, background: 'var(--border-light)' }} />
|
|
<span style={{ color: 'var(--muted)', fontSize: '9px', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Bridges</span>
|
|
<span style={{ color: 'var(--fg-bright)', fontWeight: 600 }}>{connections.length}</span>
|
|
|
|
{destination && (
|
|
<>
|
|
<div style={{ width: 1, height: 18, background: 'var(--border-light)' }} />
|
|
<span style={{ color: 'var(--cyan)', fontSize: '9px', display: 'flex', alignItems: 'center', gap: '5px' }}>
|
|
<span style={{ width: '5px', height: '5px', borderRadius: '50%', background: 'var(--cyan)', boxShadow: '0 0 6px var(--cyan)' }} />
|
|
DEST: {destSystem?.name}
|
|
<span style={{ color: 'var(--muted)' }}>· {route.length - 1} jump{route.length - 1 !== 1 ? 's' : ''}</span>
|
|
</span>
|
|
</>
|
|
)}
|
|
|
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: '4px', alignItems: 'center' }}>
|
|
<span style={{ color: 'var(--muted)', fontSize: '8px', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Zoom</span>
|
|
{[{ label: 'SYS', dist: 30 }, { label: 'REG', dist: 120 }, { label: 'ALL', dist: 280 }].map(z => (
|
|
<button key={z.label} onClick={() => handleZoomTo(z.dist)} style={{
|
|
padding: '2px 8px', fontSize: '9px', fontFamily: 'var(--font-mono)', borderRadius: 'var(--radius-sm)',
|
|
background: 'var(--surface-raised)', border: '1px solid var(--border)', color: 'var(--fg-dim)', cursor: 'pointer', fontWeight: 500,
|
|
}}>{z.label}</button>
|
|
))}
|
|
<div style={{ width: 1, height: 14, background: 'var(--border-light)', margin: '0 4px' }} />
|
|
<button onClick={handleResetView} style={{
|
|
padding: '2px 8px', fontSize: '9px', fontFamily: 'var(--font-mono)', borderRadius: 'var(--radius-sm)',
|
|
background: 'var(--surface-raised)', border: '1px solid var(--border)', color: 'var(--muted)', cursor: 'pointer',
|
|
}}>RESET</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ═══ MIDDLE AREA ═══ */}
|
|
<div style={{ flex: 1, display: 'flex', minHeight: 0, position: 'relative' }}>
|
|
|
|
{/* ═══ LEFT — System Details Panel ═══ */}
|
|
<div style={{
|
|
width: '260px', display: 'flex', flexDirection: 'column',
|
|
pointerEvents: 'auto', flexShrink: 0, minHeight: 0, overflow: 'hidden',
|
|
}}>
|
|
<div style={{
|
|
flex: 1, minHeight: 0, overflowY: 'auto', padding: '8px',
|
|
display: 'flex', flexDirection: 'column', gap: '6px',
|
|
}}>
|
|
{selected ? (
|
|
<>
|
|
{/* System Header */}
|
|
<div style={{
|
|
background: 'rgba(15,22,35,0.92)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-lg)', backdropFilter: 'blur(10px)', overflow: 'hidden',
|
|
}}>
|
|
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: selected.color, boxShadow: `0 0 8px ${selected.color}` }} />
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: '14px', fontWeight: 600, color: 'var(--fg-bright)' }}>{selected.name}</div>
|
|
</div>
|
|
<span style={{
|
|
fontFamily: 'var(--font-mono)', fontSize: '10px', padding: '1px 6px',
|
|
borderRadius: 'var(--radius-pill)', fontWeight: 600,
|
|
background: secBg(selected.security), color: secColor(selected.security),
|
|
border: `1px solid ${secColor(selected.security)}33`,
|
|
}}>{selected.security.toFixed(1)}</span>
|
|
</div>
|
|
|
|
<div style={{ padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ color: 'var(--muted)', fontSize: '10px', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Star Type</span>
|
|
<span style={{ color: 'var(--fg-dim)', fontSize: '11px' }}>{selected.type}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ color: 'var(--muted)', fontSize: '10px', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Planets</span>
|
|
<span style={{ color: 'var(--fg-dim)', fontSize: '11px', fontFamily: 'var(--font-mono)' }}>{selected.planets}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ color: 'var(--muted)', fontSize: '10px', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Stations</span>
|
|
<span style={{ color: 'var(--fg-dim)', fontSize: '11px' }}>{selected.stations?.length || 0}</span>
|
|
</div>
|
|
{selected.stations && selected.stations.length > 0 && (
|
|
<div style={{ marginTop: '2px', maxHeight: '80px', overflowY: 'auto' }}>
|
|
{selected.stations.map((stn, i) => (
|
|
<div key={i} style={{ fontSize: '9px', color: 'var(--cyan)', padding: '2px 0', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
<span style={{ width: '3px', height: '3px', borderRadius: '50%', background: 'var(--cyan)' }} />{stn}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ═══ System Details (Celestial Bodies) ═══ */}
|
|
{selectedCelestial && (
|
|
<div style={{
|
|
background: 'rgba(15,22,35,0.92)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-lg)', backdropFilter: 'blur(10px)', overflow: 'hidden',
|
|
}}>
|
|
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border)' }}>
|
|
<div style={{ fontSize: '10px', color: 'var(--fg-dim)', lineHeight: 1.5, marginBottom: '6px' }}>
|
|
{selectedCelestial.description}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
|
<span style={{ fontSize: '8px', fontFamily: 'var(--font-mono)', padding: '2px 6px', borderRadius: 'var(--radius-pill)', background: 'var(--accent-bg)', color: 'var(--accent)', border: '1px solid var(--accent-border)' }}>
|
|
{selectedCelestial.faction}
|
|
</span>
|
|
<span style={{ fontSize: '8px', fontFamily: 'var(--font-mono)', padding: '2px 6px', borderRadius: 'var(--radius-pill)', background: 'var(--surface-raised)', color: 'var(--fg-dim)' }}>
|
|
Pop: {selectedCelestial.population}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Resources */}
|
|
{selectedCelestial.resources.length > 0 && (
|
|
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border)', display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
|
<span style={{ fontSize: '8px', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--muted)', marginRight: '4px' }}>Ore:</span>
|
|
{selectedCelestial.resources.map((r, i) => (
|
|
<span key={i} style={{ fontSize: '8px', fontFamily: 'var(--font-mono)', padding: '1px 5px', borderRadius: 'var(--radius-pill)', background: 'var(--purple-bg)', color: 'var(--purple)' }}>{r}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Celestial Bodies List */}
|
|
<div style={{ padding: '4px 6px', maxHeight: '220px', overflowY: 'auto' }}>
|
|
{selectedCelestial.bodies.map((body, i) => (
|
|
<div key={i} style={{
|
|
padding: '5px 8px', display: 'flex', alignItems: 'center', gap: '6px',
|
|
borderBottom: i < selectedCelestial.bodies.length - 1 ? '1px solid rgba(28,42,63,0.3)' : 'none',
|
|
}}>
|
|
{/* Icon */}
|
|
<span style={{ width: '14px', height: '14px', borderRadius: body.type === 'belt' ? '2px' : '50%', background: body.color || '#5a4a3a', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '6px' }}>
|
|
{body.type === 'belt' ? '·' : ''}
|
|
</span>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: '10px', color: 'var(--fg-dim)', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{body.name}</div>
|
|
<div style={{ fontSize: '8px', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
|
|
{body.type === 'belt'
|
|
? `${body.count} objects · ${body.innerOrbit}-${body.outerOrbit} AU`
|
|
: `${body.type} · orbit ${body.orbit} AU · T ${body.period.toFixed(1)}s`
|
|
}
|
|
</div>
|
|
</div>
|
|
{body.ecc > 0.05 && (
|
|
<span style={{ fontSize: '7px', fontFamily: 'var(--font-mono)', color: 'var(--accent)', padding: '1px 4px', background: 'var(--accent-bg)', borderRadius: 'var(--radius-pill)' }}>
|
|
e={body.ecc.toFixed(2)}
|
|
</span>
|
|
)}
|
|
{body.moons && body.moons.length > 0 && (
|
|
<span style={{ fontSize: '7px', fontFamily: 'var(--font-mono)', color: 'var(--cyan)', padding: '1px 4px', background: 'var(--cyan-bg)', borderRadius: 'var(--radius-pill)' }}>
|
|
{body.moons.length} moon{body.moons.length > 1 ? 's' : ''}
|
|
</span>
|
|
)}
|
|
{body.hasRings && (
|
|
<span style={{ fontSize: '7px', fontFamily: 'var(--font-mono)', color: 'var(--purple)', padding: '1px 4px', background: 'var(--purple-bg)', borderRadius: 'var(--radius-pill)' }}>rings</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Connections */}
|
|
<div style={{
|
|
background: 'rgba(15,22,35,0.92)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-lg)', backdropFilter: 'blur(10px)', overflow: 'hidden',
|
|
}}>
|
|
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '9px', textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--muted)' }}>Connections</span>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '8px', color: 'var(--fg-dim)', marginLeft: 'auto' }}>
|
|
{connections.filter(([a, b]) => a === selected.id || b === selected.id).length} jumps
|
|
</span>
|
|
</div>
|
|
<div style={{ padding: '4px 6px', maxHeight: '200px', overflowY: 'auto' }}>
|
|
{connections.filter(([a, b]) => a === selected.id || b === selected.id).map(([a, b]) => {
|
|
const neighborId = a === selected.id ? b : a;
|
|
const neighbor = systems.find(s => s.id === neighborId);
|
|
if (!neighbor) return null;
|
|
return (
|
|
<div key={neighborId}
|
|
onClick={() => { setSelected(neighbor); focusTargetRef.current = { x: neighbor.x * SCALE, y: neighbor.y * SCALE, z: 0 }; }}
|
|
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ visible: true, x: Math.min(e.clientX, window.innerWidth - 200), y: Math.min(e.clientY, window.innerHeight - 250), target: neighbor }); }}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 8px',
|
|
borderRadius: '4px', cursor: 'pointer', transition: 'background 0.15s',
|
|
borderLeft: `2px solid ${secColor(neighbor.security)}`,
|
|
}}
|
|
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--surface-raised)'}
|
|
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
|
|
<span style={{ flex: 1, fontSize: '10px', color: 'var(--fg-dim)' }}>{neighbor.name}</span>
|
|
<span style={{
|
|
fontFamily: 'var(--font-mono)', fontSize: '8px', padding: '1px 4px',
|
|
borderRadius: 'var(--radius-pill)', background: secBg(neighbor.security), color: secColor(neighbor.security),
|
|
}}>{neighbor.security.toFixed(1)}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
|
|
</>
|
|
) : (
|
|
<div style={{
|
|
background: 'rgba(15,22,35,0.92)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-lg)', backdropFilter: 'blur(10px)', padding: '16px 12px', textAlign: 'center',
|
|
}}>
|
|
<div style={{ fontSize: '1.5rem', marginBottom: '8px', color: 'var(--accent)' }}>✦</div>
|
|
<div style={{ fontSize: '11px', color: 'var(--fg-dim)', lineHeight: 1.6 }}>
|
|
Click a system to select<br />Double-click to focus<br />Right-click for options<br />Scroll to zoom
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* CENTER — hover tooltip */}
|
|
<div style={{ flex: 1, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
|
|
{hovered && hovered.id !== selected?.id && (
|
|
<div style={{
|
|
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
|
background: 'rgba(15,22,35,0.92)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-md)', backdropFilter: 'blur(8px)',
|
|
padding: '6px 12px', fontFamily: 'var(--font-mono)', fontSize: '10px',
|
|
display: 'flex', alignItems: 'center', gap: '8px', whiteSpace: 'nowrap',
|
|
}}>
|
|
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: hovered.color }} />
|
|
<span style={{ color: 'var(--fg-bright)', fontWeight: 600 }}>{hovered.name}</span>
|
|
<span style={{ color: secColor(hovered.security), fontSize: '9px' }}>SEC {hovered.security.toFixed(1)}</span>
|
|
<span style={{ color: 'var(--muted)', fontSize: '9px' }}>{hovered.type}</span>
|
|
{celestialData[hovered.id] && (
|
|
<span style={{ color: 'var(--muted)', fontSize: '8px' }}>{celestialData[hovered.id].bodies.length} bodies</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ═══ RIGHT — System List ═══ */}
|
|
<div style={{
|
|
width: '220px', display: 'flex', flexDirection: 'column',
|
|
pointerEvents: 'auto', flexShrink: 0, minHeight: 0, overflow: 'hidden',
|
|
}}>
|
|
<div style={{
|
|
flex: 1, minHeight: 0, overflowY: 'auto', padding: '8px',
|
|
display: 'flex', flexDirection: 'column', gap: '6px',
|
|
}}>
|
|
{/* Search */}
|
|
<div style={{ position: 'relative' }}>
|
|
<input type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search systems..." style={{
|
|
width: '100%', padding: '6px 10px 6px 28px', fontSize: '10px', fontFamily: 'var(--font-mono)',
|
|
background: 'rgba(15,22,35,0.92)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
|
|
color: 'var(--fg)', backdropFilter: 'blur(8px)', outline: 'none',
|
|
}} />
|
|
<span style={{ position: 'absolute', left: '10px', top: '50%', transform: 'translateY(-50%)', fontSize: '11px', color: 'var(--muted)' }}>⌕</span>
|
|
</div>
|
|
|
|
{/* Filter tabs */}
|
|
<div style={{ display: 'flex', borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--border)', background: 'rgba(15,22,35,0.92)' }}>
|
|
{[
|
|
{ key: 'all', label: 'ALL' },
|
|
{ key: 'highsec', label: 'HI', col: 'var(--green)' },
|
|
{ key: 'lowsec', label: 'LO', col: 'var(--accent)' },
|
|
{ key: 'nullsec', label: 'NULL', col: 'var(--red)' },
|
|
].map(f => (
|
|
<button key={f.key} onClick={() => setSystemFilter(f.key)} style={{
|
|
flex: 1, padding: '4px 0', fontSize: '8px', fontFamily: 'var(--font-mono)',
|
|
textTransform: 'uppercase', letterSpacing: '0.06em', border: 'none',
|
|
background: systemFilter === f.key ? 'var(--surface-raised)' : 'transparent',
|
|
color: systemFilter === f.key ? (f.col || 'var(--fg-bright)') : 'var(--muted)',
|
|
cursor: 'pointer', borderBottom: systemFilter === f.key ? `1.5px solid ${f.col || 'var(--accent)'}` : '1.5px solid transparent',
|
|
}}>{f.label}</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* System list */}
|
|
<div style={{
|
|
background: 'rgba(15,22,35,0.92)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-lg)', backdropFilter: 'blur(10px)', overflow: 'hidden', flex: 1,
|
|
display: 'flex', flexDirection: 'column', minHeight: 0,
|
|
}}>
|
|
<div style={{ padding: '6px 10px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '9px', textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--muted)' }}>Systems</span>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '8px', color: 'var(--fg-dim)', marginLeft: 'auto' }}>{filteredSystems.length}</span>
|
|
</div>
|
|
<div style={{ flex: 1, overflowY: 'auto', padding: '2px' }}>
|
|
{filteredSystems.map(sys => {
|
|
const isSel = selected?.id === sys.id;
|
|
const isDest = destination === sys.id;
|
|
const isWP = waypoints.some(w => w.id === sys.id);
|
|
return (
|
|
<div key={sys.id}
|
|
onClick={() => setSelected(sys)}
|
|
onDoubleClick={() => { setSelected(sys); focusTargetRef.current = { x: sys.x * SCALE, y: sys.y * SCALE, z: 0 }; }}
|
|
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ visible: true, x: Math.min(e.clientX, window.innerWidth - 200), y: Math.min(e.clientY, window.innerHeight - 250), target: sys }); }}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: '6px', padding: '5px 8px',
|
|
borderRadius: '4px', cursor: 'pointer', transition: 'background 0.15s',
|
|
background: isSel ? 'var(--accent-bg)' : isDest ? 'var(--cyan-bg)' : 'transparent',
|
|
borderLeft: `2px solid ${isDest ? 'var(--cyan)' : isWP ? 'var(--purple)' : secColor(sys.security)}`,
|
|
}}
|
|
onMouseEnter={(e) => { if (!isSel && !isDest) e.currentTarget.style.background = 'var(--surface-raised)'; }}
|
|
onMouseLeave={(e) => { if (!isSel && !isDest) e.currentTarget.style.background = 'transparent'; }}>
|
|
<span style={{ width: '5px', height: '5px', borderRadius: '50%', background: sys.color, flexShrink: 0 }} />
|
|
<span style={{ flex: 1, fontSize: '10px', color: isSel ? 'var(--fg-bright)' : 'var(--fg-dim)', fontWeight: isSel ? 600 : 400, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{isDest ? '⊕ ' : isWP ? '◇ ' : ''}{sys.name}
|
|
</span>
|
|
<span style={{
|
|
fontFamily: 'var(--font-mono)', fontSize: '8px', padding: '1px 4px',
|
|
borderRadius: 'var(--radius-pill)', background: secBg(sys.security), color: secColor(sys.security),
|
|
}}>{sys.security.toFixed(1)}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ═══ BOTTOM BAR ═══ */}
|
|
<div style={{ display: 'flex', gap: '6px', padding: '0 8px 8px', pointerEvents: 'auto', flexShrink: 0, alignItems: 'stretch' }}>
|
|
|
|
{/* Route */}
|
|
<div style={{
|
|
background: 'rgba(15,22,35,0.92)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-lg)', backdropFilter: 'blur(10px)', overflow: 'hidden', flex: 2,
|
|
}}>
|
|
<div style={{ padding: '5px 10px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '9px', textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--muted)' }}>
|
|
<span style={{ width: '3px', height: '3px', borderRadius: '50%', background: 'var(--cyan)', display: 'inline-block', marginRight: '4px' }} />Autopilot Route
|
|
</span>
|
|
{route.length > 1 && (
|
|
<>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '8px', color: 'var(--cyan)', marginLeft: '4px' }}>{route.length - 1} jump{route.length - 1 !== 1 ? 's' : ''}</span>
|
|
<button onClick={handleClearRoute} style={{
|
|
marginLeft: 'auto', padding: '1px 6px', fontSize: '8px', fontFamily: 'var(--font-mono)',
|
|
background: 'var(--red-bg)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: 'var(--radius-sm)', color: 'var(--red)', cursor: 'pointer',
|
|
}}>CLEAR</button>
|
|
</>
|
|
)}
|
|
{!route.length && <span style={{ fontFamily: 'var(--font-mono)', fontSize: '8px', color: 'var(--muted)', marginLeft: 'auto' }}>No route set</span>}
|
|
</div>
|
|
{route.length > 0 && (
|
|
<div style={{ padding: '6px 10px', display: 'flex', alignItems: 'center', gap: '4px', overflowX: 'auto', scrollbarWidth: 'thin' }}>
|
|
{route.map((sys, i) => (
|
|
<React.Fragment key={sys.id}>
|
|
<div style={{
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px',
|
|
padding: '2px 8px', borderRadius: '4px',
|
|
background: i === route.length - 1 ? 'var(--cyan-bg)' : i === 0 ? 'var(--green-bg)' : 'transparent', minWidth: '60px',
|
|
}}>
|
|
<span style={{ fontSize: '9px', color: i === 0 ? 'var(--green)' : i === route.length - 1 ? 'var(--cyan)' : 'var(--fg-dim)', fontWeight: 600 }}>
|
|
{i === 0 ? '⬡' : i === route.length - 1 ? '⊕' : '◇'} {sys.name}
|
|
</span>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '7px', color: secColor(sys.security) }}>{sys.security.toFixed(1)}</span>
|
|
</div>
|
|
{i < route.length - 1 && <span style={{ color: 'var(--cyan)', fontSize: '10px', opacity: 0.4 }}>→</span>}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Ship Status */}
|
|
<div style={{
|
|
background: 'rgba(15,22,35,0.92)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-lg)', backdropFilter: 'blur(10px)', overflow: 'hidden', flex: 0.8,
|
|
}}>
|
|
<div style={{ padding: '5px 10px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '9px', textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--muted)' }}>
|
|
<span style={{ width: '3px', height: '3px', borderRadius: '50%', background: 'var(--accent)', display: 'inline-block', marginRight: '4px' }} />Ship
|
|
</span>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '8px', color: 'var(--fg-dim)', marginLeft: 'auto' }}>Venture</span>
|
|
</div>
|
|
<div style={{ padding: '6px 10px', display: 'flex', gap: '12px', alignItems: 'center' }}>
|
|
{[{ label: 'SH', value: 100, color: '#22d3ee' }, { label: 'AR', value: 100, color: '#f0a030' }, { label: 'HU', value: 100, color: '#22c55e' }, { label: 'CA', value: 85, color: '#a78bfa' }].map(bar => (
|
|
<div key={bar.label} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '3px', flex: 1 }}>
|
|
<div style={{ width: '100%', height: '3px', background: 'rgba(255,255,255,0.04)', borderRadius: 'var(--radius-pill)', overflow: 'hidden' }}>
|
|
<div style={{ height: '100%', width: `${bar.value}%`, background: bar.color, borderRadius: 'var(--radius-pill)' }} />
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '7px', color: bar.color }}>{bar.label}</span>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '7px', color: 'var(--fg-dim)' }}>{bar.value}%</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Wallet & Time */}
|
|
<div style={{
|
|
background: 'rgba(15,22,35,0.92)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-lg)', backdropFilter: 'blur(10px)', overflow: 'hidden', flex: 0.5,
|
|
display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '6px 10px',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '4px' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '13px', fontWeight: 700, color: 'var(--accent)', letterSpacing: '-0.02em' }}>₢125,000</span>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: '3px' }}>
|
|
<span style={{ width: '4px', height: '4px', borderRadius: '50%', background: 'var(--green)', boxShadow: '0 0 4px var(--green)' }} />
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '8px', color: 'var(--fg-dim)' }}>Online</span>
|
|
</span>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '8px', color: 'var(--muted)', marginLeft: 'auto' }}>14:23 UTC</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Minimap */}
|
|
<div style={{
|
|
background: 'rgba(15,22,35,0.92)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-lg)', backdropFilter: 'blur(10px)', overflow: 'hidden', width: '180px', flexShrink: 0,
|
|
}}>
|
|
<div style={{ padding: '4px 10px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '9px', textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--muted)' }}>Overview</span>
|
|
</div>
|
|
<div style={{ padding: '4px' }}>
|
|
<canvas ref={minimapCanvasRef} width={168} height={110} style={{ width: '100%', height: 'auto', borderRadius: 'var(--radius-sm)', display: 'block' }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
window.GDD.StarMapDemo = StarMapDemo;
|