blockyard 0.0.1 → 0.0.9
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 +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +20 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +193 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +100 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// THE KIOSK (operator, 2026-09-11: "I want a kiosk tab that has the 3d Markets viewer in one
|
|
2
|
+
// panel, and the block space panel beside it"). The two boards side by side, filling the screen,
|
|
3
|
+
// and a full-screen button -- nothing else to read, so it can run on a wall. Both panels are the
|
|
4
|
+
// same renderers their own tabs use (markets.js renderMarketsBoard, mining.js poolViewer): the
|
|
5
|
+
// same data, the same camera, the Block space panel's mode switch and refresh countdown.
|
|
6
|
+
import { poolViewer, blockFlow, flowArgs, applyMiningStyles } from './mining.js';
|
|
7
|
+
import { renderMarketsBoard, renderPriceInfo } from './markets.js';
|
|
8
|
+
import { renderDepthInto } from './depthchart.js';
|
|
9
|
+
|
|
10
|
+
let bound = false;
|
|
11
|
+
|
|
12
|
+
export function renderKiosk(s, state, h) {
|
|
13
|
+
h.mempoolDetail?.();
|
|
14
|
+
const mk = renderMarketsBoard('kMarkets', h);
|
|
15
|
+
const lab = document.getElementById('kMkLabel');
|
|
16
|
+
const text = mk?.label ?? 'asking the exchanges…';
|
|
17
|
+
if (lab && lab.textContent !== text) lab.textContent = text;
|
|
18
|
+
renderPriceInfo('kPrice', h);
|
|
19
|
+
// THE ORDER BOOK, NOT A SPREAD FIGURE (operator, 2026-09-12). Compact and toolbar-less: there is
|
|
20
|
+
// nobody at a wall display to click a zoom, so it is fixed at +-2.5% -- the band where the book
|
|
21
|
+
// actually decides the price. It shares depthchart's single poll with the Markets tab.
|
|
22
|
+
renderDepthInto('kDepth', h);
|
|
23
|
+
const sp = document.getElementById('kSpace');
|
|
24
|
+
if (sp) poolViewer(sp, s, state);
|
|
25
|
+
// THE CHAIN (operator, 2026-09-11: "There is absolutely no block chain/tip information in the
|
|
26
|
+
// kiosk ... Add a block flow panel below block space panel"): the same Block flow the Overview
|
|
27
|
+
// draws -- projected blocks, the block being built, the tip and the chain behind it
|
|
28
|
+
h.nextBlock?.();
|
|
29
|
+
const train = document.getElementById('kTrain');
|
|
30
|
+
if (train) { blockFlow(train, flowArgs(s, state), h.fmt); applyMiningStyles(train); }
|
|
31
|
+
if (!bound) {
|
|
32
|
+
const btn = document.getElementById('kFull'), box = document.getElementById('kiosk');
|
|
33
|
+
if (btn && box) {
|
|
34
|
+
bound = true;
|
|
35
|
+
btn.addEventListener('click', () => {
|
|
36
|
+
if (document.fullscreenElement) document.exitFullscreen?.();
|
|
37
|
+
else box.requestFullscreen?.().catch(() => h.toast?.('the browser refused full screen', 'bad'));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Login page. Separate file because the page's CSP is script-src 'self' -- an
|
|
2
|
+
// inline <script> would simply not run, and "the button does nothing" is a
|
|
3
|
+
// miserable bug to chase over HTTP.
|
|
4
|
+
const err = document.getElementById('err');
|
|
5
|
+
const form = document.getElementById('f');
|
|
6
|
+
const btn = document.getElementById('go');
|
|
7
|
+
const sub = document.getElementById('sub');
|
|
8
|
+
|
|
9
|
+
function show(msg, locked = false) {
|
|
10
|
+
err.textContent = msg;
|
|
11
|
+
err.classList.remove('hidden');
|
|
12
|
+
err.classList.toggle('locked', locked);
|
|
13
|
+
}
|
|
14
|
+
function hide() { err.classList.add('hidden'); }
|
|
15
|
+
|
|
16
|
+
async function api(path, opt = {}) {
|
|
17
|
+
const res = await fetch(path, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, ...opt });
|
|
18
|
+
let data = null;
|
|
19
|
+
try { data = await res.json(); } catch { data = {}; }
|
|
20
|
+
return { status: res.status, data };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Already signed in? Skip the form rather than showing a second login.
|
|
24
|
+
api('/api/me').then((r) => {
|
|
25
|
+
if (r.status === 200) window.location.replace('/');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
api('/api/health').then(({ data }) => {
|
|
29
|
+
if (!data || !Array.isArray(data.nodes)) return;
|
|
30
|
+
const online = data.nodes.filter((n) => n.online).length;
|
|
31
|
+
sub.innerHTML = `Bitcoin node monitor · <span class="${online ? 'ok' : 'bad'}">${online}/${data.nodes.length}</span> node(s) answering`;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
let countdown = null;
|
|
35
|
+
function lockCountdown(seconds) {
|
|
36
|
+
clearInterval(countdown);
|
|
37
|
+
let left = seconds;
|
|
38
|
+
btn.disabled = true;
|
|
39
|
+
const tick = () => {
|
|
40
|
+
show(`too many failed attempts; retry in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`, true);
|
|
41
|
+
if (left-- <= 0) { clearInterval(countdown); btn.disabled = false; hide(); }
|
|
42
|
+
};
|
|
43
|
+
tick();
|
|
44
|
+
countdown = setInterval(tick, 1000);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
form.addEventListener('submit', async (ev) => {
|
|
48
|
+
ev.preventDefault();
|
|
49
|
+
hide();
|
|
50
|
+
btn.disabled = true;
|
|
51
|
+
btn.textContent = 'signing in…';
|
|
52
|
+
const body = JSON.stringify({
|
|
53
|
+
username: document.getElementById('u').value.trim(),
|
|
54
|
+
password: document.getElementById('p').value,
|
|
55
|
+
});
|
|
56
|
+
try {
|
|
57
|
+
const r = await api('/api/login', { method: 'POST', body });
|
|
58
|
+
if (r.status === 429) {
|
|
59
|
+
const secs = Math.ceil((Number(/(\d+)ms/.exec(r.data?.error?.message ?? ''))?.[1] ?? 600000) / 1000);
|
|
60
|
+
lockCountdown(Number.isFinite(secs) ? Math.min(secs, 900) : 600);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (r.status !== 200 || !r.data?.ok) {
|
|
64
|
+
show(r.data?.error?.message ?? 'sign-in failed');
|
|
65
|
+
btn.disabled = false;
|
|
66
|
+
btn.textContent = 'sign in';
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// The server set two cookies: the HttpOnly session id, and `blockyard_csrf`,
|
|
70
|
+
// readable here on purpose. Mutating calls must echo that value in an
|
|
71
|
+
// X-CSRF-Token header -- reading it is the thing a cross-origin page cannot
|
|
72
|
+
// do, which is what makes the check mean anything.
|
|
73
|
+
void /(?:^|;\s*)blockyard_csrf=/.test(document.cookie);
|
|
74
|
+
const back = new URLSearchParams(location.search).get('next');
|
|
75
|
+
window.location.replace(back && back.startsWith('/') ? back : '/');
|
|
76
|
+
} catch (e) {
|
|
77
|
+
show(`cannot reach the server: ${e.message}`);
|
|
78
|
+
btn.disabled = false;
|
|
79
|
+
btn.textContent = 'sign in';
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
document.getElementById('u').focus();
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// THE MARKETS TAB (operator, 2026-09-11: "another tab that basically rips off bitcoinity.org
|
|
2
|
+
// entirely" ... "we should really leverage the 3D view we have established with the block space
|
|
3
|
+
// panel ... If we're going to render a price chart, it would be good to see it on a similar grid
|
|
4
|
+
// structure, with similar effects, just tasked to show market data").
|
|
5
|
+
//
|
|
6
|
+
// One series, two views, both driven by the toolbar (exchange, range):
|
|
7
|
+
// * the PRICE CHART (pricechart.js) -- flat and exact: axes, candles, volume, crosshair. The
|
|
8
|
+
// first cut of this tab had only rows of 3D towers with no axis, and the operator's verdict
|
|
9
|
+
// was "What the fuck is this? Where is the price chart info?!??";
|
|
10
|
+
// * the SAME CHART ON THE 3D BOARD (details3d board3d) -- operator: "Remember, we can alter the
|
|
11
|
+
// perspective. I just want to leverage the 3D view engine we have". The camera is swung round
|
|
12
|
+
// to face a shallow board (CAMERA_3D: height reads straight up, as price) and parked along the
|
|
13
|
+
// bottom of the panel; each hour is a candle FLOATING at its price -- a body from open to close
|
|
14
|
+
// and a thin wick below and above it from low to high, standing on `floor` (a resting
|
|
15
|
+
// altitude) -- with volume on the floor in front, and the engine drawing the price levels and
|
|
16
|
+
// the hours through the same projection (opts.axes). A candle's id is its exchange and hour,
|
|
17
|
+
// so a new hour slides the chart along and another exchange drops its own candles in.
|
|
18
|
+
// The server polls five exchanges' public APIs while this tab is open (server/collect/markets.js).
|
|
19
|
+
import { board3d } from './details3d.js';
|
|
20
|
+
import { loadSettings, setSetting, marketsOptions } from './settings.js';
|
|
21
|
+
import { drawPriceChart, readout, EX_COLORS } from './pricechart.js';
|
|
22
|
+
import { niceTicks } from './charts.js';
|
|
23
|
+
import { renderDepth } from './depthchart.js';
|
|
24
|
+
|
|
25
|
+
export const REFRESH_MS = 15_000;
|
|
26
|
+
export const RANGES = [[24, '24 h'], [48, '48 h'], [168, '7 d']];
|
|
27
|
+
// 72, AND THE 168 EXPERIMENT IS WHY (2026-09-12). The original note here read "168 towers on the
|
|
28
|
+
// board is a comb, not a chart". The operator chose to overrule it -- the 7 d button said seven
|
|
29
|
+
// days and the board drew three, undisclosed -- so the cap went to 168 and the result was worse
|
|
30
|
+
// than the truncation: not a comb, a broken chart. Measured and seen: gridW 336 units drove
|
|
31
|
+
// fitZ to zMax 101 (from 37), so the vertical scale spanned far more than the week's actual price
|
|
32
|
+
// movement and every body collapsed into a strip along the floor, in two disconnected bands, while
|
|
33
|
+
// the close line still ran the full width. Reverted by the operator's call: "back to 72".
|
|
34
|
+
//
|
|
35
|
+
// So the old note was right about the outcome and imprecise about the cause. The limit is not
|
|
36
|
+
// really "168 candles look like a comb" -- it is that widening the board drives the price band
|
|
37
|
+
// taller (fitZ solves zMax from the panel's aspect), and past roughly 72 hours the scale stops
|
|
38
|
+
// describing the data. Raising this number again means fixing that coupling first.
|
|
39
|
+
//
|
|
40
|
+
// The truncation is now STATED rather than silent: renderMarkets says so whenever the chosen range
|
|
41
|
+
// is longer than this, which is the thing the operator was right to object to.
|
|
42
|
+
export const MAX_3D_HOURS = 72;
|
|
43
|
+
// the 3D candle: two units a slot, a 1.4-unit body, a 0.4-unit wick, prices over 28 units
|
|
44
|
+
// zBase: the price band starts above the volume band -- from a low camera the volume in the
|
|
45
|
+
// front row would otherwise stand in front of the lowest candles
|
|
46
|
+
export const C3 = { slot: 2, body: 1.4, wick: 0.4, zMax: 28, depth: 8, row: 3, volMax: 4, zBase: 6 };
|
|
47
|
+
export const CAMERA_3D = {
|
|
48
|
+
// low (operator: "even lower ... more of the side view, than from above"): depth drawn at 0.3
|
|
49
|
+
oblique: { ox: 0.07, oy: 0.95, dy: 0.3, headroom: C3.zBase + C3.zMax + 2, flight: 10, anchor: 'bottom' },
|
|
50
|
+
// flat: a bowed board bent the price levels in the middle away from the labels at the edge
|
|
51
|
+
dome: 0,
|
|
52
|
+
gridStep: 2,
|
|
53
|
+
// SPACE (operator: "make the floor transparent black against a twinkling star field. Get rid of
|
|
54
|
+
// the texture entirely for the markets page"): no deck texture, a translucent black board over
|
|
55
|
+
// a twinkling star field, quieter cell lines
|
|
56
|
+
space: true,
|
|
57
|
+
// and no ground grid at all: "Just draw the one single line between the candles and the date/time"
|
|
58
|
+
floorLine: true,
|
|
59
|
+
// lit from the viewer (operator: "move the lighting to the viewer position ... everything bright
|
|
60
|
+
// and clear on the faces"): brightest toward the camera, light edges, no cast shadows
|
|
61
|
+
light: 'viewer',
|
|
62
|
+
background: 'rgba(1,2,8,1)',
|
|
63
|
+
neonCell: 'rgba(40,150,100,0.5)',
|
|
64
|
+
// a new hour slides the whole row one slot: brisk, not the block board's 20 s reshuffle
|
|
65
|
+
transition: { rise: 700, travel: 2200, drop: 1400, riseStagger: 300, dropStagger: 600, entryMs: 600 },
|
|
66
|
+
};
|
|
67
|
+
const UP = '#1fc98a', DOWN = '#ef4d5e';
|
|
68
|
+
// volume, lit as brightly as the candles (operator: "not enough light on the volume activity at the
|
|
69
|
+
// grid level")
|
|
70
|
+
const UP_V = '#2bd49a', DOWN_V = '#f0606e';
|
|
71
|
+
const UP_W = '#8af2c9', DOWN_W = '#ff9ea8';
|
|
72
|
+
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
73
|
+
// THE TOOLBAR IS A PREFERENCE (operator, 2026-09-12: "We need to remember the user settings for
|
|
74
|
+
// the Markets page"). The exchange and the range were a click that lasted until the tab closed.
|
|
75
|
+
// They are seeded from the store on first use and written back on every click, so the page opens
|
|
76
|
+
// where it was left -- on this browser, like every other display setting.
|
|
77
|
+
const M = { data: null, at: 0, busy: false, error: null, ex: null, range: null, view: null, hover: null, bound: false };
|
|
78
|
+
function prefs() {
|
|
79
|
+
if (M.ex === null || M.range === null || M.view === null) {
|
|
80
|
+
const mk = loadSettings().markets;
|
|
81
|
+
M.ex = mk.exchange;
|
|
82
|
+
M.range = Number(mk.range);
|
|
83
|
+
M.view = mk.priceView;
|
|
84
|
+
}
|
|
85
|
+
return M;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ONE VIEW OR THE OTHER (operator, 2026-09-12: "have a selector for either the 3D view or 2D view
|
|
89
|
+
// for price. Not both at the same time. Too much waste of space for that screen"). The two views
|
|
90
|
+
// draw the SAME hours from the same series -- that is the point of the pair, and also why showing
|
|
91
|
+
// both spent two tall panels saying one thing. The Kiosk is unaffected: renderMarketsBoard draws
|
|
92
|
+
// the board on its own canvas and does not consult this.
|
|
93
|
+
// 2D first, and the default (operator: "Swap 2D and 3D view buttons and make 2D view the default").
|
|
94
|
+
export const PRICE_VIEWS = Object.freeze([
|
|
95
|
+
Object.freeze({ id: '2d', label: '2D', title: '2D: the flat chart -- axes, volume and a crosshair' }),
|
|
96
|
+
Object.freeze({ id: '3d', label: '3D', title: '3D: each hour a candle floating at its price, over the star field' }),
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const r2 = (v) => Math.round(v * 100) / 100;
|
|
100
|
+
const money = (v, dp = 2) => (v == null ? '–' : v.toLocaleString('en-US', { minimumFractionDigits: dp, maximumFractionDigits: dp }));
|
|
101
|
+
|
|
102
|
+
// Pure: the chart series in, board3d tiles and axes out.
|
|
103
|
+
// THE CHART FILLS THE PANEL (operator, 2026-09-11: "bring view in lower . and not waste so much
|
|
104
|
+
// space with the top half of the market grid"). The camera fit is bound by the board's WIDTH in a
|
|
105
|
+
// wide panel, so a fixed 28-unit price height left the top half empty. The height is solved from
|
|
106
|
+
// the panel's shape instead: the most price height whose headroom still fits (obliqueFit, anchor
|
|
107
|
+
// 'bottom': gridH + (zMax + 4) * oy + 3 units tall against gridW + 9 + 2 * (zMax + 4) * ox wide).
|
|
108
|
+
export function fitZ(aspect, gridW, cam = CAMERA_3D) {
|
|
109
|
+
const { ox, oy } = cam.oblique;
|
|
110
|
+
const dyS = cam.oblique.dy ?? 1;
|
|
111
|
+
let z = C3.zMax;
|
|
112
|
+
for (let k = 0; k < 4; k++) {
|
|
113
|
+
const head = C3.zBase + z + 2 + (cam.dome ?? 0);
|
|
114
|
+
const wU = gridW + 9 + 2 * head * ox;
|
|
115
|
+
z = (wU * aspect - C3.depth * dyS - 3) / oy - 2 - (cam.dome ?? 0) - C3.zBase;
|
|
116
|
+
}
|
|
117
|
+
// (the cap was 90: a near-square panel -- the Kiosk's -- wanted ~125, so the camera fitted the
|
|
118
|
+
// board's width and left the top quarter of the panel empty)
|
|
119
|
+
return Math.max(12, Math.min(160, Math.floor(z * 0.97)));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function chart3d(ser, { hours = MAX_3D_HOURS, zMax = C3.zMax } = {}) {
|
|
123
|
+
const cs = (ser?.candles ?? []).slice(-hours).filter((k) => Number.isFinite(k.c) && Number.isFinite(k.o));
|
|
124
|
+
if (!cs.length) return { tiles: [], gridW: C3.slot, gridH: C3.depth, axes: null, lo: null, hi: null };
|
|
125
|
+
let lo = Infinity, hi = -Infinity, vmax = 1e-9;
|
|
126
|
+
for (const k of cs) { lo = Math.min(lo, k.l ?? k.c); hi = Math.max(hi, k.h ?? k.c); vmax = Math.max(vmax, k.v ?? 0); }
|
|
127
|
+
const pad = (hi - lo) * 0.04 || 1;
|
|
128
|
+
lo -= pad; hi += pad;
|
|
129
|
+
const Z = (p) => C3.zBase + ((p - lo) / (hi - lo)) * zMax;
|
|
130
|
+
const id = ser.base?.id ?? 'x';
|
|
131
|
+
const name = `${ser.base?.name ?? ''} ${ser.base?.pair ?? ''}`.trim();
|
|
132
|
+
const tiles = [];
|
|
133
|
+
cs.forEach((k, i) => {
|
|
134
|
+
const up = k.c >= k.o;
|
|
135
|
+
const x = i * C3.slot + (C3.slot - C3.body) / 2;
|
|
136
|
+
const wx = i * C3.slot + (C3.slot - C3.wick) / 2, wy = C3.row + (C3.body - C3.wick) / 2;
|
|
137
|
+
const z0 = Z(Math.min(k.o, k.c)), z1 = Z(Math.max(k.o, k.c));
|
|
138
|
+
const zl = Z(k.l ?? Math.min(k.o, k.c)), zh = Z(k.h ?? Math.max(k.o, k.c));
|
|
139
|
+
const top = Math.max(z1, z0 + 0.25);
|
|
140
|
+
const label = readout(k, name);
|
|
141
|
+
tiles.push({ txid: `v:${id}:${k.t}`, x, y: 0.5, s: C3.body, tall: r2(Math.max(0.12, ((k.v ?? 0) / vmax) * C3.volMax)), color: up ? UP_V : DOWN_V, label });
|
|
142
|
+
// the wick inside the body's footprint, at the body's depth, so its height reads as the
|
|
143
|
+
// body's does (a wick set behind the body drew a unit higher per unit of depth)
|
|
144
|
+
if (z0 - zl > 0.05) tiles.push({ txid: `wl:${id}:${k.t}`, x: wx, y: wy, s: C3.wick, floor: r2(zl), tall: r2(z0 - zl), color: up ? UP_W : DOWN_W, label });
|
|
145
|
+
tiles.push({ txid: `b:${id}:${k.t}`, x, y: C3.row, s: C3.body, floor: r2(z0), tall: r2(top - z0), color: up ? UP : DOWN, label });
|
|
146
|
+
if (zh - top > 0.05) tiles.push({ txid: `wh:${id}:${k.t}`, x: wx, y: wy, s: C3.wick, floor: r2(top), tall: r2(zh - top), color: up ? UP_W : DOWN_W, label });
|
|
147
|
+
});
|
|
148
|
+
const last = cs.at(-1);
|
|
149
|
+
const z = niceTicks(lo, hi, 6).filter((v) => v >= lo && v <= hi).map((v) => ({ z: r2(Z(v)), label: money(v, 0) }));
|
|
150
|
+
z.push({ z: r2(Z(last.c)), label: money(last.c), color: last.c >= last.o ? UP : DOWN, strong: true });
|
|
151
|
+
const every = cs.length > 48 ? 12 : 6;
|
|
152
|
+
const x = [];
|
|
153
|
+
cs.forEach((k, i) => {
|
|
154
|
+
const d = new Date(k.t);
|
|
155
|
+
if (d.getUTCHours() % every === 0) x.push({ x: i * C3.slot + C3.slot / 2, label: d.getUTCHours() === 0 ? `${MON[d.getUTCMonth()]} ${d.getUTCDate()}` : `${String(d.getUTCHours()).padStart(2, '0')}:00` });
|
|
156
|
+
});
|
|
157
|
+
// the neon line: each hour's close, at the centre of its candle
|
|
158
|
+
const line = cs.map((k, i) => ({ x: i * C3.slot + C3.slot / 2, z: r2(Z(k.c)) }));
|
|
159
|
+
return { tiles, gridW: cs.length * C3.slot, gridH: C3.depth, lo, hi, zMax, hours: cs.length, axes: { y: C3.row + C3.body / 2, zTop: C3.zBase + zMax, z, x, line } };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function summaryHtml(d, fmt) {
|
|
163
|
+
const s = d.summary ?? {};
|
|
164
|
+
const item = (k, v) => `<span><span class="k">${k}</span><b>${v}</b></span>`;
|
|
165
|
+
return [
|
|
166
|
+
item('median (USD)', s.median == null ? '–' : `$${money(s.median)}`),
|
|
167
|
+
item('spread across exchanges', s.spread == null ? '–' : `$${money(s.spread)}`),
|
|
168
|
+
item('24 h volume', s.vol24 == null ? '–' : `${fmt.num(Math.round(s.vol24))} BTC`),
|
|
169
|
+
item('reporting', `${s.reporting ?? 0} of ${(d.exchanges ?? []).filter((e) => e.quote === 'USD').length} USD books`),
|
|
170
|
+
].join('');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function tableHtml(d, fmt, now = Date.now()) {
|
|
174
|
+
const rows = (d.exchanges ?? []).map((e) => {
|
|
175
|
+
const ch = e.change24;
|
|
176
|
+
const spreadBps = e.spread != null && e.last ? (e.spread / e.last) * 1e4 : null;
|
|
177
|
+
return `<tr>
|
|
178
|
+
<td class="w"><b>${fmt.esc(e.name)}</b></td>
|
|
179
|
+
<td class="faint">${fmt.esc(e.pair)}</td>
|
|
180
|
+
<td class="r">${money(e.last)}</td>
|
|
181
|
+
<td class="r">${money(e.bid)}</td>
|
|
182
|
+
<td class="r">${money(e.ask)}</td>
|
|
183
|
+
<td class="r">${e.spread == null ? '–' : `${money(e.spread)} <span class="faint">${spreadBps < 0.1 ? '<0.1' : spreadBps.toFixed(1)} bp</span>`}</td>
|
|
184
|
+
<td class="r ${ch > 0 ? 'xpos' : ch < 0 ? 'xneg' : ''}">${ch == null ? '–' : `${ch > 0 ? '+' : ''}${(ch * 100).toFixed(2)}%`}</td>
|
|
185
|
+
<td class="r">${money(e.low24, 0)} – ${money(e.high24, 0)}</td>
|
|
186
|
+
<td class="r">${e.vol24 == null ? '–' : `${fmt.num(Math.round(e.vol24))} <span class="faint">BTC</span>`}</td>
|
|
187
|
+
<td class="r ${e.stale ? 'stale' : 'faint'}">${e.error ? `<span class="warn" title="${fmt.esc(e.error)}">${fmt.esc(e.error.slice(0, 40))}</span>` : e.at ? fmt.ago(e.at, now) : '–'}</td>
|
|
188
|
+
</tr>`;
|
|
189
|
+
}).join('');
|
|
190
|
+
return `<div class="scroll"><table class="t mktbl"><thead><tr><th>exchange</th><th>pair</th><th class="r">last</th><th class="r">bid</th><th class="r">ask</th><th class="r">spread</th><th class="r">24 h</th><th class="r">24 h low – high</th><th class="r">24 h volume</th><th class="r">updated</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// (legend3dHtml lived here: a 250px column of prose beside the board explaining that a green body
|
|
194
|
+
// closed up and a red one closed down. Removed with the legend it filled -- operator: "get rid of
|
|
195
|
+
// this. takes up too much space on the markets page" -- rather than left as an exported function
|
|
196
|
+
// with no caller.)
|
|
197
|
+
|
|
198
|
+
export function toolbarHtml(d) {
|
|
199
|
+
prefs();
|
|
200
|
+
const exs = (d?.exchanges ?? []).filter((e) => e.candles?.length);
|
|
201
|
+
const b = (attr, v, label, on) => `<button type="button" class="mkbtn${on ? ' on' : ''}" ${attr}="${v}">${label}</button>`;
|
|
202
|
+
return `<div class="mkgrp">${exs.map((e) => b('data-mkex', e.id, `<i class="mkdot" data-ex="${e.id}"></i>${e.name}`, e.id === M.ex)).join('')}</div>
|
|
203
|
+
<div class="mkgrp">${RANGES.map(([n, l]) => b('data-mkrange', n, l, n === M.range)).join('')}</div>
|
|
204
|
+
<div class="mkgrp">${PRICE_VIEWS.map((v) => b('data-mkview', v.id, v.label, v.id === M.view)).join('')}</div>`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The selected exchange's candles (the live hour closing at its ticker), the rest as lines.
|
|
208
|
+
export function chartSeries(d, ex, range, now = Date.now()) {
|
|
209
|
+
const withC = (d?.exchanges ?? []).filter((e) => e.candles?.length);
|
|
210
|
+
const base = withC.find((e) => e.id === ex) ?? withC[0];
|
|
211
|
+
if (!base) return null;
|
|
212
|
+
const candles = base.candles.slice(-range).map((k) => ({ ...k }));
|
|
213
|
+
const live = candles.at(-1);
|
|
214
|
+
if (live && base.last != null && now - live.t < 3600e3) {
|
|
215
|
+
live.c = base.last;
|
|
216
|
+
live.h = Math.max(live.h ?? base.last, base.last);
|
|
217
|
+
live.l = Math.min(live.l ?? base.last, base.last);
|
|
218
|
+
}
|
|
219
|
+
const t0 = candles[0]?.t ?? 0;
|
|
220
|
+
const overlays = withC.filter((e) => e !== base).map((e) => ({ name: e.name, color: EX_COLORS[e.id] ?? '#8aa0b4', candles: e.candles.filter((k) => k.t >= t0) }));
|
|
221
|
+
return { base, candles, overlays };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function drawChart() {
|
|
225
|
+
const canvas = document.getElementById('mkChart');
|
|
226
|
+
const { ex, range } = prefs();
|
|
227
|
+
const ser = chartSeries(M.data, ex, range);
|
|
228
|
+
if (!canvas || !ser) return;
|
|
229
|
+
drawPriceChart(canvas, { candles: ser.candles, overlays: ser.overlays, name: `${ser.base.name} ${ser.base.pair}`, hover: M.hover });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function drawBoard(id = 'mkBoard') {
|
|
233
|
+
const canvas = document.getElementById(id);
|
|
234
|
+
const { ex, range } = prefs();
|
|
235
|
+
const ser = chartSeries(M.data, ex, range);
|
|
236
|
+
if (!canvas || !ser) return null;
|
|
237
|
+
const hours = Math.min(MAX_3D_HOURS, ser.candles.length);
|
|
238
|
+
const aspect = (canvas.clientHeight || 400) / Math.max(1, canvas.clientWidth || 1000);
|
|
239
|
+
const c3 = chart3d(ser, { zMax: fitZ(aspect, hours * C3.slot) });
|
|
240
|
+
const cam = { ...CAMERA_3D, oblique: { ...CAMERA_3D.oblique, headroom: C3.zBase + c3.zMax + 2 } };
|
|
241
|
+
if (c3.tiles.length) board3d(canvas, c3.tiles, { ...cam, gridW: c3.gridW, gridH: c3.gridH, axes: c3.axes, ...marketsOptions(loadSettings()) });
|
|
242
|
+
return { c3, ser };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Draw the chosen price view, and NOT the other one.
|
|
247
|
+
*
|
|
248
|
+
* The saving is the WORK, not the pixels. Hiding a canvas in CSS would still pay for a full
|
|
249
|
+
* board3d pass or a candlestick render on every 15 s refresh, for something nobody can see. So the
|
|
250
|
+
* container is hidden AND its draw is skipped. Both elements stay in the document: the toolbar
|
|
251
|
+
* switches between them live, and markets.test.js addresses them by id.
|
|
252
|
+
*/
|
|
253
|
+
function drawPrice() {
|
|
254
|
+
const { view } = prefs();
|
|
255
|
+
const three = view !== '2d';
|
|
256
|
+
document.getElementById('mkLayout')?.classList?.toggle('hidden', !three);
|
|
257
|
+
document.getElementById('mkChart')?.classList?.toggle('hidden', three);
|
|
258
|
+
if (!three) { drawChart(); return null; }
|
|
259
|
+
return drawBoard();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function bindChart() {
|
|
263
|
+
if (M.bound) return;
|
|
264
|
+
const bar = document.getElementById('mkBar'), canvas = document.getElementById('mkChart');
|
|
265
|
+
if (!bar || !canvas) return;
|
|
266
|
+
M.bound = true;
|
|
267
|
+
bar.addEventListener('click', (e) => {
|
|
268
|
+
const btn = e.target.closest('button');
|
|
269
|
+
if (!btn) return;
|
|
270
|
+
if (btn.dataset.mkex) setSetting(loadSettings(), 'markets.exchange', (M.ex = btn.dataset.mkex));
|
|
271
|
+
if (btn.dataset.mkrange) setSetting(loadSettings(), 'markets.range', String((M.range = Number(btn.dataset.mkrange))));
|
|
272
|
+
if (btn.dataset.mkview) setSetting(loadSettings(), 'markets.priceView', (M.view = btn.dataset.mkview));
|
|
273
|
+
const html = toolbarHtml(M.data);
|
|
274
|
+
bar.innerHTML = html; bar.__html = html;
|
|
275
|
+
drawPrice();
|
|
276
|
+
});
|
|
277
|
+
canvas.addEventListener('pointermove', (e) => { const r = canvas.getBoundingClientRect(); M.hover = { x: e.clientX - r.left, y: e.clientY - r.top }; drawChart(); });
|
|
278
|
+
canvas.addEventListener('pointerleave', () => { M.hover = null; drawChart(); });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function fetchMarkets(h, now) {
|
|
282
|
+
const wait = M.data?.warming || !M.data ? 3000 : REFRESH_MS;
|
|
283
|
+
if (M.busy || now - M.at < wait) return;
|
|
284
|
+
M.busy = true;
|
|
285
|
+
M.at = now;
|
|
286
|
+
h.api('/api/markets')
|
|
287
|
+
.then((d) => { M.data = d; M.error = null; })
|
|
288
|
+
.catch((err) => { M.error = err.message; })
|
|
289
|
+
.finally(() => { M.busy = false; h.render(); });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// THE PRICE INFORMATION PANEL (operator, 2026-09-11, of the Kiosk's markets board: "Way too much
|
|
293
|
+
// empty space on top of the markets view. Perhaps shrink up markets view, and add a Price
|
|
294
|
+
// Information Panel?"). The USD median in the price line's neon yellow with its 24 h change; the
|
|
295
|
+
// 24 h high, low and volume and the spread across books; every exchange's last, change and
|
|
296
|
+
// spread; how fresh it is. Pure: the /api/markets reply in, markup out.
|
|
297
|
+
export function priceInfoHtml(d, fmt, now = Date.now()) {
|
|
298
|
+
if (!d?.exchanges) return '<div class="kp-wait">asking the exchanges…</div>';
|
|
299
|
+
const usd = d.exchanges.filter((e) => e.quote === 'USD' && e.last != null && !e.stale);
|
|
300
|
+
const med = d.summary?.median ?? null;
|
|
301
|
+
const chs = usd.map((e) => e.change24).filter((v) => v != null).sort((a, b) => a - b);
|
|
302
|
+
const ch = chs.length ? chs[Math.floor((chs.length - 1) / 2)] : null;
|
|
303
|
+
const pct = (v) => (v == null ? '–' : `${v > 0 ? '+' : ''}${(v * 100).toFixed(2)}%`);
|
|
304
|
+
const cls = (v) => (v > 0 ? 'xpos' : v < 0 ? 'xneg' : '');
|
|
305
|
+
const newest = d.exchanges.reduce((m, e) => Math.max(m, e.at ?? 0), 0);
|
|
306
|
+
// THE KIOSK KEEPS THE PRICE AND LOSES THE 24 h BOOK DETAIL (operator, 2026-09-12: "swap out 24
|
|
307
|
+
// hour order book spread and details, keep the price, but change to market order depth chart that
|
|
308
|
+
// fits within the view"). The high/low/volume/spread block and the per-exchange table are gone:
|
|
309
|
+
// the depth chart that replaces them says far more about the book than a single spread figure,
|
|
310
|
+
// and a four-column table is unreadable from across the room a kiosk is meant to be seen from.
|
|
311
|
+
// The median, its day's change and the provenance line stay -- that is "keep the price".
|
|
312
|
+
return `<div class="kp-main"><span class="kp-pair">BTC / USD</span><b class="kp-price">${med == null ? '–' : `$${money(med)}`}</b><span class="kp-chg ${cls(ch)}">${pct(ch)} <small>24 h</small></span></div>
|
|
313
|
+
<div class="kp-foot">median of ${usd.length} USD book${usd.length === 1 ? '' : 's'}${newest ? ` · updated ${fmt.ago(newest, now)}` : ''} · OKX quotes USDT</div>`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function renderPriceInfo(id, h) {
|
|
317
|
+
const el = document.getElementById(id);
|
|
318
|
+
if (!el) return;
|
|
319
|
+
const html = priceInfoHtml(M.data, h.fmt);
|
|
320
|
+
if (el.__html !== html) { el.innerHTML = html; el.__html = html; }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// The 3D board alone, on any canvas -- the Kiosk tab's markets panel (kiosk.js)
|
|
324
|
+
export function renderMarketsBoard(id, h) {
|
|
325
|
+
fetchMarkets(h, Date.now());
|
|
326
|
+
if (M.data?.enabled === false) return null;
|
|
327
|
+
const b = drawBoard(id);
|
|
328
|
+
if (!b) return null;
|
|
329
|
+
const last = b.ser.candles.at(-1)?.c;
|
|
330
|
+
return { label: `${b.ser.base.name} ${b.ser.base.pair} · last ${b.c3.hours} h${last != null ? ` · $${money(last)}` : ''}` };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function renderMarkets(s, state, h) {
|
|
334
|
+
const now = Date.now();
|
|
335
|
+
fetchMarkets(h, now);
|
|
336
|
+
const put = (id, html) => { const el = document.getElementById(id); if (el && el.__html !== html) { el.innerHTML = html; el.__html = html; } };
|
|
337
|
+
const d = M.data;
|
|
338
|
+
if (!d) { put('mkTable', `<div class="note">${M.error ? h.fmt.esc(M.error) : 'asking the exchanges…'}</div>`); return; }
|
|
339
|
+
if (d.enabled === false) { put('mkTable', `<div class="caveat">${h.fmt.esc(d.note ?? 'market data is off')}</div>`); return; }
|
|
340
|
+
put('mkSummary', summaryHtml(d, h.fmt));
|
|
341
|
+
put('mkBar', toolbarHtml(d));
|
|
342
|
+
bindChart();
|
|
343
|
+
put('mkTable', tableHtml(d, h.fmt, now));
|
|
344
|
+
renderDepth(h);
|
|
345
|
+
// one view, chosen in the toolbar; the other is neither shown nor drawn
|
|
346
|
+
drawPrice();
|
|
347
|
+
// THE BOARD'S RANGE, SAID OUT LOUD when it is shorter than the one chosen. The 3D view caps at
|
|
348
|
+
// MAX_3D_HOURS (see the note on that constant), so at 7 d it draws the most recent three. That
|
|
349
|
+
// was true before and went unmentioned, which is the one part of it the operator was right to
|
|
350
|
+
// object to: a button that says seven days while the picture shows three is a truncation the
|
|
351
|
+
// reader cannot see. Only shown when it actually applies, and only for the view it applies to.
|
|
352
|
+
const capped = prefs().view !== '2d' && M.range > MAX_3D_HOURS;
|
|
353
|
+
const capNote = capped
|
|
354
|
+
? ` The 3D board draws the most recent ${MAX_3D_HOURS} hours of the ${M.range} you picked — the price band is solved from the panel's shape, and a wider board stops describing the prices. The flat chart draws the whole range.`
|
|
355
|
+
: '';
|
|
356
|
+
put('mkNote', `Public REST APIs of ${d.exchanges.map((e) => h.fmt.esc(e.name)).join(', ')}, fetched by this server every ${Math.round((d.tickerMs ?? REFRESH_MS) / 1000)} s while this tab is open and never otherwise. OKX quotes USDT, so it is left out of the USD median and spread.${capNote}${d.warming ? ' Warming up…' : ''}`);
|
|
357
|
+
}
|