diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css
index 8aa99654..2d0dcdca 100644
--- a/app/igneum-app/ui/app.css
+++ b/app/igneum-app/ui/app.css
@@ -634,6 +634,7 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
#dag{height:120px;margin-bottom:0}
.dag-tip{position:absolute;left:0;top:0;pointer-events:none;background:var(--graphite);border:1px solid var(--line-2);border-radius:8px;padding:8px 10px;font-size:var(--t-xs);color:var(--ink-2);display:flex;flex-direction:column;gap:2px;max-width:320px;box-shadow:0 8px 24px var(--shadow);z-index:3}
.dag-tip b{color:var(--bone);font-weight:500;word-break:break-all}
+.dag-chip{position:absolute;right:10px;top:10px;font-size:var(--t-xs);color:var(--ash);background:var(--graphite);border:1px solid var(--line-2);border-radius:999px;padding:3px 10px;pointer-events:none;z-index:2}
.legend{display:flex;flex-wrap:wrap;gap:8px 16px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ash);align-items:center}
.legend span{display:inline-flex;align-items:center;gap:7px}
.lg{width:10px;height:10px;border-radius:3px;display:inline-block;border:1.5px solid var(--ash);background:var(--row)}
diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js
index 8cf86896..1d597609 100644
--- a/app/igneum-app/ui/app.js
+++ b/app/igneum-app/ui/app.js
@@ -1318,7 +1318,7 @@ if (typeof document !== 'undefined') (function () {
function mountDag() {
if (dag || typeof IgneumDag === 'undefined' || !$('dag-live')) return;
try {
- dag = IgneumDag.mount($('dag-live'), { compact: window.innerWidth < 720, window: 120, url: 'api/live', mine: 'you', tooltip: $('dag-tip'), pauseButton: $('dag-pause'), onState: function (st, why) {
+ dag = IgneumDag.mount($('dag-live'), { compact: window.innerWidth < 720, window: 120, url: 'api/live', mine: 'you', tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'), onState: function (st, why) {
var live = st === 'live' || st === 'stale';
if (dagMode !== st) { dagMode = st; setText('dag-state', st === 'live' ? 'live' : st === 'stale' ? 'behind' : st === 'failed' ? 'your blocks only' : 'connecting'); setText('dag-note', st === 'failed' ? 'The full chain view needs the next update. This is the last 10 minutes of your own blocks.' : st === 'stale' ? (why || '') : ''); }
$('dag-live').hidden = !live; $('dag-tip').hidden = true; $('dag-pause').hidden = !live; $('dag').hidden = live;
diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html
index c0c1e80a..de7d6d61 100644
--- a/app/igneum-app/ui/index.html
+++ b/app/igneum-app/ui/index.html
@@ -190,6 +190,7 @@
+
scroll to zoom ยท Esc to release
yourspendingincludedexcludedprovenlocked checkpoint
diff --git a/app/igneum-app/ui/live-dag.js b/app/igneum-app/ui/live-dag.js
index 596a9522..6bf08e11 100644
--- a/app/igneum-app/ui/live-dag.js
+++ b/app/igneum-app/ui/live-dag.js
@@ -4,10 +4,12 @@
are drawn; the edge between two selected-chain blocks is heavier and the chain glows as one path. Colour words are the
ledger's: pending, included (blue), excluded (red), proven (every shard verified or paid), locked (a checkpoint block).
Checkpoints are vertical bands at their blue score with the lock weight; a locked band is solid, a pending one dashed.
- The view scrolls at chain speed, new blocks arrive from the right with a brief glow; wheel and pinch zoom the window
- (30 to 300 s, the API's ?window=); a pause button; hover and tap show a block's details.
+ The view scrolls at chain speed, new blocks arrive from the right with a brief glow. The page's scroll passes through the
+ scene; zoom takes ctrl or cmd with the wheel, a pinch, or a click into the scene (engaged, released by Escape or a click
+ outside; opts.chip is the "scroll to zoom, Esc to release" element); window 30 to 300 s (the API's ?window=); a pause button;
+ hover and tap show a block's details.
- Usage: var dag = IgneumDag.mount(canvas, { compact, window, url, poll, mine, onState, onData, onWindow, pauseButton, tooltip })
+ Usage: var dag = IgneumDag.mount(canvas, { compact, window, url, poll, mine, onState, onData, onWindow, onEngage, pauseButton, tooltip, chip })
- url: the /api/live endpoint (default '/api/live'); poll: false stops the module's own 2 s fetch, the host then calls
dag.push(reply) with a reply of the same shape ({ok, state:{stale, age_s, height, ...}, blocks:[...], finality:{checkpoints:[...]}, proving})
whenever it has new data (every poll or every new block, either is fine; blocks already on screen are updated in place).
@@ -196,8 +198,20 @@
}
canvas.addEventListener('mousemove', function (ev) { hover = hit(ev); canvas.style.cursor = hover ? 'pointer' : 'default'; if (!pinned) showTip(hover, ev); kick(); });
canvas.addEventListener('mouseleave', function () { hover = null; if (!pinned) showTip(null); kick(); });
- canvas.addEventListener('click', function (ev) { var b = hit(ev); pinned = (pinned && b && pinned.hash === b.hash) ? null : b; showTip(pinned || hover); kick(); });
- canvas.addEventListener('wheel', function (ev) { if (!ev.ctrlKey && Math.abs(ev.deltaY) < 1) return; ev.preventDefault(); zoom(ev.deltaY > 0 ? 1.15 : 1 / 1.15); }, { passive: false });
+ canvas.addEventListener('click', function (ev) { engage(true); var b = hit(ev); pinned = (pinned && b && pinned.hash === b.hash) ? null : b; showTip(pinned || hover); kick(); });
+ // the wheel is never captured by default (6 Oct 2026, the project lead: scrolling past the scene zoomed it): the page scrolls through the
+ // scene; zoom takes ctrl or cmd with the wheel, a pinch, or a click into the scene, which engages it (opts.chip shows
+ // "scroll to zoom, Esc to release"); Escape, a click outside or leaving the scene releases it
+ var engaged = false, chip = opts.chip || null;
+ function engage(on) { if (on === engaged) return; engaged = on; canvas.classList.toggle('engaged', on); if (chip) chip.hidden = !on; if (opts.onEngage) opts.onEngage(on); }
+ canvas.addEventListener('wheel', function (ev) {
+ var zoomKey = ev.ctrlKey || ev.metaKey;
+ if (!zoomKey && !engaged) return; // a plain wheel or a two-finger scroll passes through to the page
+ if (Math.abs(ev.deltaY) < 1) return;
+ ev.preventDefault(); zoom(ev.deltaY > 0 ? 1.15 : 1 / 1.15);
+ }, { passive: false });
+ document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape' && engaged) engage(false); });
+ document.addEventListener('pointerdown', function (ev) { if (engaged && !canvas.contains(ev.target) && !(chip && chip.contains(ev.target))) engage(false); });
var pinch = null;
canvas.addEventListener('touchstart', function (ev) { if (ev.touches.length === 2) pinch = dist(ev.touches); }, { passive: true });
canvas.addEventListener('touchmove', function (ev) { if (ev.touches.length === 2 && pinch) { var d = dist(ev.touches); if (Math.abs(d - pinch) > 12) { zoom(d > pinch ? 1 / 1.1 : 1.1); pinch = d; } } }, { passive: true });
@@ -216,7 +230,7 @@
new MutationObserver(function () { readTheme(); kick(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
readTheme(); size(); if (opts.poll !== false) { poll(); setInterval(poll, 2000); } kick();
- return { push: push, setPaused: setPaused, zoom: zoom, getWindow: function () { return windowS; }, getState: function () { return { state: state, reason: reason }; }, redraw: kick, words: words };
+ return { push: push, setPaused: setPaused, zoom: zoom, engage: engage, getWindow: function () { return windowS; }, getState: function () { return { state: state, reason: reason }; }, redraw: kick, words: words };
}
root.IgneumDag = { mount: mount };
})(window);