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='
Live Follow
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 {