From 8966030fc5508a600387d4eae6cd80d77a9288f0 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 27 Sep 2026 01:41:09 -0400 Subject: [PATCH] Frame Follow above its controls and release owned graphics contexts --- web/src/cinematicChrome.test.ts | 21 +++++++++++++++++++++ web/src/cinematicChrome.ts | 21 +++++++++++++++++++++ web/src/gpuEffects.ts | 2 +- web/src/map.ts | 21 ++++++++------------- web/src/netgraph/renderer.ts | 8 +++++--- 5 files changed, 56 insertions(+), 17 deletions(-) create mode 100644 web/src/cinematicChrome.test.ts diff --git a/web/src/cinematicChrome.test.ts b/web/src/cinematicChrome.test.ts new file mode 100644 index 0000000..3d085db --- /dev/null +++ b/web/src/cinematicChrome.test.ts @@ -0,0 +1,21 @@ +// @vitest-environment jsdom +import {afterEach,expect,it} from 'vitest'; +import {followViewport} from './cinematicChrome'; +afterEach(()=>{document.body.replaceChildren();}); +function element(id:string,x:number,y:number,width:number,height:number){ + const node=document.createElement('div');node.id=id;node.className=id; + Object.defineProperties(node,{clientWidth:{value:width},clientHeight:{value:height}}); + node.getBoundingClientRect=()=>({x,y,left:x,top:y,right:x+width,bottom:y+height,width,height,toJSON:()=>({})}); + node.getClientRects=()=>[node.getBoundingClientRect()] as unknown as DOMRectList; + document.body.append(node);return node; +} +it('keeps portrait activity above the Follow card and dock',()=>{ + const stage=element('stage',0,0,390,844);element('controls',8,730,374,106);element('follow-card',8,470,374,248); + const rect=followViewport(stage); + expect(rect.bottom).toBeLessThan(470);expect(rect.right-rect.left).toBeGreaterThan(250);expect(rect.bottom-rect.top).toBeGreaterThan(250); +}); +it('uses the space beside a landscape card without covering the dock',()=>{ + const stage=element('stage',0,0,844,390);element('controls',40,330,764,52);element('follow-card',486,100,350,215); + const rect=followViewport(stage); + expect(rect.right).toBeLessThan(486);expect(rect.bottom).toBeLessThan(330);expect(rect.right-rect.left).toBeGreaterThan(350); +}); diff --git a/web/src/cinematicChrome.ts b/web/src/cinematicChrome.ts index 98d053b..17ba8c3 100644 --- a/web/src/cinematicChrome.ts +++ b/web/src/cinematicChrome.ts @@ -116,3 +116,24 @@ export function mountLayerCombinations(): void { bar.querySelector('[data-undo]')!.hidden = false; }); } + +/** The director frames activity in the space actually left by its card and dock. */ +export function followViewport(container: HTMLElement) { + const box=container.getBoundingClientRect(), width=container.clientWidth, height=container.clientHeight; + let left=24, right=width-24, top=72, bottom=height-24; + for(const selector of ['#topbar','.graph-focus']) { + const chrome=document.querySelector(selector); + if(chrome&&chrome.getClientRects().length)top=Math.max(top,chrome.getBoundingClientRect().bottom-box.top+16); + } + const dock=document.querySelector('.controls'); + if(dock)bottom=Math.min(bottom,dock.getBoundingClientRect().top-box.top-16); + const card=document.getElementById('follow-card'); + if(card&&!card.hidden){ + const bounds=card.getBoundingClientRect(); + if(bounds.width>=width*.6)bottom=Math.min(bottom,bounds.top-box.top-20); + else if(bounds.left+bounds.width/2{event.preventDefault();batch.dispose();}; const restored=()=>{if(gl)batch.initialize(gl);}; canvas.addEventListener('webglcontextlost',lost); canvas.addEventListener('webglcontextrestored',restored); - return {batch,flush:(width:number,height:number,dpr:number)=>{if(!gl)return;const w=Math.round(width*dpr),h=Math.round(height*dpr);if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;}gl.viewport(0,0,w,h);gl.clearColor(0,0,0,0);gl.clear(gl.COLOR_BUFFER_BIT);batch.draw();},destroy:()=>{batch.dispose();canvas.remove();}}; + return {batch,flush:(width:number,height:number,dpr:number)=>{if(!gl)return;const w=Math.round(width*dpr),h=Math.round(height*dpr);if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;}gl.viewport(0,0,w,h);gl.clearColor(0,0,0,0);gl.clear(gl.COLOR_BUFFER_BIT);batch.draw();},destroy:()=>{canvas.removeEventListener('webglcontextlost',lost);canvas.removeEventListener('webglcontextrestored',restored);batch.dispose();gl?.getExtension('WEBGL_lose_context')?.loseContext();canvas.remove();}}; } diff --git a/web/src/map.ts b/web/src/map.ts index 6e40c64..6fbc43e 100644 --- a/web/src/map.ts +++ b/web/src/map.ts @@ -1,3 +1,4 @@ +import { followViewport } from './cinematicChrome'; import { prefersReducedMotion as displayReducedMotion } from './displayPreferences'; import { rememberNode, replaceInspector } from './selection'; import * as maplibregl from 'maplibre-gl'; @@ -638,10 +639,11 @@ export class LiveMap { if (endpoints.length === 0) return false; const container = this.map.getContainer(); const viewport = { width: container.clientWidth, height: container.clientHeight }; - const inside = endpoints.every((endpoint) => isPointInSafeArea( - this.map.project([endpoint.lng, endpoint.lat]), - viewport, - )); + const visible=followViewport(container); + const inside = endpoints.every(endpoint => { + const p=this.map.project([endpoint.lng,endpoint.lat]); + return p.x>=visible.left&&p.x<=visible.right&&p.y>=visible.top&&p.y<=visible.bottom; + }); if (inside) return false; const now = Date.now(); if (!canMoveLiveFollow(this.lastFollowMoveAt, now)) return false; @@ -652,20 +654,13 @@ export class LiveMap { this.directorTimer = undefined; this.container.dataset.cameraMode = 'idle'; }, 1500); - if (endpoints.length === 1) { - const center: [number, number] = [endpoints[0]!.lng, endpoints[0]!.lat]; - if (this.reducedMotion) this.map.jumpTo({ center }); - else this.map.easeTo({ center, duration: 1200, essential: false, easeId: 'cartolite-live-follow' }); - return true; - } const bounds = new maplibregl.LngLatBounds(); const anchor = endpoints[0]!.lng; for (const endpoint of endpoints) bounds.extend([anchor + longitudeDelta(anchor, endpoint.lng), endpoint.lat]); - const horizontal = container.clientWidth <= 620 ? 56 : 104; const camera = this.map.cameraForBounds(bounds, { - padding: { top: 96, right: horizontal, bottom: Math.min(230, viewport.height * 0.32), left: horizontal }, + padding: { top: visible.top, right: viewport.width-visible.right, bottom: viewport.height-visible.bottom, left: visible.left }, bearing: this.map.getBearing(), - maxZoom: this.followZoom, + maxZoom: endpoints.length===1 ? this.map.getZoom() : Math.min(this.followZoom,this.map.getZoom()), }); if (!camera) return false; this.map.easeTo({ ...camera, duration: this.reducedMotion ? 0 : 1400, essential: false, easeId: 'cartolite-live-follow' }); diff --git a/web/src/netgraph/renderer.ts b/web/src/netgraph/renderer.ts index a41003e..994a23e 100644 --- a/web/src/netgraph/renderer.ts +++ b/web/src/netgraph/renderer.ts @@ -1,3 +1,4 @@ +import { followViewport } from '../cinematicChrome'; import { canvasEffects } from '../gpuEffects'; import { canvasColorWithAlpha as colorWithAlpha, DISPLAY_EVENT, displayColor, displayPreferences, displayResidueAge, lightScene, lineDash, residueLifetime, prefersReducedMotion, effectStrength } from '../displayPreferences'; import type { ViewportProjector } from '../audio'; @@ -355,11 +356,12 @@ export class NetgraphRenderer implements ViewportProjector { const ids = packet.mode === 'observer' ? [packet.observer.id] : packet.segments.flatMap(hop => [hop.from.id,hop.to.id]); const points = ids.map(id => this.layout.positions.get(id)).filter((p): p is NonNullable => !!p); if (!points.length) return; - if (points.every(p => { const q=this.screenPoint(p.id); return q.x>72&&q.x100&&q.y { const q=this.screenPoint(p.id); return q.x>=view.left&&q.x<=view.right&&q.y>=view.top&&q.y<=view.bottom; })) return; const xs=points.map(p=>p.x),ys=points.map(p=>p.y); const left=Math.min(...xs),right=Math.max(...xs),top=Math.min(...ys),bottom=Math.max(...ys); - const scale=Math.min(this.scale,Math.max(80,this.width-180)/Math.max(1,right-left),Math.max(80,this.height-300)/Math.max(1,bottom-top)); - this.animateView((left+right)/2,(top+bottom)/2,Math.max(.015,scale)); + const scale=Math.max(.015,Math.min(this.scale,(view.right-view.left)/Math.max(1,right-left),(view.bottom-view.top)/Math.max(1,bottom-top))); + this.animateView((left+right)/2-((view.left+view.right)/2-this.width/2)/scale,(top+bottom)/2-((view.top+view.bottom)/2-this.height/2)/scale,scale); } captureFollowArea(): (id: string) => boolean {