blockyard 0.0.9 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +251 -1
- package/README.md +42 -23
- package/bin/blockyard.js +2 -1
- package/docs/API.md +16 -14
- package/docs/ARCHITECTURE.md +92 -5
- package/docs/CONFIGURATION.md +33 -26
- package/docs/GETTING-STARTED.md +5 -2
- package/docs/INSTALL.md +90 -33
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/SECURITY.md +32 -15
- package/docs/TROUBLESHOOTING.md +35 -1
- package/docs/USER-GUIDE.md +266 -26
- package/package.json +1 -1
- package/public/404.html +1 -1
- package/public/css/app.css +306 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +295 -103
- package/public/js/agents.js +228 -51
- package/public/js/app.js +82 -8
- package/public/js/blockscene3d.js +179 -27
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1456 -71
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/login.js +5 -0
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +310 -32
- package/public/js/panels.js +14 -10
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +103 -21
- package/public/js/soundcard.js +459 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/setup.js +34 -12
- package/scripts/shots.mjs +6 -0
- package/scripts/smoke.sh +1 -1
- package/scripts/tls.js +31 -0
- package/server/chain/index/build.js +21 -4
- package/server/collect/monitor.js +30 -1
- package/server/collect/network.js +295 -0
- package/server/config.js +46 -22
- package/server/http/api.js +49 -5
- package/server/http/games.js +77 -0
- package/server/http/server.js +8 -0
- package/server/main.js +53 -8
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +7 -5
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- package/docs/STATE-2026-09-09.md +0 -200
package/public/js/mining.js
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
// fees.chunk/chunkweight -- so the block being built is assembled from the mempool the
|
|
17
17
|
// monitor already reads, and costs the node no call at all (server/collect/gbt.js).
|
|
18
18
|
|
|
19
|
-
import { paint, COL } from './charts.js';
|
|
19
|
+
import { paint, COL, lineChart } from './charts.js';
|
|
20
|
+
import { INK } from './theme.js'; // the pie's labels and slice seams follow the theme
|
|
20
21
|
import { blockTreemap, mempoolTreemap, rateColor as rateBucketColor } from './goggles.js';
|
|
21
22
|
import { loadSettings, spaceOptions } from './settings.js';
|
|
22
23
|
// 2026-09-10: the block and the pool now draw as lit solids on a square-packed
|
|
@@ -1051,44 +1052,321 @@ export function renderMining(s, state, h) {
|
|
|
1051
1052
|
h.nextBlock?.();
|
|
1052
1053
|
blockFlow(document.getElementById('mnFlow'), flowArgs(s, state), h.fmt);
|
|
1053
1054
|
packagesView(document.getElementById('mnPackages'), a?.nextBlock?.packages, h.fmt);
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
const mp = state?.mempoolDist ?? s?.mempool ?? {};
|
|
1057
|
-
drawPoolViewer(h.canvas('gnMempoolTreemap'), s, state);
|
|
1058
|
-
const mnote = document.getElementById('gnMempoolNote');
|
|
1059
|
-
if (mnote) {
|
|
1060
|
-
const cells = mp.cells ?? [];
|
|
1061
|
-
const tail = cells.find((c) => c.aggregate) ?? null;
|
|
1062
|
-
const total = mp.totalVsize ?? 0;
|
|
1063
|
-
const drawn = cells.length - (tail ? 1 : 0);
|
|
1064
|
-
if (cells.length) {
|
|
1065
|
-
const parts = [`${fmtNum(drawn, h)} drawn`];
|
|
1066
|
-
if (tail) parts.push(`+ 1 aggregate of ${fmtNum(tail.aggregate, h)} smaller ones`);
|
|
1067
|
-
if (mp.cellCount != null) parts.push(`= ${fmtNum(mp.cellCount, h)} transactions as at the poll`);
|
|
1068
|
-
if (mp.fetchedAt != null) parts.push(`${Math.round((Date.now() - mp.fetchedAt) / 1000)}s ago`);
|
|
1069
|
-
const fits = total <= 1_000_000
|
|
1070
|
-
? '<b>everything waiting fits in a single block</b>'
|
|
1071
|
-
: 'the line sits where one block runs out and everything right of it waits';
|
|
1072
|
-
mnote.innerHTML = `${parts.join(', ')}; ${fmtNum(total, h)} vB waiting. One block is 1,000,000 vB, so ${fits}.`
|
|
1073
|
-
+ ' Ordered by feerate, because that is the order a miner takes them; colour is what each transaction pays.'
|
|
1074
|
-
+ (mp.stale ? ' <span class="warn">Last poll failed; this picture may be old.</span>' : '');
|
|
1075
|
-
} else if (mp.stale) {
|
|
1076
|
-
mnote.innerHTML = '<span class="warn">Detail stale</span> — the last poll of the full pool failed. Anything drawn above is the previous reading.';
|
|
1077
|
-
} else if (mp.count != null && mp.count > 0) {
|
|
1078
|
-
mnote.innerHTML = `<span class="warn">Cells not loaded yet</span> — ${fmtNum(mp.count, h)} transactions are waiting; the full pool is polled on the 20 s tier and this page has not received it.`;
|
|
1079
|
-
} else if (mp.count === 0) {
|
|
1080
|
-
mnote.textContent = 'The mempool is empty: nothing is waiting, so there is nothing to draw.';
|
|
1081
|
-
} else {
|
|
1082
|
-
mnote.textContent = 'Nothing to draw yet — the full pool is polled on the 20 s tier, not every second.';
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1055
|
+
// (the Mempool space viewer left this page on 2026-09-15 -- it is on Overview, Block space and
|
|
1056
|
+
// Mempool -- and with it the full-pool poll this page used to ask for)
|
|
1085
1057
|
feeLandscape(h.canvas('mnFeeLandscape'), a?.nextBlock ?? null, h.fmt);
|
|
1086
1058
|
poolTable(document.getElementById('mnPools'), a, h.fmt);
|
|
1059
|
+
networkPanels(s?.network ?? null, h);
|
|
1060
|
+
const recent = document.getElementById('mnRecent');
|
|
1061
|
+
if (recent) { const html = recentBlocksHtml(a, h.fmt, { limit: 8 }); if (recent.__html !== html) { recent.innerHTML = html; recent.__html = html; } }
|
|
1062
|
+
renderExpand(s, h);
|
|
1087
1063
|
const el = document.getElementById('mnCoverage');
|
|
1088
1064
|
if (el) el.innerHTML = coveragePanel(a, h);
|
|
1089
1065
|
applyMiningStyles(document);
|
|
1090
1066
|
}
|
|
1091
1067
|
|
|
1068
|
+
// THE NETWORK ROW (2026-09-15): reward stats, the difficulty period, a week of pools as a donut,
|
|
1069
|
+
// a year of hashrate, the adjustments table. Everything is from the snapshot's `network`, which
|
|
1070
|
+
// collect/network.js fills from the node alone; a figure it has not gathered yet is a dash and
|
|
1071
|
+
// the note says how far the week of coinbases has got.
|
|
1072
|
+
const DONUT_COLORS = ['#e8306a', '#8b3fd9', '#5b4fd6', '#3a7be0', '#2f9ee6', '#26b8c8', '#22b7a0', '#3cba6c', '#8bc34a', '#c9d02a', '#f0c419', '#f39c1f', '#ee7b2f', '#c7c9d1', '#8d93a1'];
|
|
1073
|
+
function stat(k, v, s = '', cls = '') {
|
|
1074
|
+
return `<div class="ns"><span class="k">${k}</span><span class="v ${cls}">${v}</span>${s ? `<span class="s">${s}</span>` : ''}</div>`;
|
|
1075
|
+
}
|
|
1076
|
+
function signed(pct, dp = 2) {
|
|
1077
|
+
if (pct == null || !Number.isFinite(pct)) return { text: '–', cls: '' };
|
|
1078
|
+
return { text: `${pct >= 0 ? '▴ +' : '▾ '}${pct.toFixed(dp)}%`, cls: pct >= 0 ? 'up' : 'down' };
|
|
1079
|
+
}
|
|
1080
|
+
function shortDate(ms) {
|
|
1081
|
+
if (!Number.isFinite(ms)) return '–';
|
|
1082
|
+
return new Date(ms).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
1083
|
+
}
|
|
1084
|
+
function inWords(sec) {
|
|
1085
|
+
if (!Number.isFinite(sec)) return '–';
|
|
1086
|
+
const d = sec / 86400;
|
|
1087
|
+
if (d < 1) return `~${Math.max(1, Math.round(sec / 3600))} hours`;
|
|
1088
|
+
if (d < 60) return `~${Math.round(d)} days`;
|
|
1089
|
+
const y = Math.floor(d / 365.25), rest = Math.round(d - y * 365.25);
|
|
1090
|
+
return y ? `~${y} year${y > 1 ? 's' : ''}, ${rest} days` : `~${Math.round(d)} days`;
|
|
1091
|
+
}
|
|
1092
|
+
function agoWords(ms, now = Date.now()) {
|
|
1093
|
+
const d = (now - ms) / 86400000;
|
|
1094
|
+
if (d < 1) return `${Math.max(1, Math.round(d * 24))} hours ago`;
|
|
1095
|
+
if (d < 28) return `${Math.round(d)} days ago`;
|
|
1096
|
+
return `${Math.round(d / 7)} weeks ago`;
|
|
1097
|
+
}
|
|
1098
|
+
function tera(d) { return d == null || !Number.isFinite(d) ? '–' : `${(d / 1e12).toFixed(2)}T`; }
|
|
1099
|
+
|
|
1100
|
+
// the spot price for the dollar lines, asked for at most once a minute while the page is
|
|
1101
|
+
// painted, and only ever the cached one (/api/price never starts the exchange polling)
|
|
1102
|
+
const PRICE = { usd: null, at: 0, askedAt: 0, busy: false, off: false };
|
|
1103
|
+
function askPrice(h) {
|
|
1104
|
+
const now = Date.now();
|
|
1105
|
+
if (PRICE.busy || now - PRICE.askedAt < 60_000 || typeof h.api !== 'function') return;
|
|
1106
|
+
PRICE.busy = true; PRICE.askedAt = now;
|
|
1107
|
+
h.api('/api/price').then((d) => { PRICE.usd = d?.usd ?? null; PRICE.at = d?.at ?? now; PRICE.off = d?.polling === false || d?.enabled === false; }).catch(() => {}).finally(() => { PRICE.busy = false; h.render?.(); });
|
|
1108
|
+
}
|
|
1109
|
+
const usd = (v) => (v == null || !Number.isFinite(v) ? '' : `$${v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
|
|
1110
|
+
|
|
1111
|
+
// RECENT BLOCKS, as mempool.space lists them (height, pool, reward, fees): the attributed window
|
|
1112
|
+
// the ledger already carries, the reward being the height's subsidy plus the block's fees
|
|
1113
|
+
const SATS = 100_000_000;
|
|
1114
|
+
export function subsidyAt(height) {
|
|
1115
|
+
if (!Number.isFinite(height) || height < 0) return null;
|
|
1116
|
+
const halvings = Math.floor(height / 210_000);
|
|
1117
|
+
if (halvings >= 64) return 0;
|
|
1118
|
+
let sat = 50 * SATS;
|
|
1119
|
+
for (let i = 0; i < halvings; i++) sat = Math.floor(sat / 2);
|
|
1120
|
+
return sat;
|
|
1121
|
+
}
|
|
1122
|
+
export function recentBlocksHtml(a, F, { limit = 8, now = Date.now() } = {}) {
|
|
1123
|
+
const rows = (a?.recent ?? []).slice(0, limit);
|
|
1124
|
+
if (!rows.length) return '<div class="note tiny faint">no blocks attributed yet</div>';
|
|
1125
|
+
const esc = F.esc;
|
|
1126
|
+
return `<table class="t"><thead><tr><th>Height</th><th>Pool</th><th>Mined</th><th class="r">Reward</th><th class="r">Fees</th></tr></thead><tbody>${rows.map((r) => {
|
|
1127
|
+
const name = r.poolLabel ?? r.poolName ?? r.poolKey ?? '–';
|
|
1128
|
+
const sub = subsidyAt(r.height), reward = sub != null && r.totalfee != null ? sub + r.totalfee : null;
|
|
1129
|
+
return `<tr><td><a href="#explorer/block/${r.height}">#${F.num(r.height)}</a></td><td title="${esc(r.tagText ?? '')}">${esc(String(name).length > 22 ? `${String(name).slice(0, 21)}…` : name)}</td><td>${r.at ? F.ago(r.at, now) : '–'}</td><td class="r">${reward != null ? `${(reward / SATS).toFixed(3)} BTC` : '–'}</td><td class="r">${r.totalfee != null ? `${(r.totalfee / SATS).toFixed(4)} BTC` : '–'}</td></tr>`;
|
|
1130
|
+
}).join('')}</tbody></table>`;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// "VIEW MORE »" (operator, 2026-09-15, of mempool.space: "the 'View more >>' that open up a panel
|
|
1134
|
+
// to see a full screen view of just that panel"): one of four cards, full screen, drawn from the
|
|
1135
|
+
// same snapshot every frame while open. Esc, the scrim or the button close it.
|
|
1136
|
+
const EXPAND = { kind: null, s: null, h: null };
|
|
1137
|
+
const EXPAND_TITLE = { pools: ['Pools', 'the last week’s blocks, by coinbase'], hashrate: ['Hashrate & difficulty', 'a year, one sample a day'], blocks: ['Recent blocks', 'the attributed window, newest first'], adjustments: ['Adjustments', 'each period’s first block against the one before'] };
|
|
1138
|
+
export function openExpand(kind) {
|
|
1139
|
+
const wrap = document.getElementById('expandWrap');
|
|
1140
|
+
if (!wrap || !EXPAND_TITLE[kind]) return;
|
|
1141
|
+
EXPAND.kind = kind;
|
|
1142
|
+
const [t, src] = EXPAND_TITLE[kind];
|
|
1143
|
+
const title = document.getElementById('expandTitle'), s = document.getElementById('expandSrc'), body = document.getElementById('expandBody');
|
|
1144
|
+
if (title) title.textContent = t;
|
|
1145
|
+
if (s) s.textContent = src;
|
|
1146
|
+
if (body) {
|
|
1147
|
+
body.innerHTML = kind === 'pools' ? '<div class="netstats" id="xpStats"></div><canvas class="chart donut" id="xpDonut"></canvas><div id="xpTable"></div>'
|
|
1148
|
+
: kind === 'hashrate' ? '<div class="netstats" id="xpStats"></div><canvas class="chart big" id="xpChart"></canvas>'
|
|
1149
|
+
: '<div id="xpTable"></div>';
|
|
1150
|
+
}
|
|
1151
|
+
wrap.classList.remove('hidden');
|
|
1152
|
+
if (EXPAND.s && EXPAND.h) renderExpand(EXPAND.s, EXPAND.h);
|
|
1153
|
+
}
|
|
1154
|
+
export function closeExpand() {
|
|
1155
|
+
EXPAND.kind = null;
|
|
1156
|
+
document.getElementById('expandWrap')?.classList.add('hidden');
|
|
1157
|
+
}
|
|
1158
|
+
let expandBound = false;
|
|
1159
|
+
function bindExpand() {
|
|
1160
|
+
if (expandBound || typeof document === 'undefined' || typeof document.addEventListener !== 'function' || !document.getElementById('expandWrap')) return;
|
|
1161
|
+
expandBound = true;
|
|
1162
|
+
document.addEventListener('click', (e) => {
|
|
1163
|
+
const a = e.target.closest?.('[data-expand]');
|
|
1164
|
+
if (a) { e.preventDefault(); openExpand(a.dataset.expand); }
|
|
1165
|
+
});
|
|
1166
|
+
document.getElementById('expandClose')?.addEventListener('click', closeExpand);
|
|
1167
|
+
document.getElementById('expandScrim')?.addEventListener('click', closeExpand);
|
|
1168
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && EXPAND.kind) closeExpand(); });
|
|
1169
|
+
}
|
|
1170
|
+
export function renderExpand(s, h) {
|
|
1171
|
+
EXPAND.s = s; EXPAND.h = h;
|
|
1172
|
+
if (!EXPAND.kind) return;
|
|
1173
|
+
const n = s?.network ?? null, F = h.fmt;
|
|
1174
|
+
const put = (id, html) => { const el = document.getElementById(id); if (el && el.__html !== html) { el.innerHTML = html; el.__html = html; } };
|
|
1175
|
+
if (EXPAND.kind === 'pools') {
|
|
1176
|
+
const p = n?.pools ?? {};
|
|
1177
|
+
put('xpStats', stat('Pools luck', p.luckPct == null ? '–' : `${p.luckPct.toFixed(2)}%`, p.blocks ? `${p.blocks} found · ${Math.round(p.expected)} expected` : '') + stat('Blocks (1w)', p.blocks ? F.num(p.blocks) : '–') + stat('Pools count', p.count ? String(p.count) : '–'));
|
|
1178
|
+
poolDonut(h.canvas('xpDonut'), p.pools ?? [], F);
|
|
1179
|
+
put('xpTable', (p.pools ?? []).length ? `<table class="t"><thead><tr><th>Pool</th><th class="r">Blocks</th><th class="r">Share</th><th>Labelled</th></tr></thead><tbody>${p.pools.map((x) => `<tr><td>${F.esc(x.name)}</td><td class="r">${F.num(x.blocks)}</td><td class="r">${x.sharePct.toFixed(2)}%</td><td>${x.labelled ? 'curated map' : '<span class="faint">coinbase text</span>'}</td></tr>`).join('')}</tbody></table>` : '');
|
|
1180
|
+
} else if (EXPAND.kind === 'hashrate') {
|
|
1181
|
+
const hr = n?.hashrate ?? {}, a = n?.adjustment;
|
|
1182
|
+
put('xpStats', stat('Hashrate (1w)', hr.networkHashPs != null ? F.eh(hr.networkHashPs / 1e18) : '–', 'getnetworkhashps, 1008 blocks') + stat('Difficulty', tera(n?.difficulty), a ? `period from #${F.num(a.epochStart)}` : '') + stat('Samples', hr.series?.length ? `${hr.series.length}<small>days</small>` : '–', 'one block header a day'));
|
|
1183
|
+
hashrateChart(h.canvas('xpChart'), hr.series ?? [], F);
|
|
1184
|
+
} else if (EXPAND.kind === 'blocks') {
|
|
1185
|
+
put('xpTable', recentBlocksHtml(s?.attribution, F, { limit: 200 }));
|
|
1186
|
+
} else if (EXPAND.kind === 'adjustments') {
|
|
1187
|
+
put('xpTable', adjustmentsHtml(n?.adjustments ?? [], F));
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
function adjustmentsHtml(rows, F) {
|
|
1191
|
+
if (!rows.length) return '<div class="note tiny faint">reading the periods\' first blocks…</div>';
|
|
1192
|
+
return `<table class="t"><thead><tr><th>Height</th><th>Adjusted</th><th class="r">Difficulty</th><th class="r">Change</th></tr></thead><tbody>${rows.map((x) => { const c = signed(x.changePct); return `<tr><td>#${F.num(x.height)}</td><td>${agoWords(x.time * 1000)}</td><td class="r">${tera(x.difficulty)}</td><td class="r ${c.cls}">${c.text.replace(/^[▴▾] /, '')}</td></tr>`; }).join('')}</tbody></table>`;
|
|
1193
|
+
}
|
|
1194
|
+
function hashrateChart(canvas, series, F) {
|
|
1195
|
+
if (!canvas) return;
|
|
1196
|
+
if (series.length > 2) {
|
|
1197
|
+
const pts = series.map((x) => ({ t: x.t, v: x.hashrate / 1e18 }));
|
|
1198
|
+
const dLo = Math.min(...series.map((x) => x.difficulty / 1e12)), dHi = Math.max(...series.map((x) => x.difficulty / 1e12));
|
|
1199
|
+
const smooth = pts.map((_, i) => { const w = pts.slice(Math.max(0, i - 6), i + 1); return { t: pts[i].t, v: w.reduce((s, q) => s + q.v, 0) / w.length }; });
|
|
1200
|
+
lineChart(canvas, [
|
|
1201
|
+
{ label: 'hashrate, daily', points: pts, color: 'rgba(160,200,80,0.45)', width: 1 },
|
|
1202
|
+
{ label: 'hashrate, 7-day mean', points: smooth, color: '#f0c419', width: 2 },
|
|
1203
|
+
{ label: `difficulty ${dLo.toFixed(0)}T–${dHi.toFixed(0)}T`, points: series.map((x) => ({ t: x.t, v: x.difficulty / 1e12 })), color: '#e8306a', width: 2, axis: 'right' },
|
|
1204
|
+
], {
|
|
1205
|
+
fmtY: (v) => (v >= 1000 ? `${(v / 1000).toFixed(2)}Z` : `${v.toFixed(0)}E`), fmtRight: () => '', left: 40,
|
|
1206
|
+
fmtX: (t) => new Date(t).toLocaleDateString('en-US', { month: 'short' }),
|
|
1207
|
+
min: Math.min(...pts.map((q) => q.v)) * 0.85, max: Math.max(...pts.map((q) => q.v)) * 1.05, zeroBase: false,
|
|
1208
|
+
rightMin: dLo * 0.97, rightMax: dHi * 1.03,
|
|
1209
|
+
});
|
|
1210
|
+
} else paint(canvas, { when: null, draw: () => {}, placeholder: 'reading a year of block headers…' });
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
export function networkPanels(n, h) {
|
|
1214
|
+
bindExpand();
|
|
1215
|
+
const F = h.fmt, esc = F.esc;
|
|
1216
|
+
askPrice(h);
|
|
1217
|
+
const put = (id, html) => { const el = document.getElementById(id); if (el && el.__html !== html) { el.innerHTML = html; el.__html = html; } };
|
|
1218
|
+
if (!n) {
|
|
1219
|
+
for (const id of ['mnRewards', 'mnAdjust', 'mnPoolsWeek', 'mnHashrate']) put(id, '<div class="note tiny faint">not in this snapshot yet — the node has not been asked</div>');
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
// reward stats
|
|
1223
|
+
const r = n.rewards ?? {};
|
|
1224
|
+
// the dollar lines (operator, 2026-09-15: "Add dollar figures"): under each figure while a
|
|
1225
|
+
// price is at hand; a faint note instead while market polling is off
|
|
1226
|
+
const px = PRICE.usd;
|
|
1227
|
+
const dollars = (sat) => (px != null && sat != null ? `<span class="usd">${usd(sat / 1e8 * px)}</span>` : '');
|
|
1228
|
+
put('mnRewards', r.blocks
|
|
1229
|
+
? stat('Miners reward', `${(r.minersRewardSat / 1e8).toFixed(2)}<small>BTC</small>`, `${dollars(r.minersRewardSat)}${r.blocks} blocks · #${r.from}–#${r.to}`)
|
|
1230
|
+
+ stat('Avg block fees', `${(r.avgBlockFeeSat / 1e8).toFixed(4)}<small>BTC/block</small>`, dollars(r.avgBlockFeeSat))
|
|
1231
|
+
+ stat('Avg tx fee', `${r.avgTxFeeSat == null ? '–' : F.num(r.avgTxFeeSat)}<small>sats/tx</small>`, `${dollars(r.avgTxFeeSat)}${F.num(r.txs)} transactions`)
|
|
1232
|
+
+ (px == null && PRICE.off ? '<div class="note tiny faint usdnote">dollar figures need market polling (Display settings → Markets & Price)</div>' : '')
|
|
1233
|
+
: '<div class="note tiny faint">reading the last 144 blocks…</div>');
|
|
1234
|
+
const src = document.getElementById('mnRewardsSrc'); if (src) src.textContent = `last ${r.blocks || 144} blocks · getblockstats`;
|
|
1235
|
+
// the difficulty period
|
|
1236
|
+
const a = n.adjustment, hv = n.halving;
|
|
1237
|
+
if (a) {
|
|
1238
|
+
const est = signed(a.estimatePct), prev = signed(a.previousPct);
|
|
1239
|
+
put('mnAdjust',
|
|
1240
|
+
stat('Remaining', `${F.num(a.remaining)}<small>blocks</small>`, `in ${inWords(a.etaSec)} · ${a.into} of 2016 mined`)
|
|
1241
|
+
+ stat('Estimate', `<span class="${est.cls}">${est.text}</span>`, `previous: <span class="${prev.cls}">${prev.text}</span>`)
|
|
1242
|
+
+ stat('Next halving', shortDate(hv?.at), hv ? `#${F.num(hv.nextHeight)} · in ${inWords(hv.etaSec)}` : ''));
|
|
1243
|
+
} else put('mnAdjust', '<div class="note tiny faint">reading the period\'s first block…</div>');
|
|
1244
|
+
// pools over the week
|
|
1245
|
+
const p = n.pools ?? {};
|
|
1246
|
+
const luck = p.luckPct == null ? '–' : `${p.luckPct.toFixed(2)}%`;
|
|
1247
|
+
put('mnPoolsWeek', stat('Pools luck', luck, p.blocks ? `${p.blocks} found · ${Math.round(p.expected)} expected` : '')
|
|
1248
|
+
+ stat('Blocks (1w)', p.blocks ? F.num(p.blocks) : '–')
|
|
1249
|
+
+ stat('Pools count', p.count ? String(p.count) : '–'));
|
|
1250
|
+
const note = document.getElementById('mnPoolsWeekNote');
|
|
1251
|
+
if (note) {
|
|
1252
|
+
const left = p.todo ?? 0;
|
|
1253
|
+
note.textContent = left > 0
|
|
1254
|
+
? `Filling in: ${F.num(p.filled ?? 0)} coinbases read so far, ${F.num(left)} to go (eight every few seconds, behind the live polls). Shares are of the blocks read so far.`
|
|
1255
|
+
: p.blocks ? `${F.num(p.blocks)} blocks in the last seven days, every coinbase read from this node; a name appears only where the curated pool map or your alias file says so.` : '';
|
|
1256
|
+
}
|
|
1257
|
+
poolDonut(h.canvas('mnPoolDonut'), p.pools ?? [], F);
|
|
1258
|
+
// hashrate and difficulty
|
|
1259
|
+
const hr = n.hashrate ?? {};
|
|
1260
|
+
put('mnHashrate', stat('Hashrate (1w)', hr.networkHashPs != null ? F.eh(hr.networkHashPs / 1e18) : '–', 'getnetworkhashps, 1008 blocks')
|
|
1261
|
+
+ stat('Difficulty', tera(n.difficulty), a ? `period from #${F.num(a.epochStart)}` : '')
|
|
1262
|
+
+ stat('Samples', hr.series?.length ? `${hr.series.length}<small>days</small>` : '–', 'one block header a day'));
|
|
1263
|
+
hashrateChart(h.canvas('mnHashChart'), hr.series ?? [], F);
|
|
1264
|
+
// the adjustments table
|
|
1265
|
+
put('mnAdjustments', adjustmentsHtml((n.adjustments ?? []).slice(0, 6), F)); // six on the card; every period the server carries under View more
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function poolDonut(canvas, pools, F) {
|
|
1269
|
+
// THE LABELLED PIE (operator, 2026-09-15, with the reference: "I want this view. With pool
|
|
1270
|
+
// names clustered with colored lines linking to their pie slice"): every pool named beside the
|
|
1271
|
+
// pie, the big ones on the side their slice faces, the small ones stacked where there is room,
|
|
1272
|
+
// each name joined to its own slice by a leader in the slice's colour. Slices under half a
|
|
1273
|
+
// percent are gathered into "Other (x.xx%)". Labels on a side are laid out top to bottom at
|
|
1274
|
+
// their slice's height and pushed apart where they would overlap, so the leaders fan out.
|
|
1275
|
+
if (!canvas?.getContext) return;
|
|
1276
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
1277
|
+
const w = canvas.clientWidth || 520, h = canvas.clientHeight || 440;
|
|
1278
|
+
if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); }
|
|
1279
|
+
const ctx = canvas.getContext('2d');
|
|
1280
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1281
|
+
ctx.clearRect(0, 0, w, h);
|
|
1282
|
+
const total = pools.reduce((s, p) => s + p.blocks, 0);
|
|
1283
|
+
const cx = w / 2, cy = h / 2;
|
|
1284
|
+
// THE PIE FITS ITS LABELS (operator, 2026-09-15, of a 975px window: "surely it could look better
|
|
1285
|
+
// at this sizing"): the radius is what is left after the widest label on each side and its
|
|
1286
|
+
// leader, measured, so no name is ever cut at the card's edge; where that leaves too small a
|
|
1287
|
+
// pie the names are shortened and the font drops a size first
|
|
1288
|
+
const gap = 34;
|
|
1289
|
+
const nameOf = (p, max) => (p.other ? p.name : p.name.length > max ? `${p.name.slice(0, max - 1)}…` : p.name);
|
|
1290
|
+
const preview = pools.map((p) => ({ ...p, other: false }));
|
|
1291
|
+
let font = 12.5, maxName = 16, R = 0, withPct = true;
|
|
1292
|
+
for (const [f, m, pc] of [[12.5, 16, true], [11.5, 13, true], [11, 14, false]]) {
|
|
1293
|
+
font = f; maxName = m; withPct = pc;
|
|
1294
|
+
ctx.font = `${f}px system-ui, sans-serif`;
|
|
1295
|
+
const widths = preview.map((p) => ctx.measureText(nameOf(p, m)).width + (pc ? ctx.measureText(' 100.0%').width : 0) + 8);
|
|
1296
|
+
const widest = widths.length ? Math.max(...widths) : 0;
|
|
1297
|
+
R = Math.min(h / 2 - 14, cx - gap - widest - 6);
|
|
1298
|
+
if (R >= 90 || f === 11) break; // the last step drops the shares (the reference has none) for a bigger pie
|
|
1299
|
+
}
|
|
1300
|
+
R = Math.max(50, R);
|
|
1301
|
+
const LH = font + 4, r = R * 0.28;
|
|
1302
|
+
if (!total) { ctx.fillStyle = COL.text; ctx.font = `${font}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.fillText('no blocks read yet', cx, cy); return; }
|
|
1303
|
+
// slices: the named ones, and the rest under a threshold as one "Other". The threshold starts at
|
|
1304
|
+
// half a percent and rises until each side's stack of labels FITS THE CANVAS (operator,
|
|
1305
|
+
// 2026-09-15: "getting cut off on mining screen left bottom" -- fourteen names at 16px need
|
|
1306
|
+
// 230px, and a short card gave them 220), so on a small card the small pools fold into Other
|
|
1307
|
+
// rather than run off the bottom
|
|
1308
|
+
const build = (threshold) => {
|
|
1309
|
+
const small = pools.filter((p) => (p.blocks / total) * 100 < threshold);
|
|
1310
|
+
const big = pools.filter((p) => (p.blocks / total) * 100 >= threshold);
|
|
1311
|
+
const slices = [...big, ...(small.length ? [{ name: `Other (${(small.reduce((s, p) => s + p.blocks, 0) / total * 100).toFixed(2)}%)`, blocks: small.reduce((s, p) => s + p.blocks, 0), other: true }] : [])];
|
|
1312
|
+
let a0 = -Math.PI / 2;
|
|
1313
|
+
return slices.map((p, i) => {
|
|
1314
|
+
const a1 = a0 + (p.blocks / total) * Math.PI * 2, mid = (a0 + a1) / 2;
|
|
1315
|
+
const out = { p, i, a0, a1, mid, color: p.other ? '#8d93a1' : DONUT_COLORS[i % DONUT_COLORS.length] };
|
|
1316
|
+
a0 = a1;
|
|
1317
|
+
return out;
|
|
1318
|
+
});
|
|
1319
|
+
};
|
|
1320
|
+
let laid = build(0.5);
|
|
1321
|
+
for (const threshold of [1, 2, 3, 5, 8, 12]) {
|
|
1322
|
+
const perSide = [1, -1].map((dir) => laid.filter((x) => (Math.cos(x.mid) >= 0 ? 1 : -1) === dir).length);
|
|
1323
|
+
if (Math.max(...perSide) * LH <= h - 6) break;
|
|
1324
|
+
laid = build(threshold);
|
|
1325
|
+
}
|
|
1326
|
+
// the pie
|
|
1327
|
+
for (const s of laid) {
|
|
1328
|
+
ctx.beginPath(); ctx.arc(cx, cy, R, s.a0, s.a1); ctx.arc(cx, cy, r, s.a1, s.a0, true); ctx.closePath();
|
|
1329
|
+
ctx.fillStyle = s.color; ctx.fill();
|
|
1330
|
+
ctx.strokeStyle = INK.panel; ctx.lineWidth = 1; ctx.stroke();
|
|
1331
|
+
}
|
|
1332
|
+
// the labels: each side laid out top to bottom, pushed apart to LH
|
|
1333
|
+
const side = (dir) => {
|
|
1334
|
+
const list = laid.filter((s) => (Math.cos(s.mid) >= 0 ? 1 : -1) === dir).map((s) => ({ s, y: cy + Math.sin(s.mid) * (R + 12), ax: cx + Math.cos(s.mid) * R, ay: cy + Math.sin(s.mid) * R }));
|
|
1335
|
+
list.sort((p, q) => p.y - q.y);
|
|
1336
|
+
for (let k = 1; k < list.length; k++) if (list[k].y < list[k - 1].y + LH) list[k].y = list[k - 1].y + LH;
|
|
1337
|
+
// keep them on the canvas: push the stack up from the bottom, then down from the top
|
|
1338
|
+
const over = list.length ? list[list.length - 1].y - (h - LH / 2) : 0;
|
|
1339
|
+
if (over > 0) for (const l of list) l.y -= over;
|
|
1340
|
+
for (let k = 0; k < list.length; k++) if (list[k].y < LH / 2) list[k].y = LH / 2; else if (k && list[k].y < list[k - 1].y + LH) list[k].y = list[k - 1].y + LH;
|
|
1341
|
+
return list;
|
|
1342
|
+
};
|
|
1343
|
+
ctx.font = `${font}px system-ui, sans-serif`; ctx.textBaseline = 'middle'; ctx.lineWidth = 1.5;
|
|
1344
|
+
for (const dir of [1, -1]) {
|
|
1345
|
+
const labelX = cx + dir * (R + gap), edgeX = cx + dir * (R + gap - 8);
|
|
1346
|
+
ctx.textAlign = dir > 0 ? 'left' : 'right';
|
|
1347
|
+
for (const l of side(dir)) {
|
|
1348
|
+
const name = nameOf(l.s.p, maxName);
|
|
1349
|
+
ctx.strokeStyle = l.s.color;
|
|
1350
|
+
ctx.beginPath(); ctx.moveTo(l.ax, l.ay); ctx.lineTo(edgeX, l.y); ctx.lineTo(labelX - dir * 3, l.y); ctx.stroke();
|
|
1351
|
+
ctx.fillStyle = INK.label;
|
|
1352
|
+
ctx.fillText(name, labelX, l.y);
|
|
1353
|
+
// the share, faint, after the name on the right side and before it on the left
|
|
1354
|
+
if (withPct) {
|
|
1355
|
+
const pct = `${(l.s.p.blocks / total * 100).toFixed(1)}%`;
|
|
1356
|
+
ctx.fillStyle = INK.axisText; ctx.font = `${font - 1.5}px ${'var(--mono), monospace'}`;
|
|
1357
|
+
const nameW = ctx.measureText(name).width;
|
|
1358
|
+
if (dir > 0) ctx.fillText(pct, labelX + nameW + 8, l.y); else ctx.fillText(pct, labelX - nameW - 8, l.y);
|
|
1359
|
+
ctx.font = `${font}px system-ui, sans-serif`;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
ctx.textAlign = 'center';
|
|
1364
|
+
ctx.fillStyle = COL.text; ctx.font = `600 ${font + 1}px system-ui, sans-serif`;
|
|
1365
|
+
ctx.fillText(`${F.num(total)}`, cx, cy - 7);
|
|
1366
|
+
ctx.font = `${font - 2}px system-ui, sans-serif`; ctx.fillStyle = INK.axisText;
|
|
1367
|
+
ctx.fillText('blocks', cx, cy + 8);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1092
1370
|
function coveragePanel(a, h) {
|
|
1093
1371
|
const esc = h.fmt.esc;
|
|
1094
1372
|
const row = (k, v) => `<dt>${esc(k)}</dt><dd>${v}</dd>`;
|
package/public/js/panels.js
CHANGED
|
@@ -30,7 +30,7 @@ export function renderChain(s, state, h) {
|
|
|
30
30
|
placeholder: 'no tip samples yet',
|
|
31
31
|
});
|
|
32
32
|
h.setText('chTipNote', s.tip?.headers != null && s.tip?.height != null
|
|
33
|
-
?
|
|
33
|
+
? `<span title="Headers-first: this reaches 100% only when the last block lands">${fmt.num(s.tip.height)} of ${fmt.num(s.tip.headers)} announced headers applied</span>`
|
|
34
34
|
: 'no header count reported yet');
|
|
35
35
|
|
|
36
36
|
const gap = (b.gap ?? []).filter((p) => Number.isFinite(p.v));
|
|
@@ -55,10 +55,11 @@ export function renderChain(s, state, h) {
|
|
|
55
55
|
// reads as "broken panel" unless the panel says otherwise: this figure sat empty
|
|
56
56
|
// for a day because the request asked for statistics the endpoint has never had.
|
|
57
57
|
const sizeRow = (s.blocks?.recent ?? [])[0] ?? null;
|
|
58
|
+
// one line (2026-09-15, the packing pass): the basis sentence is the note's title, the figures its text
|
|
58
59
|
h.setText('chSizeNote', sizeRow?.sizeBasis
|
|
59
|
-
? `<span class="mono"
|
|
60
|
+
? `<span class="mono" title="${fmt.esc(sizeRow.sizeBasis)}">total_size</span>`
|
|
60
61
|
+ (sizeRow.medianTxSize != null && sizeRow.swtotalSize != null && sizeRow.size != null
|
|
61
|
-
? `
|
|
62
|
+
? ` · this block: median tx ${fmt.bytes(sizeRow.medianTxSize, 0)}, witness ${fmt.bytes(sizeRow.swtotalSize, 0)} of ${fmt.bytes(sizeRow.size, 0)}`
|
|
62
63
|
: '')
|
|
63
64
|
: (sizeRow?.sizeMissing ?? 'no block stats collected yet — the monitor has not seen a new height since it started'));
|
|
64
65
|
drawFromSeries(h, 'chFeeChart', b.fee, COL.ok, (v) => fmt.short(v), { fmtTip: (v) => `${fmt.sats(v)} sat` });
|
|
@@ -71,10 +72,13 @@ export function renderChain(s, state, h) {
|
|
|
71
72
|
placeholder: 'getchaintxstats has not answered yet',
|
|
72
73
|
});
|
|
73
74
|
const cs = s.chaintxstats;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
// ONE WRAPPING LINE (2026-09-15, the packing pass): as a four-column grid these overflowed a
|
|
76
|
+
// 330px card and as two columns they made the card the row's tallest
|
|
77
|
+
const item = (k, v) => `<span><span class="k">${k}</span><b>${v}</b></span>`;
|
|
78
|
+
h.setText('chTxStats', cs
|
|
79
|
+
? item('window', `${fmt.num(cs.window_block_count ?? 0)} blocks`) + item('txs in window', fmt.num(cs.window_tx_count ?? 0))
|
|
80
|
+
+ item('all-time txs', fmt.num(cs.txcount ?? 0)) + item('rate', cs.txrate != null ? `${cs.txrate.toFixed(2)} tx/s` : '–')
|
|
81
|
+
: '<span class="faint">not yet fetched</span>');
|
|
78
82
|
|
|
79
83
|
h.setText('chState', kv([
|
|
80
84
|
['chain', s.chain ?? '–'],
|
|
@@ -89,7 +93,7 @@ export function renderChain(s, state, h) {
|
|
|
89
93
|
['pruned', s.pruned == null ? '–' : String(s.pruned)],
|
|
90
94
|
['chain work', s.chainwork ? fmt.hash(s.chainwork.replace(/^0+/, '') || '0', 6) : '–'],
|
|
91
95
|
['IBD', s.ibd == null ? '–' : String(s.ibd)],
|
|
92
|
-
['
|
|
96
|
+
['uptime', s.uptimeSec != null ? fmt.uptime(s.uptimeSec * 1000) : '–'],
|
|
93
97
|
]));
|
|
94
98
|
|
|
95
99
|
const u = s.utxo ?? {};
|
|
@@ -348,7 +352,7 @@ export function peerTableHtml(rows, fmt, { now = Date.now(), tip = null } = {})
|
|
|
348
352
|
const faint = (s) => ` <span class="faint">${fmt.esc(s)}</span>`;
|
|
349
353
|
const body = sorted.map((p) => `<tr>
|
|
350
354
|
<td>${p.inbound ? '<span class="badge muted">in</span>' : '<span class="badge">out</span>'}</td>
|
|
351
|
-
<td>${fmt.esc(p.addr ?? '–')}${p.network ? faint(p.network) : ''}</td>
|
|
355
|
+
<td class="addr" title="${fmt.esc(p.addr ?? '')}">${fmt.esc(p.addr ?? '–')}${p.network ? faint(p.network) : ''}</td>
|
|
352
356
|
<td class="w">${fmt.esc(ua(p.subver))}${p.version ? faint(String(p.version)) : ''}${svc(p).map((t) => ` <span class="badge muted">${t}</span>`).join('')}</td>
|
|
353
357
|
<td class="r">${Number.isFinite(p.conntime) ? fmt.uptime((now / 1000 - p.conntime) * 1000) : '–'}</td>
|
|
354
358
|
<td class="r">${since(p.lastrecv)}</td>
|
|
@@ -472,7 +476,7 @@ export function renderNode(s, state, h) {
|
|
|
472
476
|
h.setText('ndRpc', kv([
|
|
473
477
|
['endpoint', rpc.url ?? '–'],
|
|
474
478
|
['cookie source', rpc.cookieSource ? fmt.hash(rpc.cookieSource, 14) : '–'],
|
|
475
|
-
['in flight', '1 (by design)'
|
|
479
|
+
['in flight', '1'], // one request at a time is the design (the note under the chart says so); '(by design)' was cut off in the two-column list
|
|
476
480
|
['calls (60 s window)', String(rpc.ratePerSec ?? 0) + '/s'],
|
|
477
481
|
['total calls', fmt.num(rpc.calls)],
|
|
478
482
|
['batches', fmt.num(rpc.batches)],
|
package/public/js/pricechart.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// crosshair that says exactly what an hour did. This is that chart. The 3D board under it is a
|
|
5
5
|
// view of the same hours, not a substitute for it.
|
|
6
6
|
import { niceTicks } from './charts.js';
|
|
7
|
+
import { INK } from './theme.js'; // the axis, grid and tooltip colours of the chosen theme
|
|
7
8
|
|
|
8
9
|
export const EX_COLORS = { coinbase: '#4c8dff', kraken: '#a78bfa', bitstamp: '#2ecc8f', bitfinex: '#4dd0e1', okx: '#f0b429' };
|
|
9
10
|
const UP = '#26c281', DOWN = '#ef5350';
|
|
@@ -79,7 +80,7 @@ export function drawPriceChart(canvas, { candles = [], overlays = [], name = '',
|
|
|
79
80
|
if (!canvas?.getContext) return null;
|
|
80
81
|
const { ctx, w, h } = prep(canvas);
|
|
81
82
|
if (!candles.length) {
|
|
82
|
-
ctx.fillStyle =
|
|
83
|
+
ctx.fillStyle = INK.text; ctx.textAlign = 'center';
|
|
83
84
|
ctx.fillText('no candles yet', w / 2, h / 2);
|
|
84
85
|
return null;
|
|
85
86
|
}
|
|
@@ -92,22 +93,22 @@ export function drawPriceChart(canvas, { candles = [], overlays = [], name = '',
|
|
|
92
93
|
for (const v of niceTicks(L.lo, L.hi, Math.max(3, Math.floor(L.priceH / 46)))) {
|
|
93
94
|
const y = Math.round(L.Y(v)) + 0.5;
|
|
94
95
|
if (y < PAD.top || y > PAD.top + L.priceH) continue;
|
|
95
|
-
ctx.strokeStyle =
|
|
96
|
+
ctx.strokeStyle = INK.grid;
|
|
96
97
|
ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(right, y); ctx.stroke();
|
|
97
|
-
ctx.fillStyle =
|
|
98
|
+
ctx.fillStyle = INK.axisText;
|
|
98
99
|
ctx.fillText(money(v, 0), right + 7, y);
|
|
99
100
|
}
|
|
100
101
|
// time grid and axis
|
|
101
102
|
ctx.textAlign = 'center';
|
|
102
103
|
for (const tk of timeTicks(L.t0, L.t1, L.plotW)) {
|
|
103
104
|
const x = Math.round(L.XT(tk.t)) + 0.5;
|
|
104
|
-
ctx.strokeStyle = tk.label.includes(' ') ?
|
|
105
|
+
ctx.strokeStyle = tk.label.includes(' ') ? INK.axis : INK.grid;
|
|
105
106
|
ctx.beginPath(); ctx.moveTo(x, PAD.top); ctx.lineTo(x, L.VY0); ctx.stroke();
|
|
106
|
-
ctx.fillStyle = tk.label.includes(' ') ?
|
|
107
|
+
ctx.fillStyle = tk.label.includes(' ') ? INK.label : INK.axisText;
|
|
107
108
|
ctx.fillText(tk.label, x, h - PAD.bottom / 2);
|
|
108
109
|
}
|
|
109
110
|
// the pane edges
|
|
110
|
-
ctx.strokeStyle =
|
|
111
|
+
ctx.strokeStyle = INK.axis;
|
|
111
112
|
ctx.beginPath(); ctx.moveTo(right + 0.5, PAD.top); ctx.lineTo(right + 0.5, L.VY0); ctx.stroke();
|
|
112
113
|
ctx.beginPath(); ctx.moveTo(PAD.left, L.VY0 + 0.5); ctx.lineTo(right, L.VY0 + 0.5); ctx.stroke();
|
|
113
114
|
|
|
@@ -119,7 +120,7 @@ export function drawPriceChart(canvas, { candles = [], overlays = [], name = '',
|
|
|
119
120
|
ctx.fillStyle = k.c >= k.o ? UP_V : DOWN_V;
|
|
120
121
|
ctx.fillRect(L.X(i) - bodyW / 2, L.VY0 - vh, bodyW, vh);
|
|
121
122
|
});
|
|
122
|
-
ctx.fillStyle =
|
|
123
|
+
ctx.fillStyle = INK.textDim; ctx.textAlign = 'left';
|
|
123
124
|
ctx.fillText('volume', PAD.left + 4, L.VY0 - L.volH + 6);
|
|
124
125
|
|
|
125
126
|
// the other exchanges, as thin close lines
|
|
@@ -152,7 +153,7 @@ export function drawPriceChart(canvas, { candles = [], overlays = [], name = '',
|
|
|
152
153
|
ctx.beginPath(); ctx.moveTo(PAD.left, ly); ctx.lineTo(right, ly); ctx.stroke();
|
|
153
154
|
ctx.setLineDash([]);
|
|
154
155
|
ctx.fillStyle = lcol; ctx.fillRect(right + 1, ly - 9, PAD.right - 2, 18);
|
|
155
|
-
ctx.fillStyle =
|
|
156
|
+
ctx.fillStyle = INK.bright; ctx.textAlign = 'left';
|
|
156
157
|
ctx.fillText(money(lastK.c), right + 6, ly);
|
|
157
158
|
|
|
158
159
|
// the crosshair
|
|
@@ -161,26 +162,26 @@ export function drawPriceChart(canvas, { candles = [], overlays = [], name = '',
|
|
|
161
162
|
const i = L.index(hover.x);
|
|
162
163
|
shown = candles[i];
|
|
163
164
|
const x = Math.round(L.X(i)) + 0.5;
|
|
164
|
-
ctx.strokeStyle =
|
|
165
|
+
ctx.strokeStyle = INK.dash; ctx.setLineDash([3, 3]);
|
|
165
166
|
ctx.beginPath(); ctx.moveTo(x, PAD.top); ctx.lineTo(x, L.VY0); ctx.stroke();
|
|
166
167
|
if (hover.y >= PAD.top && hover.y <= PAD.top + L.priceH) {
|
|
167
168
|
const y = Math.round(hover.y) + 0.5;
|
|
168
169
|
ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(right, y); ctx.stroke();
|
|
169
170
|
ctx.setLineDash([]);
|
|
170
|
-
ctx.fillStyle =
|
|
171
|
-
ctx.fillStyle =
|
|
171
|
+
ctx.fillStyle = INK.cursorFill; ctx.fillRect(right + 1, y - 9, PAD.right - 2, 18);
|
|
172
|
+
ctx.fillStyle = INK.cursorText; ctx.fillText(money(L.V(hover.y)), right + 6, y);
|
|
172
173
|
}
|
|
173
174
|
ctx.setLineDash([]);
|
|
174
175
|
}
|
|
175
176
|
// readout (top-left) and overlay legend (top-right)
|
|
176
177
|
ctx.textAlign = 'left';
|
|
177
|
-
ctx.fillStyle =
|
|
178
|
+
ctx.fillStyle = INK.bright;
|
|
178
179
|
ctx.fillText(readout(shown, name), PAD.left + 2, 12);
|
|
179
180
|
ctx.textAlign = 'right';
|
|
180
181
|
let lx = right;
|
|
181
182
|
for (const o of [...overlays].reverse()) {
|
|
182
183
|
const wT = ctx.measureText(o.name).width;
|
|
183
|
-
ctx.fillStyle =
|
|
184
|
+
ctx.fillStyle = INK.label; ctx.fillText(o.name, lx, 12);
|
|
184
185
|
ctx.fillStyle = o.color; ctx.fillRect(lx - wT - 16, 11, 11, 2);
|
|
185
186
|
lx -= wT + 26;
|
|
186
187
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// QUAKE, the tab (operator, 2026-09-15: "yes, get Quake working as a diversion").
|
|
2
|
+
//
|
|
3
|
+
// The shareware QUAKE.EXE v1.06 and its PAK0.PAK from games/quake_dos/, unmodified, on the emulated
|
|
4
|
+
// PC (dosgame.js has the tab, dosworker.js the machine). Quake is a DJGPP program where DOOM was a
|
|
5
|
+
// DOS/4GW one, so the same machine plays the go32 stub and CWSDPMI for it (dospc.js bootCoff), and it
|
|
6
|
+
// asks far more of the FPU. Its keys are its own: W A S D and mouse look on the first run
|
|
7
|
+
// (dosio.js quakeAutoexec), and after that whatever the player binds in Quake's own menu.
|
|
8
|
+
import { createDosGame } from './dosgame.js';
|
|
9
|
+
|
|
10
|
+
export const renderQuake = createDosGame({
|
|
11
|
+
game: 'quake',
|
|
12
|
+
page: 'quake',
|
|
13
|
+
prefix: 'quake',
|
|
14
|
+
title: 'Quake',
|
|
15
|
+
idleText: 'The shareware episode, Dimension of the Doomed: the real QUAKE.EXE v1.06 running on a PC this monitor emulates. Click the screen to use the mouse.',
|
|
16
|
+
loadingText: 'loading the shareware episode (18 MB)…',
|
|
17
|
+
saveName: /\.SAV$/i,
|
|
18
|
+
keysText: () => 'W S walk · A D strafe · mouse to look · left click fire · Space or right click jump · Shift run · 1–8 weapons · − = smaller or bigger view (smaller runs faster) · Esc menu · ~ console · F6 quicksave · F9 quickload',
|
|
19
|
+
switches: [],
|
|
20
|
+
});
|