vibes
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
/* The operator's console: the site's look, with a red accent so it is never mistaken for the player site. */
|
||||
|
||||
body.admin { --accent: #ff5577; background: linear-gradient(180deg, #07030f, #12061f 60%, #240a1c); min-height: 100vh; }
|
||||
body.admin::after { content: ""; position: fixed; inset: 0; pointer-events: none; background: repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0 2px, rgba(0,0,0,0.12) 3px, rgba(0,0,0,0) 4px); }
|
||||
|
||||
.a-top { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1.4rem; padding: 0.7rem 1.2rem; background: rgba(7, 3, 15, 0.96); border-bottom: 2px solid var(--accent); }
|
||||
.a-brand { font-family: var(--display); letter-spacing: 0.3em; font-size: 0.95rem; }
|
||||
.a-brand b { color: var(--accent); }
|
||||
.a-note { color: var(--dim); font-size: 0.8rem; letter-spacing: 0.1em; }
|
||||
.a-main { max-width: 1240px; margin: 0 auto; padding: 1rem 1rem 6rem; position: relative; z-index: 1; }
|
||||
.a-tabs { display: flex; gap: 0.3rem; margin: 1rem 0; }
|
||||
.a-tabs .tab.on { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 16px rgba(255, 85, 119, 0.55); color: #07030f; }
|
||||
|
||||
.a-login { max-width: 420px; margin: 12vh auto 0; padding: 0 1rem; position: relative; z-index: 1; }
|
||||
.a-login h1 { font-family: var(--display); font-size: 1.6rem; letter-spacing: 0.3em; margin: 0 0 1rem; text-align: center; }
|
||||
.a-login h1 b { color: var(--accent); }
|
||||
|
||||
.a-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; padding: 0.8rem 1rem; }
|
||||
.a-filters label { display: flex; align-items: center; gap: 0.5rem; flex: 1 1 15rem; min-width: 0; max-width: 24rem; }
|
||||
.a-filters label.check { flex: none; }
|
||||
.a-filters input[type="text"] { width: 100%; max-width: 16rem; }
|
||||
/* a callsign can be 40 characters: the dropdown shrinks and truncates instead of widening the page */
|
||||
.a-filters select { flex: 1; min-width: 0; max-width: 100%; width: auto; text-overflow: ellipsis; }
|
||||
|
||||
.scroll { overflow-x: auto; }
|
||||
table.atable { width: 100%; border-collapse: collapse; font-size: 0.92rem; }
|
||||
table.atable th { text-align: left; font-weight: 400; font-size: 0.75rem; letter-spacing: 0.18em; color: var(--dim); padding: 0.55rem 0.8rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||
table.atable th button { font: inherit; letter-spacing: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0; text-transform: inherit; }
|
||||
table.atable th button:hover, table.atable th button[aria-sort] { color: var(--cyan); }
|
||||
table.atable td { padding: 0.5rem 0.8rem; border-bottom: 1px dashed var(--faint); vertical-align: top; }
|
||||
table.atable tr.arow { cursor: pointer; }
|
||||
table.atable tr.arow:hover td { background: rgba(255, 85, 119, 0.07); }
|
||||
table.atable tr.sel td { background: rgba(255, 85, 119, 0.13); }
|
||||
table.atable td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
table.atable td.nowrap { white-space: nowrap; }
|
||||
table.atable tr.more td { background: rgba(0, 0, 0, 0.35); }
|
||||
.detail-json { margin: 0; padding: 0.6rem 0.8rem; background: #05020c; border: 1px solid var(--faint); font-size: 0.85rem; overflow-x: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
|
||||
.badge.lime { color: var(--lime); }
|
||||
.badge.violet { color: var(--violet); }
|
||||
.badge.dim { color: var(--dim); }
|
||||
|
||||
.a-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1.2rem; align-items: start; }
|
||||
.a-detail { margin-top: 1.2rem; }
|
||||
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1.2rem; padding: 0.8rem 1rem; }
|
||||
.kv dt { color: var(--dim); letter-spacing: 0.12em; font-size: 0.8rem; }
|
||||
.kv dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.code-view { margin: 0; padding: 0.8rem 1rem; max-height: 22rem; overflow: auto; background: #05020c; border-top: 1px solid var(--faint); font-size: 0.85rem; white-space: pre; }
|
||||
.a-empty { padding: 1rem; color: var(--dim); letter-spacing: 0.1em; }
|
||||
.a-foot { padding: 0.7rem 1rem; display: flex; flex-wrap: wrap; align-items: center; gap: 0.8rem; color: var(--dim); }
|
||||
|
||||
/* ---- the map ---------------------------------------------------------------- */
|
||||
|
||||
.map-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.9rem; padding: 0.7rem 1rem; border-bottom: 1px solid var(--faint); }
|
||||
.map-tools .btn { min-width: 2.2rem; }
|
||||
.map-tools label.check { font-size: 0.75rem; letter-spacing: 0.12em; }
|
||||
.map-layout { display: grid; grid-template-columns: minmax(0, 1fr) 21rem; }
|
||||
.map-wrap { position: relative; height: min(72vh, 760px); min-height: 380px; background: #05020c; overflow: hidden; }
|
||||
.map-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: grab; touch-action: none; outline-offset: -3px; }
|
||||
.map-canvas:active { cursor: grabbing; }
|
||||
.map-tip { position: absolute; z-index: 2; max-width: 20rem; padding: 0.3rem 0.6rem; font-size: 0.8rem; pointer-events: none; background: rgba(7, 3, 15, 0.95); border: 1px solid var(--cyan); box-shadow: 0 0 12px rgba(40, 245, 255, 0.35); }
|
||||
.map-tip[hidden] { display: none; }
|
||||
.map-readout { position: absolute; right: 0.6rem; bottom: 0.4rem; font-size: 0.75rem; color: var(--dim); pointer-events: none; text-shadow: 0 0 4px #000; }
|
||||
.map-side { border-left: 1px solid var(--faint); min-width: 0; max-height: min(72vh, 760px); overflow-y: auto; }
|
||||
.map-info .kv { grid-template-columns: max-content minmax(0, 1fr); padding: 0.6rem 0.9rem; font-size: 0.88rem; }
|
||||
.map-legend { display: flex; flex-wrap: wrap; gap: 0.4rem 1.2rem; font-size: 0.75rem; letter-spacing: 0.12em; }
|
||||
.map-legend .dot { display: inline-block; width: 0.7rem; height: 0.7rem; border-radius: 50%; margin-right: 0.4rem; vertical-align: -0.05rem; background: var(--dim); }
|
||||
.map-legend .dot[data-c="0"] { background: #8fb4d9; }
|
||||
.map-legend .dot[data-c="1"] { background: #63e08a; }
|
||||
.map-legend .dot[data-c="2"] { background: #4ee6ff; }
|
||||
.map-legend .dot[data-c="3"] { background: #ffb62e; }
|
||||
.map-legend .dot.ship { background: none; border: 2px solid #ff3ad8; box-shadow: 0 0 6px #ff3ad8; }
|
||||
.map-legend .dot.box { background: #ffb62e; border-radius: 0; width: 0.55rem; height: 0.55rem; }
|
||||
.map-legend .dot.orbit { background: none; border-radius: 0; border-top: 2px dashed rgba(120, 245, 255, 0.9); width: 0.8rem; height: 0; margin-top: 0.35rem; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.map-layout { grid-template-columns: minmax(0, 1fr); }
|
||||
.map-side { border-left: 0; border-top: 1px solid var(--faint); max-height: none; }
|
||||
.map-wrap { height: 58vh; min-height: 320px; }
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
// Halcyon admin console: the accounts, and an audit trail of what they did.
|
||||
|
||||
import { h, clear, toast, download } from '/dom.js';
|
||||
import { hexView, parseHex } from '/hexview.js';
|
||||
import { createMap } from '/admin/map.js';
|
||||
|
||||
const root = document.getElementById('app');
|
||||
|
||||
const ACTION_LOOK = {
|
||||
register: ['lime', 'REGISTER'],
|
||||
login: ['cyan', 'LOGIN'],
|
||||
code_save: ['amber', 'CODE SAVE'],
|
||||
code_clear: ['dim', 'CODE CLEAR'],
|
||||
program_upload: ['violet', 'PROGRAM UPLOAD'],
|
||||
launch: ['mag', 'LAUNCH'],
|
||||
scrap: ['red', 'SCRAP'],
|
||||
uplink: ['cyan', 'UPLINK'],
|
||||
};
|
||||
|
||||
const state = {
|
||||
actions: [],
|
||||
accounts: [],
|
||||
sort: { key: 'id', dir: 1 },
|
||||
selected: null,
|
||||
tab: 'accounts',
|
||||
audit: { rows: [], player: '', action: '', done: false, loading: false },
|
||||
auto: null,
|
||||
map: null,
|
||||
};
|
||||
|
||||
// ---- plumbing ---------------------------------------------------------------
|
||||
|
||||
class SessionEnded extends Error {}
|
||||
|
||||
async function call(path, { method = 'GET', body } = {}) {
|
||||
const res = await fetch(`/admin/api${path}`, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (res.status === 401 && path !== '/login') {
|
||||
showLogin('YOUR SESSION HAS ENDED. LOG IN AGAIN.');
|
||||
throw new SessionEnded();
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status} ${res.statusText}`;
|
||||
try { msg = (await res.json()).error ?? msg; } catch { /* not JSON */ }
|
||||
const e = new Error(msg);
|
||||
e.status = res.status;
|
||||
throw e;
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
const pad2 = (n) => String(n).padStart(2, '0');
|
||||
function stamp(sec) {
|
||||
if (!sec) return h('span', { class: 'dim' }, 'NEVER');
|
||||
const d = new Date(sec * 1000);
|
||||
const text = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
||||
return h('span', { title: `${d.toISOString()} (UTC)` }, text);
|
||||
}
|
||||
function ago(sec) {
|
||||
if (!sec) return 'never';
|
||||
const s = Math.max(0, Math.floor(Date.now() / 1000 - sec));
|
||||
if (s < 90) return `${s}s ago`;
|
||||
if (s < 5400) return `${Math.round(s / 60)}m ago`;
|
||||
if (s < 129600) return `${Math.round(s / 3600)}h ago`;
|
||||
return `${Math.round(s / 86400)}d ago`;
|
||||
}
|
||||
const shortHash = (x) => (typeof x === 'string' ? `${x.slice(0, 10)}…` : '');
|
||||
|
||||
/** One line describing an audit entry. */
|
||||
function summary(e) {
|
||||
const d = e.detail && typeof e.detail === 'object' ? e.detail : {};
|
||||
switch (e.action) {
|
||||
case 'register': return `created the account "${d.name ?? ''}" with ship #${e.ship}`;
|
||||
case 'login': return `signed in${d.user_agent ? ` (${d.user_agent})` : ''}`;
|
||||
case 'code_save': return `saved slot ${e.slot} "${d.name ?? ''}": ${d.bytes} bytes, sha256 ${shortHash(d.sha256)}`;
|
||||
case 'code_clear': return `cleared slot ${e.slot}`;
|
||||
case 'program_upload': return `uploaded a ${d.bytes}-byte program to ship #${e.ship}, sha256 ${shortHash(d.sha256)}`;
|
||||
case 'launch': return e.slot
|
||||
? `launched ship #${e.ship} with slot ${e.slot} "${d.slot_name ?? ''}" (${d.program_bytes} bytes)`
|
||||
: `launched ship #${e.ship} with the program already sealed in it`;
|
||||
case 'scrap': return `scrapped ship #${e.ship}; a new ship replaces it on the next run`;
|
||||
case 'uplink': {
|
||||
const hex = typeof d.hex === 'string' ? d.hex : '';
|
||||
const preview = hex.slice(0, 32).match(/../g)?.join(' ').toUpperCase() ?? '';
|
||||
return `sent ship #${e.ship} ${d.bytes} bytes${preview ? `: ${preview}${hex.length > 32 ? ' …' : ''}` : ''}`;
|
||||
}
|
||||
default: return JSON.stringify(e.detail);
|
||||
}
|
||||
}
|
||||
|
||||
const badge = (action) => {
|
||||
const [cls, label] = ACTION_LOOK[action] ?? ['', action.toUpperCase()];
|
||||
return h('span', { class: `badge ${cls}` }, label);
|
||||
};
|
||||
|
||||
// ---- login ------------------------------------------------------------------
|
||||
|
||||
function showLogin(message = '') {
|
||||
stopAuto();
|
||||
const err = h('p', { class: 'form-error', role: 'alert' }, message);
|
||||
const pw = h('input', { id: 'pw', type: 'password', autocomplete: 'current-password', required: true, spellcheck: 'false' });
|
||||
const btn = h('button', { class: 'btn primary wide', type: 'submit' }, 'ENTER');
|
||||
clear(root).append(
|
||||
h('div', { class: 'a-login' },
|
||||
h('h1', null, 'HALCYON ', h('b', null, 'ADMIN')),
|
||||
h('form', {
|
||||
class: 'panel',
|
||||
onsubmit: async (ev) => {
|
||||
ev.preventDefault();
|
||||
btn.disabled = true;
|
||||
err.textContent = '';
|
||||
try {
|
||||
await call('/login', { method: 'POST', body: { password: pw.value } });
|
||||
pw.value = '';
|
||||
await boot();
|
||||
} catch (e) {
|
||||
if (e instanceof SessionEnded) return;
|
||||
err.textContent = e.status === 401 ? 'WRONG PASSWORD' : `ERROR: ${e.message}`.toUpperCase();
|
||||
btn.disabled = false;
|
||||
pw.select();
|
||||
}
|
||||
},
|
||||
},
|
||||
h('div', { class: 'panel-title' }, 'OPERATOR LOGIN'),
|
||||
h('div', { class: 'panel-body stack' },
|
||||
h('label', { for: 'pw' }, 'ADMIN PASSWORD'), pw, err, btn),
|
||||
),
|
||||
),
|
||||
);
|
||||
pw.focus();
|
||||
}
|
||||
|
||||
// ---- console ----------------------------------------------------------------
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
const s = await call('/session');
|
||||
state.actions = s.actions;
|
||||
} catch (e) {
|
||||
if (e instanceof SessionEnded) return;
|
||||
if (e.status === 404) { clear(root).append(h('div', { class: 'a-login' }, h('div', { class: 'panel' }, h('div', { class: 'panel-title' }, 'ADMIN IS OFF'), h('p', { class: 'panel-body' }, 'SET THE HALCYON_ADMIN_PASSWORD ENVIRONMENT VARIABLE ON THE SERVER TO TURN THE ADMIN CONSOLE ON.')))); return; }
|
||||
showLogin(e.message);
|
||||
return;
|
||||
}
|
||||
render();
|
||||
await loadAccounts();
|
||||
}
|
||||
|
||||
function render() {
|
||||
stopAuto();
|
||||
state.map?.destroy(); // a map from an earlier visit to the tab
|
||||
state.map = null;
|
||||
clear(root).append(
|
||||
h('header', { class: 'a-top' },
|
||||
h('span', { class: 'a-brand' }, 'HALCYON // ', h('b', null, 'ADMIN')),
|
||||
h('span', { class: 'a-note' }, 'OPERATOR CONSOLE. WHAT YOU SEE HERE IS PRIVATE.'),
|
||||
h('span', { class: 'spacer' }),
|
||||
h('button', { class: 'btn small', type: 'button', onclick: async () => { try { await call('/logout', { method: 'POST' }); } catch { /* already out */ } showLogin('LOGGED OUT.'); } }, 'LOGOUT'),
|
||||
),
|
||||
h('main', { class: 'a-main', id: 'main' },
|
||||
h('div', { class: 'a-tabs', role: 'tablist' },
|
||||
[['accounts', 'ACCOUNTS'], ['audit', 'AUDIT LOG'], ['map', 'MAP']].map(([t, label]) => h('button', { class: `tab ${state.tab === t ? 'on' : ''}`, type: 'button', role: 'tab', 'aria-selected': String(state.tab === t), onclick: () => { state.tab = t; render(); if (t === 'audit') loadAudit(true); else if (t === 'accounts') loadAccounts(); } }, label))),
|
||||
h('div', { id: 'view' }),
|
||||
),
|
||||
);
|
||||
if (state.tab === 'accounts') renderAccounts(); else if (state.tab === 'audit') renderAudit(); else renderMap();
|
||||
}
|
||||
|
||||
function renderMap() {
|
||||
state.map = createMap({
|
||||
fetchMap: () => call('/map').catch((e) => { throw e instanceof SessionEnded ? Object.assign(e, { name: 'SessionEnded' }) : e; }),
|
||||
fetchOrbit: (shipId) => call(`/map/orbit?ship=${shipId}`).catch((e) => { throw e instanceof SessionEnded ? Object.assign(e, { name: 'SessionEnded' }) : e; }),
|
||||
onOpenAccount: (id) => { state.selected = id; state.tab = 'accounts'; render(); loadAccounts(); },
|
||||
});
|
||||
clear(view()).append(state.map.el);
|
||||
state.map.load();
|
||||
}
|
||||
|
||||
const view = () => document.getElementById('view');
|
||||
|
||||
// ---- accounts ---------------------------------------------------------------
|
||||
|
||||
async function loadAccounts() {
|
||||
try {
|
||||
state.accounts = await call('/accounts');
|
||||
if (state.tab === 'accounts') renderAccounts();
|
||||
else fillAccountOptions();
|
||||
} catch (e) { if (!(e instanceof SessionEnded)) toast(`COULD NOT LOAD ACCOUNTS: ${e.message}`, 'err'); }
|
||||
}
|
||||
|
||||
const COLS = [
|
||||
['id', '#', 'num'], ['name', 'CALLSIGN', ''], ['credits', 'CREDITS', 'num'], ['ships', 'SHIPS', 'num'],
|
||||
['in_belt', 'IN BELT', 'num'], ['slots_used', 'SLOTS', 'num'], ['actions', 'ACTIONS', 'num'],
|
||||
['last_login', 'LAST LOGIN', 'nowrap'], ['last_action', 'LAST ACTION', 'nowrap'],
|
||||
];
|
||||
|
||||
let filterText = '';
|
||||
|
||||
function renderAccounts() {
|
||||
const v = clear(view());
|
||||
const search = h('input', { type: 'text', id: 'q', placeholder: 'FILTER BY CALLSIGN', value: filterText, 'aria-label': 'Filter accounts by callsign', autocomplete: 'off', spellcheck: 'false' });
|
||||
const tableHost = h('div', { class: 'scroll' });
|
||||
const count = h('span', { class: 'dim' });
|
||||
const draw = () => {
|
||||
const { key, dir } = state.sort;
|
||||
const q = filterText.trim().toLowerCase();
|
||||
const rows = state.accounts
|
||||
.filter((a) => !q || a.name.toLowerCase().includes(q) || String(a.id) === q)
|
||||
.sort((a, b) => (typeof a[key] === 'string' ? a[key].localeCompare(b[key]) : a[key] - b[key]) * dir || a.id - b.id);
|
||||
count.textContent = `${rows.length} OF ${state.accounts.length} ACCOUNTS`;
|
||||
clear(tableHost).append(
|
||||
rows.length === 0
|
||||
? h('p', { class: 'a-empty' }, 'NO ACCOUNTS MATCH.')
|
||||
: h('table', { class: 'atable' },
|
||||
h('thead', null, h('tr', null, COLS.map(([k, label]) =>
|
||||
h('th', { scope: 'col', 'aria-sort': state.sort.key === k ? (dir > 0 ? 'ascending' : 'descending') : null },
|
||||
h('button', { type: 'button', onclick: () => { state.sort = { key: k, dir: state.sort.key === k ? -dir : 1 }; draw(); } }, label + (state.sort.key === k ? (dir > 0 ? ' ▲' : ' ▼') : '')))))),
|
||||
h('tbody', null, rows.map((a) => h('tr', { class: `arow ${state.selected === a.id ? 'sel' : ''}`, tabindex: '0', onclick: () => select(a.id), onkeydown: (e) => { if (e.key === 'Enter') select(a.id); } },
|
||||
h('td', { class: 'num' }, String(a.id)),
|
||||
h('td', null, a.name),
|
||||
h('td', { class: 'num' }, a.credits.toLocaleString('en-US')),
|
||||
h('td', { class: 'num' }, String(a.ships)),
|
||||
h('td', { class: 'num' }, String(a.in_belt)),
|
||||
h('td', { class: 'num' }, `${a.slots_used}/13`),
|
||||
h('td', { class: 'num' }, String(a.actions)),
|
||||
h('td', { class: 'nowrap', title: a.last_login ? '' : 'has not signed in since logins were recorded' }, a.last_login ? `${ago(a.last_login)}` : h('span', { class: 'dim' }, '--')),
|
||||
h('td', { class: 'nowrap' }, a.last_action ? ago(a.last_action) : h('span', { class: 'dim' }, '--')),
|
||||
)))),
|
||||
);
|
||||
};
|
||||
search.addEventListener('input', () => { filterText = search.value; draw(); });
|
||||
v.append(
|
||||
h('section', { class: 'panel' },
|
||||
h('div', { class: 'panel-title' }, 'ACCOUNTS', h('span', { class: 'spacer' }), h('button', { class: 'btn small', type: 'button', onclick: loadAccounts }, 'REFRESH')),
|
||||
h('div', { class: 'a-filters' }, h('label', null, search), count),
|
||||
tableHost,
|
||||
),
|
||||
h('div', { id: 'detail' }),
|
||||
);
|
||||
draw();
|
||||
if (state.selected) select(state.selected, false);
|
||||
}
|
||||
|
||||
async function select(id, redraw = true) {
|
||||
state.selected = id;
|
||||
if (redraw) renderAccounts();
|
||||
const host = document.getElementById('detail');
|
||||
if (!host) return;
|
||||
try {
|
||||
const d = await call(`/accounts/${id}`);
|
||||
if (state.selected !== id) return;
|
||||
const code = h('pre', { class: 'code-view', hidden: true });
|
||||
const codeTitle = h('div', { class: 'panel-title', hidden: true });
|
||||
clear(host).append(h('div', { class: 'a-detail a-grid' },
|
||||
h('section', { class: 'panel' },
|
||||
h('div', { class: 'panel-title' }, `ACCOUNT #${d.id}`, h('span', { class: 'spacer' }),
|
||||
h('button', { class: 'btn small', type: 'button', onclick: () => { state.audit.player = String(d.id); state.audit.action = ''; state.tab = 'audit'; render(); loadAudit(true); } }, 'FULL AUDIT')),
|
||||
h('dl', { class: 'kv' },
|
||||
h('dt', null, 'CALLSIGN'), h('dd', null, d.name),
|
||||
h('dt', null, 'CREDITS'), h('dd', null, d.credits.toLocaleString('en-US')),
|
||||
),
|
||||
h('div', { class: 'panel-title' }, 'SHIPS'),
|
||||
d.ships.length ? h('div', { class: 'scroll' }, h('table', { class: 'atable' },
|
||||
h('thead', null, h('tr', null, ['SHIP', 'STATUS', 'PROGRAM', 'LAST DOWNLINK'].map((t) => h('th', { scope: 'col' }, t)))),
|
||||
h('tbody', null, d.ships.map((s) => h('tr', null,
|
||||
h('td', null, `#${s.id}`), h('td', null, s.status.toUpperCase()),
|
||||
h('td', null, s.program_bytes ? `${s.program_bytes} BYTES` : '--'),
|
||||
h('td', null, s.downlink_day >= 0 ? `DAY ${s.downlink_day}` : '--')))))) : h('p', { class: 'a-empty' }, 'NO SHIPS.'),
|
||||
h('div', { class: 'panel-title' }, 'CODE SLOTS'),
|
||||
d.slots.length ? h('div', { class: 'scroll' }, h('table', { class: 'atable' },
|
||||
h('thead', null, h('tr', null, ['SLOT', 'NAME', 'SIZE', 'SAVED', ''].map((t) => h('th', { scope: 'col' }, t)))),
|
||||
h('tbody', null, d.slots.map((s) => h('tr', null,
|
||||
h('td', null, String(s.slot)), h('td', null, s.name), h('td', { class: 'num' }, `${s.size}`),
|
||||
h('td', { class: 'nowrap' }, s.updated_at ? new Date(s.updated_at).toLocaleString() : '--'),
|
||||
h('td', null, h('button', { class: 'btn small', type: 'button', onclick: async () => {
|
||||
try {
|
||||
const src = await call(`/accounts/${d.id}/slots/${s.slot}`);
|
||||
codeTitle.hidden = code.hidden = false;
|
||||
codeTitle.textContent = `SLOT ${s.slot}: ${src.name} (READ ONLY)`;
|
||||
code.textContent = src.source;
|
||||
code.scrollIntoView({ block: 'nearest' });
|
||||
} catch (e) { if (!(e instanceof SessionEnded)) toast(e.message, 'err'); }
|
||||
} }, 'VIEW CODE')))))))
|
||||
: h('p', { class: 'a-empty' }, 'NO CODE SAVED.'),
|
||||
codeTitle, code,
|
||||
),
|
||||
h('section', { class: 'panel' },
|
||||
h('div', { class: 'panel-title' }, 'RECENT ACTIVITY'),
|
||||
d.audit.length ? h('div', { class: 'scroll' }, auditTable(d.audit, { showAccount: false })) : h('p', { class: 'a-empty' }, 'NOTHING RECORDED YET.'),
|
||||
),
|
||||
));
|
||||
} catch (e) { if (!(e instanceof SessionEnded)) toast(`COULD NOT LOAD THE ACCOUNT: ${e.message}`, 'err'); }
|
||||
}
|
||||
|
||||
// ---- audit ------------------------------------------------------------------
|
||||
|
||||
function auditTable(rows, { showAccount = true } = {}) {
|
||||
const open = new Set();
|
||||
const tbody = h('tbody');
|
||||
const draw = () => {
|
||||
clear(tbody);
|
||||
for (const e of rows) {
|
||||
const tr = h('tr', { class: 'arow', tabindex: '0', 'aria-expanded': String(open.has(e.id)), onclick: () => { open.has(e.id) ? open.delete(e.id) : open.add(e.id); draw(); }, onkeydown: (ev) => { if (ev.key === 'Enter') tr.click(); } },
|
||||
h('td', { class: 'nowrap' }, stamp(e.at)),
|
||||
showAccount ? h('td', null, `${e.name || '?'} `, h('span', { class: 'dim' }, `#${e.player_id}`)) : null,
|
||||
h('td', null, badge(e.action)),
|
||||
h('td', null, summary(e)),
|
||||
h('td', { class: 'nowrap dim' }, e.ip || ''),
|
||||
);
|
||||
tbody.append(tr);
|
||||
if (open.has(e.id)) {
|
||||
const d = e.detail && typeof e.detail === 'object' ? e.detail : {};
|
||||
const cell = h('td', { colspan: showAccount ? '5' : '4' }, h('pre', { class: 'detail-json' }, JSON.stringify({ id: e.id, at: new Date(e.at * 1000).toISOString(), account: e.player_id, action: e.action, ship: e.ship || undefined, slot: e.slot || undefined, ip: e.ip, detail: e.detail }, null, 2)));
|
||||
if (e.action === 'uplink' && typeof d.hex === 'string') {
|
||||
try { cell.append(hexView(parseHex(d.hex), { maxFont: 13 }).el); } catch { /* not hex: the JSON above shows it */ }
|
||||
}
|
||||
tbody.append(h('tr', { class: 'more' }, cell));
|
||||
}
|
||||
}
|
||||
};
|
||||
draw();
|
||||
return h('table', { class: 'atable' },
|
||||
h('thead', null, h('tr', null, ['TIME', showAccount ? 'ACCOUNT' : null, 'ACTION', 'WHAT', 'FROM'].filter(Boolean).map((t) => h('th', { scope: 'col' }, t)))),
|
||||
tbody);
|
||||
}
|
||||
|
||||
const PAGE = 100;
|
||||
|
||||
async function loadAudit(reset) {
|
||||
const a = state.audit;
|
||||
if (a.loading) {
|
||||
// A request is in flight. If this one is a fresh load (a filter changed), do it as
|
||||
// soon as that finishes, with the filter as it is by then; extra "load more" clicks are dropped.
|
||||
if (reset) a.reload = true;
|
||||
return;
|
||||
}
|
||||
a.reload = false;
|
||||
if (reset) { a.rows = []; a.done = false; }
|
||||
a.loading = true;
|
||||
try {
|
||||
const q = new URLSearchParams({ limit: String(PAGE) });
|
||||
if (a.player) q.set('player', a.player);
|
||||
if (a.action) q.set('action', a.action);
|
||||
if (!reset && a.rows.length) q.set('before', String(a.rows[a.rows.length - 1].id));
|
||||
const rows = await call(`/audit?${q}`);
|
||||
a.rows = reset ? rows : a.rows.concat(rows);
|
||||
a.done = rows.length < PAGE;
|
||||
} catch (e) {
|
||||
if (!(e instanceof SessionEnded)) toast(`COULD NOT LOAD THE AUDIT LOG: ${e.message}`, 'err');
|
||||
} finally {
|
||||
a.loading = false;
|
||||
}
|
||||
if (a.reload) return loadAudit(true); // a filter changed while we were loading
|
||||
if (state.tab === 'audit') renderAuditList();
|
||||
}
|
||||
|
||||
function stopAuto() { if (state.auto) { clearInterval(state.auto); state.auto = null; } }
|
||||
|
||||
/** The account dropdown, kept in step with the loaded accounts without replacing it. */
|
||||
function fillAccountOptions() {
|
||||
const sel = document.getElementById('f-player');
|
||||
if (!sel) return;
|
||||
const keep = state.audit.player;
|
||||
sel.replaceChildren(
|
||||
h('option', { value: '' }, 'ALL ACCOUNTS'),
|
||||
...state.accounts.map((x) => h('option', { value: x.id, selected: String(x.id) === keep }, `${x.name} #${x.id}`)),
|
||||
);
|
||||
sel.value = keep;
|
||||
}
|
||||
|
||||
// The filter bar is built once when the tab opens and never replaced, so a control
|
||||
// keeps its focus and a change made while results are loading is never lost. Only the
|
||||
// list underneath is redrawn.
|
||||
function renderAudit() {
|
||||
const a = state.audit;
|
||||
const v = clear(view());
|
||||
const accountSel = h('select', { id: 'f-player', 'aria-label': 'Account' });
|
||||
const actionSel = h('select', { id: 'f-action', 'aria-label': 'Action' },
|
||||
h('option', { value: '' }, 'ALL ACTIONS'),
|
||||
state.actions.map((x) => h('option', { value: x, selected: x === a.action }, (ACTION_LOOK[x]?.[1] ?? x.toUpperCase()))));
|
||||
accountSel.addEventListener('change', () => { a.player = accountSel.value; loadAudit(true); });
|
||||
actionSel.addEventListener('change', () => { a.action = actionSel.value; loadAudit(true); });
|
||||
const auto = h('input', { type: 'checkbox', id: 'auto', checked: !!state.auto });
|
||||
auto.addEventListener('change', () => {
|
||||
stopAuto();
|
||||
if (auto.checked) state.auto = setInterval(() => { if (state.tab === 'audit' && !a.loading) loadAudit(true); }, 10000);
|
||||
});
|
||||
|
||||
v.append(h('section', { class: 'panel' },
|
||||
h('div', { class: 'panel-title' }, 'AUDIT LOG', h('span', { class: 'spacer' }),
|
||||
h('button', { class: 'btn small', type: 'button', onclick: () => loadAudit(true) }, 'REFRESH')),
|
||||
h('div', { class: 'a-filters' },
|
||||
h('label', null, 'ACCOUNT', accountSel), h('label', null, 'ACTION', actionSel),
|
||||
h('label', { class: 'check', for: 'auto' }, auto, 'AUTO-REFRESH (10 S)'),
|
||||
h('span', { class: 'spacer' }),
|
||||
h('button', { class: 'btn small', id: 'csv', type: 'button', disabled: true, onclick: () => download('halcyon-audit.csv', toCSV(state.audit.rows), 'text/csv') }, 'DOWNLOAD CSV')),
|
||||
h('div', { id: 'audit-list' }),
|
||||
));
|
||||
fillAccountOptions();
|
||||
if (!state.accounts.length) loadAccounts();
|
||||
renderAuditList();
|
||||
}
|
||||
|
||||
function renderAuditList() {
|
||||
const a = state.audit;
|
||||
const host = document.getElementById('audit-list');
|
||||
if (!host) return;
|
||||
const csv = document.getElementById('csv');
|
||||
if (csv) csv.disabled = a.rows.length === 0;
|
||||
clear(host).append(
|
||||
a.rows.length ? h('div', { class: 'scroll' }, auditTable(a.rows)) : h('p', { class: 'a-empty' }, a.loading ? 'LOADING...' : 'NOTHING RECORDED FOR THIS FILTER.'),
|
||||
h('div', { class: 'a-foot' },
|
||||
h('span', null, `${a.rows.length} ENTRIES SHOWN`),
|
||||
a.done ? h('span', null, 'THAT IS EVERYTHING.') : h('button', { class: 'btn small', type: 'button', disabled: a.loading || a.rows.length === 0, onclick: () => loadAudit(false) }, 'LOAD MORE')),
|
||||
);
|
||||
}
|
||||
|
||||
const csvCell = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||
function toCSV(rows) {
|
||||
const head = ['id', 'time_utc', 'account_id', 'account', 'action', 'ship', 'slot', 'ip', 'detail'];
|
||||
return `${[head.join(','), ...rows.map((e) => [e.id, new Date(e.at * 1000).toISOString(), e.player_id, e.name, e.action, e.ship, e.slot, e.ip, JSON.stringify(e.detail)].map(csvCell).join(','))].join('\n')}\n`;
|
||||
}
|
||||
|
||||
// ---- start ------------------------------------------------------------------
|
||||
|
||||
boot().catch(() => showLogin());
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Halcyon Admin</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="stylesheet" href="/admin/admin.css">
|
||||
<script type="module" src="/admin/admin.js"></script>
|
||||
</head>
|
||||
<body class="admin">
|
||||
<div id="app"><p class="boot">CONNECTING TO THE ADMIN CONSOLE...</p></div>
|
||||
<div id="toasts" role="status" aria-live="polite"></div>
|
||||
<noscript><p class="boot">THIS CONSOLE REQUIRES JAVASCRIPT.</p></noscript>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user