/** * SystemChart — the pure geometry + stats behind the deck's MAP console * (js/ui/MapWindow.js draws on it; GameScene feeds it). * * chartBounds(objects, padding) the system's chart frame: the furthest * X/Y extents of EVERY object (planets, * stations, jump gates AND asteroid * clusters — discovered or not) plus * `padding` px (data/map.json → * bounds.padding, ~1024) on each edge. * fitToRect(bounds, w, h) the world → plate transform that fits * that frame into the map plate, centred. * navDiscoveryStats(d, sysId, c) the SYSTEM DISCOVERY share — the NAV * objects (planets + free-space stations * + jump gates; the central body is the * chart's anchor, not a count) vs. the * player's Discovery state. * resourceStats(d, sysId, c) the SYSTEM RESOURCES share — the * asteroid fields vs. Discovery (the only * resource kind for now). * * Pure (no Phaser) — Node-testable (dev/system-chart.test.mjs). The NAV * point set is SystemCategory.navPoints (the same discoverable set the * jumpgate chart gate uses) minus the central body, per the readout's * rule: planets, stations, gates. */ import { navPoints } from '../research/SystemCategory.js'; /** * The chart's bounding frame from a list of objects. * * @param {Array<{x:number, y:number, radius:number}>} objects — the system's * objects (any discoverable set; `radius` = the object's extent from its * center — planet disc radius, station/gate keepout, cluster bound) * @param {number} [padding=1024] — px of margin added on every edge * @returns {{minX:number, minY:number, maxX:number, maxY:number, * w:number, h:number, cx:number, cy:number}} * (a degenerate empty list yields a `2×padding` box about the origin) */ export function chartBounds(objects, padding = 1024) { let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const o of objects ?? []) { if (!o || typeof o.x !== 'number' || typeof o.y !== 'number') continue; const r = Math.max(0, Number(o.radius) || 0); if (o.x - r < minX) minX = o.x - r; if (o.y - r < minY) minY = o.y - r; if (o.x + r > maxX) maxX = o.x + r; if (o.y + r > maxY) maxY = o.y + r; } if (!Number.isFinite(minX) || !Number.isFinite(maxX) || !Number.isFinite(minY) || !Number.isFinite(maxY)) { // No objects: a zero-size box about the origin (the padding then makes // the chart a 2×padding box about it). minX = 0; minY = 0; maxX = 0; maxY = 0; } minX -= padding; minY -= padding; maxX += padding; maxY += padding; return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY, cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 }; } /** * Fit a world-space rect into a plate of `w × h`, centred, uniform scale. * * @param {ReturnType} bounds * @param {number} w — plate width (px) * @param {number} h — plate height (px) * @returns {{scale:number, ox:number, oy:number, toX:(x:number)=>number, * toY:(y:number)=>number}} * `toX`/`toY` map world → plate; `scale` = plate px per world px. */ export function fitToRect(bounds, w = 100, h = 100) { const scale = Math.min(w, h) / Math.max(1, Math.max(bounds.w, bounds.h)); const ox = w / 2 - bounds.cx * scale; const oy = h / 2 - bounds.cy * scale; return { scale, ox, oy, toX: (x) => x * scale + ox, toY: (y) => y * scale + oy, }; } /** * The SYSTEM DISCOVERY share (data/map.json → stats): the player's found / * total over the system's NAV objects — planets, free-space stations and * jump gates (SystemCategory.navPoints minus the central body 'home', * which is the chart's anchor rather than a discoverable count — and * minus the asteroid clusters, which are the RESOURCE side of the readout). * * @param {object} discovery — the Discovery state (isDiscovered) * @param {string} systemId * @param {object} content — the generated system content * @returns {{total:number, found:number, pct:number}} — `pct` is 0..1 * (0 when the system has no NAV objects, which the readout renders as —) */ export function navDiscoveryStats(discovery, systemId, content) { const points = navPoints(content).filter((p) => p.kind !== 'home'); const canQuery = typeof discovery?.isDiscovered === 'function'; let found = 0; for (const p of points) if (canQuery && discovery.isDiscovered(systemId, p.id)) found++; const total = points.length; return { total, found, pct: total > 0 ? found / total : 0 }; } /** * The SYSTEM RESOURCES share (data/map.json → stats): the player's found / * total over the system's asteroid fields (content.asteroids — the only * resource kind for now). * * @param {object} discovery — the Discovery state (isDiscovered) * @param {string} systemId * @param {object} content — the generated system content * @returns {{total:number, found:number, pct:number}} */ export function resourceStats(discovery, systemId, content) { const clusters = (content?.asteroids ?? []).filter((c) => c && typeof c.id === 'string'); const canQuery = typeof discovery?.isDiscovered === 'function'; let found = 0; for (const c of clusters) if (canQuery && discovery.isDiscovered(systemId, c.id)) found++; const total = clusters.length; return { total, found, pct: total > 0 ? found / total : 0 }; }