orbit/js/galaxy/GalaxyChart.js

675 lines
28 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]
* @param {Iterable<string>} [o.route] edge keys (edgeKey) on the active
* route to the destination (GameScene.routeEdgeKeys) — the lanes the
* player will fly; marked `route: true` so the plate draws them orange.
* @param {string|null} [o.destinationId] the destination SYSTEM id — its
* star is marked `isDestination: true` so the plate circles it.
* @returns {{name:string, seed:string, homeSystemId:string|null,
* currentSystemId:string|null, destinationId: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, route = null, destinationId = null } = {}) {
const visitedSet = new Set(visited ?? []);
const usedSet = new Set(used ?? []);
const liveSet = new Set(live ?? []);
const routeSet = new Set(route ?? []);
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,
// The DESTINATION star — the route's final stop. The plate circles
// it (GalaxyView) so the "where am I going" target is unmistakable.
isDestination: s.id === destinationId,
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,
// ROUTE — a lane on the active route to the destination: the
// plate's "where am I going" path, drawn in the route's orange
// (data/gates.json → route.compassColor), above used/frontier.
route: routeSet.has(key),
// 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,
destinationId,
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;
}
/* ------------------------------------------------------------------ *
* STAR ART — the zoom-bloom design for each star on the GALAXY plate.
*
* A star is a ~3px dot when the plate is fully zoomed out, and it
* BLOOMS as you zoom in (data/map.json → galaxy.stars: minPx/zoomGrow
* set the dot, art.flareAt/crownAt/surfaceAt set when each layer
* fades in):
*
* flare — diffraction spikes (the "star sparkle"), per-type count
* crown — the type's signature, all seeded per system:
* main granulation rim + corona ticks
* redDwarf breathing corona + prominence arcs
* binary an orbiting companion on a faint ellipse
* habitable the life-zone rings + orbiting planet(s)
* nebula a tilted accretion disc + speckles
* void photon ring + dark horizon + lensing ticks
* surface — a slow surface wobble + a glint (the core gains texture)
*
* Pure (no Phaser) — this module returns a list of typed primitives
* relative to the star's center; GalaxyView blits them. Node-testable
* (dev/star-art.test.mjs).
* ------------------------------------------------------------------ */
const TAU_ = Math.PI * 2;
const num = (v, d = 0) => (Number.isFinite(+v) ? +v : d);
/** Mix two #rrggbb colors (t = 0..1, toward b). Returns #rrggbb. */
export function mixHex(a, b, t) {
const p = (h) => {
const n = parseInt(String(h).replace('#', ''), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const [r1, g1, b1] = p(a);
const [r2, g2, b2] = p(b);
const c = (x, y) => Math.round(x + (y - x) * Math.min(1, Math.max(0, t)));
return (
'#' +
[c(r1, r2), c(g1, g2), c(b1, b2)]
.map((v) => v.toString(16).padStart(2, '0'))
.join('')
);
}
/**
* The star's core-dot size in plate-px at zoom z.
* minPx = the fully-zoomed-out floor (a tad bigger than a pixel);
* zoomGrow = how many px it gains per zoom step.
* @returns {number}
*/
export function starDotPx(z, cfg = {}) {
const minPx = Math.max(1, num(cfg.minPx, 3));
const grow = Math.max(0, num(cfg.zoomGrow, 1.2));
return minPx + Math.max(0, num(z, 1) - 1) * grow;
}
/** 0 below `at`, 1 at/after `at + fade`, linear between (layer fade-in). */
export function layerFade(z, at, fade) {
const f = Math.max(0.01, num(fade, 0.45));
return Math.min(1, Math.max(0, (num(z, 1) - at) / f));
}
/**
* The star art spec — the primitives to draw for one star.
* @param {string} type the archetype (main | redDwarf | binary | habitable | nebula | void)
* @param {object} o
* o.z zoom (map.galaxy.zoom.zMin..zMax)
* o.time ms (scene time — drives the slow animations)
* o.dot the core-dot px at this zoom (starDotPx × type sizeMul)
* o.pulse the type's pulse phase 0..1 (starPulse)
* o.seed pre-rolled per-system values (see below; any value may
* be missing — a fresh seed is derived deterministically)
* o.cfg the `galaxy.stars.art` config block
* @returns {{prims: object[]}} primitives in paint order (earliest first)
* each prim: { k, … } — one of:
* ellipse { rx, ry, rot, w, a, c } a tilted ring (disc layers)
* ring { r, w, a, c, dash?, dashA? } a circle (dash = n segments)
* arc { r, a0, a1, w, a, c } a partial circle (radians)
* tri { len, w, ang, a, c } a diffraction spike (triangle)
* dot { x, y, r, a, c } a filled circle (companion, planet)
* tick { r0, r1, ang, w, a, c } a radial line
* wobble { r, amp, n, rot, w, a, c } a surface-noise circle (n lobes)
* darkdot { r, a, c } a flat dark disc (the void horizon)
*/
export function starArtSpec(type, o = {}) {
const z = num(o.z, 1);
const time = num(o.time, 0);
const dot = Math.max(1, num(o.dot, 3));
const p = num(o.pulse, 0.5);
const cfg = o.cfg ?? {};
const fadeSpan = num(cfg.fade, 0.45);
const tc = typeCfg(cfg, type);
const aFlare = layerFade(z, num(tc.flareAt ?? cfg.flareAt, 1.6), fadeSpan);
const aCrown = layerFade(z, num(tc.crownAt ?? cfg.crownAt, 2.8), fadeSpan);
const aSurf = layerFade(z, num(tc.surfaceAt ?? cfg.surfaceAt, 4.5), fadeSpan);
if (aFlare + aCrown + aSurf <= 0.001) return { prims: [] };
const base = starTypeColor(type);
const seed = o.seed ?? {};
// deterministic per-system fallbacks (the painter pre-rolls these in
// _buildStars; the fallbacks keep this pure function self-sufficient)
const S = {
spikeAngle: num(seed.spikeAngle, 0),
ringRot: num(seed.ringRot, 0),
tickAngles: seed.tickAngles ?? [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((i) => (i / 12) * TAU_ + 0.07),
tickLens: seed.tickLens ?? [0.3, 0.4, 0.35, 0.45, 0.3, 0.4, 0.38, 0.32, 0.42, 0.36, 0.3, 0.44],
proms: seed.proms ?? [
{ a: 0.6, span: 0.9, r: 1.35 },
{ a: 2.7, span: 0.7, r: 1.5 },
{ a: 4.4, span: 1.0, r: 1.3 },
],
orbitRot: num(seed.orbitRot, 0.5),
compPhase: num(seed.compPhase, 0),
planetPhases: seed.planetPhases ?? [0.8, 3.9],
speckles: seed.speckles ?? [0, 1, 2, 3, 4, 5, 6, 7, 8].map((i) => ({ a: i * 0.7 + 0.3, s: 0.7 + ((i * 37) % 10) / 22 })),
glintAngle: num(seed.glintAngle, 0.9),
wobblePhase: num(seed.wobblePhase, 0),
dashOffsets: seed.dashOffsets ?? [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => i / 10),
lensAngles: seed.lensAngles ?? [0.4, 1.9, 3.6, 5.2],
};
const prims = [];
const slow = time * 0.00005; // a slow, calm rotation for all spinners
/* — NEBULA: the tilted disc goes FIRST (behind the star body) — */
if (tc.crown === 'disk' && aCrown > 0) {
const tilt = num(tc.tilt, 0.4);
const rot = S.orbitRot + slow * 0.4;
const rings = tc.rings ?? [2.1, 2.9, 3.7];
const alphas = [0.16, 0.11, 0.08];
rings.forEach((rr, i) => {
const rx = num(rr, 2 + i) * dot;
prims.push({ k: 'ellipse', rx, ry: rx * tilt, rot, w: 2.6 - i * 0.5, a: alphas[i] * aCrown, c: base });
});
// drifting speckles on the middle ring (gas clumps)
const mr = num(rings[1] ?? 2.9, 2.9) * dot;
const mry = mr * tilt;
S.speckles.forEach((sp, i) => {
const ang = sp.a + slow * 1.2;
const x = Math.cos(ang) * mr;
const y = Math.sin(ang) * mry;
// rotate into the disc plane
const cs = Math.cos(rot);
const sn = Math.sin(rot);
prims.push({
k: 'dot',
x: x * cs - y * sn,
y: x * sn + y * cs,
r: 0.7 + sp.s * 0.7,
a: (0.22 + 0.1 * Math.sin(time * 0.001 + i)) * aCrown,
c: mixHex(base, '#ffffff', 0.3),
});
});
}
/* — BINARY: the faint orbit ellipse (behind both stars) — */
if (tc.crown === 'companion' && aCrown > 0) {
const dist = num(tc.companion?.dist, 2.7) * dot;
const tilt = num(tc.companion?.tilt, 0.55);
prims.push({ k: 'ellipse', rx: dist, ry: dist * tilt, rot: S.orbitRot, w: 1, a: 0.3 * aCrown, c: base });
}
/* — SPIKES: the diffraction cross (all types but the void) — */
const spikes = Math.max(0, num(tc.spikes, 4));
if (spikes > 0 && aFlare > 0) {
const n = spikes;
const len = dot * (3.0 + 0.5 * p) * (tc.spikeLenMul ?? 1);
for (let i = 0; i < n; i++) {
const ang = S.spikeAngle + (i / n) * TAU_ + (tc.spikeRot ?? 0);
prims.push({ k: 'tri', len, w: Math.max(0.8, dot * 0.22), ang, a: 0.42 * aFlare * (tc.spikeAlpha ?? 1), c: base });
}
// a hot inner cross, rotated half a step (the sparkle's second layer)
for (let i = 0; i < n; i++) {
const ang = S.spikeAngle + (i / n) * TAU_ + Math.PI / n + (tc.spikeRot ?? 0);
prims.push({ k: 'tri', len: len * 0.45, w: Math.max(0.6, dot * 0.14), ang, a: 0.5 * aFlare, c: mixHex(base, '#ffffff', 0.55) });
}
}
/* — TYPE CROWNS — */
if (aCrown > 0) {
if (tc.crown === 'granulation') {
prims.push({ k: 'ring', r: dot * 1.42, w: 1.2, a: 0.42 * aCrown, c: base });
prims.push({ k: 'ring', r: dot * 1.1, w: 1, a: 0.28 * aCrown, c: base });
// corona ticks — seeded lengths, a slow drift
S.tickAngles.forEach((ang, i) => {
const a = ang + slow;
const r0 = dot * 1.12;
const r1 = r0 + dot * (S.tickLens[i % S.tickLens.length] ?? 0.35);
prims.push({ k: 'tick', r0, r1, ang: a, w: 1, a: 0.34 * aCrown, c: base });
});
} else if (tc.crown === 'prominences') {
// a breathing double corona
const breathe = 0.75 + 0.25 * p;
prims.push({ k: 'ring', r: dot * 1.38 * breathe, w: 1.6, a: 0.34 * aCrown, c: base });
prims.push({ k: 'ring', r: dot * 1.85 * breathe, w: 1.2, a: 0.2 * aCrown, c: base });
// prominence arcs — hot filaments arcing off the limb
S.proms.forEach((pr, i) => {
const span = num(pr.span, 0.8);
const r = num(pr.r, 1.4) * dot * (1 + 0.08 * Math.sin(time * 0.0009 + i * 2));
const a0 = num(pr.a, i) + slow * 0.7;
prims.push({ k: 'arc', r, a0, a1: a0 + span, w: 1.8, a: 0.4 * aCrown, c: base });
prims.push({ k: 'arc', r, a0: a0 + span * 0.25, a1: a0 + span * 0.75, w: 1, a: 0.5 * aCrown, c: mixHex(base, '#ffd9a0', 0.6) });
});
} else if (tc.crown === 'companion') {
// the second star, riding its seeded orbit
const dist = num(tc.companion?.dist, 2.7) * dot;
const tilt = num(tc.companion?.tilt, 0.55);
const period = Math.max(1000, num(tc.companion?.periodMs, 14000));
const ang = S.compPhase + (TAU_ * time) / period;
const lx = Math.cos(ang) * dist;
const ly = Math.sin(ang) * dist * tilt;
const cs = Math.cos(S.orbitRot);
const sn = Math.sin(S.orbitRot);
const x = lx * cs - ly * sn;
const y = lx * sn + ly * cs;
const compColor = mixHex(base, '#ffffff', 0.28);
const cr = Math.max(1.1, dot * num(tc.companion?.size, 0.5) * 0.62);
// a soft halo + core + its own mini-sparkle
prims.push({ k: 'dot', x, y, r: cr * 2.6, a: 0.14 * aCrown, c: compColor });
prims.push({ k: 'dot', x, y, r: cr * 1.5, a: 0.3 * aCrown, c: compColor });
prims.push({ k: 'dot', x, y, r: cr, a: 0.95 * aCrown, c: mixHex(compColor, '#ffffff', 0.5) });
for (let i = 0; i < 4; i++) {
const sa = S.compPhase * 1.7 + (i / 4) * TAU_;
prims.push({ k: 'tri', len: cr * 3.2, w: Math.max(0.6, cr * 0.3), ang: sa, x, y, a: 0.3 * aCrown, c: compColor });
}
} else if (tc.crown === 'lifeRing') {
// the habitable band — a soft annulus + two crisp orbits
prims.push({ k: 'ring', r: dot * 1.95, w: dot * 0.7, a: 0.12 * aCrown, c: base });
const orbits = [num(tc.ring ?? 2.2, 2.2), 1.7];
prims.push({ k: 'ring', r: dot * orbits[0], w: 1, a: 0.4 * aCrown, c: base });
prims.push({ k: 'ring', r: dot * orbits[1], w: 1, a: 0.26 * aCrown, c: base });
// orbiting world(s) with a comet trail behind
const periods = [num(tc.planetPeriodMs, 21000), num(tc.planet2PeriodMs, 33000)];
[0, 1].forEach((i) => {
const rr = dot * orbits[i];
const period = Math.max(1000, periods[i]);
const ang = (S.planetPhases[i] ?? i * 2.2) + (TAU_ * time) / period;
const x = Math.cos(ang) * rr;
const y = Math.sin(ang) * rr;
prims.push({ k: 'arc', r: rr, a0: ang - 0.85, a1: ang, w: 1.2, a: 0.3 * aCrown, c: base });
prims.push({ k: 'dot', x, y, r: Math.max(1.3, dot * num(i === 0 ? tc.planetSize : tc.planet2Size, i === 0 ? 0.16 : 0.11)), a: 0.95 * aCrown, c: mixHex(base, '#ffffff', 0.35) });
});
} else if (tc.crown === 'horizon') {
// the void — a dark horizon with a photon ring that slowly shimmers
const shim = 0.8 + 0.2 * Math.sin(time * 0.0012 + S.glintAngle * 3);
const voidC = typeof tc.voidColor === 'string' ? tc.voidColor : '#040810';
prims.push({ k: 'darkdot', r: dot * 1.02, a: 0.92 * aCrown, c: voidC });
prims.push({ k: 'ring', r: dot * 1.2, w: 1.4, a: 0.8 * aCrown * shim, c: mixHex(base, '#ffffff', 0.25) });
// a faint outer cage (lensing ticks, seeded)
prims.push({ k: 'ring', r: dot * 1.9, w: 1, a: 0.22 * aCrown, c: base, dash: 10, dashA: 0.5 });
S.lensAngles.forEach((la) => {
const a = la + slow * 0.6;
prims.push({ k: 'tick', r0: dot * 2.05, r1: dot * 2.4, ang: a, w: 1, a: 0.3 * aCrown, c: base });
});
}
}
/* — SURFACE: the core gains texture at high zoom — */
if (aSurf > 0) {
prims.push({
k: 'wobble',
r: dot * 1.0,
amp: 0.07,
n: 3,
rot: S.wobblePhase + slow * 1.5,
w: 1,
a: 0.4 * aSurf,
c: mixHex(base, '#ffffff', 0.3),
});
// the glint — a fixed hot spot on the surface (seeded bearing)
const gx = Math.cos(S.glintAngle) * dot * 0.34;
const gy = Math.sin(S.glintAngle) * dot * 0.34;
prims.push({ k: 'dot', x: gx, y: gy, r: Math.max(0.9, dot * 0.16), a: 0.7 * aSurf, c: '#ffffff' });
}
return { prims };
}
/** The art config for one type (falls back to the shared defaults). */
function typeCfg(art, type) {
const t = art?.[type] ?? {};
return {
...art,
...t,
spikes: num(t.spikes, art?.spikes ?? 4),
flareAt: num(t.flareAt, art?.flareAt ?? 1.6),
crownAt: num(t.crownAt, art?.crownAt ?? 2.8),
surfaceAt: num(t.surfaceAt, art?.surfaceAt ?? 4.5),
};
}