diff --git a/assets/videos/ss-takeoff-03.mp4 b/assets/videos/ss-takeoff-03.mp4
new file mode 100644
index 0000000..ed04d6b
Binary files /dev/null and b/assets/videos/ss-takeoff-03.mp4 differ
diff --git a/data/map.json b/data/map.json
index 7e2324a..73f088a 100644
--- a/data/map.json
+++ b/data/map.json
@@ -172,11 +172,19 @@
"void": { "speed": 0.2, "amp": 0.3 }
},
"edges": {
- "_comment": "The jump-lane web — three reads of the same thin line. used = a lane the run TRAVELED (bright glow pass + the flow packet); frontier = exactly one end charted (the next step); unexplored = the faint maze. width = plate-px at 1× (0.75–1.25× with zoom).",
+ "_comment": "The jump-lane web — three reads of the same thin line. used = a lane the run TRAVELED (bright glow pass + the flow packet); frontier = exactly one end charted (the next step); unexplored = the faint maze. route = a lane on the active SET DESTINATION path (the \"where am I going\" line, data/gates.json → route.compassColor orange) — drawn above used/frontier so the course reads clearly. width = plate-px at 1× (0.75–1.25× with zoom).",
+ "route": { "color": "#ff8c1a", "width": 2.4, "alpha": 0.95 },
"used": { "color": "#8df3ff", "width": 1.8, "alpha": 0.95 },
"frontier": { "color": "#3fb9d8", "width": 1.1, "alpha": 0.62 },
"unexplored": { "color": "#2a4f70", "width": 0.8, "alpha": 0.3 }
},
+ "destination": {
+ "_comment": "The DESTINATION star's ring on the GALAXY tab — the route's final stop, circled so the target is unmistakable. color = data/gates.json → route.compassColor (orange); width/radiusMul = the ring's plate-px width and its radius as a multiple of the star's glow radius; alpha = the ring's opacity. Shown only while a destination is set (snapshot.destinationId).",
+ "color": "#ff8c1a",
+ "width": 2.5,
+ "radiusMul": 2.1,
+ "alpha": 0.9
+ },
"hull": {
"_comment": "The CHARTED REGION — the convex hull of the visited systems, inflated by `padding` (a fraction of the plate width at 1× zoom — stable under zoom). A soft fill + a subtle double outline (the discovered-area shading). FACTIONS (planned): per-faction territories reuse this exact pass, colored per faction.",
"enabled": true,
diff --git a/dev/exit-gate-shot.html b/dev/exit-gate-shot.html
new file mode 100644
index 0000000..21d925a
--- /dev/null
+++ b/dev/exit-gate-shot.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Orbit — Exit gate shot (dev)
+
+
+
+
+
+
+
+
diff --git a/dev/exit-gate-shot.mjs b/dev/exit-gate-shot.mjs
new file mode 100644
index 0000000..0c2a941
--- /dev/null
+++ b/dev/exit-gate-shot.mjs
@@ -0,0 +1,130 @@
+/**
+ * Dev-only: boot GameScene, set a DESTINATION (a far star), and open the
+ * MAP console's CURRENT SYSTEM tab so a headless screenshot shows:
+ * · the ROUTE EXIT GATE circled in orange (the gate the player should
+ * path out of the system from to reach the destination),
+ * · an orange line from the ship to that gate ("path out of the system").
+ *
+ * node dev/server.mjs 8084
+ * node dev/shot-firefox.mjs \
+ * "http://127.0.0.1:8084/dev/exit-gate-shot.html" \
+ * "window.__EXIT_GATE ? window.__EXIT_GATE.ready : null" \
+ * EXIT_GATE.png 40000
+ *
+ * The report (top-left) lists console errors + the exit-gate state.
+ */
+import Phaser from '../js/vendor/phaser.js';
+import { config } from '../js/config/Config.js';
+import { ConfigLoader } from '../js/config/ConfigLoader.js';
+import { createGameConfig } from '../js/config/GameConfig.js';
+import { GameScene } from '../js/scenes/GameScene.js';
+
+const data = await ConfigLoader.load();
+config.init(data);
+
+const errors = [];
+const origErr = console.error.bind(console);
+console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
+window.addEventListener('error', (e) => errors.push(String(e.message)));
+window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
+
+const gameConfig = createGameConfig();
+gameConfig.scene = [GameScene];
+const game = new Phaser.Game(gameConfig);
+window.game = game;
+
+const report = document.createElement('pre');
+report.id = 'report';
+report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:70%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
+document.body.appendChild(report);
+const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); };
+setReport('booting…');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function waitScene() {
+ for (let i = 0; i < 100; i++) {
+ const s = game.scene.getScene('GameScene');
+ if (s && s.ship && s.mapWindow) return s;
+ await sleep(100);
+ }
+ throw new Error('GameScene never booted');
+}
+
+try {
+ const s = await waitScene();
+ await sleep(800);
+
+ // Pick a FAR destination (a multi-hop route, so the exit gate is the
+ // route's next gate in the CURRENT system).
+ const sysId = s.systemRecord.id;
+ const records = s.galaxy.records;
+ const cur = records.find((r) => r.id === sysId);
+ const others = records.filter((r) => r.id !== sysId);
+ others.sort((a, b) => {
+ const da = Math.hypot(a.x - cur.x, a.y - cur.y);
+ const db = Math.hypot(b.x - cur.x, b.y - cur.y);
+ return db - da;
+ });
+ const dest = others[2] ?? others[0];
+ if (!dest) throw new Error('no destination candidate');
+
+ const destContent = s.galaxy.contentOf(dest.id);
+ const destPlanet = (destContent.planets ?? [])[0];
+ const destObjId = destPlanet?.name ?? null;
+
+ // Set the destination.
+ s.setDestination(dest.id, destObjId);
+ await sleep(400);
+
+ // Discover the exit gate (the route's next gate in the CURRENT system)
+ // so it's drawn on the chart.
+ const exitGate = s.routeNextGate();
+ if (exitGate) {
+ let known = s.discovery.bySystem.get(sysId);
+ if (!known) { known = new Set(); s.discovery.bySystem.set(sysId, known); }
+ s.discovery.check(sysId, exitGate.x, exitGate.y, [{ id: exitGate.discoveryId, x: exitGate.x, y: exitGate.y, radius: 100 }]);
+ }
+ await sleep(300);
+
+ // Open the MAP console's CURRENT SYSTEM tab (the default mode).
+ s.mapWindow._switchMode('currentSystem');
+ s.mapWindow.open();
+ await sleep(1600);
+
+ // Collect the state for the report.
+ const chartSnap = s.mapChartSnapshot();
+ const exitInfo = chartSnap?.routeExitGate;
+ const exitObj = (chartSnap?.objects ?? []).find((o) => o?.id === exitInfo?.id);
+
+ window.__EXIT_GATE = {
+ ready: true,
+ errors,
+ destSystem: dest.id,
+ destName: dest.name,
+ exitGateId: exitInfo?.id,
+ exitGateName: exitInfo?.name,
+ exitGateDiscovered: exitObj?.discovered,
+ exitGateX: exitInfo?.x,
+ exitGateY: exitInfo?.y,
+ shipX: chartSnap?.ship?.x,
+ shipY: chartSnap?.ship?.y,
+ destinationId: chartSnap?.destinationId,
+ };
+
+ const lines = [
+ `DESTINATION: ${dest.name} (${dest.id})`,
+ `EXIT GATE: ${exitInfo?.name ?? 'null'} (${exitInfo?.id ?? 'null'})`,
+ `EXIT GATE DISCOVERED: ${exitObj?.discovered}`,
+ `EXIT GATE POS: (${exitInfo?.x?.toFixed(0)}, ${exitInfo?.y?.toFixed(0)})`,
+ `SHIP POS: (${chartSnap?.ship?.x?.toFixed(0)}, ${chartSnap?.ship?.y?.toFixed(0)})`,
+ `DESTINATION IN CURRENT SYS: ${chartSnap?.destinationId ?? 'no (route exit gate shown)'}`,
+ `ERRORS: ${errors.length ? errors.join(' | ') : 'none'}`,
+ ];
+ setReport(lines);
+ console.log('EXIT GATE READY', JSON.stringify(window.__EXIT_GATE, null, 2));
+} catch (e) {
+ const lines = ['FATAL: ' + (e?.message ?? e), ...errors];
+ setReport(lines);
+ window.__EXIT_GATE = { ready: true, errors: [...errors, String(e?.message ?? e)] };
+}
diff --git a/dev/galaxy-map.test.mjs b/dev/galaxy-map.test.mjs
index edd10eb..f852817 100644
--- a/dev/galaxy-map.test.mjs
+++ b/dev/galaxy-map.test.mjs
@@ -126,6 +126,28 @@ const b = others[1];
const vb = snap2.systems.find((s) => s.id === e.b).visited;
return va !== vb;
}).length);
+
+ // ROUTE + DESTINATION: the active SET DESTINATION path (its lanes marked
+ // `route: true`) and the destination star (`isDestination: true`).
+ const routeLane = edgeKey(homeId, a.id);
+ const snapR = buildGalaxySnapshot({
+ galaxy,
+ visited: [homeId],
+ used: [],
+ live: new Set(),
+ currentSystemId: homeId,
+ route: [routeLane],
+ destinationId: a.id,
+ });
+ check('snapR: destinationId carried', snapR.destinationId === a.id);
+ check('snapR: destination star flagged', snapR.systems.find((s) => s.id === a.id)?.isDestination === true);
+ check('snapR: non-destination stars unflagged', snapR.systems.find((s) => s.id === b.id)?.isDestination === false);
+ check('snapR: route lane flagged', snapR.edges.find((e) => e.key === routeLane)?.route === true);
+ check('snapR: non-route lanes unflagged', snapR.edges.every((e) => e.route === (e.key === routeLane)));
+ // No route / no destination → everything unflagged (the default).
+ const snapNR = buildGalaxySnapshot({ galaxy, visited: [homeId], currentSystemId: homeId });
+ check('snapNR: no destination → all unflagged', snapNR.destinationId === null && snapNR.systems.every((s) => s.isDestination === false));
+ check('snapNR: no route → no route edges', snapNR.edges.every((e) => e.route === false));
}
// ---------------------------------------------------------------------------
diff --git a/dev/route-shot.html b/dev/route-shot.html
new file mode 100644
index 0000000..2a61ff6
--- /dev/null
+++ b/dev/route-shot.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Orbit — Route shot (dev)
+
+
+
+
+
+
+
+
diff --git a/dev/route-shot.mjs b/dev/route-shot.mjs
new file mode 100644
index 0000000..10cc10d
--- /dev/null
+++ b/dev/route-shot.mjs
@@ -0,0 +1,154 @@
+/**
+ * Dev-only: boot GameScene, set a DESTINATION (a far star's first planet),
+ * and open the MAP console's GALAXY tab so a headless screenshot shows:
+ * · the route (the active SET DESTINATION path) in ORANGE on the lanes,
+ * · the destination star circled in orange,
+ * · the compass's route gate (orange, full-sized).
+ *
+ * node dev/server.mjs 8083 # static server
+ * node dev/shot-firefox.mjs \
+ * "http://127.0.0.1:8083/dev/route-shot.html" \
+ * "window.__ROUTE_SHOT ? window.__ROUTE_SHOT.ready : null" \
+ * ROUTE_SHOT.png 40000
+ *
+ * The report (top-left) lists console errors + the route state.
+ */
+import Phaser from '../js/vendor/phaser.js';
+import { config } from '../js/config/Config.js';
+import { ConfigLoader } from '../js/config/ConfigLoader.js';
+import { createGameConfig } from '../js/config/GameConfig.js';
+import { GameScene } from '../js/scenes/GameScene.js';
+
+const data = await ConfigLoader.load();
+config.init(data);
+
+// Capture console errors + uncaught exceptions for the report.
+const errors = [];
+const origErr = console.error.bind(console);
+console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
+window.addEventListener('error', (e) => errors.push(String(e.message)));
+window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
+
+const gameConfig = createGameConfig();
+gameConfig.scene = [GameScene];
+const game = new Phaser.Game(gameConfig);
+window.game = game;
+
+// The report lives OUTSIDE the canvas — a DOM
the screenshot can
+// always read (headless canvases don't have to cooperate).
+const report = document.createElement('pre');
+report.id = 'report';
+report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:70%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
+document.body.appendChild(report);
+const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); };
+setReport('booting…');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function waitScene() {
+ for (let i = 0; i < 100; i++) {
+ const s = game.scene.getScene('GameScene');
+ if (s && s.ship && s.mapWindow) return s;
+ await sleep(100);
+ }
+ throw new Error('GameScene never booted');
+}
+
+try {
+ const s = await waitScene();
+ await sleep(800); // let the boot settle
+
+ // Pick a FAR destination: the furthest star from the current system
+ // (a multi-hop route, so the orange path is visible on several lanes).
+ const sysId = s.systemRecord.id;
+ const records = s.galaxy.records;
+ const cur = records.find((r) => r.id === sysId);
+ const others = records.filter((r) => r.id !== sysId);
+ others.sort((a, b) => {
+ const da = Math.hypot(a.x - cur.x, a.y - cur.y);
+ const db = Math.hypot(b.x - cur.x, b.y - cur.y);
+ return db - da; // farthest first
+ });
+ // Use the 3rd-furthest (leave some margin so the route is a real path,
+ // not a single hop).
+ const dest = others[2] ?? others[0];
+ if (!dest) throw new Error('no destination candidate');
+
+ // Discover the destination's first planet (so it has an objectId).
+ const destContent = s.galaxy.contentOf(dest.id);
+ const destPlanet = (destContent.planets ?? [])[0];
+ const destObjId = destPlanet?.name ?? null;
+
+ // Set the destination (the route is derived from it).
+ s.setDestination(dest.id, destObjId);
+ await sleep(400);
+
+ // Open the MAP console's GALAXY tab (the route + destination ring are
+ // drawn there). Use the window's own open() (reveal tween + state).
+ s.mapWindow._switchMode('galaxy');
+ s.mapWindow.open();
+ await sleep(1600); // let the reveal tween + ripple settle + the route paint
+
+ // Collect the route state for the report.
+ const plan = s.routePlan();
+ const routeKeys = s.routeEdgeKeys();
+ const snap = s.galaxySnapshot();
+ const destStar = snap?.systems?.find((x) => x.isDestination);
+ const routeEdges = (snap?.edges ?? []).filter((e) => e.route);
+
+ // --- Now switch to the SYSTEM tab and open the destination system's chart.
+ // --- Discover the destination object first (so it's drawn on the chart),
+ // --- then the destination ring should appear around it.
+ const destSysId = dest.id;
+ if (destPlanet) {
+ // Discover the destination planet (so it's drawn on the chart).
+ let known = s.discovery.bySystem.get(destSysId);
+ if (!known) { known = new Set(); s.discovery.bySystem.set(destSysId, known); }
+ s.discovery.check(destSysId, destPlanet.x, destPlanet.y, [{ id: destPlanet.name, x: destPlanet.x, y: destPlanet.y, radius: 100 }]);
+ }
+ await sleep(300);
+
+ // Switch to the SYSTEM tab and open the destination system's chart.
+ s.mapWindow._switchMode('system');
+ s.mapWindow._openSystem(destSysId);
+ await sleep(1200); // let the chart repaint
+
+ // Collect the chart state for the report.
+ const chartSnap = s.systemChartSnapshotFor(destSysId);
+ const destInChart = chartSnap?.objects?.find((o) => o.id === chartSnap.destinationId);
+
+ window.__ROUTE_SHOT = {
+ ready: true,
+ errors,
+ destSystem: dest.id,
+ destName: dest.name,
+ destObjId,
+ plan: { path: plan?.path, hops: plan?.hops },
+ routeKeys,
+ destStar: destStar ? { id: destStar.id, name: destStar.name } : null,
+ routeEdgeCount: routeEdges.length,
+ destinationId: snap?.destinationId,
+ chartDestinationId: chartSnap?.destinationId,
+ chartDestDiscovered: destInChart?.discovered,
+ chartDestName: destInChart?.name,
+ };
+
+ const lines = [
+ `DESTINATION: ${dest.name} (${dest.id})`,
+ `OBJECT: ${destObjId}`,
+ `ROUTE: ${plan?.path?.join(' → ') ?? 'null'}`,
+ `HOPS: ${plan?.hops}`,
+ `ROUTE EDGES: ${routeEdges.length}`,
+ `DEST STAR: ${destStar ? `${destStar.name} (${destStar.id})` : 'null'}`,
+ `CHART DEST: ${chartSnap?.destinationId ?? 'null'}`,
+ `CHART DEST DISCOVERED: ${destInChart?.discovered}`,
+ `CHART DEST NAME: ${destInChart?.name}`,
+ `ERRORS: ${errors.length ? errors.join(' | ') : 'none'}`,
+ ];
+ setReport(lines);
+ console.log('ROUTE SHOT READY', JSON.stringify(window.__ROUTE_SHOT, null, 2));
+} catch (e) {
+ const lines = ['FATAL: ' + (e?.message ?? e), ...errors];
+ setReport(lines);
+ window.__ROUTE_SHOT = { ready: true, errors: [...errors, String(e?.message ?? e)] };
+}
diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md
index bdc6dd2..21dc8fe 100644
--- a/docs/PROJECT_NOTES.md
+++ b/docs/PROJECT_NOTES.md
@@ -495,6 +495,39 @@ of storing a progress counter) makes the follow/detour rules automatic:
**Tuning** — `data/gates.json → route` (the orange `compassColor`, the
`typeLabel` / `destTypeLabel`, the three toasts with their
`{dest}`/`{hops}` placeholders).
+
+**Visualization** — the active route is drawn on the MAP console:
+- **GALAXY tab** — the route's lanes are drawn in **ORANGE**
+ (`data/map.json → galaxy.edges.route`, color = `route.compassColor`)
+ above the used/frontier/unexplored reads, and the DESTINATION STAR is
+ circled in orange (`data/map.json → galaxy.destination`) — the
+ "where am I going" path + target across the whole map. The route's
+ edge keys are computed by `GameScene.routeEdgeKeys()` (the consecutive
+ system pairs of `planRoute`'s path) and passed to
+ `buildGalaxySnapshot` (which marks `route: true` on those edges and
+ `isDestination: true` on the destination star). `GalaxyView._redrawEdges`
+ draws the route lanes (three-pass glow, like traveled) and
+ `GalaxyView._drawMarkers` draws the destination ring (a breathing
+ orange circle, shown only while a destination is set).
+- **CURRENT SYSTEM tab** — two cases:
+ - **Destination is IN this system** — the destination object is circled
+ in orange (the route's final stop). `GameScene.mapChartSnapshot`
+ sets `destinationId` on the snapshot; `MapWindow.paintChart` draws the
+ ring (step 8b) over the object (shown only when the object is
+ discovered — a ring over a ghost would be noise).
+ - **Destination is OUTSIDE this system** — the ROUTE EXIT GATE (the jump
+ gate the player should path out of the system from, `routeNextGate`) is
+ circled in orange + a dashed orange line is drawn from the ship to it
+ ("path out of the system"). `GameScene.mapChartSnapshot` sets
+ `routeExitGate` on the snapshot (the gate's id/x/y/name); `MapWindow.
+ paintChart` draws the ring + line (step 8c). The gate may be
+ undiscovered (the player hasn't seen it yet) — the ring + line still
+ show WHERE to go, which is the point of the route.
+- **SYSTEM tab** — the destination object is circled in orange when
+ viewing the destination system's chart. `GameScene.systemChartSnapshotFor`
+ passes `destinationId` to `systemChartSnapshot` (which carries it on
+ the snapshot); `MapWindow.paintChart` draws the ring (same step 8b).
+
**Tests** — `dev/route.test.mjs` (already-there short-circuit, path
validity over real gate edges, `next` is a gate neighbour, determinism +
tree symmetry (a→b reversed === b→a), strong connectivity across sampled
diff --git a/js/galaxy/GalaxyChart.js b/js/galaxy/GalaxyChart.js
index 4297141..998f0ca 100644
--- a/js/galaxy/GalaxyChart.js
+++ b/js/galaxy/GalaxyChart.js
@@ -49,14 +49,21 @@ export function edgeKey(a, b) {
* (`"${from}>${to}"`, GameScene.activatedGates) — the lanes a JUMP
* can currently be confirmed from the current system
* @param {string|null} [o.currentSystemId]
+ * @param {Iterable} [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, systems:Array