diff --git a/web/e2e/cinematic.spec.ts b/web/e2e/cinematic.spec.ts
index 18cf7f5..5a6f121 100644
--- a/web/e2e/cinematic.spec.ts
+++ b/web/e2e/cinematic.spec.ts
@@ -79,3 +79,42 @@ test('the Android shell owns keep-awake instead of a second browser lock', async
await page.addInitScript(()=>Object.defineProperty(navigator,'userAgent',{get:()=> 'Mozilla/5.0 CartoLiteAndroid/1.1.0'}));
for(const path of ['/','/netgraph/']) { await page.goto(path); await expect(page.locator(path==='/'?'#app':'#netgraph-app')).toHaveAttribute('data-screen-awake','native'); }
});
+
+test('Netgraph Follow holds ten seconds, filters a selected node and waits honestly', async ({page},info) => {
+ await page.emulateMedia({reducedMotion:'reduce'});
+ await visualFixture(page);await page.goto('/netgraph/?node=visual-a');
+ await expect(page.locator('#netgraph-stage')).toHaveAttribute('data-selected-node-id','visual-a');
+ await expect(page.locator('html')).toHaveAttribute('data-fixture-stream','ready');
+ await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000));
+ await page.getByRole('button',{name:/Follow$/,exact:false}).click();
+ const card=page.locator('#follow-card');
+ await expect(card).toBeVisible();
+ await card.getByLabel('Follow scope').selectOption('node');
+ await visualPacket(page,1,'Text');
+ await expect(card.locator('[data-detail]')).toHaveText('Text · 1 confirmed hop');
+ await expect(card.locator('output')).toHaveText('10s');
+ await page.clock.fastForward(4000);await visualPacket(page,2,'Advert');
+ await expect(card.locator('output')).toHaveText('6s');
+ await expect(card.locator('[data-detail]')).toContainText('Text');
+ await page.clock.fastForward(6000);
+ await expect(card.locator('[data-detail]')).toContainText('Advert');
+ await card.getByRole('button',{name:'Hold',exact:true}).click();
+ await page.clock.fastForward(11000);
+ await expect(card.locator('[data-state]')).toContainText('Held');
+ await visualPacket(page,3,'Trace');
+ await card.getByRole('button',{name:'Next',exact:true}).click();
+ await expect(card.locator('[data-detail]')).toContainText('Trace');
+ await page.screenshot({path:info.outputPath('netgraph-follow.png')});
+ await card.getByRole('button',{name:'Inspect',exact:true}).click();
+ await expect(page.locator('.node-inspector')).toBeVisible();
+ await page.getByRole('button',{name:'Close node details',exact:true}).click();
+ await expect(card).toBeVisible();
+ await card.getByRole('button',{name:'Next',exact:true}).click();
+ await expect(card.locator('[data-title]')).toHaveText('Waiting for activity');
+ await expect(card.getByRole('button',{name:'Inspect',exact:true})).toBeDisabled();
+ // A confirmed link that does not touch the captured node must not be selected.
+ await page.evaluate(()=>window.dispatchEvent(new CustomEvent('visual-packet',{detail:{seq:4,id:'unrelated',at:Date.now(),payloadType:'Text',mode:'route',segments:[{routeId:'other',fromId:'visual-b',toId:'visual-c'}]}})));
+ await expect(card.locator('[data-title]')).toHaveText('Waiting for activity');
+ await card.getByLabel('Follow scope').focus();await page.keyboard.press('Escape');
+ await expect(card).toBeHidden();
+});
diff --git a/web/src/cinematic.css b/web/src/cinematic.css
index ddc7297..3f60b04 100644
--- a/web/src/cinematic.css
+++ b/web/src/cinematic.css
@@ -70,7 +70,7 @@ html:root[data-cinematic] .follow-card .follow-scope, html:root[data-cinematic]
html:root[data-cinematic] .controls #layers-panel { height:min(750px,calc(100dvh - 150px)); }
-@media(max-width:900px),(pointer:coarse) { html:root[data-cinematic] #app[data-follow-inspect="true"]:has(#node-inspector-sheet:not([hidden])) .follow-card { visibility:hidden; } }
+@media(max-width:900px),(pointer:coarse) { html:root[data-cinematic] :is(#app,#netgraph-app)[data-follow-inspect="true"]:has(#node-inspector-sheet:not([hidden])) .follow-card { visibility:hidden; } }
html:root[data-cinematic][data-text-size="large"] .control-button { font-size:15px; }
:root[data-text-size="large"] #layers-panel .control-button { font-size:15px; }
@@ -89,3 +89,13 @@ html:root[data-cinematic] .control-button { min-width:44px; }
html:root[data-cinematic] #app #legend[data-focused="true"] #legend-toggle { display:none; }
html:root[data-cinematic] #app #legend[data-collapsed="true"][data-focused="false"]:not(:has(.region-legend:not([hidden]))) { background:transparent; border-color:transparent; box-shadow:none; }
}
+
+html:root[data-cinematic] .follow-card { position:fixed; z-index:8; color:var(--ink); }
+html:root[data-cinematic] .follow-card[hidden] { display:none; }
+html:root[data-cinematic] .follow-card header { display:flex; align-items:center; padding-left:12px; }
+html:root[data-cinematic] .follow-card header strong { flex:1; font-size:12px; }
+html:root[data-cinematic] .follow-copy { display:grid; gap:6px; padding:8px 12px; overflow-wrap:anywhere; }
+html:root[data-cinematic] .follow-card footer { display:flex; justify-content:space-between; padding:8px 12px; }
+html:root[data-cinematic] .follow-card :is(footer,.follow-copy span) { color:var(--muted); font-size:12px; }
+html:root[data-cinematic] .follow-card progress { display:block; width:100%; height:3px; accent-color:var(--focus-ring); }
+html:root[data-cinematic] .director-enabled:not([data-follow-inspect="true"]) .node-inspector-sheet { visibility:hidden; pointer-events:none; }
diff --git a/web/src/netgraph/follow.ts b/web/src/netgraph/follow.ts
index aab7d6e..3e55e06 100644
--- a/web/src/netgraph/follow.ts
+++ b/web/src/netgraph/follow.ts
@@ -8,18 +8,24 @@ export function mountGraphFollow(graph: NetgraphRenderer, select: (id: string) =
document.querySelector('.controls')!.append(button);
const card=document.createElement('section');card.id='follow-card';card.className='follow-card glass';card.hidden=true;card.setAttribute('aria-label','Live Follow activity');
card.innerHTML='
Waiting for activityNew live packets only
';
- document.querySelector('.netgraph-app')!.append(card);
- const queue=new FollowQueue();let enabled=false,held=false;let current:PacketView|undefined;let ids:Set|undefined;
+ const app=document.querySelector('.netgraph-app')!;app.append(card);
+ const queue=new FollowQueue();let enabled=false,held=false;let current:PacketView|undefined;let matches:((id:string)=>boolean)|undefined;
const hold=card.querySelector('[data-hold]')!;
const inspect=card.querySelector('[data-inspect]')!;
const scope=card.querySelector('select')!;
- const setEnabled=(value:boolean)=>{enabled=value;held=false;queue.clear();button.setAttribute('aria-pressed',String(value));card.hidden=!value;hold.textContent='Hold';};
+ const clearActivity=()=>{current=undefined;inspect.disabled=true;card.querySelector('[data-title]')!.textContent='Waiting for activity';card.querySelector('[data-detail]')!.textContent='New live packets only';card.querySelector('output')!.value='';card.querySelector('progress')!.value=0;};
+ const setEnabled=(value:boolean)=>{enabled=value;held=false;queue.clear();clearActivity();graph.holdView();app.classList.toggle('director-enabled',value);delete app.dataset.followInspect;button.setAttribute('aria-pressed',String(value));card.hidden=!value;hold.textContent='Hold';};
button.addEventListener('click',()=>setEnabled(!enabled));
card.querySelector('[data-close]')!.addEventListener('click',()=>setEnabled(false));
- scope.addEventListener('change',()=>{ids=scope.value==='area'?graph.visibleNodeIDs():scope.value==='node'?new Set(graph.selectedNode()?[graph.selectedNode()!]:[]):undefined;queue.clear();});
- hold.addEventListener('click',()=>{held=!held;queue.setHeld(held,Date.now());hold.textContent=held?'Continue':'Hold';});
- card.querySelector('[data-next]')!.addEventListener('click',()=>{held=false;queue.next();hold.textContent='Hold';tick();});
- inspect.addEventListener('click',()=>{const id=current&&followEndpoints(current)[0]?.id;if(id){held=true;queue.setHeld(true,Date.now());hold.textContent='Continue';select(id);}});
+ scope.addEventListener('change',()=>{
+ const selected=graph.selectedNode();
+ matches=scope.value==='area'?graph.captureFollowArea():scope.value==='node'?(id=>id===selected):undefined;
+ queue.clear();held=false;hold.textContent='Hold';clearActivity();graph.holdView();
+ if(scope.value==='node'&&!selected)card.querySelector('[data-detail]')!.textContent='Select a node, then choose Selected node';
+ });
+ hold.addEventListener('click',()=>{held=!held;if(held)graph.holdView();queue.setHeld(held,Date.now());hold.textContent=held?'Continue':'Hold';});
+ card.querySelector('[data-next]')!.addEventListener('click',()=>{held=false;delete app.dataset.followInspect;queue.next();hold.textContent='Hold';tick();});
+ inspect.addEventListener('click',()=>{const id=current&&followEndpoints(current)[0]?.id;if(id){held=true;app.dataset.followInspect='true';graph.holdView();queue.setHeld(true,Date.now());hold.textContent='Continue';select(id);}});
const pause=()=>{if(enabled&&!held){held=true;queue.setHeld(true,Date.now());hold.textContent='Continue';}};
const stage=document.getElementById('netgraph-stage')!;
stage.addEventListener('pointerdown',pause);stage.addEventListener('wheel',pause,{passive:true});stage.addEventListener('keydown',pause);
@@ -28,9 +34,14 @@ export function mountGraphFollow(graph: NetgraphRenderer, select: (id: string) =
if(held){card.querySelector('[data-state]')!.textContent='Held · explore freely';return;}
const packet=queue.take(Date.now());
if(packet){current=packet;inspect.disabled=false;const summary=followSummary(packet);card.querySelector('[data-title]')!.textContent=summary.title;card.querySelector('[data-detail]')!.textContent=summary.detail;graph.followPacket(packet);}
- const remaining=queue.remaining(Date.now());card.querySelector('output')!.value=remaining?`${remaining}s`:'';card.querySelector('progress')!.value=remaining;
+ const remaining=queue.remaining(Date.now());if(!remaining&¤t)clearActivity();card.querySelector('output')!.value=remaining?`${remaining}s`:'';card.querySelector('progress')!.value=remaining;
card.querySelector('[data-state]')!.textContent=remaining?'Next activity in':'Waiting for activity';
}
+ const dismiss=(event:KeyboardEvent)=>{
+ if(event.key!=='Escape'||!enabled||document.querySelector('.controls button[aria-expanded="true"]'))return;
+ event.preventDefault();event.stopImmediatePropagation();setEnabled(false);button.focus();
+ };
+ document.addEventListener('keydown',dismiss);
const timer=window.setInterval(tick,250);
- return {offer(packet:PacketView){if(!enabled)return;const points=followEndpoints(packet);if(ids && (scope.value==='node'?!points.some(p=>ids!.has(p.id)):!points.every(p=>ids!.has(p.id))))return;const nearby=points.some(point=>{const p=graph.projectEndpoint(point);return p.x>=0&&p.x<=stage.clientWidth&&p.y>=0&&p.y<=stage.clientHeight;});queue.offer(packet,nearby?2:0,Date.now());tick();},destroy(){clearInterval(timer);stage.removeEventListener('pointerdown',pause);stage.removeEventListener('wheel',pause);stage.removeEventListener('keydown',pause);card.remove();button.remove();}};
+ return {offer(packet:PacketView){if(!enabled)return;const points=followEndpoints(packet);if(matches && (scope.value==='node'?!points.some(p=>matches!(p.id)):!points.every(p=>matches!(p.id))))return;const nearby=points.some(point=>{const p=graph.projectEndpoint(point);return p.x>=0&&p.x<=stage.clientWidth&&p.y>=0&&p.y<=stage.clientHeight;});queue.offer(packet,nearby?2:0,Date.now());tick();},destroy(){clearInterval(timer);document.removeEventListener('keydown',dismiss);stage.removeEventListener('pointerdown',pause);stage.removeEventListener('wheel',pause);stage.removeEventListener('keydown',pause);card.remove();button.remove();}};
}
diff --git a/web/src/netgraph/renderer.ts b/web/src/netgraph/renderer.ts
index ce70a96..a41003e 100644
--- a/web/src/netgraph/renderer.ts
+++ b/web/src/netgraph/renderer.ts
@@ -362,10 +362,17 @@ export class NetgraphRenderer implements ViewportProjector {
this.animateView((left+right)/2,(top+bottom)/2,Math.max(.015,scale));
}
- visibleNodeIDs(): Set {
- return new Set([...this.layout.positions.keys()].filter(id => this.pointVisible(this.screenPoint(id), 0)));
+ captureFollowArea(): (id: string) => boolean {
+ const left=this.centerX-this.width/(2*this.scale), right=this.centerX+this.width/(2*this.scale);
+ const top=this.centerY-this.height/(2*this.scale), bottom=this.centerY+this.height/(2*this.scale);
+ return id => {
+ const point=this.layout.positions.get(id);
+ return !!point && point.x>=left && point.x<=right && point.y>=top && point.y<=bottom;
+ };
}
+ holdView(): void { this.cancelViewAnimation(); }
+
selectedNode(): string | null { return this.selectedNodeID; }
setFocus(mode: 'all' | 'area' | 'component'): boolean {