igneum/site/live.html
igneum-josh 84a6de2608 Home live scene reads the chain, not the window: chain block, shards proven, last lock, live colours, a plain unavailable state
The "LIVE DEVNET" scene on the home page showed blocks 21, proven 0, locked 3 and grey nodes while the devnet stood past
chain block 140,000 with 2,090 shards paid and a checkpoint locking every 30 s. Four causes, all in site/index.html:
- "blocks" was the scene's own spawn counter, never the chain;
- "proven" counted blocks of the 90 s window whose shards were all paid, and proofs land a median 380 s behind the tip
  (median_proof_lag_s), so the window can never hold one;
- "locked" counted locked checkpoint blocks inside the same 90 s window (always about 3);
- the live path skipped the colour state machine, so every real block stayed grey, and a failed first fetch left the
  simulation running under a label that looked live, with no further polls.

Now: /api/live carries state.height (the chain block number, the same field /api/stats reports, one indexed subquery);
the scene's counters read chain block (state.height), shards proven (proving.paid_shards_total, the node's count of paid
shard records, with the 10-minute count and the median lag in the note) and last lock (finality.latest_locked_index);
live blocks take the observer's own words (pending, included, excluded, proven, locked checkpoint, final to its left),
refreshed on every poll, with a legend in those words; the hero tile shows the chain block instead of the node's
held-block count; polling never stops (2 s while fresh, 10 s while off) and the off state is labelled "simulated preview ·
live feed unavailable" or "observer offline, last update N ago" with a note that the counters count simulated blocks.
Also found while verifying: the scene advanced a fixed 16 ms per frame, so it ran at double speed on 120 Hz displays;
it now uses the real frame time, capped at 50 ms.

/live had the same window bug in its "proven 0/16" counter; it now reads the observer's 10-minute truth (chain blocks
with every shard paid, of the chain blocks planned in 10 min) with the lag and the chain total in its title.

Verified against the live API through tools/site-serve.mjs in the built-in browser and headless Chromium:
chain block 140,489, shards proven 2,130, last lock #6,784; the failed-fetch state shown by blocking /api/live.
Screenshots: docs/plans/site-ui-3-shots/item0/ (1440 and 390, live and failed). Checks: identity grep 0 hits,
link check 708 links 0 broken, site/api tests 14 pass, site build clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:14:05 +01:00

670 lines
62 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Igneum live devnet</title>
<meta name="description" content="The Igneum devnet as it runs: blocks, miners, difficulty and peers, read from a node every two seconds.">
<link rel="canonical" href="https://igneum.network/live">
<meta name="theme-color" content="#0C0C0E">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Igneum">
<meta property="og:title" content="Igneum live devnet">
<meta property="og:description" content="The Igneum devnet as it runs: blocks, miners, difficulty and peers, read from a node every two seconds.">
<meta property="og:url" content="https://igneum.network/live">
<meta property="og:image" content="https://igneum.network/og-small.png?v=3">
<meta property="og:image:width" content="256">
<meta property="og:image:height" content="256">
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Igneum live devnet">
<meta name="twitter:description" content="The Igneum devnet as it runs: blocks, miners, difficulty and peers, read from a node every two seconds.">
<meta name="twitter:image" content="https://igneum.network/og-small.png?v=3">
<meta name="twitter:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Crect width='1024' height='1024' fill='%230C0C0E'/%3E%3Cg transform='translate(166.95 166.95) scale(6.901)'%3E%3Cpolygon points='50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34' fill='%23F2541B'/%3E%3Cpolygon points='50,42 59,58 50,82 41,58' fill='%230C0C0E'/%3E%3C/g%3E%3C/svg%3E" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
<link rel="icon" href="/icon-192.png" type="image/png" sizes="192x192">
<link rel="icon" href="/icon-512.png" type="image/png" sizes="512x512">
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="manifest" href="/site.webmanifest">
<!-- head:start -->
<link rel="preload" href="/fonts/unbounded-900.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/plex-sans-400.woff2" as="font" type="font/woff2" crossorigin>
<style>
/* Shared by every page. Source: site/partials/head.html, injected by site/build.mjs between the head markers. Edit the partial, not the page. */
/* Fonts, self-hosted (latin subsets, OFL): Unbounded 500/700/900, IBM Plex Sans 400/500/600, IBM Plex Mono 400/500. Fallbacks carry size and ascent overrides so the swap does not move the layout. */
@font-face{font-family:'Unbounded';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/unbounded-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/unbounded-700.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(/fonts/unbounded-900.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-sans-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-sans-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/plex-sans-600.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-mono-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-mono-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'Unbounded Fallback';src:local('Arial Black'),local('Arial-Black'),local('Impact');size-adjust:114%;ascent-override:87.5%;descent-override:21.5%;line-gap-override:0%}
@font-face{font-family:'Plex Sans Fallback';src:local('Arial'),local('Helvetica Neue'),local('Helvetica');size-adjust:100.5%;ascent-override:102%;descent-override:27.4%;line-gap-override:0%}
@font-face{font-family:'Plex Mono Fallback';src:local('Courier New'),local('Menlo');size-adjust:100%;ascent-override:102.5%;descent-override:27.5%;line-gap-override:0%}
:root{--f-sans:'IBM Plex Sans','Plex Sans Fallback',system-ui,-apple-system,sans-serif;--f-display:'Unbounded','Unbounded Fallback',sans-serif;--f-mono:'IBM Plex Mono','Plex Mono Fallback',ui-monospace,Menlo,monospace;
/* site chrome tokens (nav, footer, skip link). Dark by default; the litepaper sets light values and resets these in dark mode */
--nav-h:68px;--ui-bg:rgba(12,12,14,.84);--ui-menu:#0C0C0E;--ui-ink:#F4F1EC;--ui-ink-2:#C9C7C2;--ui-ash:#9A9A9E;--ui-line:#2A2A30;--ui-line-2:#3A3A42;--ui-accent:#F2541B;--ui-accent-ink:#0C0C0E;--ui-hot:#FFB35C;--ui-hover:#FF6A2B;--ui-tint:rgba(242,84,27,.12)}
html{-webkit-text-size-adjust:100%}
h1,h2,h3{text-wrap:balance}
p,li,dd,figcaption{text-wrap:pretty}
a:focus-visible,button:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--ui-accent);outline-offset:3px;border-radius:6px}
.skip{position:absolute;left:12px;top:-80px;z-index:50;background:var(--ui-accent);color:var(--ui-accent-ink);padding:10px 14px;border-radius:10px;font:600 15px/1.2 var(--f-sans);text-decoration:none}
.skip:focus{top:12px}
/* nav: one bar on every page, the mark in its black square, seven links and the miner button, a menu under 941 px (the eight items need 688 px beside the brand at 15 px, so the bar fits from 941 px up without a wrapped label) */
.nav{position:sticky;top:0;z-index:20;background:var(--ui-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--ui-line);color:var(--ui-ink)}
.nav .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:var(--nav-h)}
.nav a{color:inherit;text-decoration:none}
.brand{display:inline-flex;align-items:center;gap:10px;color:var(--ui-ink);text-decoration:none;flex:0 0 auto}
.brand .mark{display:block;flex:0 0 auto}
.brand .word{font-family:var(--f-display);font-weight:900;font-size:20px;letter-spacing:.06em;line-height:1}
.nav .links{display:flex;align-items:center;gap:clamp(14px,2vw,26px);font-size:15px;font-weight:500}
.nav .links a{color:var(--ui-ink-2);padding:4px 0;border-bottom:2px solid transparent;white-space:nowrap;transition:color .15s ease,border-color .15s ease}
.nav .links a:hover,.nav .brand:hover{color:var(--ui-hot);text-decoration:none}
.nav .brand:hover{color:var(--ui-ink)}
.nav .links a[aria-current="page"]{color:var(--ui-ink);border-bottom-color:var(--ui-accent)}
.nav .links a.cta{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:8px 16px;border-radius:10px;font-weight:600;background:var(--ui-accent);color:var(--ui-accent-ink);border:1px solid var(--ui-accent);transition:background .15s ease,transform .15s ease}
.nav .links a.cta:hover{background:var(--ui-hover);border-color:var(--ui-hover);color:var(--ui-accent-ink);transform:translateY(-1px)}
.burger{display:none;background:none;border:1px solid var(--ui-line-2);color:var(--ui-ink);border-radius:10px;width:44px;height:44px;align-items:center;justify-content:center;cursor:pointer;padding:0;flex:0 0 auto}
.burger .x{display:none}.burger[aria-expanded="true"] .x{display:block}.burger[aria-expanded="true"] .bars{display:none}
@media (max-width:940px){
.nav .links{display:none;position:absolute;left:0;right:0;top:100%;background:var(--ui-menu);border-bottom:1px solid var(--ui-line);box-shadow:0 24px 40px rgba(0,0,0,.35);padding:10px var(--gutter,16px) 18px;flex-direction:column;align-items:stretch;gap:2px}
.nav .links a{padding:13px 10px;border-radius:10px;border-bottom:0;font-size:16px}
.nav .links a[aria-current="page"]{background:var(--ui-tint)}
.nav .links a.cta{margin-top:8px;min-height:48px}
.nav .links.open{display:flex}
.burger{display:inline-flex}
}
/* footer, the same on every page */
.foot{border-top:1px solid var(--ui-line);color:var(--ui-ink);margin-top:var(--sec,64px)}
.foot .wrap{padding-block:48px 32px}
.foot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,200px),1fr));gap:32px}
.foot-brand{display:flex;flex-direction:column;gap:12px;max-width:34ch}
.foot-brand .brand{align-self:flex-start}.foot-brand .word{font-size:16px}
.foot-brand p{color:var(--ui-ash);font-size:14px;margin:0}
.foot-col{display:flex;flex-direction:column;gap:10px;font-size:15px}
.foot-col .eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ui-ash);margin-bottom:4px}
.foot-col a{color:var(--ui-ink-2);text-decoration:none;display:inline-flex;align-items:center;gap:6px;align-self:flex-start}
.foot-col a:hover{color:var(--ui-hot);text-decoration:none}
.foot-base{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px 24px;margin-top:36px;padding-top:20px;border-top:1px solid var(--ui-line);font-size:13px;color:var(--ui-ash)}
.foot-base span{text-wrap:balance}.foot-base .mono{font-family:var(--f-mono)}
.foot-imprint{flex-basis:100%;text-wrap:pretty}.foot-base a{color:inherit;text-decoration:none}.foot-base a:hover{color:var(--ui-hot)}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
</style>
<!-- head:end -->
<style>
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;
/* one system for every page: container, gutter, section rhythm, card and tile boxes, heading scale (same tokens as the homepage) */
--max:1200px;--gutter:clamp(16px,4vw,32px);--sec:clamp(56px,8vw,96px);--head:clamp(40px,6vw,64px);
--card-pad:clamp(18px,3vw,28px);--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:24px;--gap-tile:12px;
--fs-hero:clamp(40px,7vw,76px);--fs-h1:clamp(32px,5.5vw,56px);--fs-h2:clamp(28px,4.2vw,44px);--fs-h3:clamp(18px,2vw,22px);--fs-tile:clamp(20px,2.2vw,26px)}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--f-sans);font-size:17px;line-height:1.55;-webkit-font-smoothing:antialiased;overflow-x:hidden}
a{color:inherit;text-decoration:none}
a:hover{color:var(--molten)}
a:focus-visible,button:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
img,svg,canvas{max-width:100%}
.wrap{max-width:var(--max);margin:0 auto;padding-inline:var(--gutter)}
.mono{font-family:var(--f-mono)}
.eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
h1,h2,h3{font-family:var(--f-display);margin:0;line-height:1.08;text-wrap:balance}
h1{font-weight:900;font-size:var(--fs-h1);letter-spacing:-.01em}
h2{font-weight:700;font-size:var(--fs-h3)}
p{margin:0}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:12px 22px;border-radius:10px;font-weight:600;font-size:16px;border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer}
.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
.btn.primary:hover{background:#FF6A2B;border-color:#FF6A2B;color:var(--ember-ink)}
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0}
/* nav and footer: shared, see site/partials */
/* page */
.head{padding-block:var(--head) var(--gap);display:flex;flex-direction:column;gap:14px}
.head p{color:var(--ink-2);max-width:60ch;font-size:16px}
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile);margin-bottom:var(--gap)}
@media (max-width:700px){.strip{grid-template-columns:repeat(2,minmax(0,1fr))}}
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
.cell .k{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
.cell .v{font-family:var(--f-display);font-weight:700;font-size:var(--fs-tile);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cell .v small{font-family:var(--f-mono);font-weight:500;font-size:13px;color:var(--ash);margin-left:4px;letter-spacing:.04em}
@media (max-width:700px){.cell .v{font-size:19px}}
.cell .v.name{font-family:var(--f-mono);font-size:15px;font-weight:500}
.cell .s{font-family:var(--f-mono);font-size:12px;color:var(--ash);text-wrap:balance}
.cell.status .v{display:flex;align-items:center;gap:10px}
.dot{width:10px;height:10px;border-radius:50%;background:var(--molten);display:inline-block;flex:0 0 10px;animation:pulse 2s ease-in-out infinite}
.off .dot{background:var(--ash);animation:none}
.off .cell.status .v{color:var(--ash)}
.on .cell.status .v{color:var(--molten)}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 8px rgba(255,179,92,0)}}
@media (prefers-reduced-motion:reduce){.dot{animation:none}}
.viz-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px 20px;margin-bottom:12px}
.viz-stats{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:12px;color:var(--ash)}
.viz-stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums}
#dag{display:block;width:100%;height:calc(clamp(240px,38vw,380px) + 18px + 64px);border-radius:12px;background:var(--obsidian);touch-action:pan-y}
@media (max-width:640px){#dag{height:calc(clamp(240px,38vw,380px) + 18px + 50px)}}
.legend{display:flex;flex-direction:column;gap:8px;margin-top:16px;font-size:13px;color:var(--ink-2)}
.legend div{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px}
.legend .lk{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);min-width:64px}
.legend span{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.legend span.txt{white-space:normal;display:inline;color:var(--ash)}
.sw{width:14px;height:14px;border-radius:4px;display:inline-block}
.two{display:grid;gap:var(--gap);grid-template-columns:1fr;margin-top:var(--gap)}
@media (min-width:900px){.two{grid-template-columns:1.1fr .9fr}}
.tbl{overflow-x:auto;margin-top:12px;-webkit-overflow-scrolling:touch}
table{border-collapse:collapse;width:100%;font-size:14px}
th,td{padding:10px 10px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
th{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500}
td.n{font-variant-numeric:tabular-nums;text-align:right}th.n{text-align:right}
tr:last-child td{border-bottom:0}
.id{font-family:var(--f-mono);color:var(--molten)}
.bar{height:6px;border-radius:3px;background:var(--line);overflow:hidden;min-width:60px}
.bar i{display:block;height:100%;background:var(--ember)}
.feed{display:flex;flex-direction:column;margin-top:12px;font-family:var(--f-mono);font-size:13px;color:var(--ink-2)}
.feed div{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line);padding:9px 0;align-items:baseline}
.feed div:last-child{border-bottom:0}
.feed .t{color:var(--ash);flex:0 0 auto;font-size:12px}
.feed .k{color:var(--molten)}
.empty{color:var(--ash);font-size:14px;padding:12px 0}
#spark{display:block;width:100%;height:120px;border-radius:12px;background:var(--obsidian);margin-top:12px}
.note{font-size:13px;color:var(--ash);margin-top:14px}
main{padding-bottom:var(--sec)}
.foot{margin-top:0}
</style>
</head>
<body class="off">
<!-- nav:start -->
<a class="skip" href="#main">Skip to content</a>
<nav class="nav" aria-label="Main">
<div class="wrap">
<a href="/" class="brand" aria-label="Igneum home">
<svg class="mark" viewBox="0 0 1024 1024" width="36" height="36" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg>
<span class="word">IGNEUM</span>
</a>
<button class="burger" id="nav-burger" type="button" aria-expanded="false" aria-controls="nav-links" aria-label="Menu"><svg class="bars" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg><svg class="x" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"></path></svg></button>
<div class="links" id="nav-links">
<a href="/litepaper" data-nav="litepaper">Litepaper</a>
<a href="/live" data-nav="live" aria-current="page">Live devnet</a>
<a href="/bench" data-nav="bench">Engineering log</a>
<a href="/miner" data-nav="miner">Miner</a>
<a href="/wallet" data-nav="wallet">Wallet</a>
<a href="/evidence" data-nav="evidence">Evidence</a>
<a href="https://github.com/igneum-network/spec" rel="noopener">GitHub</a>
<a href="/miner" class="cta">The miner</a>
</div>
</div>
</nav>
<script>
(function(){
// the menu: open on the button, closed by any link, a tap outside, Escape (focus back on the button) or a resize past 940 px
var b=document.getElementById('nav-burger'),l=document.getElementById('nav-links');if(!b||!l)return;
function set(o){l.classList.toggle('open',o);b.setAttribute('aria-expanded',o?'true':'false');}
b.addEventListener('click',function(){set(!l.classList.contains('open'));});
l.addEventListener('click',function(e){if(e.target.closest('a'))set(false);});
document.addEventListener('click',function(e){if(l.classList.contains('open')&&!l.contains(e.target)&&!b.contains(e.target))set(false);});
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&l.classList.contains('open')){set(false);b.focus();}});
var mq=window.matchMedia('(min-width:941px)');if(mq.addEventListener)mq.addEventListener('change',function(){set(false);});
})();
</script>
<!-- nav:end -->
<main id="main" class="wrap">
<div class="head">
<div class="eyebrow">devnet v0</div>
<h1>Live devnet</h1>
<p>A node is read every two seconds. Every block below is real: its parents, its miner, whether it sits on the selected chain, and whether it is a locked checkpoint. A checkpoint locks when the finality rule's quorum signs it. Once the proving layer is activated, every chain block's shards show as they are planned, proven and paid.</p>
<p>Fees: the devnet meters with its prototype table until DAA score 210,000; from there the adopted table applies (300 pgas per transaction, 30,000-pgas shards, floors of 100 gwei per gas and 10,000 gwei per pgas) and the provers' program carries both.</p>
</div>
<div class="strip" aria-live="polite">
<div class="cell status"><div class="k">Status</div><div class="v"><span class="dot"></span><span id="st-status">OFFLINE</span></div><div class="s" id="st-age">no observer update yet</div></div>
<div class="cell"><div class="k">Network</div><div class="v name" id="st-network">igneum-devnet</div><div class="s" id="st-node">node</div></div>
<div class="cell"><div class="k">Blocks / s</div><div class="v" id="st-bps">0.00</div><div class="s">over 60 s</div></div>
<div class="cell"><div class="k">Blue score</div><div class="v" id="st-blue">0</div><div class="s" id="st-count">0 blocks</div></div>
<div class="cell"><div class="k">Difficulty</div><div class="v" id="st-diff">0</div><div class="s">target per block</div></div>
<div class="cell"><div class="k">Hash rate</div><div class="v" id="st-hps">0</div><div class="s">network estimate</div></div>
<div class="cell"><div class="k">Identities</div><div class="v" id="st-miners">0</div><div class="s">vote keys active in 10 min, a card runs several</div></div>
<div class="cell"><div class="k">Peers</div><div class="v" id="st-peers">0</div><div class="s" id="st-mempool">mempool 0</div></div>
</div>
<div class="card" aria-label="Block DAG, finality and proving, live">
<div class="viz-head">
<div class="eyebrow"><span class="dot" style="margin-right:8px"></span>blocks, finality, proving</div>
<div class="viz-stats mono"><span>on screen <b id="c-screen">0</b></span><span>chain <b id="c-chain">0</b></span><span>identities <b id="c-idents">0</b></span><span>last lock <b id="c-locked">none</b></span><span>proven <b id="c-proven">pending</b></span></div>
</div>
<canvas id="dag" aria-hidden="true"></canvas>
<div class="legend">
<div><span class="lk">blocks</span><span><i class="sw" style="background:var(--ember);border:1.5px solid #5CB8FF"></i>chain</span><span><i class="sw" style="background:rgba(92,184,255,.7);border:1.5px solid #5CB8FF"></i>included, paid</span><span><i class="sw" style="border:1.5px solid var(--ash)"></i>pending</span><span><i class="sw" style="border:1.5px solid rgba(154,154,158,.6);background:linear-gradient(45deg,transparent 44%,var(--ash) 44%,var(--ash) 56%,transparent 56%) center/8px 8px no-repeat"></i>excluded</span><span><i class="sw" style="border:2px solid var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)"></i>just arrived</span><span><i class="sw" style="width:22px;height:0;border-top:2px solid var(--ember)"></i>selected chain</span><span class="txt">one lane per miner, colour from its id; hover a block, or tap it to keep its details up</span></div>
<div><span class="lk">finality</span><span><i class="sw" style="width:22px;height:10px;background:rgba(242,84,27,.22);border-radius:2px"></i>final</span><span><i class="sw" style="width:3px;height:12px;background:var(--molten);border-radius:1px"></i>locked checkpoint</span><span><i class="sw" style="width:22px;height:10px;background:rgba(154,154,158,.10);border:1px solid rgba(154,154,158,.3);border-radius:2px"></i>proposed</span><span class="txt">reads "paused" whenever under two thirds of the weight is signing</span></div>
<div><span class="lk">proving</span><span><i class="sw" style="width:12px;height:9px;border:1px solid rgba(154,154,158,.55);border-radius:2px"></i>shard planned</span><span><i class="sw" style="width:12px;height:9px;border:1px solid var(--molten);background:rgba(255,179,92,.25);border-radius:2px"></i>proving</span><span><i class="sw" style="width:12px;height:9px;background:#3FD39A;border-radius:2px"></i>verified, prover colour</span><span><i class="sw" style="width:12px;height:9px;background:#3FD39A;border-radius:2px;position:relative"><i style="position:absolute;left:3px;top:1px;width:3px;height:5px;border:solid #0C0C0E;border-width:0 1.5px 1.5px 0;transform:rotate(45deg)"></i></i>paid</span><span><i class="sw" style="width:0;height:12px;border-left:1px dashed var(--molten)"></i>where proofs land behind the tip</span><span class="txt">hover a cell, or tap it to keep its details up</span></div>
</div>
</div>
<div class="two">
<div class="card">
<div class="viz-head"><h2>Miners</h2><div class="eyebrow">last 10 min</div></div>
<div class="tbl">
<table>
<thead><tr><th>Miner</th><th class="n">Blocks</th><th>Share</th><th>Last seen</th><th>Engine</th></tr></thead>
<tbody id="miners"><tr><td colspan="5" class="empty">No blocks in the last 10 minutes.</td></tr></tbody>
</table>
</div>
<p class="note">The short id is the first 8 hex characters of the vote key hash in each header. The engine is what the miner writes into the coinbase; the devnet miner writes nothing yet.</p>
</div>
<div class="card">
<div class="viz-head"><h2>Events</h2><div class="eyebrow">newest first</div></div>
<div class="feed" id="events"><div class="empty">No events yet.</div></div>
</div>
</div>
<div class="card" style="margin-top:var(--gap)">
<div class="viz-head"><h2>Blocks per minute</h2><div class="eyebrow" id="spark-range">last hour</div></div>
<canvas id="spark" aria-hidden="true"></canvas>
<p class="note" id="spark-note">Counted by the observer as blocks arrive.</p>
</div>
</main>
<!-- footer:start -->
<footer class="foot">
<div class="wrap">
<div class="foot-grid">
<div class="foot-brand">
<a href="/" class="brand" aria-label="Igneum home"><svg class="mark" viewBox="0 0 1024 1024" width="30" height="30" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg><span class="word">IGNEUM</span></a>
<p>Mined by GPUs. Proven by fire.</p>
</div>
<nav class="foot-col" aria-label="Read">
<div class="eyebrow">Read</div>
<a href="/litepaper">Litepaper</a>
<a href="/litepaper#limits">What Igneum does not claim</a>
<a href="/litepaper#randomx">Igneum vs RandomX</a>
<a href="/litepaper#shoulders">Built on the shoulders</a>
<a href="/bench">Engineering log</a>
<a href="/evidence">Evidence</a>
<a href="/ledger">Ledger: every criticism</a>
</nav>
<nav class="foot-col" aria-label="Run">
<div class="eyebrow">Run</div>
<a href="/miner">The miner</a>
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
<a href="/faucet">Testnet faucet</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>
<a href="/live">Live devnet</a>
<a href="/explorer">Explorer</a>
<a href="/#journey">Journey</a>
<a href="/wallet">Add Igneum to MetaMask</a>
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
<a href="/miner#get">Miner downloads: public testnet</a>
<a href="mailto:hello@igneum.network">Report a flaw: hello@igneum.network</a>
<a href="https://github.com/igneum-network/spec/issues" rel="noopener">Or open an issue on the spec</a>
</nav>
</div>
<div class="foot-base">
<span class="foot-imprint">Igneum Labs LTD · Unit IH-00-01-01-OF-01, Level 01, Innovation One, Dubai International Financial Centre · <a href="mailto:hello@igneum.network">hello@igneum.network</a></span>
<span>© 2026 Igneum. Nothing on this page is an offer to sell anything.</span>
<span class="mono">igneum.network</span>
</div>
</div>
</footer>
<!-- footer:end -->
<script>
(function(){
var reduce=window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// the DAG canvas is redrawn every frame, so its backing store is capped at 1.5x: text stays crisp on a phone and the fill
// cost on a 2x laptop display drops by 44%
var dpr=Math.min(1.5,window.devicePixelRatio||1);
// formatting
function $(id){return document.getElementById(id);}
function compact(n){if(n===null||n===undefined||isNaN(n))return '0';var a=Math.abs(n);if(a>=1e12)return (n/1e12).toFixed(2)+'T';if(a>=1e9)return (n/1e9).toFixed(2)+'G';if(a>=1e6)return (n/1e6).toFixed(2)+'M';if(a>=1e3)return (n/1e3).toFixed(1)+'k';return String(Math.round(n));}
function hashrate(n){if(n===null||n===undefined)return 'n/a';var u=['H/s','kH/s','MH/s','GH/s','TH/s'],i=0;while(n>=1000&&i<u.length-1){n/=1000;i++;}return (i?n.toFixed(1):Math.round(n))+' '+u[i];}
function rel(ms){var s=Math.max(0,Math.round(ms/1000));if(s<60)return s+' s ago';var m=Math.floor(s/60);if(m<60)return m+' min ago';var h=Math.floor(m/60);if(h<24)return h+' h '+(m%60)+' min ago';return Math.floor(h/24)+' d ago';}
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
// ---------- three strips on one time axis ----------
// One canvas, one requestAnimationFrame loop, newest at the right. BLOCKS (lanes: one per miner in the visible window,
// most blocks on top, the least active share an "others" lane; labels in a left gutter; edges under blocks), then a
// FINALITY bar, then a PROVING strip with one cell per shard of every chain block. All three share px-per-second.
var c=$('dag'),x=c.getContext('2d'),W=0,H=0,BH=0,S=22,G=78,narrow=false,level=0,maxLanes=8,FIN_H=18,PROV_H=64;
var blocks=[],byHash={},miners={},lanes=[],laneN=1,othersN=0,fin=null,prov=null,idents=0;
var LV=[{S:22,s:14,pps:40,npps:14},{S:16,s:12,pps:26,npps:10},{S:12,s:10,pps:18,npps:8}];
var pxPerSec=40,targetPx=40,bps=0,live=false,serverNow=0,serverAt=0,frozenAt=null,note='';
// ?window=N (30 to 300 s): a fixed long view for diagnosis, the API returns the same span; the default stays the adaptive scale
var fixedWindow=Math.max(0,Math.min(300,Number(new URLSearchParams(location.search).get('window'))||0));if(fixedWindow&&fixedWindow<30)fixedWindow=30;
var running=false,needSize=true,inView=true,ptrX=-1,ptrY=-1,laneLast=[],laneRun=[],lagNote='';
// pin: a tapped or clicked block (i = -1) or shard cell (i = shard index). It stays until a tap or click lands elsewhere,
// Escape, or the block leaves the strip (then pinFade holds it for a 200 ms fade). Hover never touches it.
var pin=null,pinFade=null,PIN_FADE=200,closeRect=null,panelRect=null,down=null,TAP_SLOP=10;
var TOP=20,BOT=12;
// 12 hues that sit beside ember on obsidian; the index comes from the short id, so a miner (or a prover) keeps its colour across reloads
var PAL=['#5CB8FF','#3FD39A','#F5C542','#C49BFF','#FF7AA8','#8FD15F','#FFB08A','#6F8CFF','#D9E25A','#FF8FD6','#4FD8D8','#E0A96D'];
function colour(id){var v=0;for(var i=0;i<id.length;i++)v=(v*33+id.charCodeAt(i))>>>0;return PAL[v%PAL.length];}
function jitter(h){var v=0;for(var i=0;i<8;i++)v=(v*31+h.charCodeAt(i))%1000;return v/1000-0.5;}
function vnow(){if(frozenAt!==null)return frozenAt;return serverNow+(performance.now()-serverAt);}
function laneY(l){var u=BH-TOP-BOT,p=u/Math.max(1,laneN);return TOP+(l+0.5)*p;}
function size(){
needSize=false;W=c.clientWidth;H=c.clientHeight;if(!W||!H)return;
c.width=Math.round(W*dpr);c.height=Math.round(H*dpr);x.setTransform(dpr,0,0,dpr,0,0);
narrow=W<560;G=narrow?36:80;PROV_H=narrow?50:64;BH=H-FIN_H-PROV_H;applyLevel();
for(var id in miners)miners[id].y=laneY(miners[id].lane); // a resize is a layout change: lanes snap, nothing eases from a stale position
}
function applyLevel(){
var L=LV[level];S=narrow?L.s:L.S;targetPx=narrow?L.npps:L.pps;
if(fixedWindow){targetPx=(W-G)/fixedWindow;S=fixedWindow>120?(narrow?8:10):(narrow?L.s:LV[1].S);}
maxLanes=Math.max(2,Math.min(14,Math.floor((BH-TOP-BOT)/(S*1.7))));
layoutLanes();
}
function pickLevel(){
// projected blocks on screen at the base scale drives the step, with hysteresis so it never flickers
var proj=bps*(W-G)/(narrow?LV[0].npps:LV[0].pps),up=[48,110],down=[36,85],next=level;
if(level<2&&proj>up[level])next=level+1;else if(level>0&&proj<down[level-1])next=level-1;
if(fixedWindow)next=fixedWindow>120?2:1;
if(next!==level){level=next;applyLevel();}
}
function layoutLanes(){
// rank miners by blocks in the window; one lane each, the overflow shares the last lane with a count
var ids=Object.keys(miners);
ids.sort(function(a,b){return miners[b].n-miners[a].n||(a<b?-1:1);});
laneN=Math.max(1,Math.min(maxLanes,ids.length));
othersN=ids.length>laneN?ids.length-(laneN-1):0;
lanes=[];
ids.forEach(function(id,i){var m=miners[id],l=othersN&&i>=laneN-1?laneN-1:i;m.lane=l;if(m.y===undefined&&BH>0)m.y=laneY(l);if(!othersN||l<laneN-1)lanes[l]=m;});
while(laneLast.length<laneN){laneLast.push(0);laneRun.push(0);}
}
function ingest(d){
if(needSize)size(); // the first poll can land before the first frame; the counts below need the canvas width
var now=performance.now();serverNow=new Date(d.now).getTime();serverAt=now;
fin=d.finality||null;prov=d.proving||null;idents=d.state.miners_10m||0;
d.blocks.forEach(function(b){var e=byHash[b.hash];
if(e){e.chain=b.chain;e.locked=!!b.locked;e.final=!!b.final;e.color=b.color||'pending';e.shards=b.shards||[];e.proven=!!b.proven;return;}
e={hash:b.hash,ts:b.ts,blue_score:b.blue_score,daa:b.daa,parents:b.parents||[],chain:b.chain,color:b.color||'pending',miner:b.miner||'?',born:now,j:jitter(b.hash),locked:!!b.locked,final:!!b.final,shards:b.shards||[],proven:!!b.proven,px:0,py:0,on:false};
byHash[b.hash]=e;blocks.push(e);});
// keep what the target scale will show (the scale eases toward it), plus 20 s so a scale change never drops a block early
var cut=vnow()-((W-G)/Math.max(2,Math.min(pxPerSec,targetPx)))*1000-20000;
blocks=blocks.filter(function(b){var keep=b.ts>cut;if(!keep)delete byHash[b.hash];return keep;});
blocks.sort(function(a,b){return a.ts-b.ts;});
for(var id in miners)miners[id].n=0;
blocks.forEach(function(b){var m=miners[b.miner];if(!m){m=miners[b.miner]={id:b.miner,n:0,col:colour(b.miner),tag:b.miner.slice(0,3),lane:0};}m.n++;});
for(id in miners)if(!miners[id].n)delete miners[id];
bps=d.state.blocks_per_second_60s||0;
pickLevel();layoutLanes();
var vis=vnow()-((W-G)/Math.max(2,targetPx))*1000,on=0,chain=0,planned=0,proven=0;
blocks.forEach(function(b){if(b.ts>vis){on++;if(b.chain){chain++;if(b.shards.length){planned++;if(b.proven)proven++;}}}});
$('c-screen').textContent=on;$('c-chain').textContent=chain;$('c-idents').textContent=idents;
var f=fin;$('c-locked').textContent=f&&f.latest_locked_index?('#'+f.latest_locked_index+(f.checkpoints&&f.checkpoints.length?', '+lockAge(f):'')):(f&&f.supported?'none yet':'n/a');
// 6 Oct 2026: proofs land a median of minutes behind the tip, so the on-screen window (proven+'/'+planned) read 0 for ever; the
// counter now reads the observer's 10-minute truth: chain blocks with every shard paid, of the chain blocks planned in 10 min
var pe=$('c-proven');if(prov&&prov.supported&&prov.active){pe.textContent=(prov.blocks_fully_proven_10m||0)+'/'+(prov.blocks_10m||0)+' in 10 min';pe.title=(prov.shards_paid_10m||0)+' shards paid in the last 10 minutes'+(prov.median_proof_lag_s?', landing a median '+Math.round(prov.median_proof_lag_s)+' s behind the tip':'')+'; '+(prov.paid_shards_total!==null&&prov.paid_shards_total!==undefined?Number(prov.paid_shards_total).toLocaleString('en-GB')+' paid on the chain so far':'');}
else{pe.textContent=prov&&prov.supported?'not active':'no proving layer';pe.title='';}
if(!running&&!document.hidden)kick();
}
function lockAge(f){var cps=f.checkpoints||[],lk=null;for(var i=cps.length-1;i>=0;i--)if(cps[i].state==='locked'){lk=cps[i];break;}
if(!lk||!lk.locked_at)return '';return rel(vnow()-new Date(lk.locked_at).getTime());}
function rrect(px,py,w,h,r){x.beginPath();x.moveTo(px+r,py);x.lineTo(px+w-r,py);x.quadraticCurveTo(px+w,py,px+w,py+r);x.lineTo(px+w,py+h-r);x.quadraticCurveTo(px+w,py+h,px+w-r,py+h);x.lineTo(px+r,py+h);x.quadraticCurveTo(px,py+h,px,py+h-r);x.lineTo(px,py+r);x.quadraticCurveTo(px,py,px+r,py);x.closePath();}
// a cubic with horizontal tangents; the control offset is clamped so a link across many lanes reads as a gentle S, never a loop
function link(a,b){var d=Math.max(S*0.6,Math.min(48,Math.abs(b.px-a.px)*0.5));x.moveTo(a.px,a.py);x.bezierCurveTo(a.px+d,a.py,b.px-d,b.py,b.px,b.py);}
// shard cells: a column under each chain block, shard 0 at the top; sizes from the zoom level, never wider than the block
function cellGeom(n){var avail=PROV_H-12,gap=2,ch=Math.min(narrow?7:9,Math.floor((avail-(n-1)*gap)/Math.max(1,n))),cw=Math.max(3,Math.min(narrow?10:12,Math.round(S*0.7),Math.round(pxPerSec*0.85)));return {ch:ch,cw:cw,gap:gap,top:BH+FIN_H+6+Math.max(0,Math.floor((avail-(n*ch+(n-1)*gap))/2))};}
function hitAt(qx,qy,touch){
// returns {b, i}: a block (i = -1) in the blocks strip, or a shard cell (i = shard index) in the proving strip.
// A finger is wider than a pointer: a touch gets a larger radius, the nearest target still wins; hover is unchanged
if(qx<0)return null;
if(qy<BH){var best=null,bd=S*(touch?1.3:0.9);
for(var i=0;i<blocks.length;i++){var b=blocks[i];if(!b.on)continue;var dx=Math.abs(b.px-qx),dy=Math.abs(b.py-qy);if(dx<bd&&dy<bd&&dx+dy<bd*1.5){best=b;bd=Math.max(dx,dy);}}
return best?{b:best,i:-1}:null;}
if(qy>=BH+FIN_H&&provOn()){var bb=null,bdx=1e9;
for(i=0;i<blocks.length;i++){b=blocks[i];if(!b.on||!b.chain||!b.shards.length)continue;var ddx=Math.abs(b.px-qx);if(ddx<bdx){bdx=ddx;bb=b;}}
if(!bb)return null;var g=cellGeom(bb.shards.length),ns=bb.shards.length;if(bdx>Math.max(g.cw,S*0.5)*(touch?1.8:1))return null;
var row=Math.floor((qy-g.top)/(g.ch+g.gap));
if(touch&&(row<0||row>=ns)&&qy>=g.top-8&&qy<=g.top+ns*(g.ch+g.gap)+8)row=Math.max(0,Math.min(ns-1,row));
if(row<0||row>=ns)return null;return {b:bb,i:row};}
return null;
}
function hover(){return ptrX<0?null:hitAt(ptrX,ptrY,false);}
// the pinned target while its block is still drawn; null once it has scrolled out of the strip
function pinned(){if(!pin)return null;var p=byHash[pin.h];return p&&p.on?{b:p,i:pin.i}:null;}
function same(a,b){return !!(a&&b&&a.b===b.b&&a.i===b.i);}
function inRect(r,qx,qy){return !!(r&&qx>=r[0]&&qx<r[0]+r[2]&&qy>=r[1]&&qy<r[1]+r[3]);}
function setPin(t){pin=t?{h:t.b.hash,i:t.i,b:t.b}:null;pinFade=null;if(reduce)kick();}
function clearPin(){if(!pin&&!pinFade)return;pin=null;pinFade=null;if(reduce)kick();}
// a tap or click at (qx, qy): the close control clears, the panel itself is inert, a block or cell pins (a second tap on the
// pinned one keeps it), anything else on the canvas clears
function tap(qx,qy,touch){
if(inRect(closeRect,qx,qy)){clearPin();return;}
if(inRect(panelRect,qx,qy))return;
var t=hitAt(qx,qy,touch);
if(t)setPin(t);else clearPin();
}
// the three panel lines for a target, its anchor y for the floating panel, and its border colour
function lines(t){var b=t.b,tcol=miners[b.miner]?miners[b.miner].col:'#3A3A42',l1,l2,l3,ay;
if(t.i<0){l1=b.hash.slice(0,8)+' '+(b.locked?'locked':b.chain?'chain':b.color==='blue'?'included, paid':b.color==='red'?'excluded':'pending')+(b.chain&&b.shards.length?(b.proven?', proven':', '+b.shards.length+(b.shards.length===1?' shard':' shards')):'');l2='blue '+compact(b.blue_score)+' daa '+compact(b.daa);l3=b.parents.length+(b.parents.length===1?' parent':' parents')+' '+b.miner;ay=b.py;}
else{var sd=b.shards[t.i]||{};l1=b.hash.slice(0,8)+' shard '+(t.i+1)+' of '+b.shards.length+' '+(sd.state||'gone');l2='prover '+(sd.prover||'none yet')+' lag '+(sd.lag!==null&&sd.lag!==undefined?sd.lag+' s':'n/a');l3=(sd.payout!==null&&sd.payout!==undefined?sd.payout+' IGN':'unpaid')+(sd.pgas?' '+compact(sd.pgas)+' pgas':'');ay=BH+FIN_H+PROV_H/2;if(sd.prover)tcol=colour(sd.prover);}
return [l1,l2,l3,ay,tcol];}
function provOn(){return !!(prov&&prov.supported&&prov.active);}
function draw(now){
if(needSize)size();if(!W||!H)return;
pxPerSec+=(targetPx-pxPerSec)*0.06;
var t=vnow(),xr=W-S,i,b,m,n=blocks.length,id;
for(id in miners){m=miners[id];var ty=laneY(m.lane);if(m.y===undefined)m.y=ty;m.y+=(ty-m.y)*0.12;if(Math.abs(ty-m.y)<0.05)m.y=ty;}
for(i=0;i<laneN;i++){laneLast[i]=-1e9;laneRun[i]=0;}
// positions: time on x, lane on y; same-lane neighbours closer than a block are nudged up and down in a fixed sequence
for(i=0;i<n;i++){b=blocks[i];m=miners[b.miner];if(!m)continue;var l=m.lane;b.px=Math.min(xr,xr-(t-b.ts)*pxPerSec/1000);
if(b.px-laneLast[l]<S*1.25)laneRun[l]++;else laneRun[l]=0;laneLast[l]=b.px;
var k=laneRun[l]%3,dy=k===0?0:(k===1?1:-1)*S*0.62;b.py=m.y+dy;b.on=b.px>G-S;}
x.clearRect(0,0,W,H);
// the newest locked checkpoint on screen: its x bounds the final region of the finality bar
var lk=null,fx=-1;
for(i=0;i<n;i++){b=blocks[i];if(b.locked&&(!lk||b.ts>lk.ts))lk=b;if(b.final&&b.px>fx)fx=b.px;}
if(lk)fx=lk.px;
// lane rails
x.strokeStyle='rgba(42,42,48,0.75)';x.lineWidth=1;x.beginPath();for(i=0;i<laneN;i++){var y=Math.round(laneY(i))+0.5;x.moveTo(G,y);x.lineTo(W,y);}x.stroke();
x.save();x.beginPath();x.rect(G,0,W-G,BH);x.clip();
// side links: each side block to its first parent on screen, faint and short
x.strokeStyle='rgba(154,154,158,0.3)';x.lineWidth=1;x.beginPath();
for(i=0;i<n;i++){b=blocks[i];if(b.chain||!b.on)continue;var q=byHash[b.parents[0]];if(q&&q.px>G-S*2)link(q,b);}
x.stroke();
// the selected chain as one path through the chain blocks
x.strokeStyle=level?'rgba(242,84,27,0.6)':'rgba(242,84,27,0.85)';x.lineWidth=narrow||level?1.5:2;x.beginPath();var prev=null;
for(i=0;i<n;i++){b=blocks[i];if(!b.chain)continue;if(prev&&b.px>G-S*2)link(prev,b);prev=b;}
x.stroke();
// the pinned block scrolled out: let it go with a short fade instead of a snap (reduced motion: at once)
var pv=pinned();
if(pin&&!pv){pinFade=reduce?null:{b:pin.b,i:pin.i,at:now};pin=null;}
if(pinFade&&now-pinFade.at>=PIN_FADE)pinFade=null;
// hovered block (pointer devices only) and pinned block: all of their parent edges, bright
var hv=hover(),hb=hv?hv.b:null,pb=pv?pv.b:null;
if(pb&&pv.i<0){x.strokeStyle='rgba(255,179,92,0.7)';x.lineWidth=1.5;x.beginPath();for(i=0;i<pb.parents.length;i++){var pq0=byHash[pb.parents[i]];if(pq0)link(pq0,pb);}x.stroke();}
if(hb&&hv.i<0&&hb!==pb){x.strokeStyle='rgba(255,179,92,0.9)';x.lineWidth=1.5;x.beginPath();for(i=0;i<hb.parents.length;i++){var pq=byHash[hb.parents[i]];if(pq)link(pq,hb);}x.stroke();}
// blocks
var h=S/2,r=S*0.24,tip=null;
for(i=0;i<n;i++){b=blocks[i];if(!b.on)continue;m=miners[b.miner];var age=now-b.born,fl=age<900?1-age/900:0;
if(fl>0){var g=x.createRadialGradient(b.px,b.py,0,b.px,b.py,S*1.6);g.addColorStop(0,'rgba(255,179,92,'+(0.5*fl)+')');g.addColorStop(1,'rgba(255,179,92,0)');x.fillStyle=g;x.beginPath();x.arc(b.px,b.py,S*1.6,0,Math.PI*2);x.fill();}
if(b.chain){x.fillStyle='#F2541B';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=m?m.col:'#9A9A9E';x.lineWidth=1.5;rrect(b.px-h-1,b.py-h-1,S+2,S+2,r+1);x.stroke();tip=b;}
else if(b.color==='blue'){var mc=m?m.col:'#9A9A9E';x.fillStyle=mc+'B3';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=fl>0?'rgba(255,179,92,0.95)':mc;x.lineWidth=1.5;rrect(b.px-h,b.py-h,S,S,r);x.stroke();}
else{x.fillStyle='#0C0C0E';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=fl>0?'rgba(255,179,92,0.95)':(b.color==='red'?'rgba(154,154,158,0.6)':'rgba(154,154,158,0.85)');x.lineWidth=1.5;rrect(b.px-h,b.py-h,S,S,r);x.stroke();
if(b.color==='red'){x.strokeStyle='rgba(154,154,158,0.9)';x.lineWidth=1.5;x.beginPath();x.moveTo(b.px-h*0.5,b.py+h*0.5);x.lineTo(b.px+h*0.5,b.py-h*0.5);x.stroke();}}
if(fl>0&&b.chain){x.strokeStyle='rgba(255,179,92,'+fl+')';x.lineWidth=2;rrect(b.px-h-3,b.py-h-3,S+6,S+6,r+2);x.stroke();}
if(hb===b&&b!==pb){x.strokeStyle='#F4F1EC';x.lineWidth=1.5;rrect(b.px-h-3,b.py-h-3,S+6,S+6,r+2);x.stroke();}
// the pinned block: a molten ring with a glow, so the reader can find it as the strip moves
if(pb===b){x.save();x.shadowColor='rgba(255,179,92,0.8)';x.shadowBlur=8;x.strokeStyle='#FFB35C';x.lineWidth=2;rrect(b.px-h-5,b.py-h-5,S+10,S+10,r+3);x.stroke();x.restore();}}
x.font='500 10px IBM Plex Mono, monospace';
// the chain tip carries its short id; nothing else is labelled unless hovered or pinned
if(tip){var lt=tip.hash.slice(0,8),lw=x.measureText(lt).width,lh=11,pad=3;
// candidate label boxes around the tip: above-right, above-left, below-right, below-left, left, right; the first that
// stays inside the strip and overlaps no other on-screen block wins, so the id is never drawn over a neighbour
var cands=[[tip.px+h+2-lw,tip.py-h-6-lh],[tip.px-h-2-lw,tip.py-h-6-lh],[tip.px+h+2-lw,tip.py+h+4],[tip.px-h-2-lw,tip.py+h+4],[tip.px-h-6-lw,tip.py-lh/2],[tip.px+h+6,tip.py-lh/2]];
var pick=null,ci;for(ci=0;ci<cands.length&&!pick;ci++){var cx=cands[ci][0],cy=cands[ci][1];if(cx<G+2||cx+lw>W-2||cy<2||cy+lh>BH-2)continue;var clash=false;
for(var bi=0;bi<n&&!clash;bi++){var ob=blocks[bi];if(ob===tip||!ob.on)continue;if(cx-pad<ob.px+h&&cx+lw+pad>ob.px-h&&cy-pad<ob.py+h&&cy+lh+pad>ob.py-h)clash=true;}
if(!clash)pick=cands[ci];}
if(!pick)pick=cands[0];x.fillStyle='rgba(244,241,236,0.92)';x.textAlign='left';x.textBaseline='top';x.fillText(lt,Math.max(G+2,Math.min(W-2-lw,pick[0])),Math.max(2,pick[1]));x.textBaseline='alphabetic';}
x.restore();
// ---- FINALITY bar: regions along the same axis. Paused finality shows only the pause (R4.6.3: never 'final' while paused)
var y0=BH,yb=y0+3,hb2=FIN_H-6;
x.strokeStyle='rgba(42,42,48,0.9)';x.lineWidth=1;x.beginPath();x.moveTo(G,y0+0.5);x.lineTo(W,y0+0.5);x.moveTo(G,y0+FIN_H+0.5);x.lineTo(W,y0+FIN_H+0.5);x.stroke();
x.save();x.beginPath();x.rect(G,y0+1,W-G,FIN_H-1);x.clip();
x.font='500 10px IBM Plex Mono, monospace';x.textAlign='left';x.textBaseline='middle';
var ftxt=null,fcol='rgba(154,154,158,0.9)';
if(!fin||!fin.supported){ftxt='no finality data';}
else if(!fin.active){var wts=fin.weights,silent=wts&&wts.total_weight?Math.max(0,Math.round(100*(1-(wts.active_weight||0)/wts.total_weight))):null;
x.fillStyle='rgba(154,154,158,0.10)';x.fillRect(G,yb,W-G,hb2);ftxt='finality paused: '+(silent?silent+'% of weight silent':'not enough weight signing yet');fcol='#FFB35C';}
else{
// final: everything up to the newest locked checkpoint on screen
if(fx>G){x.fillStyle='rgba(242,84,27,0.22)';x.fillRect(G,yb,fx-G,hb2);}
// proposed: from the newest lock to the newest proposed checkpoint on screen, faint; every checkpoint a tick
var cps=fin.checkpoints||[],px2=-1,ci2,cb;
for(ci2=0;ci2<cps.length;ci2++){cb=byHash[cps[ci2].hash];if(!cb||!cb.on)continue;if(cps[ci2].state!=='locked'&&cb.px>px2)px2=cb.px;}
if(px2>Math.max(G,fx)){x.fillStyle='rgba(154,154,158,0.10)';x.fillRect(Math.max(G,fx),yb,px2-Math.max(G,fx),hb2);}
for(ci2=0;ci2<cps.length;ci2++){cb=byHash[cps[ci2].hash];if(!cb||!cb.on)continue;var lkd=cps[ci2].state==='locked';
x.fillStyle=lkd?'#FFB35C':'rgba(154,154,158,0.55)';x.fillRect(Math.round(cb.px)-(lkd?1:0.5),yb,lkd?2:1,hb2);}
if(fin.latest_locked_index){ftxt='locked #'+fin.latest_locked_index+(lockAge(fin)?', '+lockAge(fin):'');fcol='#FFB35C';}
else ftxt='no lock yet';
// the label sits beside the newest lock while that block is on screen, else at the left edge of the bar
if(ftxt&&lk&&fx>G){var tw0=x.measureText(ftxt).width,lx0=fx+8+tw0<W-4?fx+8:fx-8-tw0;x.fillStyle='rgba(12,12,14,0.82)';x.fillRect(lx0-3,yb,tw0+6,hb2);x.fillStyle=fcol;x.fillText(ftxt,lx0,y0+FIN_H/2);ftxt=null;}
}
if(ftxt){x.fillStyle=fcol;x.fillText(ftxt,G+8,y0+FIN_H/2);}
x.textBaseline='alphabetic';x.restore();
// ---- PROVING strip: one cell per shard under its chain block, and where proofs land behind the tip
var y1=BH+FIN_H;
x.save();x.beginPath();x.rect(G,y1+1,W-G,H-y1-1);x.clip();
x.font='500 10px IBM Plex Mono, monospace';x.textAlign='left';x.textBaseline='middle';
if(!provOn()){var ptxt=!prov||!prov.supported?'Proving layer: '+(prov&&prov.reason?prov.reason:'node without proving'):'Proving layer: not yet activated on this devnet; activation '+(prov.activation_daa!==null&&prov.activation_daa!==undefined?'at DAA '+prov.activation_daa.toLocaleString('en-GB')+(prov.tip_daa?' (tip '+prov.tip_daa.toLocaleString('en-GB')+')':''):'not set');
if(narrow)ptxt=ptxt.replace('Proving layer: ','').replace(' on this devnet','').replace(/ \(tip [^)]*\)/,'');
x.fillStyle='rgba(154,154,158,0.9)';
// a line wider than the strip breaks at its semicolon into two lines, so the phone never clips the activation height
if(x.measureText(ptxt).width>W-G-16&&ptxt.indexOf('; ')>0){var parts=ptxt.split('; ');x.fillText(parts[0]+';',G+8,y1+PROV_H/2-7);x.fillText(parts.slice(1).join('; '),G+8,y1+PROV_H/2+7);}
else x.fillText(ptxt,G+8,y1+PROV_H/2);}
else{
// the lag line: proofs land about median_proof_lag_s behind the tip
var lag=prov.median_proof_lag_s;
if(lag!==null&&lag!==undefined){var lx=Math.round(xr-lag*pxPerSec)+0.5,lagTxt=lag<1?'under 1 s':lag<120?Math.round(lag)+' s':Math.floor(lag/60)+' min '+Math.round(lag%60)+' s';
if(lx>G){x.strokeStyle='rgba(255,179,92,0.7)';x.lineWidth=1;x.setLineDash([3,4]);x.beginPath();x.moveTo(lx,y1+3);x.lineTo(lx,H-3);x.stroke();x.setLineDash([]);
var ltx=narrow?'lag '+lagTxt:'proofs land '+lagTxt+' behind the tip',ltw=x.measureText(ltx).width;x.fillStyle='#FFB35C';x.textAlign=lx+6+ltw<W-4?'left':'right';x.fillText(ltx,lx+(x.textAlign==='left'?6:-6),y1+9);x.textAlign='left';}
else{x.fillStyle='#FFB35C';x.fillText(narrow?'lag '+lagTxt+', off screen':'proofs land '+lagTxt+' behind the tip, left of this window',G+8,y1+9);}}
for(i=0;i<n;i++){b=blocks[i];if(!b.on||!b.chain||!b.shards.length)continue;var sh=b.shards,ng=cellGeom(sh.length);
for(var si=0;si<sh.length;si++){var sc=sh[si],cx0=Math.round(b.px-ng.cw/2),cy0=ng.top+si*(ng.ch+ng.gap),st=sc.state,pc=sc.prover?colour(sc.prover):'#9A9A9E';
if(st==='verified'||st==='paid'){x.fillStyle=pc;rrect(cx0,cy0,ng.cw,ng.ch,2);x.fill();
if(st==='paid'){x.strokeStyle='#0C0C0E';x.lineWidth=1.5;x.beginPath();x.moveTo(cx0+ng.cw*0.25,cy0+ng.ch*0.55);x.lineTo(cx0+ng.cw*0.45,cy0+ng.ch*0.8);x.lineTo(cx0+ng.cw*0.78,cy0+ng.ch*0.25);x.stroke();}}
else if(st==='proving'){x.fillStyle='rgba(255,179,92,0.25)';rrect(cx0,cy0,ng.cw,ng.ch,2);x.fill();x.strokeStyle='rgba(255,179,92,0.9)';x.lineWidth=1;rrect(cx0+0.5,cy0+0.5,ng.cw-1,ng.ch-1,2);x.stroke();}
else{x.strokeStyle='rgba(154,154,158,0.55)';x.lineWidth=1;rrect(cx0+0.5,cy0+0.5,ng.cw-1,ng.ch-1,2);x.stroke();}
if(pb===b&&pv.i===si){x.save();x.shadowColor='rgba(255,179,92,0.8)';x.shadowBlur=6;x.strokeStyle='#FFB35C';x.lineWidth=1.5;rrect(cx0-2.5,cy0-2.5,ng.cw+5,ng.ch+5,3);x.stroke();x.restore();}
else if(hb===b&&hv.i===si){x.strokeStyle='#F4F1EC';x.lineWidth=1;rrect(cx0-1.5,cy0-1.5,ng.cw+3,ng.ch+3,3);x.stroke();}}}
}
x.textBaseline='alphabetic';x.restore();
// the detail panel on the canvas, no DOM: a block, or one shard cell. A hovered target that is not the pinned one gets
// the floating panel beside it, as before. Otherwise the pinned target (or the one fading out) gets the fixed panel at
// the top left of the strip, where the oldest blocks are: it never moves as new blocks arrive.
x.save();x.beginPath();x.rect(G,0,W-G,H);x.clip();x.font='500 10px IBM Plex Mono, monospace';x.textAlign='left';
closeRect=null;panelRect=null;
var show=hv&&!same(hv,pv)?hv:(pv||pinFade),fixed=show&&show!==hv;
if(show){var L=lines(show),tw=Math.max(x.measureText(L[0]).width,x.measureText(L[1]).width,x.measureText(L[2]).width)+20,th,tx,tty;
if(!fixed){th=52;tx=show.b.px+h+10;tty=L[3]-th/2;
if(tx+tw>W-4)tx=show.b.px-h-10-tw;if(tx<G+4)tx=G+4;tty=Math.max(4,Math.min(H-th-4,tty));
x.fillStyle='rgba(22,22,26,0.96)';rrect(tx,tty,tw,th,6);x.fill();x.strokeStyle=L[4];x.lineWidth=1;rrect(tx+0.5,tty+0.5,tw-1,th-1,6);x.stroke();
x.fillStyle='#F4F1EC';x.fillText(L[0],tx+10,tty+16);x.fillStyle='#C9C7C2';x.fillText(L[1],tx+10,tty+31);x.fillStyle='#9A9A9E';x.fillText(L[2],tx+10,tty+46);}
else{th=72;tw=Math.max(tw,112);tx=G+6;tty=6;var CL=32,fade=show===pinFade?Math.max(0,1-(now-pinFade.at)/PIN_FADE):1;
x.globalAlpha=fade;
x.fillStyle='rgba(22,22,26,0.96)';rrect(tx,tty,tw,th,6);x.fill();x.strokeStyle=L[4];x.lineWidth=1;rrect(tx+0.5,tty+0.5,tw-1,th-1,6);x.stroke();
// header: a small pinned label and a 32 px close control for a thumb
x.font='500 9px IBM Plex Mono, monospace';x.fillStyle='#FFB35C';x.fillText('PINNED',tx+10,tty+14);
var cxm=tx+tw-CL/2,cym=tty+CL/2;x.strokeStyle='rgba(154,154,158,0.55)';x.lineWidth=1;x.beginPath();x.arc(cxm,cym,8.5,0,Math.PI*2);x.stroke();
x.strokeStyle='#F4F1EC';x.lineWidth=1.5;x.beginPath();x.moveTo(cxm-3.5,cym-3.5);x.lineTo(cxm+3.5,cym+3.5);x.moveTo(cxm+3.5,cym-3.5);x.lineTo(cxm-3.5,cym+3.5);x.stroke();
x.font='500 10px IBM Plex Mono, monospace';x.fillStyle='#F4F1EC';x.fillText(L[0],tx+10,tty+36);x.fillStyle='#C9C7C2';x.fillText(L[1],tx+10,tty+51);x.fillStyle='#9A9A9E';x.fillText(L[2],tx+10,tty+66);
x.globalAlpha=1;
if(show===pv){closeRect=[tx+tw-CL,tty,CL,CL];panelRect=[tx,tty,tw,th];}}}
x.restore();
// pointer devices: a hand over a block, a cell or the close control
var cur=hv||(ptrX>=0&&inRect(closeRect,ptrX,ptrY))?'pointer':'';if(c.style.cursor!==cur)c.style.cursor=cur;
// gutter: lane labels in the miner's colour, strip names, over anything that scrolled under it
x.fillStyle='#0C0C0E';x.fillRect(0,0,G,H);x.strokeStyle='rgba(42,42,48,0.9)';x.lineWidth=1;x.beginPath();x.moveTo(G-0.5,0);x.lineTo(G-0.5,H);x.stroke();
x.textAlign='left';x.font='500 '+(narrow?10:11)+'px IBM Plex Mono, monospace';
for(i=0;i<laneN;i++){var ly2=laneY(i)+4,lm=lanes[i];
if(lm){x.fillStyle=lm.col;x.fillText(narrow?lm.tag:lm.id,8,ly2);}
else{x.fillStyle='rgba(154,154,158,0.9)';x.fillText(narrow?'+'+othersN:'+'+othersN+' more',8,ly2);}}
x.font='500 '+(narrow?9:10)+'px IBM Plex Mono, monospace';x.fillStyle='rgba(154,154,158,0.9)';x.textBaseline='middle';
x.fillText(narrow?'fin':'finality',8,BH+FIN_H/2);x.fillText(narrow?'prf':'proving',8,BH+FIN_H+(provOn()?12:PROV_H/2));x.textBaseline='alphabetic';
if(lagNote){x.font='500 10px IBM Plex Mono, monospace';x.textAlign='right';x.fillStyle='#FFB35C';x.fillText(lagNote,W-6,12);}
if(!n){x.fillStyle='rgba(154,154,158,0.9)';x.font='500 13px IBM Plex Mono, monospace';x.textAlign='center';x.fillText(lagNote?'no block has reached the observer for '+lagNote.replace('observer ','').replace(' behind',''):(live?'waiting for the first block':'no observer data'),G+(W-G)/2,BH/2);}
if(note){x.fillStyle='rgba(12,12,14,0.72)';x.fillRect(0,BH/2-22,W,44);x.fillStyle='#F4F1EC';x.font='500 13px IBM Plex Mono, monospace';x.textAlign='center';x.fillText(note,W/2,BH/2+5);}
}
// Idle, the scene moves at 14 to 40 px/s, so 30 frames a second is enough; a pointer or a pinned block gets every frame.
// ?perf=1 exposes window.__igneumPerf = {frames, drawMs, since} so the draw cost can be read from the console.
var PERF=/[?&]perf=1/.test(location.search)?(window.__igneumPerf={frames:0,drawMs:0,since:performance.now()}):null,lastDraw=0;
function frame(now){if(!running)return;
if(ptrX>=0||pin||pinFade||now-lastDraw>=31){lastDraw=now;var t0=PERF?performance.now():0;draw(now);if(PERF){PERF.frames++;PERF.drawMs+=performance.now()-t0;}}
requestAnimationFrame(frame);}
function start(){if(running||reduce)return;if(document.hidden||!inView)return;running=true;requestAnimationFrame(frame);}
function stop(){running=false;}
function kick(){if(!running)requestAnimationFrame(function(now){draw(now);});}
document.addEventListener('visibilitychange',function(){if(document.hidden)stop();else start();});
if('IntersectionObserver' in window){new IntersectionObserver(function(es){inView=es[0].isIntersecting;if(inView)start();else stop();},{threshold:0}).observe(c);}
if('ResizeObserver' in window){new ResizeObserver(function(){needSize=true;kick();}).observe(c);}
else window.addEventListener('resize',function(){needSize=true;kick();});
// hover: pointer devices only, never a touch; it reads the strip and leaves the pin alone
c.addEventListener('pointermove',function(e){if(e.pointerType==='touch')return;ptrX=e.offsetX;ptrY=e.offsetY;if(reduce)kick();});
c.addEventListener('pointerleave',function(){ptrX=-1;ptrY=-1;if(reduce)kick();});
// tap or click: one pointer, down to up, under 10 px of travel, so a scroll on a phone never pins. Pointer events only;
// the canvas ignores click, so a touch tap fires once (its synthetic click is dropped below)
c.addEventListener('pointerdown',function(e){if(e.button&&e.button!==0)return;down=down?null:{id:e.pointerId,x:e.offsetX,y:e.offsetY};});
c.addEventListener('pointerup',function(e){if(!down||down.id!==e.pointerId){down=null;return;}
var dx=e.offsetX-down.x,dy=e.offsetY-down.y;down=null;if(dx*dx+dy*dy>TAP_SLOP*TAP_SLOP)return;tap(e.offsetX,e.offsetY,e.pointerType==='touch');});
c.addEventListener('pointercancel',function(){down=null;});
// anywhere else on the page clears the pin; Escape too
document.addEventListener('click',function(e){if(e.target!==c)clearPin();});
document.addEventListener('keydown',function(e){if(e.key==='Escape')clearPin();});
start();
// ---------- sparkline ----------
var sc=$('spark'),sx=sc.getContext('2d');
function spark(pairs){var w=sc.clientWidth,h=sc.clientHeight;sc.width=w*dpr;sc.height=h*dpr;sx.setTransform(dpr,0,0,dpr,0,0);sx.clearRect(0,0,w,h);
if(!pairs||!pairs.length){sx.fillStyle='rgba(154,154,158,0.9)';sx.font='500 12px IBM Plex Mono, monospace';sx.textAlign='center';sx.fillText('no data yet',w/2,h/2);return;}
var vals=pairs.map(function(p){return p[1];}),max=Math.max(1,Math.max.apply(null,vals)),pad=10,n=vals.length;
function px(i){return pad+i*(w-2*pad)/Math.max(1,n-1);}function py(v){return h-pad-(v/max)*(h-2*pad-12);}
sx.strokeStyle='rgba(42,42,48,0.9)';sx.lineWidth=1;sx.beginPath();sx.moveTo(pad,py(0));sx.lineTo(w-pad,py(0));sx.stroke();
sx.beginPath();sx.moveTo(px(0),py(0));vals.forEach(function(v,i){sx.lineTo(px(i),py(v));});sx.lineTo(px(n-1),py(0));sx.closePath();sx.fillStyle='rgba(242,84,27,0.14)';sx.fill();
sx.beginPath();vals.forEach(function(v,i){i?sx.lineTo(px(i),py(v)):sx.moveTo(px(i),py(v));});sx.strokeStyle='#F2541B';sx.lineWidth=2;sx.lineJoin='round';sx.stroke();
var li=n-1;sx.fillStyle='#FFB35C';sx.beginPath();sx.arc(px(li),py(vals[li]),3,0,Math.PI*2);sx.fill();
sx.fillStyle='rgba(154,154,158,0.9)';sx.font='500 10px IBM Plex Mono, monospace';sx.textAlign='left';sx.fillText('max '+max+' / min',pad,12);sx.textAlign='right';sx.fillText('now',w-pad,12);
var total=vals.reduce(function(a,b){return a+b;},0);$('spark-note').textContent=total+' blocks in the last hour, '+(total/60).toFixed(1)+' per minute on average.';}
// ---------- polling ----------
var minersEl=$('miners'),eventsEl=$('events'),failures=0;
function setStatus(on,ageText){document.body.className=on?'on':'off';$('st-status').textContent=on?'LIVE':'OFFLINE';$('st-age').textContent=ageText;}
function render(d){
var s=d.state,on=!s.stale;
var lag=s.observer_lag_s;lagNote=on&&lag!==null&&lag>30?'observer '+(lag<120?Math.round(lag)+' s':Math.round(lag/60)+' min')+' behind':'';
setStatus(on,s.age_s===null?'no observer update yet':(lagNote?lagNote+(s.queue_depth?', '+s.queue_depth+' queued':''):'observer updated '+rel(s.age_s*1000)));
$('st-network').textContent=s.network||'igneum-devnet';$('st-node').textContent=s.node_version?'node '+s.node_version:'node';
$('st-bps').textContent=(s.blocks_per_second_60s||0).toFixed(2);
$('st-blue').textContent=compact(s.blue_score);$('st-count').textContent=compact(s.block_count)+' blocks';
$('st-diff').textContent=compact(s.difficulty);
var hr=hashrate(s.hashes_per_second_estimate).split(' ');$('st-hps').innerHTML=hr.length>1?esc(hr[0])+'<small>'+esc(hr[1])+'</small>':esc(hr[0]);
$('st-miners').textContent=s.miners_10m;$('st-peers').textContent=s.peers===null?'0':s.peers;$('st-mempool').textContent='mempool '+(s.mempool||0);
var now=new Date(d.now).getTime();
if(d.miners.length){minersEl.innerHTML=d.miners.map(function(m){return '<tr><td><span class="id">'+esc(m.id)+'</span></td><td class="n">'+m.blocks+'</td><td><div style="display:flex;align-items:center;gap:8px"><div class="bar" style="width:70px"><i style="width:'+m.share+'%"></i></div><span class="mono" style="font-size:12px">'+m.share+'%</span></div></td><td>'+rel(now-new Date(m.last_seen).getTime())+'</td><td>'+(m.engine?esc(m.engine):'<span style="color:var(--ash)">not reported</span>')+'</td></tr>';}).join('');}
else minersEl.innerHTML='<tr><td colspan="5" class="empty">No blocks in the last 10 minutes.</td></tr>';
if(d.events.length){eventsEl.innerHTML=d.events.map(function(e){return '<div><span><span class="k">'+esc(e.kind)+'</span> '+esc(e.text)+'</span><span class="t">'+rel(now-new Date(e.ts).getTime())+'</span></div>';}).join('');}
else eventsEl.innerHTML='<div class="empty">No events yet.</div>';
spark(s.blocks_per_minute);
live=on;
if(on){frozenAt=null;note='';ingest(d);}
else{if(frozenAt===null)frozenAt=vnow()||now;note='observer offline, last update '+(s.age_s===null?'unknown':rel(s.age_s*1000));}
}
function poll(){fetch('/api/live'+(fixedWindow?'?window='+fixedWindow:''),{cache:'no-store'}).then(function(r){if(!r.ok)throw new Error(r.status);return r.json();}).then(function(d){if(!d.ok)throw new Error(d.error);failures=0;render(d);}).catch(function(){failures++;if(failures>=3){setStatus(false,'api unreachable');if(frozenAt===null)frozenAt=vnow();note='api unreachable, scene frozen';}});}
poll();setInterval(poll,2000);
})();
</script>
</body>
</html>