Files
2026-10-01 22:16:48 +02:00

621 lines
25 KiB
JavaScript
Raw Permalink 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.
// 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(); },
};
}