Keep Netgraph Follow scoped, readable and steady

This commit is contained in:
n30nex committed 2026-09-27 01:07:04 -04:00
1 parent e9f8f4f326
commit b1b45a160e
4 files changed
+79 -12

No files matched your search

+39
View File
@@ -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();
});
+11 -1
View File
@@ -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; }
+20 -9
View File
@@ -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='<header><strong>Live Follow</strong><button type="button" data-hold>Hold</button><button type="button" id="follow-close" data-close aria-label="Close Live Follow">×</button></header><label class="follow-scope">Follow area<select aria-label="Follow scope"><option value="all">Everywhere</option><option value="area">This area</option><option value="node">Selected node</option></select></label><div class="follow-copy"><strong data-title>Waiting for activity</strong><span data-detail>New live packets only</span></div><footer><span data-state>Waiting for activity</span><output aria-label="Seconds until next activity"></output></footer><progress max="10" value="0" aria-label="Time remaining on this activity"></progress><div class="follow-actions"><button type="button" data-next>Next</button><button type="button" data-inspect disabled>Inspect</button></div>';
document.querySelector('.netgraph-app')!.append(card);
const queue=new FollowQueue();let enabled=false,held=false;let current:PacketView|undefined;let ids:Set<string>|undefined;
const app=document.querySelector<HTMLElement>('.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<HTMLButtonElement>('[data-hold]')!;
const inspect=card.querySelector<HTMLButtonElement>('[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&&current)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();}};
}
+9 -2
View File
@@ -362,10 +362,17 @@ export class NetgraphRenderer implements ViewportProjector {
this.animateView((left+right)/2,(top+bottom)/2,Math.max(.015,scale));
}
visibleNodeIDs(): Set<string> {
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 {