/** * GalaxyChart — the pure model + geometry behind the MAP console's * GALAXY tab (js/ui/GalaxyView.js renders it; GameScene feeds it). * * edgeKey(a, b) the undirected jump-lane key — min/max pair * joined with '<' (save-stable: the same lane * from either end) * buildGalaxySnapshot(o) the live GALAXY plate data — the systems * (position, type, visited), the dedup'd jump * lanes (used / frontier / live), and the * readout stats. Per-system `faction: null` * is the FACTIONS seam (planned — see * docs/PROJECT_NOTES.md "Factions"): the * plate already reserves a faction color * layer + a territory-region pass for it. * convexHull(points) monotone-chain hull (interior + collinear * points dropped) * paddedHullPolygon(p, pad) the hull inflated by `pad` world-px (the * CHARTED REGION outline; 1 pt → ring, * 2 → capsule) * starPulse(type, timeMs) the per-star-type pulse phase 0..1 * (data/map.json → galaxy.pulse) * starTypeColor(type) the archetype's chart color * (data/systems.json → types) * * Pure (no Phaser) — Node-testable (dev/galaxy-map.test.mjs). */ import { config } from '../config/Config.js'; /** * The undirected key of a jump lane between systems a and b. * @param {string} a * @param {string} b * @returns {string} `min} [o.visited] system ids the run has ENTERED * @param {Iterable} [o.used] lane keys (edgeKey) the run has * TRAVELED * @param {Iterable} [o.live] activated gate keys * (`"${from}>${to}"`, GameScene.activatedGates) — the lanes a JUMP * can currently be confirmed from the current system * @param {string|null} [o.currentSystemId] * @returns {{name:string, seed:string, homeSystemId:string|null, * currentSystemId:string|null, systems:Array, edges:Array, * stats:{systems:number, visited:number, lanes:number, lanesUsed:number}}} */ export function buildGalaxySnapshot({ galaxy, visited = null, used = null, live = null, currentSystemId = null } = {}) { const visitedSet = new Set(visited ?? []); const usedSet = new Set(used ?? []); const liveSet = new Set(live ?? []); const network = galaxy?.jumpNetwork ?? null; const homeId = galaxy?.homeSystemId ?? null; const systems = []; for (const s of galaxy?.records ?? []) { if (!s || typeof s.id !== 'string') continue; systems.push({ id: s.id, name: s.name, type: s.type, x: s.x, y: s.y, visited: visitedSet.has(s.id), isHome: s.id === homeId, isCurrent: s.id === currentSystemId, gates: network?.gates?.get?.(s.id)?.length ?? 0, // FACTIONS (planned — not yet implemented): the system's faction // id + the player's relation tier (Neutral / Friendly / Aligned / // Hostile) will land here. The galaxy plate reserves a faction // color layer on the stars + a territory-region fill (the same // hull pass as the charted region) for when it ships. faction: null, }); } const ids = new Set(systems.map((s) => s.id)); const seen = new Set(); const edges = []; for (const [a, dests] of network?.gates ?? []) { if (!ids.has(a) || !Array.isArray(dests)) continue; for (const b of dests) { if (!ids.has(b) || a === b) continue; const key = edgeKey(a, b); if (seen.has(key)) continue; seen.add(key); const used = usedSet.has(key); const lo = a < b ? a : b; const hi = a < b ? b : a; edges.push({ a: lo, b: hi, key, used, // FRONTIER — a lane leaving the charted region (exactly one end // visited): the "next step" of the maze, drawn brighter than the // unexplored web. frontier: !used && visitedSet.has(lo) !== visitedSet.has(hi), // LIVE — an activated gate on this lane out of the CURRENT // system: the plate's CONFIRM JUMP applies to these. live: currentSystemId != null && (liveSet.has(`${currentSystemId}>${lo}`) || liveSet.has(`${currentSystemId}>${hi}`)), }); } } const stats = { systems: systems.length, visited: systems.filter((s) => s.visited).length, lanes: edges.length, lanesUsed: edges.filter((e) => e.used).length, }; return { name: galaxy?.name ?? 'UNKNOWN GALAXY', seed: galaxy?.seed ?? '', homeSystemId: homeId, currentSystemId, systems, edges, stats, }; } // ── charted-region geometry ────────────────────────────────────────────────── /** * Monotone-chain convex hull. * @param {Array<{x:number, y:number}>} points * @returns {Array<{x:number, y:number}>} the hull vertices in order * (fewer than 3 input points → the input itself; all-collinear input * → the two extreme points) */ export function convexHull(points) { const pts = (points ?? []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)); if (pts.length < 3) return pts.map((p) => ({ x: p.x, y: p.y })); const sorted = [...pts].sort((a, b) => a.x - b.x || a.y - b.y); const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); const lower = []; for (const p of sorted) { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop(); lower.push(p); } const upper = []; for (let i = sorted.length - 1; i >= 0; i--) { const p = sorted[i]; while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop(); upper.push(p); } return lower.slice(0, -1).concat(upper.slice(0, -1)); } /** Shoelace signed area (positive for the hull orientation convexHull * yields — the paddedHullPolygon outward-normal formula assumes it). */ function signedArea(pts) { let s = 0; for (let i = 0; i < pts.length; i++) { const a = pts[i]; const b = pts[(i + 1) % pts.length]; s += a.x * b.y - b.x * a.y; } return s / 2; } /** An n-gon ring of radius r about (cx, cy). */ function ring(cx, cy, r, n = 40) { const out = []; for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2; out.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r }); } return out; } /** A capsule: the segment a→b thickened by r (both end-discs). */ function capsule(a, b, r) { if (r <= 0) return [a, b].map((p) => ({ x: p.x, y: p.y })); const dx = b.x - a.x; const dy = b.y - a.y; const len = Math.hypot(dx, dy) || 1; const nx = dy / len; const ny = -dx / len; // either normal — the shape is symmetric const n = 14; const out = []; for (let i = 0; i <= n; i++) { const t = i / n; out.push({ x: a.x + dx * t + nx * r, y: a.y + dy * t + ny * r }); } for (let i = n; i >= 0; i--) { const t = i / n; out.push({ x: a.x + dx * t - nx * r, y: a.y + dy * t - ny * r }); } return out; } /** * The charted-region polygon: the convex hull of the given points * inflated by `pad` (world px) — each edge translated outward along its * normal, corners bridged (the Minkowski-sum-with-a-disc shape, sampled). * * @param {Array<{x:number, y:number}>} points — the region's points * @param {number} [pad=0] the inflation, in the same units as the points * @returns {Array<{x:number, y:number}>} a closed polygon (length ≥ 2) */ export function paddedHullPolygon(points, pad = 0) { const hull = convexHull(points); const p = Math.max(0, Number(pad) || 0); if (hull.length === 0) return []; if (hull.length === 1) return ring(hull[0].x, hull[0].y, p); if (hull.length === 2) return capsule(hull[0], hull[1], p); if (p <= 0) return hull; // Outward normals assume the convexHull orientation — enforce it. const poly = signedArea(hull) < 0 ? [...hull].reverse() : hull; const n = poly.length; // Each edge translated outward by p along its normal… const offs = poly.map((a, i) => { const b = poly[(i + 1) % n]; const dx = b.x - a.x; const dy = b.y - a.y; const len = Math.hypot(dx, dy) || 1; const nx = dy / len; const ny = -dx / len; // outward (see signedArea's orientation note) return { p: { x: a.x + nx * p, y: a.y + ny * p }, d: { x: dx, y: dy } }; }); // …and corner i = where edge (i−1)'s offset line meets edge i's — // the true parallel polygon (the Minkowski sum's sharp corners). const out = []; for (let i = 0; i < n; i++) { const L1 = offs[(i - 1 + n) % n]; const L2 = offs[i]; const det = L1.d.x * L2.d.y - L1.d.y * L2.d.x; if (Math.abs(det) < 1e-12) { out.push({ x: L2.p.x, y: L2.p.y }); continue; } const t = ((L2.p.x - L1.p.x) * L2.d.y - (L2.p.y - L1.p.y) * L2.d.x) / det; out.push({ x: L1.p.x + t * L1.d.x, y: L1.p.y + t * L1.d.y }); } return out; } // ── star animation ─────────────────────────────────────────────────────────── /** * A star-type's pulse phase, 0..1 — the per-archetype heartbeat of the * galaxy plate (data/map.json → galaxy.pulse.: `speed` in Hz, * `amp` = the swing depth 0..1 (1 = full 0..1 swing, 0 = steady 0.5)). * Deterministic for (type, timeMs, phase) — the per-star `phase` is a * seeded offset (GalaxyView) so stars don't beat in unison. * * @param {string} type the system archetype (data/systems.json keys) * @param {number} timeMs scene time (ms) * @param {number} [phase=0] per-star phase offset (radians) * @returns {number} 0.5 - 0.5·amp … 0.5 + 0.5·amp */ export function starPulse(type, timeMs = 0, phase = 0) { const pc = config.get(`map.galaxy.pulse.${type}`, {}); const speed = Math.max(0.02, Number(pc?.speed ?? 1) || 1); const amp = Math.min(1, Math.max(0, Number(pc?.amp ?? 0.6) || 0)); return 0.5 + 0.5 * amp * Math.sin((timeMs / 1000) * speed * Math.PI * 2 + phase); } /** * The archetype's chart color (data/systems.json → types..theme.color) * — a CSS hex string, or `fallback` for an unknown/misconfigured type. * @param {string} type * @param {string} [fallback] * @returns {string} '#rrggbb' */ export function starTypeColor(type, fallback = '#9fb6d8') { const hex = config.get(`systems.types.${type}.theme.color`, null); return typeof hex === 'string' && /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : fallback; } // ── plate clipping (the chart stays INSIDE its window) ──────────────────────────── // // The galaxy is drawn in world space and magnified by the view (zoom/pan), so // at high zoom the lanes/hull run past the plate's padded frame. The plate is // the window onto the galaxy — its content is clipped to the plate rect. // (The vendored v4 build has no mask API, so the drawing passes clip their // own geometry with these two pure functions.) /** * Liang-Barsky: clip a line segment to an axis-aligned rect. * @param {number} x1 @param {number} y1 @param {number} x2 @param {number} y2 * @param {{x:number, y:number, w:number, h:number}} r the plate rect * @returns {number[]|null} [x1, y1, x2, y2] — or null when fully outside */ export function clipLineToRect(x1, y1, x2, y2, r) { let t0 = 0; let t1 = 1; const dx = x2 - x1; const dy = y2 - y1; const clips = [ [-dx, x1 - r.x], [dx, r.x + r.w - x1], [-dy, y1 - r.y], [dy, r.y + r.h - y1], ]; for (const [p, q] of clips) { if (p === 0) { if (q < 0) return null; // parallel and outside continue; } const t = q / p; if (p < 0) { if (t > t1) return null; if (t > t0) t0 = t; } else { if (t < t0) return null; if (t < t1) t1 = t; } } return [x1 + t0 * dx, y1 + t0 * dy, x1 + t1 * dx, y1 + t1 * dy]; } /** * Sutherland-Hodgman: clip a polygon to an axis-aligned rect (four * half-plane passes). The result keeps winding; it is empty when the * polygon is fully outside. * @param {Array<{x:number, y:number}>} pts * @param {{x:number, y:number, w:number, h:number}} r the plate rect * @returns {Array<{x:number, y:number}>} the clipped polygon (0..n pts) */ export function clipPolygonToRect(pts, r) { const x1 = r.x; const y1 = r.y; const x2 = r.x + r.w; const y2 = r.y + r.h; const clipEdge = (list, inside, cross) => { const out = []; for (let i = 0; i < list.length; i++) { const a = list[i]; const b = list[(i + 1) % list.length]; const ain = inside(a); const bin = inside(b); if (ain && bin) out.push(b); else if (ain) out.push(cross(a, b)); else if (bin) { out.push(cross(a, b)); out.push(b); } } return out; }; const xInt = (bnd) => (a, b) => { const t = (bnd - a.x) / (b.x - a.x); return { x: bnd, y: a.y + t * (b.y - a.y) }; }; const yInt = (bnd) => (a, b) => { const t = (bnd - a.y) / (b.y - a.y); return { x: a.x + t * (b.x - a.x), y: bnd }; }; let list = pts; list = clipEdge(list, (p) => p.x >= x1, xInt(x1)); list = clipEdge(list, (p) => p.x <= x2, xInt(x2)); list = clipEdge(list, (p) => p.y >= y1, yInt(y1)); list = clipEdge(list, (p) => p.y <= y2, yInt(y2)); return list; }