orbit/js/galaxy/GalaxyChart.js

369 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<max` — identical from either end
*/
export function edgeKey(a, b) {
return a <= b ? `${a}<${b}` : `${b}<${a}`;
}
/**
* The live galaxy-plate snapshot (see the file header).
*
* @param {object} o
* @param {import('./Galaxy.js').Galaxy} o.galaxy
* @param {Iterable<string>} [o.visited] system ids the run has ENTERED
* @param {Iterable<string>} [o.used] lane keys (edgeKey) the run has
* TRAVELED
* @param {Iterable<string>} [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<object>, edges:Array<object>,
* 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 (i1)'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.<type>: `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.<t>.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;
}