import { LandscapeRenderer, KIND } from './renderer.js';
import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, folderBlades, folderLabel, facadeQuad, heading, tourStops, encodePlace, decodePlace, joystick, buildNavGrid, routesFrom, spawnWanderers, stepWanderers, alertsByPath, burns, tapeQuads, cityWalls, posterQuads, seededRandom, WALL, ROUTE_KIND, enterBuilding, MOVE } from './city.js';
import { LabelAtlas, PosterAtlas } from './labels.js';
import { apiFetch, progressEvents } from './api.mjs';
const configuredBackend=window.CODENAVIGATOR_CONFIG?.backendUrl?.trim()||'';
const API_BASE=configuredBackend.replace(/\/+$/,'');
const apiUrl=path=>`${API_BASE}${path}`;
const $ = selector => document.querySelector(selector);
const $$ = selector => [...document.querySelectorAll(selector)];
document.addEventListener('pointerdown',()=>{document.documentElement.dataset.inputMethod='pointer';},true);
document.addEventListener('keydown',()=>{document.documentElement.dataset.inputMethod='keyboard';},true);
const glCanvas = $('#glCanvas');
const overlay = $('#overlayCanvas');
const overlayCtx = overlay.getContext('2d');
const viewport = $('#viewport');
const MAX_2D_ZOOM=260;
const MIN_3D_DISTANCE=.52;
let renderer;
try { renderer = new LandscapeRenderer(glCanvas); }
catch (error) { $('#emptyState').hidden=false; $('#emptyState strong').textContent='WebGL2 is unavailable'; $('#emptyState span').textContent=error.message; throw error; }
const palettes = [
{ application:'#1fbf8c', library:'#28aa8b', test:'#c7a62e', generated:'#cc7741', vendor:'#4e83bc', platform:'#d05492', docs:'#8d9561', unknown:'#65706a' },
{ application:'#48a9e6', library:'#7874db', test:'#e6a04b', generated:'#d6627c', vendor:'#45b39d', platform:'#b781d4', docs:'#9ca35c', unknown:'#697277' },
{ application:'#87b95d', library:'#39a5a0', test:'#e2bf52', generated:'#e37d52', vendor:'#527ac2', platform:'#c76699', docs:'#a4a4a4', unknown:'#6b706d' },
];
let paletteIndex=0;
let files=[];
let edges=[];
let layoutItems=[];
let directoryRects=[];
let fileById=new Map();
let layoutById=new Map();
let referenceDegree=new Map();
let edgesByFile=new Map();
let sceneStats={definitions:0,totalLines:0,known:0,inferred:0,layers:new Map()};
let spatialGrid=new Map();
let visibleScreenItems=[];
let overviewCanvas=null,overviewContext=null,overviewIndex=0,overviewUploaded=-1,overviewUploadedAt=0;
let codeTextureBytes=0;
const codeTextureLru=new Map(),codeTextureBudget=192*1024*1024,overviewScale=3;
let selected=null;
let history=[];
let searchHits=[];
let expandedCoverageLayer=null;
let coverageActiveIndex=-1;
let cameraAnimation=null;
let cityModel=null,cityEntries=[],cityIndex=null,collisionIndex=null,cityWallBoxes=[],cityFitted=false,cityDistricts=[],cityFolders=[],navGrid=null,cityRoutes=[],wanderers=[],showResidents=true;
let currentSnapshot=null;
let currentName='';
let currentMetric='lines';
let showCode=true;
let showLegend=false;
let dirty=true;
let pointer={down:false,x:0,y:0,startX:0,startY:0,action:'orbit',id:null};
let lastFrame=performance.now(), frameSamples=[];
const layerLabels = { application:'Application',library:'Libraries',test:'Tests',generated:'Generated',vendor:'Vendor',platform:'Platform',docs:'Documentation',unknown:'Other' };
function toRgb(hex){const value=parseInt(hex.slice(1),16);return[((value>>16)&255)/255,((value>>8)&255)/255,(value&255)/255];}
function fileWeight(file){
if(currentMetric==='references'){
const degree=referenceDegree.get(file.id)||0;
return Math.max(1,degree*55+(file.symbolCount??file.symbols?.length??0)*7);
}
return Math.max(1,file.lines);
}
function makeTree(list){
// Repositories frequently keep source files at their root. Root participates
// in the same layout contract as every child directory.
const root={name:currentName,path:'',children:new Map(),files:[],weight:0,depth:0};
for(const file of list){
const parts=file.path.split('/'), filename=parts.pop(); let node=root;
for(const part of parts){
if(!node.children.has(part)) node.children.set(part,{name:part,path:node.path?`${node.path}/${part}`:part,children:new Map(),files:[],weight:0,depth:node.depth+1});
node=node.children.get(part);
}
node.files.push({name:filename,file,weight:fileWeight(file)});
}
function weigh(node){
let weight=0;const layers=new Map();
for(const item of node.files||[]){weight+=item.weight;const layer=item.file.layer in layerLabels?item.file.layer:'unknown';layers.set(layer,(layers.get(layer)||0)+item.weight);}
for(const child of node.children.values()){const childWeight=weigh(child);weight+=childWeight;layers.set(child.layer,(layers.get(child.layer)||0)+childWeight);}
node.weight=weight;node.layer=[...layers.entries()].sort((a,b)=>b[1]-a[1])[0]?.[0]||'unknown';return weight;
}
weigh(root); return root;
}
function computeLayout(){
const root=makeTree(files); layoutItems=[];directoryRects=[];
function visit(node,x,y,w,h,depth){
directoryRects.push({node,x,y,w,h,depth});
const pad=Math.min(3.2,1.2+depth*.35,w*.07,h*.07),header=Math.min(8,Math.max(0,h*.05)),ix=x+pad,iy=y+pad+header,iw=Math.max(.01,w-pad*2),ih=Math.max(.01,h-pad*2-header);
const values=[...node.children.values(),...(node.files||[])],total=values.reduce((sum,value)=>sum+value.weight,0);
// A file should be compared with its siblings, not visually erased by the
// aggregate weight of a huge neighboring subtree. The local floor keeps
// every entry readable while leaving at least 87.5% of each region fully
// proportional to the selected metric.
const floor=total/Math.max(1,values.length*8);
const entries=values.map(value=>({value,weight:Math.max(value.weight,floor)})).sort((a,b)=>b.weight-a.weight);
squarifiedLayout(entries,ix,iy,iw,ih,depth,(wrapped,rx,ry,rw,rh)=>{
const entry=wrapped.value;
if(entry.file){
const inset=Math.min(1,Math.min(rw,rh)*.08),layer=entry.file.layer in palettes[paletteIndex]?entry.file.layer:'unknown';
layoutItems.push({...entry.file,x:rx+inset,y:ry+inset,w:Math.max(.1,rw-inset*2),h:Math.max(.1,rh-inset*2),color:toRgb(palettes[paletteIndex][layer]),height:4+Math.log2(entry.file.lines+entry.file.complexity*5)*5});
}else visit(entry,rx,ry,rw,rh,depth+1);
});
}
visit(root,0,0,1000,680,0);
layoutById=new Map(layoutItems.map(item=>[item.id,item]));buildSpatialGrid();resetCodeCaches();
renderer.setData(layoutItems);computeCity();renderer.setSelected(selected?.id||0);dirty=true;
}
// The city shares files, colours and the code overview atlas with the landscape but has its own
// street layout in metres. Each layout item keeps its building as item.city.
function computeCity(){
cityModel=layoutCity(files,currentName);
const instances=[{x:-80,y:-80,w:cityModel.width+160,h:cityModel.height+160,height:.02,color:[0,0,0],kind:KIND.ground}];
for(const block of cityModel.blocks)if(block.depth>0)instances.push({x:block.x,y:block.y,w:block.w,h:block.h,height:.1+.06*Math.min(block.depth,4),color:[.95,.9,.78],kind:KIND.block});
cityEntries=[];
for(const item of layoutItems){
const building=cityModel.buildings.get(item.id);if(!building)continue;
item.city={...building,file:item};cityEntries.push(item.city);
// More definitions light more windows.
const symbols=item.symbolCount??item.symbols?.length??0;
instances.push({...building,color:item.color,atlas:[item.x/1000,item.y/680,item.w/1000,item.h/680],kind:KIND.building,id:item.id,lit:Math.min(.85,.06+Math.sqrt(symbols)/9)});
}
cityIndex=spatialIndex(cityEntries);
// The wall keeps walkers, residents and routes inside; it's in the collision index but not the pick index.
cityWallBoxes=cityWalls(cityModel);
collisionIndex=spatialIndex([...cityEntries,...cityWallBoxes]);
navGrid=buildNavGrid([...cityEntries,...cityWallBoxes],cityModel.blocks,cityModel);
updateFires();
spawnResidents();
// Each folder's own buildings (not its subfolders'), for street-corner signs.
const byDirectory=new Map();
for(const b of cityEntries){const list=byDirectory.get(b.file.directory||'');if(list)list.push(b);else byDirectory.set(b.file.directory||'',[b]);}
cityFolders=cityModel.blocks.map(block=>({block,buildings:byDirectory.get(block.node.path)||[],label:folderLabel(block.node.path,currentName)})).filter(folder=>folder.buildings.length);
const tally=node=>{let files=node.files.length,lines=node.files.reduce((n,f)=>n+(f.lines||0),0);for(const child of node.children.values()){const t=tally(child);files+=t.files;lines+=t.lines;}return{files,lines};};
cityDistricts=cityModel.blocks.filter(block=>block.depth===1).map(block=>({block,...tally(block.node),top:Math.max(10,...[...cityIndex.near(block.x+block.w/2,block.y+block.h/2,Math.hypot(block.w,block.h)/2)].filter(b=>b.x>=block.x&&b.x+b.w<=block.x+block.w&&b.y>=block.y&&b.y+b.h<=block.y+block.h).map(b=>b.height))}));
for(const wall of cityWallBoxes)instances.push({...wall,color:[.78,.95,.21],kind:KIND.wall});
pastePosters();
renderer.setCity(instances,{width:cityModel.width,height:cityModel.height});
buildMinimap();updateCityHud();updateTrails();updateRoutes();updateBeacons();signState.key='';facadeState={entry:null,texture:null};renderer.setFacade(null);
}
// ---- City navigation: helicopter, walk and fly cameras, HUD, crosshair and minimap ----
const heldKeys=new Set(),MOVE_KEYS=new Set(['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','ShiftLeft','ShiftRight','Space','KeyC']);
let minimapBase=null,cityTargetEntry=null,lastAddress='';
const COARSE=matchMedia('(pointer: coarse)').matches;
const CITY_HELP=COARSE?{
heli:'Drag orbit · Pinch zoom · Double-tap a street to walk',
walk:'Left thumb move · Right thumb look · Tap a building to inspect',
fly:'Left thumb fly · Right thumb look · Pinch climb',
}:{
heli:'Drag orbit · Shift+drag pan · Scroll zoom · Double-click a street to walk',
walk:'Click to look · WASD move · Shift run · E inspect · Esc release mouse',
fly:'Click to look · WASD fly · Space/C up/down · Shift fast · E inspect',
};
function setCityView(view,at){
const c=renderer.city;if(!cityModel||(view===c.view&&!at))return;
cancelCameraAnimation();
const {eye,forward}=renderer.cityPose();
if(view==='heli'){
// Rise out of the street, keeping the heading.
Object.assign(c,{view,x:c.ex,y:c.ey,yaw:c.lookYaw,pitch:.62,distance:Math.max(3,c.ez)});
animateCameraTo({distance:Math.max(180,c.ez*2)},c,1000);
if(document.pointerLockElement)document.exitPointerLock();
}else{
const fromHeli=c.view==='heli';
c.view=view;
if(fromHeli||at){
if(fromHeli)Object.assign(c,{ex:eye[0],ey:eye[1],ez:eye[2],lookYaw:Math.atan2(-forward[0],-forward[1]),lookPitch:Math.asin(Math.max(-1,Math.min(1,forward[2])))});
// With routes showing, walking starts at the selected building's door facing down the first route.
// Every route starts (imports) or ends (dependents) at the selected building; walk out from its door.
const first=cityRoutes[0],route=!at&&view==='walk'&&first?.points.length>2?(first.outgoing?first.points:[...first.points].reverse()).slice(1):null; // skip the point inside the building
const target=at||route?.[0]||[c.x,c.y],spot=collide(target[0],target[1],MOVE.radius*3,collisionIndex);
const facing=route?Math.atan2(-(route[1][0]-route[0][0]),-(route[1][1]-route[0][1])):null;
animateCameraTo({ex:spot.x,ey:spot.y,ez:view==='walk'?MOVE.eyeHeight:Math.min(Math.max(60,c.ez*.5),260),lookYaw:facing??(view==='walk'?openHeading(spot.x,spot.y,c.lookYaw):c.lookYaw),lookPitch:view==='walk'?(route?-.18:0):-.3},c,1100);
}else if(view==='walk'){
const spot=collide(c.ex,c.ey,MOVE.radius*3,collisionIndex);
animateCameraTo({ex:spot.x,ey:spot.y,ez:MOVE.eyeHeight,lookPitch:0},c,800);
}
}
updateCityHud();dirty=true;
}
// Of eight headings, the one with the longest clear view at eye level, preferring the current one.
function openHeading(x,y,current){
let best=current,clear=-1;
for(let i=0;i<8;i++){
const yaw=current+i*Math.PI/4,dir=[-Math.sin(yaw),-Math.cos(yaw),0];
const distance=Math.min(pickRay([x,y,MOVE.eyeHeight],dir,cityEntries,400)?.distance??400,400);
if(distance>clear+5){clear=distance;best=yaw;}
}
return best;
}
function stepCity(seconds){
const down=code=>heldKeys.has(code)?1:0;
stepCamera(renderer.city,{
forward:Math.max(-1,Math.min(1,down('KeyW')+down('ArrowUp')-down('KeyS')-down('ArrowDown')+touchMove.forward)),
right:Math.max(-1,Math.min(1,down('KeyD')+down('ArrowRight')-down('KeyA')-down('ArrowLeft')+touchMove.right)),
up:down('Space')-down('KeyC'),
run:heldKeys.has('ShiftLeft')||heldKeys.has('ShiftRight'),
},seconds,collisionIndex,{width:cityModel.width,height:cityModel.height,edge:WALL.margin-MOVE.radius});
dirty=true;
}
function lookCity(dx,dy){
const c=renderer.city;
c.lookYaw-=dx*.0022;c.lookPitch=Math.max(-1.45,Math.min(1.45,c.lookPitch-dy*.0022));dirty=true;
}
function handleCityKey(event){
const c=renderer.city;
if(event.code==='KeyT'){event.preventDefault();tour?endTour():startTour();return true;}
if(tour)endTour();
if(/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName))return false;
const views={Digit1:'heli',Digit2:'walk',Digit3:'fly'};
if(views[event.code]){event.preventDefault();setCityView(views[event.code]);return true;}
if(event.code==='KeyE'){event.preventDefault();inspectCityTarget(true);return true;}
if(c.view!=='heli'&&MOVE_KEYS.has(event.code)){event.preventDefault();cancelCameraAnimation();heldKeys.add(event.code);dirty=true;return true;}
return false;
}
document.addEventListener('keyup',event=>heldKeys.delete(event.code));
window.addEventListener('blur',()=>heldKeys.clear());
document.addEventListener('mousemove',event=>{if(document.pointerLockElement===glCanvas&&renderer.mode==='city')lookCity(event.movementX,event.movementY);});
document.addEventListener('pointerlockchange',()=>{if(!document.pointerLockElement)heldKeys.clear();updateCityHud();});
function inspectCityTarget(openPanel){
if(!cityTargetEntry)return;
selectFile(cityTargetEntry.file);
if(openPanel){switchTab('inspector');if(document.pointerLockElement)document.exitPointerLock();}
}
function updateCityHud(){
const inCity=renderer.mode==='city'&&!!cityModel,view=renderer.city.view;
$('#cityHud').hidden=!inCity;$('#minimap').hidden=!inCity;$('#cityAddress').hidden=!inCity;
$('#crosshair').hidden=!inCity||view==='heli';
if(!inCity||view==='heli'){$('#cityTarget').hidden=true;cityTargetEntry=null;}
$$('[data-city-view]').forEach(button=>button.classList.toggle('active',button.dataset.cityView===view));
$('#cityHelp').innerHTML=CITY_HELP[view];
if(!inCity&&document.pointerLockElement)document.exitPointerLock();
}
$$('[data-city-view]').forEach(button=>button.addEventListener('click',()=>{button.blur();endTour();setCityView(button.dataset.cityView);}));
// ---- Touch: left-thumb joystick while walking or flying, drag to look, pinch to zoom ----
const touches=new Map();let joy=null,pinch=null,touchMove={forward:0,right:0};
function startTouch(event){
touches.set(event.pointerId,{x:event.clientX,y:event.clientY});
if(touches.size===2){
pointer.down=false;endJoystick();
const [a,b]=[...touches.values()];pinch={distance:Math.hypot(a.x-b.x,a.y-b.y)};
viewport.setPointerCapture(event.pointerId);event.preventDefault();return true;
}
const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top;
if(renderer.mode==='city'&&renderer.city.view!=='heli'&&x=2){
const [a,b]=[...touches.values()],distance=Math.hypot(a.x-b.x,a.y-b.y),ratio=distance/Math.max(1,pinch.distance);pinch.distance=distance;
const amount=-Math.log(ratio||1),c=renderer.city;
if(renderer.mode==='city'){if(c.view==='heli')dollyCity(amount);else if(c.view==='fly')c.ez=Math.max(MOVE.eyeHeight,Math.min(3000,c.ez-amount*120));}
else if(renderer.mode==='3d')dolly3d(amount);
else renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*ratio));
dirty=true;return true;
}
if(joy&&event.pointerId===joy.id){
const dx=event.clientX-joy.x0,dy=event.clientY-joy.y0,length=Math.min(56,Math.hypot(dx,dy)),angle=Math.atan2(dy,dx);
touchMove=joystick(dx,dy);
$('#joystick').firstElementChild.style.transform=`translate(${Math.cos(angle)*length}px,${Math.sin(angle)*length}px)`;
dirty=true;return true;
}
return false;
}
function endTouch(event){
touches.delete(event.pointerId);
if(joy?.id===event.pointerId)endJoystick();
if(touches.size<2)pinch=null;
}
function endJoystick(){joy=null;touchMove={forward:0,right:0};$('#joystick').hidden=true;}
viewport.addEventListener('pointercancel',event=>{if(event.pointerType==='touch')endTouch(event);});
// ---- Guided tour: a helicopter flight over the largest districts ----
let tour=null;
function startTour(){
if(!cityModel||renderer.mode!=='city')return;
const stops=tourStops(cityDistricts);if(!stops.length)return;
if(renderer.city.view!=='heli')setCityView('heli');
tour={stops,index:-1,dwellUntil:0};$('#tourButton').classList.add('active');nextTourStop();
}
function nextTourStop(){
tour.index++;
const caption=$('#tourCaption');
if(tour.index>=tour.stops.length){
const size=Math.max(cityModel.width,cityModel.height);
animateCameraTo({x:cityModel.width/2,y:cityModel.height/2,distance:size*1.05,pitch:.72,yaw:renderer.city.yaw+.6},renderer.city,3000);
endTour();return;
}
const {district,pose}=tour.stops[tour.index];
animateCameraTo({...pose,yaw:renderer.city.yaw+((pose.yaw-renderer.city.yaw)%(Math.PI*2))},renderer.city,2600);
tour.dwellUntil=performance.now()+2600+3400;
caption.hidden=false;
caption.innerHTML=`${tour.index+1} / ${tour.stops.length}${escapeHtml(district.block.node.name)}${format(district.files)} files · ${format(district.lines)} lines`;
}
function advanceTour(now,seconds){
if(!cameraAnimation&&now=tour.dwellUntil)nextTourStop();
}
function endTour(){if(!tour)return;tour=null;$('#tourCaption').hidden=true;$('#tourButton').classList.remove('active');}
$('#tourButton').addEventListener('click',event=>{event.currentTarget.blur();tour?endTour():startTour();});
// ---- Shareable places: repository, view, camera and file in the URL fragment ----
let currentRepoUrl='';
$('#shareButton').addEventListener('click',async event=>{
const button=event.currentTarget,labelElement=button.querySelector('.label'),label=labelElement.textContent;button.blur();
const flash=text=>{labelElement.textContent=text;button.classList.add('done');setTimeout(()=>{labelElement.textContent=label;button.classList.remove('done');},1800);};
if(!currentRepoUrl){flash('Needs a GitHub repo');return;}
const hash=encodePlace({repo:currentRepoUrl,view:renderer.mode,camera:renderer.city,file:selected?.path});
window.history.replaceState(null,'',hash);
try{await navigator.clipboard.writeText(location.href);flash('Link copied');}catch{flash('Link in address bar');}
});
// ---- Opening shared links: #repo=…&view=…&cam=…&file=… ----
const repoName=url=>url.replace(/^https:\/\/github\.com\//,'');
function pendingPlace(){try{return JSON.parse(sessionStorage.getItem('codenav.pendingPlace')||'null');}catch{return null;}}
function clearPendingPlace(){try{sessionStorage.removeItem('codenav.pendingPlace');}catch{}}
const sameRepo=(a,b)=>!!a&&!!b&&a.replace(/\/+$/,'').replace(/\.git$/,'').toLowerCase()===b.replace(/\/+$/,'').replace(/\.git$/,'').toLowerCase();
function openPlaceFromHash(){
const place=decodePlace(location.hash);if(!place)return;
// Already open in this tab: just go there.
if(sameRepo(place.repo,currentRepoUrl)&&files.length){goToPlace(place);return;}
try{sessionStorage.setItem('codenav.pendingPlace',JSON.stringify(place));}catch{}
if(dialog.open)dialog.close();
$('#githubInput').value=place.repo;
const where=place.file?place.file.split('/').pop():'the shared spot';
pullGithub(place.repo,'Opening a shared link',`${repoName(place.repo)} isn't loaded here yet, so it's being pulled from GitHub. You'll be taken to ${where} as soon as it's built.`);
}
window.addEventListener('hashchange',openPlaceFromHash);
function applyPendingPlace(){
const place=pendingPlace();
if(!place||!sameRepo(place.repo,currentRepoUrl))return;
clearPendingPlace();
goToPlace(place);
}
// Switch view, select the file and fly the camera to the linked position.
function goToPlace(place){
$(`[data-view="${place.view}"]`)?.click();
const file=place.file&&files.find(candidate=>candidate.path===place.file);
if(file)selectFile(file);
const target=place.view==='city'&&place.camera;
if(!target){if(file)focusFile(file);dirty=true;return;}
const c=renderer.city,{view,...pose}=target;
cancelCameraAnimation();endTour();
if(view!=='heli'){
// Start ground views from where the helicopter is looking, so the flight has somewhere to come from.
const {eye,forward}=renderer.cityPose();
Object.assign(c,{ex:eye[0],ey:eye[1],ez:eye[2],lookYaw:Math.atan2(-forward[0],-forward[1]),lookPitch:Math.asin(Math.max(-1,Math.min(1,forward[2])))});
}
c.view=view;updateCityHud();
animateCameraTo(pose,c,1800);
}
// A shared link to a repository this visitor can't clone: most likely private, with GitHub access
// that has expired or was never granted on this device. The pending place is kept, so signing in
// carries straight on to the shared spot.
function showPrivateLinkWarning(place,authRequired){
showError('',authRequired);
$('#progressTitle').textContent='Private repository';
$('#progressMessage').textContent=authRequired==='install'
?`This link points to ${repoName(place.repo)}, a private repository your GitHub account can't open here. Grant access to it, then open the link again.`
:`This link points to ${repoName(place.repo)}, a private repository, and your GitHub access has expired (or you haven't signed in on this device). Sign in with GitHub to carry on to the shared spot.`;
$('#progressFill').style.background='var(--yellow)';
$('#progressCount').textContent='';
}
// Crosshair target, street address and minimap, refreshed with each city frame.
function updateCityReadouts(){
const c=renderer.city,{eye,forward}=renderer.cityPose();
if(c.view!=='heli'){
const hit=pickRay(eye,forward,cityEntries,90),entry=hit?.entry||null;
if(entry!==cityTargetEntry){
cityTargetEntry=entry;const target=$('#cityTarget');target.hidden=!entry;
if(entry)target.innerHTML=`${escapeHtml(entry.file.name)}E${escapeHtml(entry.file.path)} · ${format(entry.file.lines)} lines`;
}
}
const [px,py]=c.view==='heli'?[c.x,c.y]:[c.ex,c.ey],block=blockAt(cityModel.blocks,px,py);
const address=[currentName,...(block?.node.path.split('/')||[])].join(' / ');
if(address!==lastAddress){lastAddress=address;$('#cityAddress').textContent=address;}
drawMinimap();
}
const MINIMAP={width:220,height:150,pad:8};
function minimapTransform(){
// Leave room for the city wall around the edge.
const scale=Math.min((MINIMAP.width-MINIMAP.pad*2)/(cityModel.width+WALL.margin*2),(MINIMAP.height-MINIMAP.pad*2)/(cityModel.height+WALL.margin*2));
return {scale,ox:(MINIMAP.width-cityModel.width*scale)/2,oy:(MINIMAP.height-cityModel.height*scale)/2};
}
function buildMinimap(){
const dpr=2,canvas=document.createElement('canvas');canvas.width=MINIMAP.width*dpr;canvas.height=MINIMAP.height*dpr;
const ctx=canvas.getContext('2d'),{scale,ox,oy}=minimapTransform();ctx.scale(dpr,dpr);
ctx.strokeStyle='rgba(200,241,53,.7)';ctx.lineWidth=1.5;ctx.strokeRect(ox-WALL.margin*scale,oy-WALL.margin*scale,(cityModel.width+WALL.margin*2)*scale,(cityModel.height+WALL.margin*2)*scale);
ctx.fillStyle='rgba(243,236,220,.06)';
for(const block of cityModel.blocks)if(block.depth===1)ctx.fillRect(ox+block.x*scale,oy+block.y*scale,block.w*scale,block.h*scale);
for(const item of layoutItems){const b=item.city;if(!b||!fileAlerts.has(item.path))continue;ctx.fillStyle='#ff4d3d';ctx.beginPath();ctx.arc(ox+(b.x+b.w/2)*scale,oy+(b.y+b.h/2)*scale,2.6,0,Math.PI*2);ctx.fill();}
for(const item of layoutItems){const b=item.city;if(!b)continue;ctx.fillStyle=`rgba(${item.color.map(v=>Math.round(v*255)).join(',')},.85)`;ctx.fillRect(ox+b.x*scale,oy+b.y*scale,Math.max(.6,b.w*scale),Math.max(.6,b.h*scale));}
minimapBase=canvas;
}
function drawMinimap(){
const canvas=$('#minimap');if(canvas.hidden||!minimapBase)return;
const dpr=2;if(canvas.width!==MINIMAP.width*dpr){canvas.width=MINIMAP.width*dpr;canvas.height=MINIMAP.height*dpr;}
const ctx=canvas.getContext('2d'),c=renderer.city,{scale,ox,oy}=minimapTransform();
ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,canvas.width,canvas.height);ctx.drawImage(minimapBase,0,0);ctx.setTransform(dpr,0,0,dpr,0,0);
const heli=c.view==='heli',x=ox+(heli?c.x:c.ex)*scale,y=oy+(heli?c.y:c.ey)*scale,yaw=heli?c.yaw:c.lookYaw;
if(selected?.id){const b=layoutById.get(selected.id)?.city;if(b){ctx.strokeStyle='#fff08a';ctx.lineWidth=1.5;ctx.strokeRect(ox+b.x*scale-1.5,oy+b.y*scale-1.5,b.w*scale+3,b.h*scale+3);}}
for(const route of cityRoutes){
const dependent=route.kind===ROUTE_KIND.dependent;
ctx.strokeStyle=dependent?'rgba(255,77,199,.95)':route.kind===ROUTE_KIND.knownImport?'rgba(54,211,194,.9)':'rgba(247,189,77,.9)';
ctx.lineWidth=1.4;ctx.setLineDash(dependent?[3,2]:[]);ctx.lineDashOffset=dependent?-(performance.now()/120)%5:0;ctx.beginPath();
route.points.forEach(([px,py],i)=>i?ctx.lineTo(ox+px*scale,oy+py*scale):ctx.moveTo(ox+px*scale,oy+py*scale));ctx.stroke();
}
ctx.setLineDash([]);
// Heading arrow: yaw 0 points north (up the map).
ctx.save();ctx.translate(x,y);ctx.rotate(-yaw);
ctx.fillStyle='rgba(54,211,194,.18)';ctx.beginPath();ctx.moveTo(0,0);ctx.arc(0,0,26,-Math.PI/2-.5,-Math.PI/2+.5);ctx.closePath();ctx.fill();
ctx.fillStyle='#fff';ctx.strokeStyle='#07090d';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(0,-7);ctx.lineTo(5,5);ctx.lineTo(0,2.5);ctx.lineTo(-5,5);ctx.closePath();ctx.stroke();ctx.fill();
ctx.restore();
}
$('#minimap').addEventListener('pointerdown',event=>{
event.stopPropagation();if(!cityModel)return;
const rect=event.currentTarget.getBoundingClientRect(),{scale,ox,oy}=minimapTransform();
const x=(event.clientX-rect.left-ox)/scale,y=(event.clientY-rect.top-oy)/scale,c=renderer.city;
if(c.view==='heli')animateCameraTo({x,y},c,700);
else{const spot=c.view==='walk'?collide(x,y,MOVE.radius*3,collisionIndex):{x,y};animateCameraTo({ex:spot.x,ey:spot.y},c,700);}
});
function fitCity(){
if(!cityModel)return;
Object.assign(renderer.city,{view:'heli',x:cityModel.width/2,y:cityModel.height/2,yaw:-.35,pitch:.72,distance:Math.max(cityModel.width,cityModel.height)*1.05});
cityFitted=true;dirty=true;
}
function resetCodeCaches(){
overviewUploaded=-1;overviewCanvas=document.createElement('canvas');overviewCanvas.width=1000*overviewScale;overviewCanvas.height=680*overviewScale;overviewContext=overviewCanvas.getContext('2d',{alpha:true});overviewIndex=0;codeTextureBytes=0;codeTextureLru.clear();
}
const spatialCell=50;
function buildSpatialGrid(){
spatialGrid=new Map();
for(const item of layoutItems){
const x0=Math.floor(item.x/spatialCell),x1=Math.floor((item.x+item.w)/spatialCell),y0=Math.floor(item.y/spatialCell),y1=Math.floor((item.y+item.h)/spatialCell);
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){const key=`${x}:${y}`,cell=spatialGrid.get(key);if(cell)cell.push(item);else spatialGrid.set(key,[item]);}
}
}
function visibleLayout(w,h){
let candidates=layoutItems;
if(renderer.mode==='2d'&&renderer.camera.zoom>1){
const halfW=w/(2*renderer.camera.zoom),halfH=h/(2*renderer.camera.zoom),x0=Math.floor((renderer.camera.x-halfW)/spatialCell),x1=Math.floor((renderer.camera.x+halfW)/spatialCell),y0=Math.floor((renderer.camera.y-halfH)/spatialCell),y1=Math.floor((renderer.camera.y+halfH)/spatialCell),seen=new Set(),near=[];
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++)for(const item of spatialGrid.get(`${x}:${y}`)||[])if(!seen.has(item.id)){seen.add(item.id);near.push(item);}
candidates=near;
}
const visible=[];
for(const item of candidates){const rect=renderer.screenRect(item);if(rect.x>w||rect.y>h||rect.x+rect.w<0||rect.y+rect.h<0)continue;visible.push({item,rect});}
return visible;
}
function applySnapshot(snapshot){
sourceQueue.length=0;queuedSources.clear();
files=snapshot.files||[];for(const file of files)file.symbols||=[];edges=snapshot.edges||[];currentName=snapshot.name||'codebase';currentSnapshot=snapshot.id||null;selected=null;history=[];searchHits=[];expandedCoverageLayer=null;coverageActiveIndex=-1;cameraAnimation=null;
fileById=new Map(files.map(file=>[file.id,file]));referenceDegree=new Map();edgesByFile=new Map();let known=0,inferred=0;for(const edge of edges){referenceDegree.set(edge.from,(referenceDegree.get(edge.from)||0)+1);referenceDegree.set(edge.to,(referenceDegree.get(edge.to)||0)+1);for(const id of [edge.from,edge.to]){const list=edgesByFile.get(id);if(list)list.push(edge);else edgesByFile.set(id,[edge]);}if(edge.confidence==='known')known++;else inferred++;}
const layers=new Map();for(const file of files){const layer=file.layer in layerLabels?file.layer:'unknown';layers.set(layer,(layers.get(layer)||0)+1);}sceneStats={definitions:snapshot.definitions??files.reduce((n,file)=>n+(file.symbolCount||0),0),totalLines:snapshot.totalLines??files.reduce((n,file)=>n+file.lines,0),known,inferred,layers};
computeLayout();fitScene();fitCity();updateStats(snapshot);renderInspector();renderResults([]);renderHistory();
$('#breadcrumbText').textContent=currentName;$('#emptyState').hidden=files.length>0;$('#welcome').hidden=true;
currentRepoUrl=snapshot.source==='github'?lastGithubUrl.replace(/\/+$/,'').replace(/\.git$/,''):'';
endTour();applyPendingPlace();loadAlerts();
}
function fitScene(){
cameraAnimation=null;
if(renderer.mode==='city'){fitCity();return;}
renderer.camera.x=500;renderer.camera.y=340;
renderer.camera.zoom=Math.min((viewport.clientWidth-24)/1000,(viewport.clientHeight-24)/680);
renderer.camera.distance=3.5;renderer.camera.yaw=-.12;renderer.camera.pitch=.76;dirty=true;
}
function pan3d(dx,dy){
const {yaw,distance}=renderer.camera;
const scale=Math.max(.18,distance*.29);
const cy=Math.cos(yaw),sy=Math.sin(yaw);
// Keep the grabbed ground point beneath the pointer in screen space.
renderer.camera.x+=(-dx*cy-dy*sy)*scale;
renderer.camera.y+=( dx*sy-dy*cy)*scale;
}
function dragCity(dx,dy,action){
const c=renderer.city;
if(action==='pan'){const scale=c.distance*.0016,cy=Math.cos(c.yaw),sy=Math.sin(c.yaw);c.x+=(-dx*cy-dy*sy)*scale;c.y+=(dx*sy-dy*cy)*scale;}
else{c.yaw-=dx*.007;c.pitch=Math.max(.08,Math.min(1.5,c.pitch+dy*.006));}
}
function dollyCity(amount){
const c=renderer.city,size=Math.max(cityModel?.width||1000,cityModel?.height||1000);
if(c.view==='heli')c.distance=Math.max(12,Math.min(size*3,c.distance*Math.exp(amount)));
}
function dolly3d(amount){
renderer.camera.distance=Math.max(MIN_3D_DISTANCE,Math.min(14,renderer.camera.distance*Math.exp(amount)));
}
function updateStats(snapshot={}){
const definitions=snapshot.definitions??files.reduce((n,file)=>n+file.symbols.length,0);
const references=snapshot.references??edges.length;
$('#headlineStats').textContent=`${format(definitions)} definitions · ${format(references)} references`;
$('#fileStats').textContent=`${format(files.length)} files · ${format(snapshot.totalLines??files.reduce((n,f)=>n+f.lines,0))} lines`;
}
function format(value){return Number(value||0).toLocaleString();}
function escapeHtml(value=''){return String(value).replace(/[&<>"]/g,char=>({'&':'&','<':'<','>':'>','"':'"'}[char]));}
const syntaxPalette={plain:'#b6bfd0',comment:'#5c667a',string:'#8fd694',number:'#f5a97f',keyword:'#c69cff',type:'#6ec6ff',function:'#7aa7ff',property:'#5fd3c6',operator:'#8f9ab0',constant:'#ff8fa3',tag:'#5fd3c6'};
// Matches the renderer's tile top so code drawn over a 3D roof blends with the WebGL face.
const TILE_TOP='#16140f';
const syntaxKeywords=new Set(`abstract as async await break case catch class const continue crate def default defer delete do else enum export extends extern false final finally fn for from func function get go if impl import in instanceof interface is lambda let loop match mod module mut namespace new nil None null of override package private protected pub public raise readonly require return self set static struct super switch this throw trait true try type typeof undefined union unsafe use using var virtual void where while with yield`.split(' '));
const syntaxConstants=new Set('true false null nil none undefined nan infinity self this super'.split(' '));
const tokenPattern=/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b0x[\da-f]+\b|\b0b[01]+\b|#[\da-f]{3,8}\b|\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b|[A-Za-z_$][\w$-]*|\s+|./gi;
function syntaxKind(file={}){
const ext=(file.extension||file.name?.split('.').pop()||'').toLowerCase(),language=(file.language||'').toLowerCase();
if(['html','htm','xml','svg','vue','svelte'].includes(ext))return'markup';
if(['css','scss','sass','less'].includes(ext))return'css';
if(['json','jsonc','toml','yaml','yml'].includes(ext))return'data';
if(['md','mdx','rst','txt'].includes(ext)||language==='docs')return'docs';
if(['py','pyi','rb','r','pl','sh','bash','zsh','fish'].includes(ext))return'hash-comment';
if(['sql','lua','hs'].includes(ext))return'dash-comment';
return language==='other'?'plain':'code';
}
function commentStart(line,kind){
const markers=kind==='markup'?['