// 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(); }, }; }