This commit is contained in:
root
2026-10-01 22:16:48 +02:00
parent b6d1909d7f
commit 9dfdf79fa7
39 changed files with 4560 additions and 181 deletions
+620
View File
@@ -0,0 +1,620 @@
// The admin map: a top-down (ecliptic plane) view of the belt. Asteroids, the
// station, drifting containers and ships are drawn on a canvas that pans and
// zooms; each ship carries an arrow for its velocity.
//
// Coordinates are kilometres from the Star, X to the right and Y up. The map
// is flat, so the Z part of a position or velocity appears in the details.
import { h, clear } from '/dom.js';
const ORE_COLORS = ['#8fb4d9', '#63e08a', '#4ee6ff', '#ffb62e']; // iron, nickel, ice, platinum
const SHIP_COLOR = '#ff3ad8';
const K_MIN = 2e-5; // px per km
const K_MAX = 3000;
const ARROW_PX_PER_KMS = 16; // arrow length per km/s of speed, on screen at any zoom
// What survives closing the tab and coming back: where you were looking and what you chose.
const saved = { view: null, sel: null, opts: { rings: true, grid: true, labels: true, ranges: true, containers: true, lost: false } };
try { Object.assign(saved.opts, JSON.parse(localStorage.getItem('halcyon.adminmap') ?? '{}')); } catch { /* defaults */ }
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const fmt = (n, d = 0) => Number(n).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
const dominant = (ore) => ore.reduce((best, v, i) => (v > ore[best] ? i : best), 0);
function niceStep(min) {
const p = 10 ** Math.floor(Math.log10(min));
for (const m of [1, 2, 5, 10]) if (p * m >= min) return p * m;
return p * 10;
}
function fmtKm(v) {
const a = Math.abs(v);
if (a >= 1e6) return `${+(v / 1e6).toPrecision(3)} M`;
if (a >= 1e3) return `${+(v / 1e3).toPrecision(3)} k`;
return `${+v.toPrecision(3)}`;
}
/**
* createMap({ fetchMap, fetchOrbit, onOpenAccount }) returns { el, load, destroy }.
* fetchMap() resolves the /admin/api/map document. fetchOrbit(shipId) resolves
* the predicted coasting path for that ship (/admin/api/map/orbit).
*/
export function createMap({ fetchMap, fetchOrbit, onOpenAccount }) {
let data = null;
let W = 0;
let H = 0;
let dpr = 1;
const view = saved.view ?? { cx: 0, cy: 0, k: 1e-4 };
let sel = saved.sel; // { type: 'ship'|'asteroid'|'container'|'station', id }
let hover = null;
let drawQueued = false;
const opts = saved.opts;
let orbit = null; // { ship, points, closed, hit_star } for the selected ship, once loaded
let orbitSeq = 0; // guards against a stale response landing after a newer selection
// ---- DOM ------------------------------------------------------------------
const canvas = h('canvas', {
class: 'map-canvas', tabindex: '0', role: 'application',
'aria-label': 'Map of the belt. Arrow keys pan, plus and minus zoom, zero fits the belt.',
});
const tip = h('div', { class: 'map-tip', hidden: true, role: 'tooltip' });
const readout = h('div', { class: 'map-readout' }, '');
const wrap = h('div', { class: 'map-wrap' }, canvas, tip, readout);
const dayLabel = h('span', { class: 'dim' }, 'LOADING...');
const info = h('div', { class: 'map-info' });
const shipList = h('div', { class: 'scroll' });
const btn = (label, title, onclick) => h('button', { class: 'btn small', type: 'button', title, 'aria-label': title, onclick }, label);
const toggle = (key, label) => {
const box = h('input', { type: 'checkbox', checked: !!opts[key] });
box.addEventListener('change', () => { opts[key] = box.checked; persist(); schedule(); });
return h('label', { class: 'check' }, box, label);
};
const legend = h('div', { class: 'map-legend' },
['IRON', 'NICKEL', 'ICE', 'PLATINUM'].map((n, i) => h('span', null, h('i', { class: 'dot', 'data-c': String(i) }), n)),
h('span', null, h('i', { class: 'dot ship' }), 'SHIP + VELOCITY ARROW'),
h('span', null, h('i', { class: 'dot box' }), 'CONTAINER'),
h('span', null, h('i', { class: 'dot orbit' }), 'SELECTED SHIP: PREDICTED PATH'),
);
const el = h('div', { class: 'stack-lg' },
h('section', { class: 'panel' },
h('div', { class: 'panel-title' }, 'BELT MAP', h('span', { class: 'spacer' }), dayLabel,
h('button', { class: 'btn small', type: 'button', onclick: () => load() }, 'REFRESH')),
h('div', { class: 'map-tools' },
btn('+', 'Zoom in', () => zoomBy(1.6)), btn('−', 'Zoom out', () => zoomBy(1 / 1.6)),
btn('FIT BELT', 'Fit the whole belt', fit), btn('STATION', 'Centre on the station', () => { if (data) { view.cx = data.station.x; view.cy = data.station.y; view.k = Math.max(view.k, 4e-4); changed(); } }),
h('span', { class: 'spacer' }),
toggle('grid', 'GRID'), toggle('rings', 'ORBITS'), toggle('labels', 'LABELS'), toggle('ranges', 'RANGES'), toggle('containers', 'CONTAINERS'), toggle('lost', 'LOST SHIPS'),
),
h('div', { class: 'map-layout' }, wrap,
h('aside', { class: 'map-side' },
h('div', { class: 'panel-title' }, 'SELECTED'), info,
h('div', { class: 'panel-title' }, 'SHIPS'), shipList)),
h('div', { class: 'a-foot' }, legend),
),
);
function persist() {
saved.view = view;
saved.sel = sel;
try { localStorage.setItem('halcyon.adminmap', JSON.stringify(opts)); } catch { /* ignore */ }
}
// ---- geometry ---------------------------------------------------------------
const sx = (x) => W / 2 + (x - view.cx) * view.k;
const sy = (y) => H / 2 - (y - view.cy) * view.k;
const wx = (px) => view.cx + (px - W / 2) / view.k;
const wy = (py) => view.cy - (py - H / 2) / view.k;
function zoomAt(px, py, factor) {
const x = wx(px);
const y = wy(py);
view.k = clamp(view.k * factor, K_MIN, K_MAX);
view.cx = x - (px - W / 2) / view.k;
view.cy = y + (py - H / 2) / view.k;
changed();
}
const zoomBy = (f) => zoomAt(W / 2, H / 2, f);
function fit() {
const r = (data?.belt.outer ?? 3e6) * 1.1;
view.cx = 0;
view.cy = 0;
view.k = clamp((Math.min(W, H) / 2) / r, K_MIN, K_MAX);
changed();
}
function flyTo(x, y, minK = 2e-3) {
view.cx = x;
view.cy = y;
view.k = clamp(Math.max(view.k, minK), K_MIN, K_MAX);
changed();
}
function changed() { persist(); schedule(); }
// ---- drawing ----------------------------------------------------------------
function schedule() {
if (drawQueued) return;
drawQueued = true;
requestAnimationFrame(() => { drawQueued = false; draw(); });
}
function arrow(ctx, x, y, dx, dy, len) {
const ex = x + dx * len;
const ey = y + dy * len;
ctx.beginPath();
ctx.moveTo(x + dx * 7, y + dy * 7);
ctx.lineTo(ex, ey);
ctx.stroke();
const head = 7;
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - dx * head + dy * head * 0.5, ey - dy * head - dx * head * 0.5);
ctx.lineTo(ex - dx * head - dy * head * 0.5, ey - dy * head + dx * head * 0.5);
ctx.closePath();
ctx.fill();
}
function circle(ctx, x, y, r) { ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); }
function ring(ctx, r, style, dash) {
const px = r * view.k;
if (px < 3 || px > 4e5) return; // too small to see, or so large it is a straight line off screen
ctx.setLineDash(dash ?? []);
ctx.strokeStyle = style;
circle(ctx, sx(0), sy(0), px);
ctx.stroke();
ctx.setLineDash([]);
}
function draw() {
if (!W || !H) return;
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#05020c';
ctx.fillRect(0, 0, W, H);
ctx.font = '11px ui-monospace, "DejaVu Sans Mono", monospace';
ctx.textBaseline = 'alphabetic';
canvas.dataset.scale = String(view.k);
canvas.dataset.cx = String(view.cx);
canvas.dataset.cy = String(view.cy);
if (!data) return;
// grid, with its coordinates along the edges
if (opts.grid) {
const step = niceStep(80 / view.k);
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(120, 100, 200, 0.16)';
ctx.fillStyle = 'rgba(160, 145, 220, 0.75)';
ctx.beginPath();
for (let x = Math.ceil(wx(0) / step) * step; x <= wx(W); x += step) { const px = Math.round(sx(x)) + 0.5; ctx.moveTo(px, 0); ctx.lineTo(px, H); }
for (let y = Math.ceil(wy(H) / step) * step; y <= wy(0); y += step) { const py = Math.round(sy(y)) + 0.5; ctx.moveTo(0, py); ctx.lineTo(W, py); }
ctx.stroke();
for (let x = Math.ceil(wx(0) / step) * step; x <= wx(W); x += step) ctx.fillText(fmtKm(x), sx(x) + 3, H - 5);
for (let y = Math.ceil(wy(H) / step) * step; y <= wy(0); y += step) ctx.fillText(fmtKm(y), 4, sy(y) - 3);
}
ctx.lineWidth = 1;
if (opts.rings) {
ring(ctx, data.belt.inner, 'rgba(255, 182, 46, 0.35)', [6, 6]);
ring(ctx, data.belt.outer, 'rgba(255, 182, 46, 0.35)', [6, 6]);
ring(ctx, data.station.orbit_radius, 'rgba(40, 245, 255, 0.28)');
}
// the Star and the zone around it that destroys ships
const cx0 = sx(0);
const cy0 = sy(0);
const zone = data.star.radius * view.k;
if (zone > 1.5) {
ctx.fillStyle = 'rgba(255, 85, 119, 0.14)';
ctx.strokeStyle = 'rgba(255, 85, 119, 0.6)';
circle(ctx, cx0, cy0, zone);
ctx.fill();
ctx.stroke();
}
const glow = ctx.createRadialGradient(cx0, cy0, 0, cx0, cy0, 12);
glow.addColorStop(0, '#fff27a');
glow.addColorStop(0.4, '#ffb62e');
glow.addColorStop(1, 'rgba(255, 58, 216, 0)');
ctx.fillStyle = glow;
circle(ctx, cx0, cy0, 12);
ctx.fill();
// asteroids
const margin = 12;
let drawn = 0;
for (const a of data.asteroids) {
const x = sx(a.x);
const y = sy(a.y);
if (x < -margin || y < -margin || x > W + margin || y > H + margin) continue;
drawn++;
const r = clamp(1.6 + Math.log10(a.total + 1) * 0.55, 2, 5);
ctx.fillStyle = ORE_COLORS[dominant(a.ore)];
ctx.globalAlpha = 0.9;
circle(ctx, x, y, r);
ctx.fill();
ctx.globalAlpha = 1;
if (opts.ranges && data.ranges.mining * view.k >= 3) {
ctx.strokeStyle = 'rgba(99, 224, 138, 0.35)';
ctx.setLineDash([3, 3]);
circle(ctx, x, y, data.ranges.mining * view.k);
ctx.stroke();
ctx.setLineDash([]);
}
if (opts.labels && view.k > 6e-4) { ctx.fillStyle = 'rgba(200, 190, 240, 0.8)'; ctx.fillText(`#${a.id}`, x + r + 3, y + 3); }
}
canvas.dataset.asteroidsDrawn = String(drawn);
// station
const stx = sx(data.station.x);
const sty = sy(data.station.y);
ctx.strokeStyle = '#28f5ff';
ctx.fillStyle = 'rgba(40, 245, 255, 0.25)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.rect(stx - 5, sty - 5, 10, 10);
ctx.fill();
ctx.stroke();
if (opts.ranges && data.ranges.docking * view.k >= 4) {
ctx.setLineDash([4, 4]);
circle(ctx, stx, sty, data.ranges.docking * view.k);
ctx.stroke();
ctx.setLineDash([]);
}
if (opts.labels) { ctx.fillStyle = '#28f5ff'; ctx.fillText('STATION', stx + 9, sty - 7); }
ctx.lineWidth = 1;
// drifting containers
if (opts.containers) {
ctx.fillStyle = '#ffb62e';
for (const c of data.containers) {
const x = sx(c.x);
const y = sy(c.y);
if (x < -margin || y < -margin || x > W + margin || y > H + margin) continue;
ctx.fillRect(x - 2, y - 2, 4, 4);
if (c.ore > 0) { ctx.strokeStyle = '#ffb62e'; ctx.strokeRect(x - 4, y - 4, 8, 8); }
}
}
// the selected ship's predicted path, coasting with the engine off from here on (drawn
// under the ships, so a ship marker and its velocity arrow are never obscured by it)
if (orbit && sel?.type === 'ship' && orbit.ship === sel.id && orbit.points.length > 1) {
ctx.strokeStyle = orbit.hit_star ? 'rgba(255, 133, 92, 0.85)' : 'rgba(120, 245, 255, 0.8)';
ctx.lineWidth = 1.5;
if (!orbit.closed) ctx.setLineDash([5, 4]); // open path: it does not return to where it started
ctx.beginPath();
orbit.points.forEach((pt, i) => {
const x = sx(pt[0]);
const y = sy(pt[1]);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.setLineDash([]);
ctx.lineWidth = 1;
}
canvas.dataset.orbitPoints = orbit && sel?.type === 'ship' && orbit.ship === sel.id ? String(orbit.points.length) : '0';
// ships, each with an arrow for its velocity
for (const s of data.ships) {
if (!s.alive && !opts.lost) continue;
const x = sx(s.x);
const y = sy(s.y);
if (x < -120 || y < -120 || x > W + 120 || y > H + 120) continue;
if (!s.alive) {
ctx.strokeStyle = 'rgba(160, 150, 190, 0.8)';
ctx.beginPath();
ctx.moveTo(x - 4, y - 4); ctx.lineTo(x + 4, y + 4); ctx.moveTo(x + 4, y - 4); ctx.lineTo(x - 4, y + 4);
ctx.stroke();
} else {
const v = Math.hypot(s.vx, s.vy);
ctx.shadowColor = SHIP_COLOR;
ctx.shadowBlur = 8;
ctx.strokeStyle = SHIP_COLOR;
ctx.fillStyle = SHIP_COLOR;
ctx.lineWidth = 2;
if (v > 1e-6) arrow(ctx, x, y, s.vx / v, -s.vy / v, clamp(v * ARROW_PX_PER_KMS, 14, 120));
ctx.lineWidth = 1.5;
ctx.fillStyle = '#05020c';
circle(ctx, x, y, 5);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
ctx.lineWidth = 1;
}
if (opts.labels) {
// on the side away from the arrow, so the two never overlap
const away = s.alive && s.vx > 0;
ctx.textAlign = away ? 'right' : 'left';
ctx.fillStyle = s.alive ? '#ffd6f7' : 'rgba(160, 150, 190, 0.9)';
ctx.fillText(`${s.name || '?'} #${s.id}`, away ? x - 9 : x + 9, y - 9);
ctx.textAlign = 'left';
}
}
// the selection
const p = position(sel);
if (p) {
ctx.strokeStyle = '#28f5ff';
ctx.lineWidth = 2;
circle(ctx, sx(p.x), sy(p.y), 11);
ctx.stroke();
ctx.lineWidth = 1;
}
// scale bar
const bar = niceStep(90 / view.k);
const px = bar * view.k;
ctx.strokeStyle = '#e9e4ff';
ctx.fillStyle = '#e9e4ff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(14, H - 26); ctx.lineTo(14 + px, H - 26); ctx.moveTo(14, H - 30); ctx.lineTo(14, H - 22); ctx.moveTo(14 + px, H - 30); ctx.lineTo(14 + px, H - 22);
ctx.stroke();
ctx.lineWidth = 1;
ctx.fillText(`${fmtKm(bar)} km`, 18, H - 32);
}
// ---- objects ------------------------------------------------------------------
function find(sel_) {
if (!data || !sel_) return null;
switch (sel_.type) {
case 'ship': return data.ships.find((s) => s.id === sel_.id) ?? null;
case 'asteroid': return data.asteroids.find((a) => a.id === sel_.id) ?? null;
case 'container': return data.containers.find((c) => c.id === sel_.id) ?? null;
case 'station': return data.station;
default: return null;
}
}
const position = (sel_) => find(sel_);
/** The object nearest the point (in pixels), if one is close enough to mean it. */
function hit(px, py) {
if (!data) return null;
let best = null;
let bestD = Infinity;
const consider = (type, obj, reach, weight = 0) => {
const d = Math.hypot(sx(obj.x) - px, sy(obj.y) - py) - weight;
if (d <= reach && d < bestD) { best = { type, id: obj.id, obj }; bestD = d; }
};
consider('station', { ...data.station, id: 0 }, 12, 4);
for (const s of data.ships) if (s.alive || opts.lost) consider('ship', s, 13, 6);
if (opts.containers) for (const c of data.containers) consider('container', c, 8, 2);
for (const a of data.asteroids) consider('asteroid', a, 8);
return best;
}
// ---- details ------------------------------------------------------------------
const row = (k, v) => [h('dt', null, k), h('dd', null, v)];
const xyz = (o) => `${fmt(o.x)}, ${fmt(o.y)}, ${fmt(o.z)}`;
const vel = (o) => `${fmt(o.vx, 3)}, ${fmt(o.vy, 3)}, ${fmt(o.vz, 3)}`;
function orbitStatus(shipId) {
if (!orbit || orbit.ship !== shipId) return 'DRAWING...';
if (orbit.hit_star) return 'ENGINE OFF FROM HERE: FALLS INTO THE STAR';
if (orbit.closed) return 'ENGINE OFF FROM HERE: CLOSES AFTER ONE TURN (drawn)';
return 'ENGINE OFF FROM HERE: DOES NOT CLOSE (drawn)';
}
function renderInfo() {
clear(info);
const o = find(sel);
if (!o) { info.append(h('p', { class: 'a-empty' }, 'CLICK A SHIP, ASTEROID OR CONTAINER.')); return; }
const items = [];
if (sel.type === 'ship') {
const speed = Math.hypot(o.vx, o.vy, o.vz);
items.push(
...row('CALLSIGN', h('span', null, o.name || '?', ' ', h('button', { class: 'linklike', type: 'button', onclick: () => onOpenAccount(o.owner) }, `(ACCOUNT #${o.owner})`))),
...row('SHIP', `#${o.id}`),
...row('STATE', o.alive ? 'IN THE BELT' : 'LOST'),
...row('POSITION KM', xyz(o)),
...row('FROM THE STAR', `${fmt(Math.hypot(o.x, o.y, o.z))} km`),
...row('VELOCITY KM/S', vel(o)),
...row('SPEED', `${fmt(speed, 3)} km/s`),
...row('HEADING', `${fmt(((Math.atan2(o.vy, o.vx) * 180) / Math.PI + 360) % 360, 1)}° from +X`),
...row('FUEL', `${fmt(o.fuel)} kg`),
...row('THROTTLE', `${fmt(o.throttle / 10, 1)}%${o.mining ? ', MINING' : ''}`),
...row('IN TOW', `${o.containers_in_tow} containers, ${fmt(o.ore_in_tow)} kg of ore`),
...row('EARNED', fmt(o.earned)),
...row('PREDICTED ORBIT', orbitStatus(o.id)),
);
} else if (sel.type === 'asteroid') {
const names = data.ores;
items.push(
...row('ASTEROID', `#${o.id}`),
...row('POSITION KM', xyz(o)),
...row('ORBIT RADIUS', `${fmt(o.orbit_radius)} km`),
...row('PERIOD', `${fmt(o.period / 86400, 1)} days`),
...row('INCLINATION', `${fmt((o.inclination * 180) / Math.PI, 1)}°`),
...row('ORE', `${fmt(o.total)} kg`),
...names.map((n, i) => [h('dt', null, ` ${n.toUpperCase()}`), h('dd', null, `${fmt(o.ore[i])} kg`)]).flat(),
);
} else if (sel.type === 'container') {
items.push(
...row('CONTAINER', `#${o.id}`),
...row('POSITION KM', xyz(o)),
...row('ORE', `${fmt(o.ore)} kg`),
...row('VANISHES IN', `${fmt((o.expires_at - data.time) / 86400, 1)} days`),
);
} else {
items.push(
...row('STATION', 'DROPOFF POINT'),
...row('POSITION KM', xyz(o)),
...row('VELOCITY KM/S', vel(o)),
...row('ORBIT RADIUS', `${fmt(o.orbit_radius)} km`),
);
}
info.append(h('dl', { class: 'kv' }, ...items));
}
function renderShips() {
clear(shipList);
if (!data || data.ships.length === 0) { shipList.append(h('p', { class: 'a-empty' }, 'NO SHIPS HAVE LAUNCHED.')); return; }
shipList.append(h('table', { class: 'atable' },
h('thead', null, h('tr', null, ['CALLSIGN', 'SHIP', 'KM/S', ''].map((t) => h('th', { scope: 'col' }, t)))),
h('tbody', null, data.ships.map((s) => h('tr', { class: `arow ${sel?.type === 'ship' && sel.id === s.id ? 'sel' : ''}`, tabindex: '0',
onclick: () => choose({ type: 'ship', id: s.id }, true), onkeydown: (e) => { if (e.key === 'Enter') choose({ type: 'ship', id: s.id }, true); } },
h('td', null, s.name || '?'), h('td', null, `#${s.id}`), h('td', { class: 'num' }, fmt(Math.hypot(s.vx, s.vy, s.vz), 2)),
h('td', null, s.alive ? '' : h('span', { class: 'badge red' }, 'LOST')))))));
}
function choose(next, center = false) {
sel = next;
persist();
updateOrbit();
renderInfo();
renderShips();
const o = find(next);
if (center && o) flyTo(o.x, o.y);
else schedule();
}
/** (Re)fetches the predicted path for the selected ship, if any; drops it otherwise. */
function updateOrbit() {
const mine = ++orbitSeq;
orbit = null;
if (!sel || sel.type !== 'ship' || !fetchOrbit) { schedule(); return; }
const shipId = sel.id;
fetchOrbit(shipId).then(
(o) => { if (mine === orbitSeq) { orbit = o; schedule(); renderInfo(); } },
(e) => { if (mine === orbitSeq && !(e && e.name === 'SessionEnded')) { orbit = null; schedule(); renderInfo(); } },
);
}
// ---- input ----------------------------------------------------------------------
const pointers = new Map();
let moved = false;
let pinch = null;
const local = (e) => { const r = canvas.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
canvas.addEventListener('pointerdown', (e) => {
try { canvas.setPointerCapture(e.pointerId); } catch { /* the pointer has already gone (a finger lifted mid-gesture) */ }
canvas.focus({ preventScroll: true });
pointers.set(e.pointerId, local(e));
moved = false;
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
pinch = { d: Math.hypot(a[0] - b[0], a[1] - b[1]), mx: (a[0] + b[0]) / 2, my: (a[1] + b[1]) / 2 };
}
});
canvas.addEventListener('pointermove', (e) => {
const [px, py] = local(e);
readout.textContent = data ? `X ${fmt(wx(px))} Y ${fmt(wy(py))} km` : '';
if (!pointers.has(e.pointerId)) { hoverAt(px, py); return; }
const [ox, oy] = pointers.get(e.pointerId);
pointers.set(e.pointerId, [px, py]);
if (pointers.size === 1) {
if (Math.hypot(px - ox, py - oy) > 0 && (moved || Math.hypot(px - ox, py - oy) > 1)) {
view.cx -= (px - ox) / view.k;
view.cy += (py - oy) / view.k;
moved = true;
hideTip();
changed();
}
} else if (pointers.size === 2 && pinch) {
const [a, b] = [...pointers.values()];
const d = Math.hypot(a[0] - b[0], a[1] - b[1]);
const mx = (a[0] + b[0]) / 2;
const my = (a[1] + b[1]) / 2;
view.cx -= (mx - pinch.mx) / view.k;
view.cy += (my - pinch.my) / view.k;
if (pinch.d > 0) zoomAt(mx, my, d / pinch.d);
pinch = { d, mx, my };
moved = true;
}
});
const release = (e) => {
if (!pointers.has(e.pointerId)) return;
const [px, py] = pointers.get(e.pointerId);
pointers.delete(e.pointerId);
if (pointers.size < 2) pinch = null;
if (e.type === 'pointerup' && !moved && pointers.size === 0) {
const h_ = hit(px, py);
choose(h_ ? { type: h_.type, id: h_.id } : null);
}
};
canvas.addEventListener('pointerup', release);
canvas.addEventListener('pointercancel', release);
canvas.addEventListener('pointerleave', () => { hideTip(); readout.textContent = ''; });
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const [px, py] = local(e);
zoomAt(px, py, Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0016)));
}, { passive: false });
canvas.addEventListener('dblclick', (e) => { const [px, py] = local(e); zoomAt(px, py, 2); });
canvas.addEventListener('keydown', (e) => {
const pan = 60 / view.k;
const acts = {
'+': () => zoomBy(1.4), '=': () => zoomBy(1.4), '-': () => zoomBy(1 / 1.4), _: () => zoomBy(1 / 1.4),
ArrowLeft: () => { view.cx -= pan; changed(); }, ArrowRight: () => { view.cx += pan; changed(); },
ArrowUp: () => { view.cy += pan; changed(); }, ArrowDown: () => { view.cy -= pan; changed(); },
0: fit, Home: fit, Escape: () => choose(null),
};
if (acts[e.key]) { e.preventDefault(); acts[e.key](); }
});
function hoverAt(px, py) {
const found = hit(px, py);
hover = found;
canvas.style.cursor = found ? 'pointer' : 'grab';
if (!found) { hideTip(); return; }
const o = found.obj;
let text = '';
if (found.type === 'ship') text = `${o.name || '?'} #${o.id}: ${fmt(Math.hypot(o.vx, o.vy, o.vz), 2)} km/s`;
else if (found.type === 'asteroid') text = `Asteroid #${o.id}: ${fmt(o.total)} kg of ore (${data.ores[dominant(o.ore)]})`;
else if (found.type === 'container') text = `Container #${o.id}: ${fmt(o.ore)} kg of ore`;
else text = 'Station';
tip.textContent = text;
tip.hidden = false;
tip.style.left = `${clamp(px + 14, 0, W - 180)}px`;
tip.style.top = `${clamp(py + 14, 0, H - 30)}px`;
}
function hideTip() { tip.hidden = true; hover = null; }
// ---- sizing and loading ----------------------------------------------------------
function resize() {
const r = wrap.getBoundingClientRect();
if (!r.width || !r.height) return;
dpr = Math.min(window.devicePixelRatio || 1, 2);
const first = W === 0;
W = Math.round(r.width);
H = Math.round(r.height);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
if (first && !saved.view && data) fit();
schedule();
}
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
ro?.observe(wrap);
async function load() {
try {
data = await fetchMap();
} catch (e) {
if (e && e.name === 'SessionEnded') return;
dayLabel.textContent = `COULD NOT LOAD THE MAP: ${e.message}`.toUpperCase();
return;
}
dayLabel.textContent = `WORLD AT THE START OF DAY ${data.day}`;
canvas.dataset.day = String(data.day);
if (sel && !find(sel)) sel = null;
if (!saved.view && W) { fit(); }
updateOrbit();
renderInfo();
renderShips();
schedule();
}
return {
el,
load,
destroy() { ro?.disconnect(); persist(); },
};
}