Frame Follow above its controls and release owned graphics contexts

This commit is contained in:
n30nex
2026-09-27 01:41:09 -04:00
parent a8f8c42f9f
commit 8966030fc5
5 changed files with 56 additions and 17 deletions
+21
View File
@@ -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);
});
+21
View File
@@ -116,3 +116,24 @@ export function mountLayerCombinations(): void {
bar.querySelector<HTMLButtonElement>('[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<HTMLElement>(selector);
if(chrome&&chrome.getClientRects().length)top=Math.max(top,chrome.getBoundingClientRect().bottom-box.top+16);
}
const dock=document.querySelector<HTMLElement>('.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<box.left+width/2)left=Math.max(left,bounds.right-box.left+24);
else right=Math.min(right,bounds.left-box.left-24);
}
top=Math.min(top,height-48);bottom=Math.max(top+24,bottom);
return {left,right:Math.max(left+48,right),top,bottom};
}
+1 -1
View File
@@ -185,5 +185,5 @@ export function canvasEffects(parent: HTMLElement, before: HTMLCanvasElement) {
if(gl)batch.initialize(gl);
const lost=(event: Event)=>{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();}};
}
+8 -13
View File
@@ -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' });
+5 -3
View File
@@ -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<typeof p> => !!p);
if (!points.length) return;
if (points.every(p => { const q=this.screenPoint(p.id); return q.x>72&&q.x<this.width-72&&q.y>100&&q.y<this.height-160; })) return;
const view=followViewport(this.stage);
if (points.every(p => { 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 {