Back to Drone Zone — deep field
SOURCE / PINNED RELEASE

Made of little things.

Drone Zone — deep field

Release
578f277a8be1
Author-recorded commit
a96742b428de…
License
LICENSE
Author’s source reference
nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-9753e6cf3ec

Archive hash verified: f7f3bf8ff3482551. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/scene.ts
import galaxyUrl from './assets/galaxy_1.jpg?inline';
import fragment from './galaxy.frag?raw';
import skyUrl from './assets/image.png?inline';
import starsUrl from './assets/image1.png?inline';
import sunriseUrl from './assets/image2.png?inline';
import nebulaUrl from './assets/image3.png?inline';

export interface FieldSettings { motion: number; stars: number; fog: boolean; distortion: boolean }
export { observations } from './field-data.js';
import { observations, createRoutes, sampleRoute, principalHighway, navigation, tourRange, createSystems, orbitPoint, createCamera, projectPoint, createTransit, advanceTransit, createDetours, updateDetour, type Detour, type Transit, type Point, type Route } from './field-data.js';
const clamp = (n:number,a:number,b:number) => Math.max(a,Math.min(b,n));
let seed=71821;
function random() { seed=(seed*1664525+1013904223)>>>0; return seed/4294967296; }
const calloutSources=[
  {url:skyUrl,label:'OPTICAL / DISTANT LIGHT',width:86},
  {url:starsUrl,label:'OPTICAL / STELLAR FIELD',width:130},
  {url:sunriseUrl,label:'OPTICAL / LIMB LIGHT',width:104},
  {url:nebulaUrl,label:'OPTICAL / NEBULA',width:91},
];
const calloutAssignments:Record<number,number>={0:3,1:1,5:2,8:2,12:3,16:0,18:1};
interface Star extends Point { size:number; phase:number; warm:boolean }

/** Pure drawing and local interaction; all network and media authority stays in the runtime. */
export class GalaxyField {
  private canvas:HTMLCanvasElement;
  private overlay:HTMLCanvasElement;
  private ctx:CanvasRenderingContext2D;
  private gl:WebGLRenderingContext|null=null;
  private program:WebGLProgram|null=null;
  private texture:WebGLTexture|null=null;
  private slices:WebGLTexture|null=null;
  private buffer:WebGLBuffer|null=null;
  private uniforms:Record<string,WebGLUniformLocation|null>={};
  private photo=new Image();
  private callouts:HTMLImageElement[]=[];
  private ready=false;
  private width=1; private height=1; private dpr=1;
  private pan={x:0,y:0}; private target={x:0,y:0};
  private pointer={x:0,y:0}; private look={x:0,y:0};
  private zoom=1; private targetZoom=1; private time=0; private last=0;
  private frame=0; private dead=false; private reduced=matchMedia('(prefers-reduced-motion: reduce)');
  private pointers=new Map<number,{x:number;y:number}>();
  private travel=0; private pinched=false;
  private routes:Route[]=[]; private stars:Star[]=[];
  private transits:Transit[]=[];
  private systems=createSystems();
  private detours:Detour[]=[];
  private camera=createCamera(1,1,this.pan,this.look,1,.6);
  private resizeObserver:ResizeObserver;
  private events=new AbortController();
  private selected=-1; private hover=-1;
  private hitPoints:{x:number;y:number}[]=[];
  frozen=this.reduced.matches;
  touring=!this.reduced.matches;
  private tourPhase=-.42;
  routesVisible=true; annotationsVisible=true;
  settings:FieldSettings={motion:.6,stars:1,fog:true,distortion:true};
  onSelect:(index:number)=>void=()=>{};
  onChange:(zoom:number,x:number,y:number)=>void=()=>{};
  onFrozen:(frozen:boolean)=>void=()=>{};
  onTour:(touring:boolean)=>void=()=>{};
  onFrame:(time:number,active:boolean)=>void=()=>{};
  constructor(private element:HTMLElement) {
    this.canvas=element.querySelector<HTMLCanvasElement>('#galaxy')!;
    this.overlay=element.querySelector<HTMLCanvasElement>('#signals')!;
    this.ctx=this.overlay.getContext('2d')!;
    this.setupGL();
    this.photo.onload=()=>{ if(this.dead)return; this.ready=true; this.upload(); this.wake(); };
    this.photo.onerror=()=>{ this.ready=false; this.wake(); };
    this.photo.src=galaxyUrl;
    this.callouts=calloutSources.map(source=>{
      const image=new Image();image.onload=()=>{if(!this.dead)this.wake();};image.src=source.url;return image;
    });
    this.routes=createRoutes();this.detours=createDetours(this.routes,this.systems);
    this.transits=this.routes.map(createTransit);
    for(let i=0;i<1600;i++) this.stars.push({x:(random()-.5)*7400,y:(random()-.5)*5100,z:60+random()*480,size:.25+random()*.8,phase:random()*Math.PI*2,warm:random()>.72});
    if(this.touring) {
      const start=sampleRoute(principalHighway,tourRange.center+tourRange.radius*Math.sin(this.tourPhase));
      this.pan={x:start.x,y:start.y};this.target={...this.pan};
    }
    this.resizeObserver=new ResizeObserver(()=>this.resize()); this.resizeObserver.observe(element);
    this.bind(); this.resize();
  }
  private setupGL() {
    try {
      const gl=this.canvas.getContext('webgl',{alpha:false,antialias:false,powerPreference:'low-power'}); if(!gl)return;
      const compile=(type:number,source:string)=>{const shader=gl.createShader(type)!; gl.shaderSource(shader,source);gl.compileShader(shader);if(!gl.getShaderParameter(shader,gl.COMPILE_STATUS)){const error=gl.getShaderInfoLog(shader);gl.deleteShader(shader);throw new Error(error??'Shader compilation failed');}return shader;};
      const vertex=compile(gl.VERTEX_SHADER,'attribute vec2 a_position; varying vec2 v_uv; void main(){v_uv=(a_position+1.)*.5;gl_Position=vec4(a_position,0.,1.);}');
      const frag=compile(gl.FRAGMENT_SHADER,fragment); const program=gl.createProgram()!;
      gl.attachShader(program,vertex);gl.attachShader(program,frag);gl.linkProgram(program);gl.deleteShader(vertex);gl.deleteShader(frag);
      if(!gl.getProgramParameter(program,gl.LINK_STATUS)){gl.deleteProgram(program);throw new Error('Shader linking failed');}
      this.gl=gl;this.program=program;gl.useProgram(program);
      this.buffer=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,this.buffer);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);
      const pos=gl.getAttribLocation(program,'a_position');gl.enableVertexAttribArray(pos);gl.vertexAttribPointer(pos,2,gl.FLOAT,false,0,0);
      for(const name of ['image','slices','resolution','pan','pointer','scale','time','fog','distortion']) this.uniforms[name]=gl.getUniformLocation(program,`u_${name}`);
      this.texture=gl.createTexture();gl.bindTexture(gl.TEXTURE_2D,this.texture);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
      // Bake static slice properties once, instead of evaluating their noise
      // and envelope millions of times per animation frame.
      const data=new Uint8Array(64*4);
      const hash=(id:number,salt:number)=>{const value=Math.sin(id*127.1+salt*311.7)*43758.5453;return value-Math.floor(value);};
      for(let id=0;id<64;id++) {
        const center=.02+(id+.5)/42;
        const shoulder=Math.exp(-Math.pow((center-(center<.5?.39:.61))/.18,2));
        data[id*4]=Math.round((.105+.30*shoulder+.09*(hash(id,2.8)-.5))*255);
        data[id*4+1]=Math.round((.095+.23*shoulder+.075*(hash(id,9.1)-.5))*255);
        data[id*4+2]=Math.round((.15+.63*shoulder+.14*hash(id,7.3))*255);
        const fold=.028*Math.sin(center*8.6)+(center<.5?1:-1)*.024*shoulder+.018*(hash(id,5.6)-.5);
        data[id*4+3]=Math.round((.5+fold*4)*255);
      }
      this.slices=gl.createTexture();gl.activeTexture(gl.TEXTURE1);gl.bindTexture(gl.TEXTURE_2D,this.slices);
      gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.NEAREST);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.NEAREST);
      gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,64,1,0,gl.RGBA,gl.UNSIGNED_BYTE,data);gl.activeTexture(gl.TEXTURE0);
      this.canvas.addEventListener('webglcontextlost',e=>{e.preventDefault();this.gl=null;this.wake();},{signal:this.events.signal});
      this.canvas.addEventListener('webglcontextrestored',()=>{this.setupGL();this.upload();this.wake();},{signal:this.events.signal,once:true});
    } catch { this.gl=null; }
  }
  private upload() { const gl=this.gl;if(!gl||!this.ready)return;gl.bindTexture(gl.TEXTURE_2D,this.texture);gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,1);gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,this.photo); }
  private resize() {
    this.width=Math.max(1,this.element.clientWidth);this.height=Math.max(1,this.element.clientHeight);
    this.dpr=Math.min(devicePixelRatio||1,1.6);
    for(const canvas of [this.canvas,this.overlay]) {canvas.width=Math.round(this.width*this.dpr);canvas.height=Math.round(this.height*this.dpr);}
    this.gl?.viewport(0,0,this.canvas.width,this.canvas.height);this.wake();
  }
  private get scale() { return Math.max(this.width/1700,this.height/1080,.31)*this.zoom; }
  private project(p:Point) { return projectPoint(p,this.camera); }
  private background() {
    const gl=this.gl;
    if(gl&&this.ready) {
      gl.useProgram(this.program);gl.uniform1i(this.uniforms.image,0);gl.uniform1i(this.uniforms.slices,1);gl.uniform2f(this.uniforms.resolution,this.width,this.height);gl.uniform2f(this.uniforms.pan,this.pan.x*.65,this.pan.y*.65);
      gl.uniform2f(this.uniforms.pointer,this.look.x,this.look.y);gl.uniform1f(this.uniforms.scale,this.scale);gl.uniform1f(this.uniforms.time,this.time);
      gl.uniform1f(this.uniforms.fog,this.settings.fog?1:0);gl.uniform1f(this.uniforms.distortion,this.settings.distortion&&!this.reduced.matches?1:0);gl.drawArrays(gl.TRIANGLES,0,6);
    } else {
      const ctx=this.ctx;ctx.fillStyle='#05090d';ctx.fillRect(0,0,this.width,this.height);
      if(this.ready) {const w=2250*this.scale,h=1420*this.scale;ctx.globalAlpha=.55;ctx.drawImage(this.photo,this.width/2-w/2-this.pan.x*.65*this.scale,this.height/2-h/2-this.pan.y*.65*this.scale,w,h);ctx.globalAlpha=1;}
    }
  }
  private draw() {
    this.camera=createCamera(this.width,this.height,this.pan,this.look,this.zoom,this.settings.motion);
    const ctx=this.ctx;ctx.setTransform(this.dpr,0,0,this.dpr,0,0);ctx.clearRect(0,0,this.width,this.height);this.background();
    // Reference lattice: sparse enough to leave the photographic field breathing room.
    ctx.lineWidth=.5;ctx.strokeStyle='rgba(139,191,211,.11)';ctx.beginPath();
    for(let x=-3200;x<=3200;x+=240) for(let y=-2200;y<=2200;y+=240){const p=this.project({x,y,z:0});ctx.moveTo(p.x-2,p.y);ctx.lineTo(p.x+2,p.y);ctx.moveTo(p.x,p.y-2);ctx.lineTo(p.x,p.y+2);}ctx.stroke();
    const count=Math.floor(this.stars.length*this.settings.stars);
    for(let i=0;i<Math.min(count,this.stars.length);i++) {
      const star=this.stars[i],p=this.project(star);if(p.x<0||p.y<0||p.x>this.width||p.y>this.height)continue;
      const alpha=.28+.45*(.5+.5*Math.sin(this.time*.32+star.phase));ctx.fillStyle=`rgba(${star.warm?'247,187,149':'199,229,250'},${alpha})`;ctx.beginPath();ctx.arc(p.x,p.y,star.size*(star.z/450+.4),0,Math.PI*2);ctx.fill();
      if(i%81===0)this.flare(p.x,p.y,star.warm?'.55':'.8',.65);
    }
    if(this.routesVisible) this.drawRoutes();
    this.drawInstruments();
    this.drawObservations();
    if(this.width>=900)this.drawRuler();
  }
  private drawRoutes() {
    const ctx=this.ctx;
    ctx.lineJoin='round';
    for(let i=0;i<this.routes.length;i++) {
      const route=this.routes[i],last=route.samples.length-1;
      // These calm highways are monotonic in screen x at every permitted camera
      // angle. Clip the sample range outside the frame, never simplify the curve.
      const atX=(x:number)=>{
        let low=0,high=last;
        while(low<high){const middle=(low+high)>>>1;if(this.project(route.samples[middle]).x<x)low=middle+1;else high=middle;}
        return low/last;
      };
      const left=Math.max(0,atX(-32)-1/last),right=Math.min(1,atX(this.width+32)+1/last);
      ctx.beginPath();
      const segment=(start:number,end:number)=>{
        start=Math.max(start,left);end=Math.min(end,right);if(start>=end)return;
        const a=this.project(sampleRoute(route.samples,start));ctx.moveTo(a.x,a.y);
        for(let j=Math.floor(start*last)+1;j<end*last;j++){const p=this.project(route.samples[j]);ctx.lineTo(p.x,p.y);}
        const b=this.project(sampleRoute(route.samples,end));ctx.lineTo(b.x,b.y);
      };
      let start=0;
      for(const [entry,exit] of route.gaps){segment(start,entry);start=exit;}
      segment(start,1);
      ctx.strokeStyle=`rgba(${route.color},${route.opacity})`;ctx.lineWidth=route.weight;ctx.stroke();
      // Sparse interruptions include longer transfers. The paired gates
      // stay visible at every zoom and align exactly with the missing geometry.
      for(const [entry,exit] of route.gaps) for(const [t,direction] of [[entry,1],[exit,-1]]) {
        const p=this.project(sampleRoute(route.samples,t));
        if(p.x<-20||p.x>this.width+20||p.y<-20||p.y>this.height+20)continue;
        const q=this.project(sampleRoute(route.samples,t+.0002));
        ctx.save();ctx.translate(p.x,p.y);ctx.rotate(Math.atan2(q.y-p.y,q.x-p.x));
        ctx.strokeStyle=`rgba(${route.color},.8)`;ctx.lineWidth=.9;
        ctx.beginPath();ctx.moveTo(-direction*3,-4);ctx.lineTo(0,0);ctx.lineTo(-direction*3,4);
        ctx.moveTo(direction*3,-3);ctx.lineTo(direction*3,3);ctx.stroke();ctx.restore();
      }
      if(i%4===0||route.gaps.length||route.destination!==undefined) {
        const transit=this.transits[i],p=this.project(sampleRoute(route.samples,transit.t));
        if(p.x>-50&&p.y>-50&&p.x<this.width+50&&p.y<this.height+50) {
          if(transit.warping) {
            const tail=this.project(sampleRoute(route.samples,Math.max(0,transit.t-route.speed*.7)));
            ctx.strokeStyle=`rgba(${route.color},.8)`;ctx.lineWidth=1.1;
            ctx.beginPath();ctx.moveTo(tail.x,tail.y);ctx.lineTo(p.x,p.y);ctx.stroke();this.flare(p.x,p.y,'.95',.6);
          } else if(i%12===0||route.gaps.length)this.flare(p.x,p.y,'.95',.65+Math.sin(this.time*.9+i)*.08);
          else {ctx.save();ctx.translate(p.x,p.y);ctx.rotate(Math.PI/4);ctx.strokeStyle=`rgba(${route.color},.9)`;ctx.lineWidth=.9;ctx.strokeRect(-2.5,-2.5,5,5);ctx.restore();}
        }
        if(transit.flash>0&&!this.reduced.matches) {
          const gate=this.project(sampleRoute(route.samples,transit.entry));
          if(gate.x>-50&&gate.y>-50&&gate.x<this.width+50&&gate.y<this.height+50) {
            ctx.save();ctx.globalAlpha=transit.flash/.34;this.flare(gate.x,gate.y,'1',1.65);ctx.restore();
          }
        }
      }
      if(this.annotationsVisible&&this.zoom>1.4&&i%11===0) {
        const t=.455+(i%7)*.012,p=this.project(sampleRoute(route.samples,t)),next=this.project(sampleRoute(route.samples,t+.0002));
        if(p.x>0&&p.x<this.width&&p.y>50&&p.y<this.height-95) {
          ctx.save();ctx.translate(p.x,p.y);ctx.rotate(Math.atan2(next.y-p.y,next.x-p.x));
          ctx.font='7px monospace';ctx.fillStyle=`rgba(${route.color},.62)`;ctx.fillText(route.label,5,-5);ctx.restore();
        }
      }
    }
  }
  private drawSystems() {
    const ctx=this.ctx;
    for(const system of this.systems) {
      const center=this.project(observations[system.index]);
      const reach=200*this.scale;
      if(center.x<-reach||center.y<-reach||center.x>this.width+reach||center.y>this.height+reach)continue;
      const bodies=[];
      for(const orbit of system.orbits) {
        // Screen-space resolution grows with magnification. Analytic sampling
        // and a periodic final point keep the seam and the tightest arc smooth.
        const steps=Math.max(128,Math.ceil(Math.PI*Math.sqrt(orbit.radius*this.scale*12)));
        const points=Array.from({length:steps+1},(_,i)=>this.project(orbitPoint(orbit,i/steps*Math.PI*2)));
        const color=orbit.warm?'214,155,113':'137,193,213';
        for(const front of [false,true]) {
          ctx.beginPath();let connected=false;
          for(let j=0;j<points.length;j++) {
            const p=points[j];
            if((p.depth>=center.depth)!==front){connected=false;continue;}
            if(!connected){const previous=points[Math.max(0,j-1)];ctx.moveTo(previous.x,previous.y);}
            ctx.lineTo(p.x,p.y);connected=true;
          }
          ctx.strokeStyle=`rgba(${color},${front?.48:.18})`;ctx.lineWidth=front?.85:.6;ctx.stroke();
        }
        bodies.push({p:this.project(orbitPoint(orbit,orbit.phase+this.time*orbit.speed)),warm:orbit.warm,transfer:this.routesVisible&&this.detours.some(detour=>detour.orbit===orbit)});
      }
      // A shaded sphere, rather than a flat symbol, orbits on each tilted plane.
      for(const {p,warm,transfer} of bodies.sort((a,b)=>a.p.depth-b.p.depth)) {
        const radius=Math.max(1.3,Math.min(4.2,this.scale*2.4));
        const light=ctx.createRadialGradient(p.x-radius*.4,p.y-radius*.4,0,p.x,p.y,radius);
        light.addColorStop(0,warm?'#f0d4ad':'#dfedf4');light.addColorStop(.4,warm?'#aa8062':'#789cb6');light.addColorStop(1,'#101d2b');
        ctx.fillStyle=light;ctx.beginPath();ctx.arc(p.x,p.y,radius,0,Math.PI*2);ctx.fill();
        if(transfer) {
          ctx.strokeStyle='rgba(181,219,230,.64)';ctx.lineWidth=.7;ctx.beginPath();ctx.arc(p.x,p.y,radius+4,.25,Math.PI*1.65);ctx.stroke();
          if(this.annotationsVisible&&this.zoom>1.5){ctx.font='6px monospace';ctx.fillStyle='rgba(182,214,222,.6)';ctx.fillText('ORBITAL TRANSFER',p.x+12,p.y+14);}
        }
      }
    }
  }
  private drawInstruments() {
    const ctx=this.ctx;
    this.drawSystems();
    observations.forEach((node,index)=>{
      if(index<7)return;
      const p=this.project(node),r=(38+index%4*9)*Math.min(this.zoom,2);
      if(p.x<-r||p.y<-r||p.x>this.width+r||p.y>this.height+r)return;
      ctx.save();ctx.translate(p.x,p.y);ctx.strokeStyle='rgba(144,190,206,.28)';ctx.lineWidth=.6;
      if(this.systems.some(system=>system.index===index)) {
        // Orbital geometry is rendered in world space above.
      } else if(index%3===1) {
        ctx.rotate(index*.3);for(let ring=0;ring<3;ring++){const length=r+ring*12;ctx.strokeRect(-length/2,-length/2,length,length);}
        ctx.beginPath();ctx.moveTo(-r*1.3,0);ctx.lineTo(r*1.3,0);ctx.moveTo(0,-r*1.3);ctx.lineTo(0,r*1.3);ctx.stroke();
      } else {
        ctx.beginPath();ctx.arc(0,0,r,.3,Math.PI*1.8);ctx.stroke();
        for(let tick=0;tick<24;tick++){const a=tick/24*Math.PI*2;ctx.beginPath();ctx.moveTo(Math.cos(a)*r,Math.sin(a)*r);ctx.lineTo(Math.cos(a)*(r+(tick%3===0?8:3)),Math.sin(a)*(r+(tick%3===0?8:3)));ctx.stroke();}
      }
      ctx.restore();
      if(this.zoom>1.65&&this.annotationsVisible) {
        const x=p.x+28,y=p.y+42;ctx.font='7px monospace';ctx.fillStyle='rgba(176,207,216,.57)';
        ctx.fillText(`ARCHIVE ${String(index+1).padStart(3,'0')} / SIMULATED`,x,y);
        ctx.fillText(`X ${node.x.toFixed(1)}   Y ${node.y.toFixed(1)}`,x,y+12);
        ctx.strokeStyle='rgba(129,183,207,.4)';ctx.beginPath();
        for(let j=0;j<=72;j++){const wave=Math.sin(j*.34+index)*Math.cos(j*.11+index)*8; if(j===0)ctx.moveTo(x+j,y+32+wave);else ctx.lineTo(x+j,y+32+wave);}ctx.stroke();
      }
    });
  }
  private flare(x:number,y:number,alpha:string,scale:number) {
    const ctx=this.ctx;const r=18*scale;const gradient=ctx.createRadialGradient(x,y,0,x,y,r);gradient.addColorStop(0,`rgba(224,244,255,${alpha})`);gradient.addColorStop(.09,'rgba(180,215,255,.5)');gradient.addColorStop(.28,'rgba(113,177,249,.13)');gradient.addColorStop(1,'rgba(65,137,213,0)');ctx.fillStyle=gradient;ctx.fillRect(x-r,y-r,r*2,r*2);
    ctx.strokeStyle='rgba(211,236,255,.7)';ctx.lineWidth=.6;ctx.beginPath();ctx.moveTo(x-r*1.45,y);ctx.lineTo(x+r*1.45,y);ctx.moveTo(x,y-r*.4);ctx.lineTo(x,y+r*.4);ctx.stroke();ctx.fillStyle='#ecf6ff';ctx.fillRect(x-.7,y-.7,1.4,1.4);
  }
  private drawObservations() {
    const ctx=this.ctx;this.hitPoints=[];
    observations.forEach((node,index)=>{
      const p=this.project(node);this.hitPoints.push(p);if(p.x<-170||p.y<-100||p.x>this.width+170||p.y>this.height+100)return;
      const active=this.selected===index||this.hover===index;
      ctx.strokeStyle=active?'rgba(197,234,240,.95)':'rgba(150,197,209,.56)';ctx.lineWidth=active?1.1:.85;
      const size=active?12:7;ctx.beginPath();ctx.moveTo(p.x,p.y-size);ctx.lineTo(p.x+size,p.y);ctx.lineTo(p.x,p.y+size);ctx.lineTo(p.x-size,p.y);ctx.closePath();ctx.stroke();
      ctx.beginPath();ctx.moveTo(p.x-17,p.y);ctx.lineTo(p.x-10,p.y);ctx.moveTo(p.x+10,p.y);ctx.lineTo(p.x+17,p.y);ctx.moveTo(p.x,p.y-17);ctx.lineTo(p.x,p.y-10);ctx.moveTo(p.x,p.y+10);ctx.lineTo(p.x,p.y+17);ctx.stroke();
      this.flare(p.x,p.y,'.8',active?.8:.37);
      if(!this.annotationsVisible||this.width<240)return;
      const labelX=p.x+24,labelY=p.y-25;ctx.font='8px "SFMono-Regular",Consolas,monospace';ctx.fillStyle=active?'#d2eef4':'rgba(192,217,225,.78)';ctx.fillText(`${String(index+1).padStart(2,'0')} / ${node.label}`,labelX,labelY);
      ctx.fillStyle='rgba(157,193,204,.48)';ctx.font='7px "SFMono-Regular",Consolas,monospace';ctx.fillText(node.value,labelX,labelY+13);
      ctx.strokeStyle='rgba(148,199,215,.26)';ctx.beginPath();ctx.moveTo(p.x+7,p.y-7);ctx.lineTo(labelX-5,labelY-4);ctx.lineTo(labelX+38,labelY-4);ctx.stroke();
      const sourceIndex=calloutAssignments[index],image=this.callouts[sourceIndex];
      if(this.width>=600&&image?.complete&&image.naturalWidth>0) {
        const source=calloutSources[sourceIndex],w=source.width,h=Math.round(w*image.naturalHeight/image.naturalWidth);
        const system=this.systems.find(system=>system.index===index);
        const extent=system?system.orbits[system.orbits.length-1].radius*this.scale*1.18:0;
        const x=system?p.x+extent+28:p.x-25,y=p.y+36;
        if(system) {
          ctx.strokeStyle='rgba(154,197,214,.24)';ctx.lineWidth=.6;ctx.beginPath();
          ctx.moveTo(p.x+extent*.8,p.y+22);ctx.lineTo(x-12,y+12);ctx.lineTo(x-3,y+12);ctx.stroke();
        }
        ctx.strokeStyle='rgba(154,197,214,.45)';ctx.strokeRect(x-3,y-3,w+6,h+6);ctx.save();ctx.globalAlpha=.94;
        ctx.drawImage(image,x,y,w,h);ctx.restore();
        ctx.strokeStyle='rgba(154,197,214,.2)';ctx.strokeRect(x-7,y-7,w+14,h+14);ctx.font='6px monospace';ctx.fillStyle='rgba(192,216,225,.65)';ctx.fillText(source.label,x,y+h+17);
      }
    });
  }
  private drawRuler() {
    const ctx=this.ctx;ctx.strokeStyle='rgba(164,200,215,.3)';ctx.fillStyle='rgba(164,200,215,.45)';ctx.font='7px monospace';
    for(let x=340;x<this.width-190;x+=80){ctx.beginPath();ctx.moveTo(x,26);ctx.lineTo(x,30);ctx.stroke();ctx.fillText(((x-this.width/2)/this.scale/100).toFixed(1),x-5,20);}
    ctx.save();ctx.translate(this.width-26,this.height*.42);ctx.rotate(Math.PI/2);ctx.fillStyle='rgba(160,198,213,.45)';ctx.fillText('CONTINUOUS FIELD / NO FIXED DESTINATION',0,0);ctx.restore();
  }
  private animate=(stamp:number)=>{
    this.frame=0;if(this.dead||document.hidden)return;
    const delta=Math.min((stamp-(this.last||stamp))/1000,.06);this.last=stamp;
    if(!this.frozen) {
      const elapsed=delta*this.settings.motion;this.time+=elapsed;
      this.detours.forEach(detour=>updateDetour(detour,this.time));
      this.transits.forEach((transit,i)=>advanceTransit(transit,this.routes[i],elapsed));
    }
    if(this.touring&&!this.frozen&&this.settings.motion>0) {
      this.tourPhase+=delta/160*(.5+this.settings.motion*.5);
      const destination=sampleRoute(principalHighway,tourRange.center+tourRange.radius*Math.sin(this.tourPhase));
      this.target={x:destination.x,y:destination.y};
    }
    const ease=this.reduced.matches?1:1-Math.exp(-delta*(this.touring?.9:8));
    this.pan.x+=(this.target.x-this.pan.x)*ease;this.pan.y+=(this.target.y-this.pan.y)*ease;this.zoom+=(this.targetZoom-this.zoom)*ease;
    this.look.x+=(this.pointer.x-this.look.x)*ease;this.look.y+=(this.pointer.y-this.look.y)*ease;
    this.draw();this.onChange(this.zoom,this.pan.x,this.pan.y);
    this.onFrame(this.time,!this.frozen&&!this.reduced.matches&&this.settings.motion>0);
    const moving=Math.abs(this.pan.x-this.target.x)+Math.abs(this.pan.y-this.target.y)+Math.abs(this.zoom-this.targetZoom)+Math.abs(this.look.x-this.pointer.x)+Math.abs(this.look.y-this.pointer.y)>.002;
    if(!this.frozen&&this.settings.motion>0||moving)this.frame=requestAnimationFrame(this.animate);
  };
  wake() { if(!this.frame&&!this.dead&&!document.hidden){this.last=0;this.frame=requestAnimationFrame(this.animate);} }
  stopTour() {if(!this.touring)return;this.touring=false;this.target={...this.pan};this.onTour(false);this.wake();}
  toggleTour() {
    if(this.touring){this.stopTour();return;}
    if(this.settings.motion===0)return;
    if(this.frozen)this.setFrozen(false);
    let closest=.5,distance=Infinity;
    for(let t=tourRange.center-tourRange.radius;t<=tourRange.center+tourRange.radius;t+=.001){const p=sampleRoute(principalHighway,t),d=Math.hypot(p.x-this.pan.x,p.y-this.pan.y);if(d<distance){closest=t;distance=d;}}
    this.tourPhase=Math.asin(clamp((closest-tourRange.center)/tourRange.radius,-1,1));this.touring=true;this.onTour(true);this.wake();
  }
  setFrozen(value:boolean) {this.frozen=value;if(value){this.stopTour();this.target={...this.pan};this.targetZoom=this.zoom;}this.onFrozen(value);this.wake();}
  setSettings(value:Partial<FieldSettings>) {Object.assign(this.settings,value);if(this.settings.motion===0)this.stopTour();this.wake();}
  select(index:number,center=false) {this.stopTour();this.selected=index;if(center&&index>=0){const p=observations[index];this.target.x=clamp(p.x,-navigation.panX,navigation.panX);this.target.y=clamp(p.y,-navigation.panY,navigation.panY);}this.wake();}
  magnify(factor:number) {this.stopTour();this.targetZoom=clamp(this.targetZoom*factor,navigation.minZoom,navigation.maxZoom);this.wake();}
  reset() {this.stopTour();this.target={x:0,y:0};this.targetZoom=1;this.wake();}
  private move(dx:number,dy:number) {this.stopTour();this.target.x=clamp(this.target.x+dx/this.scale,-navigation.panX,navigation.panX);this.target.y=clamp(this.target.y+dy/this.scale,-navigation.panY,navigation.panY);this.wake();}
  private bind() {
    const options={signal:this.events.signal};
    this.element.addEventListener('pointerdown',e=>{if(e.button!==0)return;this.stopTour();const bounds=this.element.getBoundingClientRect();this.hover=this.hitPoints.findIndex(p=>Math.hypot(p.x-e.clientX+bounds.left,p.y-e.clientY+bounds.top)<23);this.element.focus({preventScroll:true});this.element.setPointerCapture(e.pointerId);this.pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});if(this.pointers.size===1){this.travel=0;this.pinched=false;}else this.pinched=true;this.element.classList.add('dragging');},options);
    this.element.addEventListener('pointermove',e=>{
      const bounds=this.element.getBoundingClientRect(),x=e.clientX-bounds.left,y=e.clientY-bounds.top;
      this.pointer={x:(x/this.width-.5)*2,y:(y/this.height-.5)*2};
      this.hover=this.hitPoints.findIndex(p=>Math.hypot(p.x-x,p.y-y)<23);this.element.classList.toggle('over-beacon',this.hover>=0);
      const old=this.pointers.get(e.pointerId);if(old){
        const dx=e.clientX-old.x,dy=e.clientY-old.y;this.travel+=Math.hypot(dx,dy);
        if(this.pointers.size===2){const other=[...this.pointers.entries()].find(([id])=>id!==e.pointerId)![1];const before=Math.hypot(old.x-other.x,old.y-other.y),after=Math.hypot(e.clientX-other.x,e.clientY-other.y);if(before>2)this.magnify(after/before);this.move(-dx*.5,-dy*.5);}
        else this.move(-dx,-dy);
        this.pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
      }this.wake();
    },options);
    const end=(e:PointerEvent)=>{const tracked=this.pointers.has(e.pointerId);this.pointers.delete(e.pointerId);if(!this.pointers.size)this.element.classList.remove('dragging');if(tracked&&e.type==='pointerup'&&!this.pinched&&this.travel<6&&this.hover>=0)this.onSelect(this.hover);};
    this.element.addEventListener('pointerup',end,options);this.element.addEventListener('pointercancel',end,options);this.element.addEventListener('lostpointercapture',end,options);
    this.element.addEventListener('pointerleave',()=>{if(!this.pointers.size){this.pointer={x:0,y:0};this.hover=-1;this.wake();}},options);
    this.element.addEventListener('wheel',e=>{e.preventDefault();const unit=e.deltaMode===1?16:e.deltaMode===2?this.height:1;if(e.ctrlKey||e.metaKey)this.magnify(Math.exp(-e.deltaY*unit*.004));else this.move((e.deltaX+(e.shiftKey?e.deltaY:0))*unit,e.shiftKey?0:e.deltaY*unit);},{...options,passive:false});
    this.element.addEventListener('keydown',e=>{let handled=true;switch(e.key){case'ArrowLeft':this.move(-70,0);break;case'ArrowRight':this.move(70,0);break;case'ArrowUp':this.move(0,-70);break;case'ArrowDown':this.move(0,70);break;case'+':case'=':this.magnify(1.1);break;case'-':this.magnify(1/1.1);break;case'Home':this.reset();break;case' ':this.setFrozen(!this.frozen);break;default:handled=false;}if(handled)e.preventDefault();},options);
    document.addEventListener('visibilitychange',()=>{if(document.hidden){cancelAnimationFrame(this.frame);this.frame=0;}else this.wake();},options);
    this.reduced.addEventListener('change',()=>this.setFrozen(this.reduced.matches),options);
  }
  destroy() {this.dead=true;cancelAnimationFrame(this.frame);this.events.abort();this.resizeObserver.disconnect();this.photo.onload=null;this.callouts.forEach(image=>{image.onload=null;image.onerror=null;});this.gl?.deleteTexture(this.texture);this.gl?.deleteTexture(this.slices);this.gl?.deleteBuffer(this.buffer);this.gl?.deleteProgram(this.program);}
}