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.
Files changed (58) hide show
  1. package/CHANGELOG.md +251 -1
  2. package/README.md +42 -23
  3. package/bin/blockyard.js +2 -1
  4. package/docs/API.md +16 -14
  5. package/docs/ARCHITECTURE.md +92 -5
  6. package/docs/CONFIGURATION.md +33 -26
  7. package/docs/GETTING-STARTED.md +5 -2
  8. package/docs/INSTALL.md +90 -33
  9. package/docs/MEASUREMENTS.md +147 -0
  10. package/docs/SECURITY.md +32 -15
  11. package/docs/TROUBLESHOOTING.md +35 -1
  12. package/docs/USER-GUIDE.md +266 -26
  13. package/package.json +1 -1
  14. package/public/404.html +1 -1
  15. package/public/css/app.css +306 -82
  16. package/public/donate-qr.png +0 -0
  17. package/public/index.html +295 -103
  18. package/public/js/agents.js +228 -51
  19. package/public/js/app.js +82 -8
  20. package/public/js/blockscene3d.js +179 -27
  21. package/public/js/charts.js +21 -21
  22. package/public/js/depthchart.js +31 -27
  23. package/public/js/details3d.js +1456 -71
  24. package/public/js/doom.js +31 -0
  25. package/public/js/dosaudio.js +48 -0
  26. package/public/js/dosgame.js +389 -0
  27. package/public/js/dosio.js +186 -0
  28. package/public/js/dospc.js +1353 -0
  29. package/public/js/dosworker.js +196 -0
  30. package/public/js/login.js +5 -0
  31. package/public/js/markets.js +46 -8
  32. package/public/js/mining.js +310 -32
  33. package/public/js/panels.js +14 -10
  34. package/public/js/pricechart.js +14 -13
  35. package/public/js/quake.js +20 -0
  36. package/public/js/settings.js +103 -21
  37. package/public/js/soundcard.js +459 -0
  38. package/public/js/theme.js +235 -0
  39. package/public/js/wolf3d.js +22 -0
  40. package/public/js/x86.js +1978 -0
  41. package/scripts/donate-qr.py +12 -9
  42. package/scripts/dos-bench.js +56 -0
  43. package/scripts/setup.js +34 -12
  44. package/scripts/shots.mjs +6 -0
  45. package/scripts/smoke.sh +1 -1
  46. package/scripts/tls.js +31 -0
  47. package/server/chain/index/build.js +21 -4
  48. package/server/collect/monitor.js +30 -1
  49. package/server/collect/network.js +295 -0
  50. package/server/config.js +46 -22
  51. package/server/http/api.js +49 -5
  52. package/server/http/games.js +77 -0
  53. package/server/http/server.js +8 -0
  54. package/server/main.js +53 -8
  55. package/server/tls/selfsigned.js +160 -0
  56. package/systemd/blockyard.service +7 -5
  57. package/docs/PRIVATE-LEADERBOARD.md +0 -230
  58. package/docs/STATE-2026-09-09.md +0 -200
@@ -912,8 +912,20 @@ export const FX_NONE = Object.freeze({ glow: 0, outline: 0, lift: 0, color: null
912
912
  // just outside one side of the board and leaving past the other
913
913
  export function fxFront(fx) {
914
914
  const qs = [0, fx.gridW * fx.dx, fx.gridH * fx.dy, fx.gridW * fx.dx + fx.gridH * fx.dy];
915
- const lo = Math.min(...qs) - 4, hi = Math.max(...qs) + 4;
916
- return lo + fx.u * (hi - lo);
915
+ // THE MARGIN IS THE PANEL'S, NOT A CONSTANT (operator, 2026-09-15: "It needs to cleanly travel on
916
+ // and off screen"). Four units clears the BOARD, and the panel reaches much further than that --
917
+ // measured 21.8 units beyond a 96-wide board -- so a front that started four units out began its
918
+ // sweep in plain view and ended it there. render3d measures the real overhang from the viewRect
919
+ // and puts it on `fx.margin`; four is the fallback for a caller with no panel (tests).
920
+ const m = fx.margin ?? 4;
921
+ const lo = Math.min(...qs) - m, hi = Math.max(...qs) + m;
922
+ // A SEARCHLIGHT PANS (operator, 2026-09-15: "the scanner needs to be more like a search light,
923
+ // slowly panning back and forth"). A single linear pass is a scanner; sweeping out and back is a
924
+ // light being AIMED, which is what reads as something looking for things. `pan` is set for the
925
+ // top-down board only -- on the price chart the camera is low and one pass along the hours is
926
+ // the right motion.
927
+ const t = fx.pan ? 1 - Math.abs(((fx.u * 2) % 2) - 1) : fx.u;
928
+ return lo + t * (hi - lo);
917
929
  }
918
930
 
919
931
  // a deterministic 0..1 from an integer: where a firework bursts, which cube flares. Hashed, not
@@ -957,8 +969,26 @@ export function fxAt(t, fx) {
957
969
  return { glow: A * w, outline: 0.8 * A * w, lift: 0, color: [255, 255, 255] };
958
970
  }
959
971
  case 'scan': {
960
- const w = g((along() - fxFront(fx)) / 1.6);
961
- return { glow: 0.8 * A * w, outline: 0.5 * A * w, lift: 0, color: [120, 220, 255] };
972
+ // the beam's own width -- ten units, so a whole swath of blocks is lit as it passes
973
+ // (2026-09-15: "Reach more blocks with the scanner effect")
974
+ // THE LIGHT IS THE POINT (operator, 2026-09-15: "We really need to make it much more obvious
975
+ // somehow that the blocks are being illuminated"). The swath tracks the beam's own half-width
976
+ // (fx.beamR, set with the cone) so light and geometry cannot disagree, and it hits harder:
977
+ // a near-white glow, a bright outline and real lift, so a struck cube stands up out of the
978
+ // board rather than merely brightening.
979
+ const w = g((along() - fxFront(fx)) / (fx.beamR ?? 5.5));
980
+ return { glow: Math.min(1, 1.6 * A * w), outline: Math.min(1, 1.2 * A * w), lift: 1.4 * A * w, color: [225, 248, 255] };
981
+ }
982
+ case 'xray': {
983
+ // X-RAY (2026-09-15, operator: "Add xray as additional choosable effect"): a front sweeps the
984
+ // board and behind it every tile goes x-ray -- its body dims to glass and its edges and a
985
+ // raster across its faces light up (buildScene's xray treatment) -- then develops back to
986
+ // solid over a few units, the front itself a bright edge
987
+ const d = fxFront(fx) - along(); // > 0 once the front has passed
988
+ const edge = g(d / 1.2);
989
+ const back = d > 0 && d < 9 ? (d < 5 ? 1 : 1 - (d - 5) / 4) : 0;
990
+ const xray = Math.max(edge, back) * A;
991
+ return xray > 0.02 ? { glow: 0.5 * A * edge, outline: A * Math.max(edge, 0.8 * back), lift: 0, color: [190, 240, 255], xray } : FX_NONE;
962
992
  }
963
993
  case 'ball': {
964
994
  // "illuminating everything it comes near": brightest right under the ball, gone ~5 units off
@@ -997,30 +1027,41 @@ export function fxAt(t, fx) {
997
1027
  return { glow: hot * A * w, outline: 0.8 * hot * A * w, lift: (inward ? 0.4 : 3.2) * A * w,
998
1028
  color: inward ? [150, 190, 255] : [255, 255, 245] };
999
1029
  }
1000
- case 'firework': {
1001
- // three bursts, each at its own moment and place, each a ring that expands and dies
1002
- let glow = 0, outline = 0, lift = 0, col = [255, 200, 120];
1003
- for (let i = 0; i < 3; i++) {
1004
- const t0 = 0.05 + 0.26 * i, life = 0.45;
1005
- const v = (fx.u - t0) / life;
1006
- if (!(v > 0 && v < 1)) continue;
1007
- const bx = fxHash(fx.seed + i * 31 + 1) * fx.gridW, by = fxHash(fx.seed + i * 31 + 2) * fx.gridH;
1008
- const r = 9 * Math.pow(v, 0.55);
1009
- const w = g((Math.hypot(cx - bx, cy - by) - r) / 1.4) * (1 - v);
1010
- if (w > glow) {
1011
- glow = w; outline = 0.9 * w; lift = 1.8 * w;
1012
- col = [[255, 170, 110], [140, 220, 255], [220, 160, 255]][i];
1013
- }
1014
- }
1015
- return glow > 0.02 ? { glow: A * glow, outline: A * outline, lift: A * lift, color: col } : FX_NONE;
1016
- }
1030
+ // 'firework' lights through fxNow's heads now (details3d fireworkShells): one head per live
1031
+ // shell, in the shell's colour -- see the heads branch below
1017
1032
  case 'flare': {
1018
1033
  // one cube goes supernova and lights its neighbourhood -- the same hashed cube every replay
1019
1034
  const fxp = fxHash(fx.seed + 7) * fx.gridW, fyp = fxHash(fx.seed + 8) * fx.gridH;
1020
1035
  const bell = Math.sin(Math.PI * fx.u);
1021
1036
  const d = Math.hypot(cx - fxp, cy - fyp);
1022
- const w = g(d / (1.2 + 7 * bell)) * bell;
1023
- return w > 0.02 ? { glow: A * w, outline: 0.8 * A * w, lift: 1.4 * A * w, color: [255, 245, 205] } : FX_NONE;
1037
+ // A SUPERNOVA'S LIGHT (2026-09-15, drawn in details3d drawSupernova): on a thin board -- the
1038
+ // price board, eight deep -- the light reaches three times as far, so the chart is lit, and
1039
+ // it cools with the remnant, gold to red to violet, after the white of the blast
1040
+ const thin = fx.gridH <= 12;
1041
+ const w = g(d / (1.2 + (thin ? 22 : 7) * bell)) * bell;
1042
+ const cool = Math.max(0, Math.min(1, (fx.u - 0.2) / 0.8));
1043
+ // white at the blast, then blue, then violet, as the cloud goes (details3d drawSupernova)
1044
+ const col = cool < 0.3 ? [Math.round(255 - 105 * cool / 0.3), Math.round(255 - 60 * cool / 0.3), 255] : [Math.round(150 + 25 * (cool - 0.3) / 0.7), Math.round(195 - 85 * (cool - 0.3) / 0.7), 255];
1045
+ // THE SHOCKWAVE THROWS WHAT IT CROSSES (operator, 2026-09-15: "Consider perturbing all the
1046
+ // candles that are affected by the shockwave"): the ring drawn in drawSupernova (4.5 shell
1047
+ // radii, out over u 0.14-0.6) runs through the tiles here too -- a tile it reaches is thrown
1048
+ // up hard and lit white, and shakes itself out for a while after the front has passed
1049
+ let lift = 1.4 * A * w, glow = A * w, outline = 0.8 * A * w, colour = col;
1050
+ if (fx.u >= 0.14) {
1051
+ const fr = Math.min(1, (fx.u - 0.14) / 0.46), ring = (thin ? 27 : 31) * (1 - Math.pow(1 - fr, 2.2));
1052
+ const front = g((d - ring) / 1.8) * (1 - fr * 0.6);
1053
+ const passed = ring - d; // > 0 once the front has gone by
1054
+ // the shaking starts as the front APPROACHES, five units out, not once it has passed
1055
+ // (operator: "trigger their shaking sooner in the blast radius")
1056
+ const shake = passed > -5 && fx.u < 0.75 ? Math.max(0, Math.sin(fx.u * 70 + jitterOf(t.txid, 'sn' + fx.seed) * 6.28)) * Math.exp(-Math.max(0, passed) / 12) * (1 - (fx.u - 0.14) / 0.61) : 0;
1057
+ if (front > 0.02 || shake > 0.02) {
1058
+ lift = Math.max(lift, 0.5 * A * front + 0.12 * A * shake); // a tenth of the first cut (operator: "affects the candles too much", "half as much still", "we really need to turn down the block shaking")
1059
+ glow = Math.max(glow, A * front + 0.4 * A * shake);
1060
+ outline = Math.max(outline, A * front);
1061
+ if (front > w) colour = [255, 255, 255];
1062
+ }
1063
+ }
1064
+ return glow > 0.02 || lift > 0.05 ? { glow: Math.min(1, glow), outline: Math.min(1, outline), lift, color: colour } : FX_NONE;
1024
1065
  }
1025
1066
  case 'wave': {
1026
1067
  // a swell rolling across the board: the cubes rise and fall with it, several crests at once
@@ -1125,6 +1166,9 @@ export function fxAt(t, fx) {
1125
1166
  const col = [Math.round(140 + 115 * ph), Math.round(90 + 140 * (1 - ph)), Math.round(190 + 60 * ph)];
1126
1167
  return { glow: 0.85 * A * w, outline: 0.2 * A * w, lift: 0, color: col };
1127
1168
  }
1169
+ case 'pulse': // the price line's surge lights the candles under its head (details3d fxNow)
1170
+ case 'firework': // each shell lights its surroundings in its colour (details3d fxNow)
1171
+ case 'blackhole': // the hole's head shrinks the candles it reaches toward nothing (scale) and lights the rest orange
1128
1172
  case 'lightcycle':
1129
1173
  case 'packets':
1130
1174
  case 'centipede':
@@ -1151,10 +1195,29 @@ export function fxAt(t, fx) {
1151
1195
  // the very fade that meant it was working. Measured: 0 cubes hidden over a whole run.
1152
1196
  // So the physical effect uses PROXIMITY alone and the visual effect keeps using w.
1153
1197
  const reach = g(Math.hypot(ddx, ddy) / Math.max(0.2, hd.r ?? 0.8));
1198
+ // `release` (1 unless the head says otherwise): the head letting go of everything it
1199
+ // holds, evenly -- the pull and the scale fade by it, so a tile under the head glides
1200
+ // home over the head's whole release rather than when its reach finally passes it
1201
+ const rel = hd.release ?? 1;
1154
1202
  best = {
1155
1203
  glow: 0.85 * w, outline: 0.8 * w, lift: (hd.lift ?? 0) * w, color: hd.color,
1156
1204
  hide: (hd.hide ?? 0) > 0 && reach > 0.45 ? 1 : 0,
1157
- scale: hd.scale == null ? 1 : 1 - (1 - hd.scale) * reach,
1205
+ scale: hd.scale == null ? 1 : 1 - (1 - hd.scale) * reach * rel,
1206
+ // `pull`: how hard the head drags this tile toward itself (the black hole; buildScene
1207
+ // shears the faces toward the head's screen point by it)
1208
+ pull: (hd.pull ?? 0) * reach * rel, pullAt: hd.pull ? { x: hd.x, y: hd.y, z: hd.z ?? 0 } : null,
1209
+ // the pull at the head's full size (rPeak): the orbit is laid out against this, so it
1210
+ // does not unwind as the head shrinks; the current pull only says how far along the
1211
+ // line from home to that orbit the tile sits
1212
+ pullPeak: (hd.pull ?? 0) * (hd.rPeak ? g(Math.hypot(ddx, ddy) / Math.max(0.2, hd.rPeak)) : reach),
1213
+ // `lean`: how much the corners lean and stretch toward the head on top of the glide
1214
+ // (1 on the price board; 0 on the block board, where the cubes are lifted out whole)
1215
+ lean: hd.lean ?? 1,
1216
+ // `shrink`: scale the footprint with the height, so the cube stays a cube as it goes
1217
+ shrink: hd.shrink ? 1 : 0,
1218
+ // `orbitR`: the head's reach in grid units -- buildScene lays the block board's orbits
1219
+ // out at a fixed distance round the hole from it, rather than part-way home
1220
+ orbitR: hd.r ?? 0,
1158
1221
  };
1159
1222
  }
1160
1223
  }
@@ -1220,6 +1283,12 @@ export function buildScene(tiles, o = {}) {
1220
1283
  const ground = [];
1221
1284
  const air = [];
1222
1285
  const shadows = [];
1286
+ // IN THE AIR, PAINTED LAST (operator, 2026-09-15, of the black hole on the block board: "There
1287
+ // are too many collision errors happening"): a cube pulled off the board toward the hole was
1288
+ // still painted in the board's own order, so it cut through the neighbours it was passing over.
1289
+ // Its faces are moved to the end of the frame instead, the least-pulled first, so the ones
1290
+ // nearest the hole are on top of everything
1291
+ const pulled = new Map();
1223
1292
  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1224
1293
  const note = (p) => {
1225
1294
  if (p.x < minX) minX = p.x;
@@ -1266,10 +1335,70 @@ export function buildScene(tiles, o = {}) {
1266
1335
  // A SHORTER CUBE, the same way: `tall` on a copy. A collapse or a dig is a height override,
1267
1336
  // never an edit to the tile the board was built from.
1268
1337
  const sc = fxv.scale == null ? 1 : fxv.scale;
1269
- const shaped = sc < 0.999 ? { ...t, tall: Math.max(0.04, cubeHeight(t) * sc) } : t;
1338
+ let shaped = sc < 0.999 ? { ...t, tall: Math.max(0.04, cubeHeight(t) * sc) } : t;
1339
+ // a WHOLE smaller cube, not a plate, where the head asks for it (the black hole over the block
1340
+ // board): the footprint shrinks about its centre with the height
1341
+ if (sc < 0.999 && fxv.shrink) { const s2 = Math.max(0.08, t.s * Math.max(0.08, sc)); shaped = { ...shaped, x: t.x + (t.s - s2) / 2, y: t.y + (t.s - s2) / 2, s: s2 }; }
1270
1342
  // NEON is a flat cube: no facets, no crown -- solid faces and the tubes on their edges
1271
1343
  const f = tileFaces(fxv.lift > 0.001 ? { ...shaped, fxz: fxv.lift } : shaped, o, t.s >= facetMin && o.neon !== true);
1272
- const a = t.alpha ?? 1;
1344
+ // SPAGHETTIFIED (operator, 2026-09-15: "the candles need to stretch and bend toward the hole
1345
+ // before they shrink"): a tile within a pulling head's reach has its faces sheared toward the
1346
+ // head's point on screen -- the top corners dragged hard, the base corners a little -- so it
1347
+ // leans and lengthens toward the hole before the scale takes it. Each corner object is moved
1348
+ // once, however many faces share it.
1349
+ if (fxv.pull > 0.001 && fxv.pullAt) {
1350
+ pulled.set(String(t.txid), fxv.pull);
1351
+ // ...AND ORBITS (operator, 2026-09-15: "The chart bars need to cleanly orbit the disc"): the
1352
+ // tile is swept round the hole along the disk's flow as it is drawn in -- its centre swings
1353
+ // about the hole by an angle that grows with the pull and the clock, on a radius that
1354
+ // shrinks with the pull, flattened toward the disk's plane -- and its corners lean and
1355
+ // stretch toward the hole on top of that
1356
+ const hp = project(fxv.pullAt.x, fxv.pullAt.y, fxv.pullAt.z, o);
1357
+ // EVERY CORNER THE CUBE HAS, the bevel's inner quad included -- it was left at home while
1358
+ // the top and sides moved, so a bevelled cube on the block board drew as a slab stretched
1359
+ // from its old place to its new one (operator, 2026-09-15: "too much bad shearing"); the
1360
+ // crown's points are projected later through f.P, which is shifted the same way below
1361
+ const pts = [...f.top, ...f.sides.flatMap((sd) => sd.points), ...(f.inset ?? [])];
1362
+ const uniq = [...new Set(pts)];
1363
+ const cx0 = uniq.reduce((a, p) => a + p.x, 0) / uniq.length, cy0 = uniq.reduce((a, p) => a + p.y, 0) / uniq.length;
1364
+ // THE ORBIT IS LAID OUT AGAINST THE HOLE'S FULL SIZE, and the tile sits somewhere on the
1365
+ // straight line between its home and that orbit by the CURRENT pull (operator, 2026-09-15:
1366
+ // "Instead of the bars reversing into place, can they just reorient themselves into their
1367
+ // original positions when the blackhole starts to disappear?"). While the hole grows the
1368
+ // tile glides out to its orbit and turns with it; while it shrinks the pull falls and the
1369
+ // tile glides straight home -- the orbit never unwinds.
1370
+ // STEADILY (operator, 2026-09-15: "The bars don't cleanly leave or return ... They sorta get
1371
+ // ripped off, and snapped back into place"): the capture and the release are eased with a
1372
+ // smoothstep, and the orbit is KEPLERIAN -- its angular speed goes with the square of the
1373
+ // pull, so a bar far out barely turns and one close in whips round -- where before every
1374
+ // bar's target swept a wide circle at one speed and a bar just being caught was yanked after
1375
+ // it. The angle's starting offset is small for the same reason.
1376
+ const peak = Math.max(fxv.pullPeak ?? fxv.pull, 0.001), kl = Math.min(1, fxv.pull / peak), k0 = kl * kl * (3 - 2 * kl);
1377
+ const dx = cx0 - hp.x, dy = cy0 - hp.y, r0 = Math.hypot(dx, dy) || 1, a0 = Math.atan2(dy, dx);
1378
+ // HALF THE SPEED (operator, 2026-09-15: "The bars sping around much much too quickly. Try to
1379
+ // calm it down by at least half to start" -- then "slow the spin ... by another half. Want it
1380
+ // smoother and calmer"): 0.00035 a millisecond at full pull, a quarter of the 0.0014 it was
1381
+ const swing = peak * peak * (o.now ?? 0) * 0.00035 + 0.5 * peak; // clockwise on screen, Keplerian
1382
+ // ROUND THE HOLE, on the block board (operator, 2026-09-15: "the blocks don't look right"):
1383
+ // part-way home from a hole twenty units up left the cubes hanging in the middle distance;
1384
+ // each is carried instead to its own ring round the hole, one and a half to three reaches
1385
+ // out in screen pixels, and glides straight back from it as the pull falls
1386
+ const ring = fxv.lean === 0 && fxv.orbitR > 0 && o.unit ? fxv.orbitR * o.unit * (1.5 + 1.5 * fxHash(Math.round(t.x * 31 + t.y * 7 + 3))) : null;
1387
+ const r1 = ring != null ? r0 + (ring - r0) * peak : r0 * (1 - 0.55 * peak), a1 = a0 + swing;
1388
+ const cx1 = hp.x + Math.cos(a1) * r1, cy1 = hp.y + Math.sin(a1) * r1 * (1 - 0.6 * peak); // flattened toward the disk
1389
+ const sx = (cx1 - cx0) * k0, sy = (cy1 - cy0) * k0;
1390
+ const tops = new Set([...f.top, ...f.sides.flatMap((sd) => [sd.points[0], sd.points[1]])]);
1391
+ for (const p of uniq) {
1392
+ p.x += sx; p.y += sy; // carried toward, and round, the orbit
1393
+ const k = (tops.has(p) ? 0.55 : 0.2) * peak * k0 * (fxv.lean ?? 1); // and leaning in (not on the block board: lean 0)
1394
+ p.x += (hp.x - p.x) * k; p.y += (hp.y - p.y) * k;
1395
+ }
1396
+ const P0 = f.P, kc = 0.55 * peak * k0 * (fxv.lean ?? 1);
1397
+ f.P = (x, y, z) => { const p = P0(x, y, z); const q = { x: p.x + sx, y: p.y + sy }; q.x += (hp.x - q.x) * kc; q.y += (hp.y - q.y) * kc; return q; };
1398
+ }
1399
+ // an x-rayed tile's body is glass: most of its alpha goes, and its edges come back below
1400
+ const xray = fxv.xray ?? 0;
1401
+ const a = (t.alpha ?? 1) * (1 - 0.72 * xray);
1273
1402
  const airborne = (t.z ?? 0) > 0.02;
1274
1403
  const out = airborne && !o.oblique ? air : ground;
1275
1404
  // A tile that just locked flashes brighter for a moment -- the Tetris
@@ -1582,6 +1711,23 @@ export function buildScene(tiles, o = {}) {
1582
1711
  // a tint at the top's weight (0.5) was there in the pixels and nowhere to the eye (measured:
1583
1712
  // the scan's peak moved a candle's face by a 0.26 cyan wash). At 0.85 it reads as struck.
1584
1713
  if (pulse > 0.03 && o.axes) for (const side of f.sides) out.push({ txid: t.txid, face: 'glow', points: side.points, fill: `rgba(${fxv.color.join(',')},${round3(0.85 * pulse * a)})` });
1714
+ // THE X-RAY TREATMENT: bright edges round every face, and a raster of thin lines across the top
1715
+ // and the sides, so the tile reads as a wireframe scan of itself
1716
+ if (xray > 0.02) {
1717
+ const xc = `rgba(200,245,255,${round3(0.95 * xray)})`;
1718
+ out.push({ txid: t.txid, face: 'outline', points: f.top, fill: 'rgba(0,0,0,0)', stroke: xc, lw: 1.2, always: true });
1719
+ for (const side of f.sides) out.push({ txid: t.txid, face: 'outline', points: side.points, fill: 'rgba(0,0,0,0)', stroke: xc, lw: 1.2, always: true });
1720
+ const raster = (poly) => {
1721
+ const [p0, p1, p2, p3] = poly; // p0->p1 and p3->p2 are the edges the lines run between
1722
+ for (let i = 1; i <= 3; i++) {
1723
+ const u = i / 4;
1724
+ const a0 = { x: p0.x + (p3.x - p0.x) * u, y: p0.y + (p3.y - p0.y) * u }, b0 = { x: p1.x + (p2.x - p1.x) * u, y: p1.y + (p2.y - p1.y) * u };
1725
+ out.push({ txid: t.txid, face: 'outline', points: [a0, b0], fill: 'rgba(0,0,0,0)', stroke: `rgba(160,230,255,${round3(0.45 * xray)})`, lw: 0.8, always: true });
1726
+ }
1727
+ };
1728
+ raster(f.top);
1729
+ for (const side of f.sides) raster(side.points);
1730
+ }
1585
1731
  if (fxv.outline > 0.03) {
1586
1732
  const col = fxv.color.join(',');
1587
1733
  out.push({ txid: t.txid, face: 'outline', points: f.top, fill: 'rgba(0,0,0,0)', stroke: `rgba(${col},${round3(0.95 * fxv.outline * a)})`, lw: 1 + 4 * fxv.outline });
@@ -1630,7 +1776,13 @@ export function buildScene(tiles, o = {}) {
1630
1776
  if (o.oblique && !viewerLit && zt < 1.5) shadows.push(...restingShadowOps(t, o, 1 - zt / 1.5));
1631
1777
  }
1632
1778
  }
1633
- const ops = o.oblique ? [...shadows, ...ground] : [...ground, ...shadows, ...air];
1779
+ let ops = o.oblique ? [...shadows, ...ground] : [...ground, ...shadows, ...air];
1780
+ if (pulled.size) {
1781
+ const rest = [], lifted = [];
1782
+ ops.forEach((op, i) => (pulled.has(String(op.txid)) ? lifted : rest).push({ op, i }));
1783
+ lifted.sort((p, q) => (pulled.get(String(p.op.txid)) - pulled.get(String(q.op.txid))) || (p.i - q.i));
1784
+ ops = [...rest.map((e) => e.op), ...lifted.map((e) => e.op)];
1785
+ }
1634
1786
  return { ops, bounds: ops.length ? { minX, maxX, minY, maxY } : null, count: ordered.length };
1635
1787
  }
1636
1788
 
@@ -12,12 +12,11 @@
12
12
  // old chart and calling it current is the same sin as inventing a number, so the
13
13
  // pill says how old it is.
14
14
  const M = { top: 8, right: 10, bottom: 18, left: 46 };
15
- const COL = {
16
- grid: '#20252d', axis: '#2c323c', text: '#6a7484', textDim: '#4d5665',
17
- accent: '#f7931a', ok: '#2ecc8f', warn: '#f0b429', bad: '#ef5a5a',
18
- info: '#58a6ff', purple: '#a78bfa', cyan: '#4dd0e1', pink: '#f06ba7',
19
- };
20
- const PALETTE = [COL.accent, COL.info, COL.ok, COL.purple, COL.cyan, COL.pink, COL.warn, COL.bad];
15
+ // THE COLOURS COME FROM THE THEME (theme.js, 2026-09-16): COL is the theme module's live object,
16
+ // refilled when the operator picks a theme, and the chrome colours below read INK the same way --
17
+ // a chart painted from literals of the dark look was unreadable on a light card.
18
+ import { COL, INK } from './theme.js';
19
+ const palette = () => [COL.accent, COL.info, COL.ok, COL.purple, COL.cyan, COL.pink, COL.warn, COL.bad];
21
20
 
22
21
  function prep(canvas) {
23
22
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -64,7 +63,7 @@ export function resetCanvas(canvas) {
64
63
  export function empty(canvas, msg = 'no data from the node yet') {
65
64
  if (canvas.__hasData) { markStale(canvas, msg); return; }
66
65
  const { ctx, w, h } = prep(canvas);
67
- ctx.fillStyle = '#4d5665';
66
+ ctx.fillStyle = INK.textDim;
68
67
  ctx.textAlign = 'center';
69
68
  ctx.fillText(msg, w / 2, h / 2);
70
69
  ctx.textAlign = 'left';
@@ -83,8 +82,8 @@ export function markStale(canvas, msg = 'no fresh samples') {
83
82
  const tw = ctx.measureText(label).width;
84
83
  const pw = tw + 20;
85
84
  const x = Math.max(4, w - pw - 6);
86
- ctx.fillStyle = '#12151a';
87
- ctx.strokeStyle = '#3d4552';
85
+ ctx.fillStyle = INK.panel;
86
+ ctx.strokeStyle = INK.panelLine;
88
87
  ctx.lineWidth = 1;
89
88
  ctx.beginPath();
90
89
  ctx.roundRect(x, 4, pw, 16, 5);
@@ -94,7 +93,7 @@ export function markStale(canvas, msg = 'no fresh samples') {
94
93
  ctx.beginPath();
95
94
  ctx.arc(x + 9, 12, 3, 0, 7);
96
95
  ctx.fill();
97
- ctx.fillStyle = '#b9c3d1';
96
+ ctx.fillStyle = INK.label;
98
97
  ctx.textAlign = 'left';
99
98
  ctx.fillText(label, x + 15, 12);
100
99
  }
@@ -205,7 +204,8 @@ export function lineChart(canvas, series, opts = {}) {
205
204
 
206
205
  for (let si = 0; si < pts.length; si++) {
207
206
  const s = pts[si];
208
- const color = s.color ?? PALETTE[si % PALETTE.length];
207
+ const pal = palette();
208
+ const color = s.color ?? pal[si % pal.length];
209
209
  const yOf = s.axis === 'right' ? Y2 : (opts.logY ? (v) => logY(v, lo, hi, L, plotH) : Y);
210
210
  const sorted = s.points.slice().sort((a, b) => a.t - b.t);
211
211
 
@@ -318,15 +318,15 @@ function attachLineTip(canvas, opts) {
318
318
  if (!rows.length) return hideTip(canvas);
319
319
  redraw(canvas);
320
320
  const { ctx } = ctxOf(canvas);
321
- ctx.strokeStyle = '#ffffff30';
321
+ ctx.strokeStyle = INK.faintLine;
322
322
  ctx.beginPath(); ctx.moveTo(nearX, c.L.top); ctx.lineTo(nearX, c.L.top + c.plotH); ctx.stroke();
323
323
  const fmt = opts.fmtTip ?? ((v, s) => short(v));
324
324
  const boxW = 118;
325
325
  const boxH = 14 + rows.length * 12;
326
326
  const bx = Math.min(c.w - boxW - 4, nearX + 8);
327
327
  const by = c.L.top + 4;
328
- ctx.fillStyle = '#0c0e11e8';
329
- ctx.strokeStyle = '#2c323c';
328
+ ctx.fillStyle = INK.tip;
329
+ ctx.strokeStyle = INK.tipLine;
330
330
  ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 5); ctx.fill(); ctx.stroke();
331
331
  ctx.textAlign = 'left';
332
332
  ctx.fillStyle = COL.text;
@@ -335,11 +335,11 @@ function attachLineTip(canvas, opts) {
335
335
  const y = by + 20 + i * 12;
336
336
  ctx.fillStyle = row.color;
337
337
  ctx.fillRect(bx + 6, y - 3, 6, 2);
338
- ctx.fillStyle = '#c7cfdb';
338
+ ctx.fillStyle = INK.tipText;
339
339
  const label = (row.label || '').slice(0, 12);
340
340
  ctx.fillText(label, bx + 16, y - 2);
341
341
  ctx.textAlign = 'right';
342
- ctx.fillStyle = '#e8edf5';
342
+ ctx.fillStyle = INK.bright;
343
343
  ctx.fillText(fmt(row.v, row), bx + boxW - 6, y - 2);
344
344
  ctx.textAlign = 'left';
345
345
  });
@@ -512,7 +512,7 @@ export function meter(canvas, { value, max, label, danger = 0.9, fmt }) {
512
512
  const a1 = Math.PI * 2;
513
513
  ctx.lineWidth = Math.max(7, r * 0.34);
514
514
  ctx.lineCap = 'butt';
515
- ctx.strokeStyle = '#171b21';
515
+ ctx.strokeStyle = INK.panel;
516
516
  ctx.beginPath(); ctx.arc(cx, cy, r, a0, a1); ctx.stroke();
517
517
  // the region past which the node starts feerate-evicting
518
518
  ctx.strokeStyle = hexA(COL.bad, 0.5);
@@ -520,7 +520,7 @@ export function meter(canvas, { value, max, label, danger = 0.9, fmt }) {
520
520
  const col = pct >= danger ? COL.bad : pct > 0.75 ? COL.warn : COL.ok;
521
521
  ctx.strokeStyle = col;
522
522
  ctx.beginPath(); ctx.arc(cx, cy, r, a0, a0 + Math.PI * Math.min(1, pct)); ctx.stroke();
523
- ctx.fillStyle = '#e8edf5';
523
+ ctx.fillStyle = INK.bright;
524
524
  ctx.textAlign = 'center';
525
525
  ctx.font = '600 17px ui-monospace, monospace';
526
526
  ctx.fillText(`${((value / max) * 100).toFixed(1)}%`, cx, cy - 4);
@@ -556,12 +556,12 @@ export function stackedBars(canvas, rows, opts = {}) {
556
556
  ctx.fillStyle = COL.text;
557
557
  ctx.textAlign = 'right';
558
558
  ctx.fillText(String(r.label ?? '').slice(0, 20), L.left - 6, y + rowH / 2);
559
- ctx.fillStyle = '#12151a';
559
+ ctx.fillStyle = INK.panel;
560
560
  ctx.fillRect(L.left, y + 2, plotW, rowH - 5);
561
561
  const bw = (r.value / max) * plotW;
562
562
  ctx.fillStyle = r.color ?? COL.accent;
563
563
  ctx.fillRect(L.left, y + 2, Math.max(1, bw), rowH - 5);
564
- ctx.fillStyle = '#e8edf5';
564
+ ctx.fillStyle = INK.bright;
565
565
  ctx.textAlign = 'left';
566
566
  ctx.fillText(String(r.display ?? short(r.value)), L.left + Math.min(bw + 5, plotW - 40), y + rowH / 2);
567
567
  });
@@ -632,4 +632,4 @@ function hexA(hex, a) {
632
632
  }
633
633
  function clampN(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
634
634
 
635
- export { COL, PALETTE };
635
+ export { COL, palette };
@@ -10,15 +10,15 @@
10
10
  // gives 500 levels, OKX 5000 -- a percent or two either side) is not extended: its line ends,
11
11
  // the end is marked, and the total past it counts only the books that reach.
12
12
  import { niceTicks } from './charts.js';
13
+ import { INK } from './theme.js'; // the axis, grid and tooltip colours of the chosen theme
13
14
  import { prep } from './pricechart.js';
14
15
 
15
16
  export const AGOS = [[60, '1m'], [300, '5m'], [600, '10m'], [1800, '30m'], [3600, '1h']];
16
17
  export const ZOOMS = [[0.01, '±1%'], [0.025, '±2.5%'], [0.05, '±5%'], [0.1, '±10%']];
17
18
  export const DEPTH_MS = 30_000;
18
19
  const D = { data: null, at: 0, busy: false, key: null, ago: 600, zoom: 0.05, hover: null, bound: false, error: null };
19
- const BID = '#3dff7a', ASK = '#ff4545';
20
- const BID_T = 'rgba(61,255,122,0.32)', ASK_T = 'rgba(255,69,69,0.32)';
21
- const BID_THEN = 'rgba(61,255,122,0.55)', ASK_THEN = 'rgba(255,69,69,0.55)';
20
+ // the two sides' colours come from the theme's ink (theme.js, 2026-09-16): neon green and red on a
21
+ // dark ground, the theme's own good and bad on a light one, where neon has no contrast
22
22
  const PAD_FULL = { top: 28, right: 62, bottom: 22, left: 56 };
23
23
  // COMPACT, for the kiosk. The full chart spends 62 px on the right purely to label the change
24
24
  // bars' axis and 56 on the left for the cumulative-BTC labels plus a rotated caption -- on a wall
@@ -90,13 +90,13 @@ export function depthSums(ser, pct) {
90
90
  return { bids: ser.bid[ib] ?? null, asks: ser.ask[ia] ?? null, atLeast: Boolean(ser.bidPart?.[ib] || ser.askPart?.[ia]) };
91
91
  }
92
92
 
93
- export function drawDepth(canvas, ser, { zoom = 0.05, hover = null, compact = false } = {}) {
93
+ export function drawDepth(canvas, ser, { zoom = 0.05, hover = null, compact = false, empty = null } = {}) {
94
94
  if (!canvas?.getContext) return null;
95
95
  const PAD = compact ? PAD_COMPACT : PAD_FULL;
96
96
  const { ctx, w, h } = prep(canvas);
97
97
  if (!ser) {
98
- ctx.fillStyle = '#6a7484'; ctx.textAlign = 'center';
99
- ctx.fillText('asking the exchanges for their order books…', w / 2, h / 2);
98
+ ctx.fillStyle = INK.text; ctx.textAlign = 'center';
99
+ ctx.fillText(empty ?? 'asking the exchanges for their order books…', w / 2, h / 2);
100
100
  return null;
101
101
  }
102
102
  const plotW = Math.max(10, w - PAD.left - PAD.right), plotH = Math.max(40, h - PAD.top - PAD.bottom);
@@ -120,13 +120,13 @@ export function drawDepth(canvas, ser, { zoom = 0.05, hover = null, compact = fa
120
120
  for (const v of niceTicks(0, vmax, Math.max(3, Math.floor(plotH / 44)))) {
121
121
  const y = Math.round(Y(v)) + 0.5;
122
122
  if (y < PAD.top) continue;
123
- ctx.strokeStyle = '#1a2029'; line(PAD.left, y, w - PAD.right, y);
124
- ctx.fillStyle = '#7d8898'; ctx.fillText(btc(v), PAD.left - 6, y);
123
+ ctx.strokeStyle = INK.grid; line(PAD.left, y, w - PAD.right, y);
124
+ ctx.fillStyle = INK.axisText; ctx.fillText(btc(v), PAD.left - 6, y);
125
125
  }
126
126
  if (!compact) {
127
127
  ctx.save?.();
128
128
  ctx.translate(12, PAD.top + plotH / 2); ctx.rotate(-Math.PI / 2);
129
- ctx.textAlign = 'center'; ctx.fillStyle = '#56606e'; ctx.fillText('BTC, cumulative', 0, 0);
129
+ ctx.textAlign = 'center'; ctx.fillStyle = INK.textDim; ctx.fillText('BTC, cumulative', 0, 0);
130
130
  ctx.restore?.();
131
131
  }
132
132
  ctx.setTransform(Math.min(globalThis.devicePixelRatio || 1, 2), 0, 0, Math.min(globalThis.devicePixelRatio || 1, 2), 0, 0);
@@ -137,10 +137,10 @@ export function drawDepth(canvas, ser, { zoom = 0.05, hover = null, compact = fa
137
137
  const v = e === 0 ? 0 : Math.sign(e) * 10 ** Math.abs(e);
138
138
  const y = Math.round(YC(v)) + 0.5;
139
139
  if (!compact) {
140
- ctx.fillStyle = '#7d8898';
140
+ ctx.fillStyle = INK.axisText;
141
141
  ctx.fillText(v === 0 ? '0' : `${v > 0 ? '' : '-'}${shortN(Math.abs(v))}`, w - PAD.right + 6, y);
142
142
  }
143
- if (v === 0) { ctx.strokeStyle = '#2a323d'; line(PAD.left, y, w - PAD.right, y); }
143
+ if (v === 0) { ctx.strokeStyle = INK.axis; line(PAD.left, y, w - PAD.right, y); }
144
144
  }
145
145
  }
146
146
  // price axis
@@ -148,10 +148,10 @@ export function drawDepth(canvas, ser, { zoom = 0.05, hover = null, compact = fa
148
148
  for (const p of niceTicks(pmin, pmax, Math.max(3, Math.floor(plotW / 90)))) {
149
149
  const x = Math.round(X(p)) + 0.5;
150
150
  if (x < PAD.left || x > w - PAD.right) continue;
151
- ctx.strokeStyle = '#161b22'; line(x, PAD.top, x, PAD.top + plotH);
152
- ctx.fillStyle = '#7d8898'; ctx.fillText(zoom <= 0.01 ? money(p) : kUsd(p), x, h - PAD.bottom / 2);
151
+ ctx.strokeStyle = INK.grid; line(x, PAD.top, x, PAD.top + plotH);
152
+ ctx.fillStyle = INK.axisText; ctx.fillText(zoom <= 0.01 ? money(p) : kUsd(p), x, h - PAD.bottom / 2);
153
153
  }
154
- ctx.strokeStyle = '#2a323d';
154
+ ctx.strokeStyle = INK.axis;
155
155
  line(PAD.left + 0.5, PAD.top, PAD.left + 0.5, PAD.top + plotH);
156
156
  line(w - PAD.right + 0.5, PAD.top, w - PAD.right + 0.5, PAD.top + plotH);
157
157
  line(PAD.left, PAD.top + plotH + 0.5, w - PAD.right, PAD.top + plotH + 0.5);
@@ -180,15 +180,15 @@ export function drawDepth(canvas, ser, { zoom = 0.05, hover = null, compact = fa
180
180
  ctx.stroke();
181
181
  ctx.setLineDash([]); ctx.lineWidth = 1;
182
182
  };
183
- for (const e of ser.exchanges) { curve(e.bids, BID_T, 1); curve(e.asks, ASK_T, 1); }
184
- if (ser.change) { curve(ser.thenBid, BID_THEN, 1.4, [5, 4]); curve(ser.thenAsk, ASK_THEN, 1.4, [5, 4]); }
183
+ for (const e of ser.exchanges) { curve(e.bids, INK.bidT, 1); curve(e.asks, INK.askT, 1); }
184
+ if (ser.change) { curve(ser.thenBid, INK.bidThen, 1.4, [5, 4]); curve(ser.thenAsk, INK.askThen, 1.4, [5, 4]); }
185
185
  const only = (arr, part, want) => arr.map((v, i) => (v != null && (part[i] === want || (i > 0 && part[i - 1] === want) || (i < arr.length - 1 && part[i + 1] === want)) ? v : null));
186
- curve(only(ser.bid, ser.bidPart, true), 'rgba(61,255,122,0.5)', 2.2, [2, 3]);
187
- curve(only(ser.ask, ser.askPart, true), 'rgba(255,69,69,0.5)', 2.2, [2, 3]);
188
- curve(only(ser.bid, ser.bidPart, false), BID, 2.2);
189
- curve(only(ser.ask, ser.askPart, false), ASK, 2.2);
186
+ curve(only(ser.bid, ser.bidPart, true), INK.bidHalf, 2.2, [2, 3]);
187
+ curve(only(ser.ask, ser.askPart, true), INK.askHalf, 2.2, [2, 3]);
188
+ curve(only(ser.bid, ser.bidPart, false), INK.bid, 2.2);
189
+ curve(only(ser.ask, ser.askPart, false), INK.ask, 2.2);
190
190
  // the mid, and where each shallow book ends
191
- ctx.strokeStyle = 'rgba(220,230,240,0.35)';
191
+ ctx.strokeStyle = INK.dash;
192
192
  const xm = Math.round(X(ser.mid)) + 0.5;
193
193
  line(xm, PAD.top, xm, PAD.top + plotH);
194
194
  ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace';
@@ -197,9 +197,9 @@ export function drawDepth(canvas, ser, { zoom = 0.05, hover = null, compact = fa
197
197
  for (const p of [r.low, r.high]) {
198
198
  if (p == null || p < pmin || p > pmax) continue;
199
199
  const x = Math.round(X(p)) + 0.5;
200
- ctx.strokeStyle = 'rgba(200,210,225,0.5)'; line(x, PAD.top + plotH - 8, x, PAD.top + plotH);
200
+ ctx.strokeStyle = INK.dash; line(x, PAD.top + plotH - 8, x, PAD.top + plotH);
201
201
  if (!compact) {
202
- ctx.fillStyle = '#8994a3'; ctx.textAlign = 'center';
202
+ ctx.fillStyle = INK.label; ctx.textAlign = 'center';
203
203
  ctx.fillText(`${r.name} ends`, x, PAD.top + plotH - 14 - 11 * (lane++ % 3));
204
204
  }
205
205
  }
@@ -207,14 +207,14 @@ export function drawDepth(canvas, ser, { zoom = 0.05, hover = null, compact = fa
207
207
  ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace';
208
208
 
209
209
  // readout: the pointer's price, or the sums near the mid
210
- ctx.textAlign = 'left'; ctx.fillStyle = '#dfe6ee';
210
+ ctx.textAlign = 'left'; ctx.fillStyle = INK.bright;
211
211
  if (hover && hover.x >= PAD.left && hover.x <= w - PAD.right) {
212
212
  const p = pmin + ((hover.x - PAD.left) / plotW) * (pmax - pmin);
213
213
  const i = Math.max(0, Math.min(ser.prices.length - 1, Math.round((p - ser.prices[0]) / ser.step)));
214
214
  const bidSide = ser.prices[i] <= ser.mid;
215
215
  const cum = bidSide ? ser.bid[i] : ser.ask[i];
216
216
  const per = ser.exchanges.map((e) => [e.name, bidSide ? e.bids[i] : e.asks?.[i]]).filter(([, v]) => v != null).map(([nm, v]) => `${nm} ${btc(v)}`).join(' · ');
217
- ctx.strokeStyle = 'rgba(200,215,230,0.5)'; ctx.setLineDash([3, 3]);
217
+ ctx.strokeStyle = INK.dash; ctx.setLineDash([3, 3]);
218
218
  const x = Math.round(X(ser.prices[i])) + 0.5;
219
219
  line(x, PAD.top, x, PAD.top + plotH);
220
220
  ctx.setLineDash([]);
@@ -237,8 +237,12 @@ export function depthBarHtml(ago = D.ago, zoom = D.zoom) {
237
237
  <div class="mkgrp"><span class="mklab">price window</span>${ZOOMS.map(([z, l]) => b('data-zoom', z, l, z === zoom)).join('')}</div>`;
238
238
  }
239
239
 
240
+ // what the empty chart says while the feed is off: the switch, or the server's hard off
241
+ const offText = (d) => (d?.enabled === false ? (d.polling === false ? 'market polling is off — Display settings → Markets & Price → Enable market polling' : 'market data is off on this server') : null);
242
+
240
243
  export function depthNote(d, fmt) {
241
244
  if (!d) return 'asking the exchanges for their order books…';
245
+ if (d.enabled === false) return d.note ?? 'market data is off on this monitor';
242
246
  if (d.warming) return 'reading the order books for the first time…';
243
247
  const agoL = AGOS.find(([s]) => s === d.ago)?.[1] ?? `${d.ago} s`;
244
248
  const parts = [`books read at ${clock(d.at)} UTC, every ${Math.round((d.bookMs ?? DEPTH_MS) / 1000)} s while this tab is open`];
@@ -252,7 +256,7 @@ export function depthNote(d, fmt) {
252
256
  function draw() {
253
257
  const canvas = document.getElementById('mkDepth');
254
258
  if (!canvas) return;
255
- drawDepth(canvas, depthSeries(D.data), { zoom: D.zoom, hover: D.hover });
259
+ drawDepth(canvas, depthSeries(D.data), { zoom: D.zoom, hover: D.hover, empty: offText(D.data) });
256
260
  }
257
261
 
258
262
  /**
@@ -282,7 +286,7 @@ export function renderDepthInto(canvasId, h, { zoom = 0.025, compact = true } =
282
286
  ensureDepth(h);
283
287
  const canvas = document.getElementById(canvasId);
284
288
  if (!canvas) return null;
285
- return drawDepth(canvas, depthSeries(D.data), { zoom, compact });
289
+ return drawDepth(canvas, depthSeries(D.data), { zoom, compact, empty: offText(D.data) });
286
290
  }
287
291
 
288
292
  export function renderDepth(h) {