Add City mode: codebase laid out as streets and buildings

city.js lays the directory tree out at human scale: top-level folders
become districts separated by 18 m avenues, nested folders become
blocks with narrower streets and raised pavements, and files become
buildings whose footprint follows their lines and whose height follows
their complexity. The squarified treemap moves into city.js and is
shared with the landscape.

The renderer gains a second instance layer with per-instance kind,
code-atlas rectangle and selection flag, a helicopter camera in metres,
screen-to-world rays, and geometry lookup per mode. City mode supports
orbit, pan, dolly, ray picking, focusing a building and district names.
Node tests cover layout invariants, heights, ray/box hits and the
spatial index.
This commit is contained in:
Andy Dixon
2026-05-10 15:50:23 +01:00
parent eae93d93dc
commit ae8b0a6b80
7 changed files with 462 additions and 123 deletions
+67 -45
View File
@@ -1,4 +1,5 @@
import { LandscapeRenderer } from './renderer.js';
import { LandscapeRenderer, KIND } from './renderer.js';
import { layoutCity, squarifiedLayout, spatialIndex, pickRay } from './city.js';
import { apiFetch, progressEvents } from './api.mjs';
const configuredBackend=window.CODENAVIGATOR_CONFIG?.backendUrl?.trim()||'';
@@ -44,6 +45,7 @@ let searchHits=[];
let expandedCoverageLayer=null;
let coverageActiveIndex=-1;
let cameraAnimation=null;
let cityModel=null,cityEntries=[],cityIndex=null,cityFitted=false;
let currentSnapshot=null;
let currentName='';
let currentMetric='lines';
@@ -85,36 +87,6 @@ function makeTree(list){
weigh(root); return root;
}
function squarifiedLayout(entries,x,y,w,h,depth,output){
if(!entries.length||w<=0||h<=0)return;
const total=entries.reduce((sum,entry)=>sum+Math.max(.0001,entry.weight),0),scale=w*h/total;
const remaining=entries.map(entry=>({entry,area:Math.max(.0001,entry.weight)*scale}));
const worst=(row,side)=>{
if(!row.length)return Infinity;
const sum=row.reduce((value,item)=>value+item.area,0),largest=Math.max(...row.map(item=>item.area)),smallest=Math.min(...row.map(item=>item.area)),sideSquared=side*side;
return Math.max(sideSquared*largest/(sum*sum),(sum*sum)/(sideSquared*smallest));
};
const place=row=>{
const area=row.reduce((value,item)=>value+item.area,0);
if(w>=h){
const rowWidth=Math.min(w,area/Math.max(.0001,h));let rowY=y;
for(const item of row){const itemHeight=item.area/Math.max(.0001,rowWidth);output(item.entry,x,rowY,rowWidth,itemHeight,depth);rowY+=itemHeight;}
x+=rowWidth;w=Math.max(0,w-rowWidth);
}else{
const rowHeight=Math.min(h,area/Math.max(.0001,w));let rowX=x;
for(const item of row){const itemWidth=item.area/Math.max(.0001,rowHeight);output(item.entry,rowX,y,itemWidth,rowHeight,depth);rowX+=itemWidth;}
y+=rowHeight;h=Math.max(0,h-rowHeight);
}
};
let row=[];
while(remaining.length){
const next=remaining[0],side=Math.max(.0001,Math.min(w,h));
if(!row.length||worst([...row,next],side)<=worst(row,side)){row.push(remaining.shift());}
else{place(row);row=[];}
}
if(row.length)place(row);
}
function computeLayout(){
const root=makeTree(files); layoutItems=[];directoryRects=[];
function visit(node,x,y,w,h,depth){
@@ -137,7 +109,29 @@ function computeLayout(){
}
visit(root,0,0,1000,680,0);
layoutById=new Map(layoutItems.map(item=>[item.id,item]));buildSpatialGrid();resetCodeCaches();
renderer.setData(layoutItems);dirty=true;
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:[.55,.6,.9],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);
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});
}
cityIndex=spatialIndex(cityEntries);
renderer.setCity(instances,{width:cityModel.width,height:cityModel.height});
}
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(){
@@ -169,12 +163,13 @@ function applySnapshot(snapshot){
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();updateStats(snapshot);renderInspector();renderResults([]);renderHistory();
computeLayout();fitScene();fitCity();updateStats(snapshot);renderInspector();renderResults([]);renderHistory();
$('#breadcrumbText').textContent=currentName;$('#emptyState').hidden=files.length>0;$('#welcome').hidden=true;
}
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;
@@ -189,6 +184,16 @@ function pan3d(dx,dy){
renderer.camera.y+=( dx*sy-dy*cy)*scale;
}
function dragCity(dx,dy,action){
const c=renderer.city;if(c.view!=='heli')return;
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)));
}
@@ -314,7 +319,7 @@ function selectFile(file,addHistory=true){
if(!file)coverageActiveIndex=-1;
if(file&&addHistory){history=history.filter(item=>item.id!==file.id);history.unshift(file);history=history.slice(0,30);renderHistory();}
$('#breadcrumbText').textContent=file?`${currentName} › ${file.path}`:currentName;
renderInspector();if(file)loadFileDetails(file);dirty=true;
renderer.setSelected(file?.id||0);renderInspector();if(file)loadFileDetails(file);dirty=true;
}
function renderInspector(){
@@ -388,19 +393,21 @@ function renderHistory(){
function focusFile(file){
const item=layoutById.get(file.id);if(!item)return;
if(renderer.mode==='city'){const b=item.city;if(b){renderer.city.view='heli';animateCameraTo({x:b.x+b.w/2,y:b.y+b.h/2,distance:Math.max(90,Math.max(b.w,b.h,b.height)*3.2)},renderer.city);}return;}
const target={x:item.x+item.w/2,y:item.y+item.h/2};
if(renderer.mode==='2d'){const fit=Math.min(viewport.clientWidth/Math.max(.2,item.w*1.35),viewport.clientHeight/Math.max(.2,item.h*1.35),MAX_2D_ZOOM);target.zoom=Math.min(MAX_2D_ZOOM,Math.max(fit,9/codeWorldFont(item)));}
else target.distance=2.25;
animateCameraTo(target);
}
function animateCameraTo(target){
const from={},to={};for(const [key,value] of Object.entries(target)){from[key]=renderer.camera[key];to[key]=value;}
if(matchMedia('(prefers-reduced-motion: reduce)').matches){Object.assign(renderer.camera,to);cameraAnimation=null;dirty=true;return;}
cameraAnimation={from,to,start:performance.now(),duration:620};dirty=true;
function animateCameraTo(target,camera=activeCamera(),duration=620){
const from={},to={};for(const [key,value] of Object.entries(target)){from[key]=camera[key];to[key]=value;}
if(matchMedia('(prefers-reduced-motion: reduce)').matches){Object.assign(camera,to);cameraAnimation=null;dirty=true;return;}
cameraAnimation={camera,from,to,start:performance.now(),duration};dirty=true;
}
function cancelCameraAnimation(){cameraAnimation=null;}
function activeCamera(){return renderer.mode==='city'?renderer.city:renderer.camera;}
function switchTab(name){$$('.tab').forEach(tab=>{const active=tab.dataset.tab===name;tab.classList.toggle('active',active);tab.setAttribute('aria-selected',String(active));tab.tabIndex=active?0:-1;});$$('.panel').forEach(panel=>panel.classList.toggle('active',panel.id===`panel-${name}`));}
$$('.tab').forEach(tab=>tab.addEventListener('click',()=>switchTab(tab.dataset.tab)));
@@ -431,6 +438,7 @@ function drawOverlay(){
resizeOverlay();const ctx=overlayCtx,w=overlay.clientWidth,h=overlay.clientHeight;ctx.clearRect(0,0,w,h);ctx.save();ctx.font='600 11px Inter, system-ui, sans-serif';ctx.textBaseline='top';
codeTexturesPending=false;
codeTextureDeadline=performance.now()+5;
if(renderer.mode==='city'){visibleScreenItems=[];drawCityOverlay(ctx);ctx.restore();return;}
const hitIds=new Set(searchHits.map(hit=>hit.entityId??hit.id)),occupied=[],visible=visibleLayout(w,h);visibleScreenItems=visible;
let labelCount=0;
if(showCode&&renderer.mode==='2d'){
@@ -463,6 +471,18 @@ function drawOverlay(){
ctx.restore();
}
function drawCityOverlay(ctx){
if(showCode){fillCodeOverview();uploadCodeOverview();}
if(!cityModel)return;
const occupied=[],w=renderer.width,h=renderer.height;let count=0;
if(selected?.id){const b=layoutById.get(selected.id)?.city;if(b){const p=renderer.project([b.x+b.w/2,b.y+b.h/2,b.height+2]);if(p.x>=0&&p.x<=w&&p.y>=0&&p.y<=h)drawChip(ctx,selected.name,p.x,p.y-22,220,'#fff08a',occupied,true,'center');}}
for(const block of cityModel.blocks){
if(block.depth!==1||count>=40)continue;
const p=renderer.project([block.x+block.w/2,block.y+block.h/2,0]);if(!(p.x>=0&&p.x<=w&&p.y>=0&&p.y<=h))continue;
if(drawChip(ctx,`${block.node.name}/`,p.x,p.y-8,180,'#8d7dff',occupied,false,'center'))count++;
}
}
function tileCorners(item){return [[item.x,item.y],[item.x+item.w,item.y],[item.x+item.w,item.y+item.h],[item.x,item.y+item.h]].map(([x,y])=>renderer.project([x,y,item.height||0]));}
function drawSelectedConnections(ctx){
if(!selected)return;
@@ -588,7 +608,7 @@ function drawCodeTexture(ctx,item,rect){
function animate(now){
if(cameraAnimation){
const progress=Math.min(1,(now-cameraAnimation.start)/cameraAnimation.duration),eased=1-(1-progress)**4;
for(const key of Object.keys(cameraAnimation.to))renderer.camera[key]=cameraAnimation.from[key]+(cameraAnimation.to[key]-cameraAnimation.from[key])*eased;
for(const key of Object.keys(cameraAnimation.to))cameraAnimation.camera[key]=cameraAnimation.from[key]+(cameraAnimation.to[key]-cameraAnimation.from[key])*eased;
if(progress>=1)cameraAnimation=null;dirty=true;
}
if(dirty){
@@ -601,6 +621,7 @@ function animate(now){
function pickAt(x,y){
if(renderer.mode==='2d'){const world=renderer.worldAt(x,y),cell=spatialGrid.get(`${Math.floor(world.x/spatialCell)}:${Math.floor(world.y/spatialCell)}`)||[];let match=null,area=Infinity;for(const item of cell){if(world.x>=item.x&&world.x<=item.x+item.w&&world.y>=item.y&&world.y<=item.y+item.h){const next=item.w*item.h;if(next<area){match=item;area=next;}}}return match;}
if(renderer.mode==='city'){const {origin,dir}=renderer.ray(x,y);return pickRay(origin,dir,cityEntries)?.entry.file||null;}
let best=null,depth=Infinity;
for(const {item,rect} of visibleScreenItems){
if(x<rect.x||x>rect.x+rect.w||y<rect.y||y>rect.y+rect.h)continue;
@@ -617,7 +638,7 @@ function pickAt(x,y){
viewport.addEventListener('pointerdown',event=>{
// Only drags that start on the map; pointer capture would otherwise swallow clicks on floating controls.
if(event.button>2||event.target!==glCanvas)return;
const pan=renderer.mode==='3d'&&(event.button===1||event.button===2||event.shiftKey||event.altKey||event.metaKey||event.ctrlKey);
const pan=renderer.mode!=='2d'&&(event.button===1||event.button===2||event.shiftKey||event.altKey||event.metaKey||event.ctrlKey);
if(renderer.mode==='2d'&&event.button!==0)return;
event.preventDefault();
cancelCameraAnimation();
@@ -629,6 +650,7 @@ viewport.addEventListener('pointermove',event=>{
if(pointer.down&&event.pointerId===pointer.id){
const dx=event.clientX-pointer.x,dy=event.clientY-pointer.y;pointer.x=event.clientX;pointer.y=event.clientY;
if(renderer.mode==='2d'){renderer.camera.x-=dx/renderer.camera.zoom;renderer.camera.y-=dy/renderer.camera.zoom;}
else if(renderer.mode==='city')dragCity(dx,dy,pointer.action);
else if(pointer.action==='pan')pan3d(dx,dy);
else{renderer.camera.yaw-=dx*.007;renderer.camera.pitch=Math.max(.045,Math.min(1.525,renderer.camera.pitch+dy*.006));}
dirty=true;$('#tooltip').hidden=true;return;
@@ -644,11 +666,11 @@ function finishPointer(event){
viewport.addEventListener('pointerup',finishPointer);
viewport.addEventListener('pointercancel',finishPointer);
viewport.addEventListener('pointerleave',()=>{$('#tooltip').hidden=true;});
viewport.addEventListener('contextmenu',event=>{if(renderer.mode==='3d')event.preventDefault();});
viewport.addEventListener('wheel',event=>{event.preventDefault();cancelCameraAnimation();if(renderer.mode==='3d'){dolly3d(event.deltaY*.00115);dirty=true;return;}const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,before=renderer.worldAt(x,y),factor=Math.exp(-event.deltaY*.0012);renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*factor));const after=renderer.worldAt(x,y);renderer.camera.x+=before.x-after.x;renderer.camera.y+=before.y-after.y;dirty=true;},{passive:false});
viewport.addEventListener('contextmenu',event=>{if(renderer.mode!=='2d')event.preventDefault();});
viewport.addEventListener('wheel',event=>{event.preventDefault();cancelCameraAnimation();if(renderer.mode==='city'){dollyCity(event.deltaY*.00115);dirty=true;return;}if(renderer.mode==='3d'){dolly3d(event.deltaY*.00115);dirty=true;return;}const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,before=renderer.worldAt(x,y),factor=Math.exp(-event.deltaY*.0012);renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*factor));const after=renderer.worldAt(x,y);renderer.camera.x+=before.x-after.x;renderer.camera.y+=before.y-after.y;dirty=true;},{passive:false});
viewport.addEventListener('dblclick',event=>{const rect=viewport.getBoundingClientRect(),file=pickAt(event.clientX-rect.left,event.clientY-rect.top);if(file){selectFile(file);focusFile(file);}});
$$('[data-view]').forEach(button=>button.addEventListener('click',()=>{cancelCameraAnimation();$$('[data-view]').forEach(v=>v.classList.toggle('active',v===button));renderer.mode=button.dataset.view;if(renderer.mode==='3d')renderer.camera.zoom=1;$('#cameraHelp').hidden=renderer.mode!=='3d';dirty=true;}));
$$('[data-view]').forEach(button=>button.addEventListener('click',()=>{cancelCameraAnimation();$$('[data-view]').forEach(v=>v.classList.toggle('active',v===button));renderer.mode=button.dataset.view;if(renderer.mode==='3d')renderer.camera.zoom=1;if(renderer.mode==='city'&&!cityFitted)fitCity();$('#cameraHelp').hidden=renderer.mode!=='3d';dirty=true;}));
$$('[data-metric]').forEach(button=>button.addEventListener('click',()=>{$$('[data-metric]').forEach(v=>v.classList.toggle('active',v===button));currentMetric=button.dataset.metric;computeLayout();}));
$('#codeButton').classList.toggle('active',showCode);$('#codeButton').addEventListener('click',event=>{showCode=!showCode;event.currentTarget.classList.toggle('active',showCode);dirty=true;});
$('#homeButton').addEventListener('click',()=>{selectFile(null);fitScene();});
@@ -795,7 +817,7 @@ $('#fpsToggle').addEventListener('change',event=>{$('#fps').hidden=!event.target
$('#paletteButton').addEventListener('click',savePreferences);$('#codeButton').addEventListener('click',savePreferences);
$('#dismissProgress').addEventListener('click',()=>{$('#progressCard').hidden=true;});
$('#fitButton').addEventListener('click',fitScene);
function zoomStep(direction){cancelCameraAnimation();if(renderer.mode==='3d')dolly3d(-direction*.22);else renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*Math.exp(direction*.3)));dirty=true;}
function zoomStep(direction){cancelCameraAnimation();if(renderer.mode==='city')dollyCity(-direction*.22);else if(renderer.mode==='3d')dolly3d(-direction*.22);else renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*Math.exp(direction*.3)));dirty=true;}
$('#zoomInButton').addEventListener('click',()=>zoomStep(1));$('#zoomOutButton').addEventListener('click',()=>zoomStep(-1));
$$('.canvas-controls, .breadcrumb, .progress-card, .legend, .camera-help').forEach(control=>{['pointerdown','dblclick','wheel'].forEach(type=>control.addEventListener(type,event=>event.stopPropagation()));});
$$('[data-camera-preset]').forEach(button=>button.addEventListener('click',()=>{
@@ -803,7 +825,7 @@ $$('[data-camera-preset]').forEach(button=>button.addEventListener('click',()=>{
const from={...renderer.camera},to={...from,...poses[button.dataset.cameraPreset]};
cancelCameraAnimation();
if(matchMedia('(prefers-reduced-motion: reduce)').matches)Object.assign(renderer.camera,to);
else cameraAnimation={from,to,start:performance.now(),duration:450};
else cameraAnimation={camera:renderer.camera,from,to,start:performance.now(),duration:450};
dirty=true;
}));
dialog.addEventListener('click',event=>{if(event.target===dialog)dialog.close();});
+17
View File
@@ -55,3 +55,20 @@ export function orbitPose({ x, y, yaw, pitch, distance }, scale = 420, lift = .3
}
export const ORBIT_FOV = 2 * Math.atan(1 / 2.7);
// General 4x4 inverse (null when singular), for turning screen points back into world rays.
export function invert(m) {
const [a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33] = m;
const b00 = a00 * a11 - a01 * a10, b01 = a00 * a12 - a02 * a10, b02 = a00 * a13 - a03 * a10, b03 = a01 * a12 - a02 * a11;
const b04 = a01 * a13 - a03 * a11, b05 = a02 * a13 - a03 * a12, b06 = a20 * a31 - a21 * a30, b07 = a20 * a32 - a22 * a30;
const b08 = a20 * a33 - a23 * a30, b09 = a21 * a32 - a22 * a31, b10 = a21 * a33 - a23 * a31, b11 = a22 * a33 - a23 * a32;
let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
if (!det) return null;
det = 1 / det;
return new Float32Array([
(a11 * b11 - a12 * b10 + a13 * b09) * det, (a02 * b10 - a01 * b11 - a03 * b09) * det, (a31 * b05 - a32 * b04 + a33 * b03) * det, (a22 * b04 - a21 * b05 - a23 * b03) * det,
(a12 * b08 - a10 * b11 - a13 * b07) * det, (a00 * b11 - a02 * b08 + a03 * b07) * det, (a32 * b02 - a30 * b05 - a33 * b01) * det, (a20 * b05 - a22 * b02 + a23 * b01) * det,
(a10 * b10 - a11 * b08 + a13 * b06) * det, (a01 * b08 - a00 * b10 - a03 * b06) * det, (a30 * b04 - a31 * b02 + a33 * b00) * det, (a21 * b02 - a20 * b04 - a23 * b00) * det,
(a11 * b07 - a10 * b09 - a12 * b06) * det, (a00 * b09 - a01 * b07 + a02 * b06) * det, (a31 * b01 - a30 * b03 - a32 * b00) * det, (a20 * b03 - a21 * b01 + a22 * b00) * det,
]);
}
+142
View File
@@ -0,0 +1,142 @@
// Code as a city: folders become districts and blocks separated by roads, files become buildings.
// Everything here is plain data and maths in metres, so it can be tested without a browser.
// Squarified treemap: calls output(entry, x, y, w, h, depth) for each entry, largest first.
export function squarifiedLayout(entries, x, y, w, h, depth, output) {
if (!entries.length || w <= 0 || h <= 0) return;
const total = entries.reduce((sum, entry) => sum + Math.max(.0001, entry.weight), 0), scale = w * h / total;
const remaining = entries.map(entry => ({ entry, area: Math.max(.0001, entry.weight) * scale }));
const worst = (row, side) => {
if (!row.length) return Infinity;
let sum = 0, largest = 0, smallest = Infinity;
for (const item of row) { sum += item.area; largest = Math.max(largest, item.area); smallest = Math.min(smallest, item.area); }
return Math.max(side * side * largest / (sum * sum), (sum * sum) / (side * side * smallest));
};
const place = row => {
const area = row.reduce((value, item) => value + item.area, 0);
if (w >= h) {
const rowWidth = Math.min(w, area / Math.max(.0001, h)); let rowY = y;
for (const item of row) { const itemHeight = item.area / Math.max(.0001, rowWidth); output(item.entry, x, rowY, rowWidth, itemHeight, depth); rowY += itemHeight; }
x += rowWidth; w = Math.max(0, w - rowWidth);
} else {
const rowHeight = Math.min(h, area / Math.max(.0001, w)); let rowX = x;
for (const item of row) { const itemWidth = item.area / Math.max(.0001, rowHeight); output(item.entry, rowX, y, itemWidth, rowHeight, depth); rowX += itemWidth; }
y += rowHeight; h = Math.max(0, h - rowHeight);
}
};
let row = [];
while (remaining.length) {
const next = remaining[0], side = Math.max(.0001, Math.min(w, h));
if (!row.length || worst([...row, next], side) <= worst(row, side)) row.push(remaining.shift());
else { place(row); row = []; }
}
if (row.length) place(row);
}
export const CITY = {
squareMetresPerLine: 1.1, // building footprint per source line
minLines: 40, // small files still get a walkable kiosk
roadWidths: [18, 12, 8, 6], // avenues between districts, then narrower streets per folder depth
sidewalk: 2.5, // kerb inside each block before buildings start
setback: 1.2, // gap between neighbouring buildings
cellSize: 32, // spatial grid cell for collision and picking
};
// Storeys scale with complexity: a flat data file is a low warehouse, dense logic is a tower.
export function buildingHeight(file) {
return 6 + 10 * Math.log2(1 + Math.max(0, file.complexity || 0));
}
function buildTree(files, name) {
const root = { name, path: '', children: new Map(), files: [], weight: 0 };
for (const file of files) {
const parts = file.path.split('/'); 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 });
node = node.children.get(part);
}
node.files.push(file);
}
const weigh = node => {
node.weight = node.files.reduce((sum, file) => sum + Math.max(CITY.minLines, file.lines || 0), 0);
for (const child of node.children.values()) node.weight += weigh(child);
return node.weight;
};
weigh(root);
return root;
}
// Returns { width, height, buildings: Map(fileId -> {x,y,w,h,height}), blocks: [{node,depth,x,y,w,h}] }.
export function layoutCity(files, name = '') {
const root = buildTree(files, name);
const area = root.weight * CITY.squareMetresPerLine * 1.9; // roads, sidewalks and setbacks
const width = Math.sqrt(area * 1000 / 680), height = area / width;
const buildings = new Map(), blocks = [];
const visit = (node, x, y, w, h, depth) => {
if (depth > 0) {
// Half a road on every side, so neighbouring blocks are a full road apart.
const half = Math.min(CITY.roadWidths[Math.min(depth - 1, CITY.roadWidths.length - 1)] / 2, w * .2, h * .2);
x += half; y += half; w -= half * 2; h -= half * 2;
}
blocks.push({ node, depth, x, y, w, h });
const kerb = Math.min(CITY.sidewalk, w * .15, h * .15);
const values = [...node.children.values(), ...node.files.map(file => ({ file, weight: Math.max(CITY.minLines, file.lines || 0) }))];
const total = values.reduce((sum, value) => sum + value.weight, 0), 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, x + kerb, y + kerb, w - kerb * 2, h - kerb * 2, depth, (entry, rx, ry, rw, rh) => {
const value = entry.value;
if (!value.file) return visit(value, rx, ry, rw, rh, depth + 1);
const gap = Math.min(CITY.setback / 2, rw * .12, rh * .12);
buildings.set(value.file.id, { x: rx + gap, y: ry + gap, w: rw - gap * 2, h: rh - gap * 2, height: buildingHeight(value.file) });
});
};
visit(root, 0, 0, width, height, 0);
return { width, height, buildings, blocks };
}
// Uniform grid over building footprints, for collision and picking near a point or along a ray.
export function spatialIndex(entries, cellSize = CITY.cellSize) {
const cells = new Map();
for (const entry of entries) {
const x0 = Math.floor(entry.x / cellSize), x1 = Math.floor((entry.x + entry.w) / cellSize);
const y0 = Math.floor(entry.y / cellSize), y1 = Math.floor((entry.y + entry.h) / cellSize);
for (let cy = y0; cy <= y1; cy++) for (let cx = x0; cx <= x1; cx++) {
const key = cx * 65536 + cy, cell = cells.get(key);
if (cell) cell.push(entry); else cells.set(key, [entry]);
}
}
return {
cellSize,
near(x, y, radius) {
const found = new Set();
for (let cy = Math.floor((y - radius) / cellSize); cy <= Math.floor((y + radius) / cellSize); cy++)
for (let cx = Math.floor((x - radius) / cellSize); cx <= Math.floor((x + radius) / cellSize); cx++)
for (const entry of cells.get(cx * 65536 + cy) || []) found.add(entry);
return found;
},
};
}
// Distance along the ray to an axis-aligned box from z=0 to z=height, or Infinity.
export function rayBox([ox, oy, oz], [dx, dy, dz], box) {
let near = 0, far = Infinity;
for (const [o, d, min, max] of [[ox, dx, box.x, box.x + box.w], [oy, dy, box.y, box.y + box.h], [oz, dz, 0, box.height]]) {
if (Math.abs(d) < 1e-9) { if (o < min || o > max) return Infinity; continue; }
let t0 = (min - o) / d, t1 = (max - o) / d;
if (t0 > t1) [t0, t1] = [t1, t0];
near = Math.max(near, t0); far = Math.min(far, t1);
if (near > far) return Infinity;
}
return near;
}
// Nearest building hit by a ray: { entry, distance } or null.
// ponytail: tests every building; walk the spatial grid along the ray if repos pass ~100k files.
export function pickRay(origin, dir, entries, maxDistance = Infinity) {
let best = null, distance = maxDistance;
for (const entry of entries) {
const t = rayBox(origin, dir, entry);
if (t < distance) { distance = t; best = entry; }
}
return best && { entry: best, distance };
}
+1
View File
@@ -23,6 +23,7 @@
<div class="tool-group view-switch" role="group" aria-label="View mode">
<button class="tool active" data-view="2d" title="Flat map" aria-label="Flat map"><svg viewBox="0 0 24 24"><rect x="3.5" y="3.5" width="17" height="17" rx="1.5"/><path d="M3.5 11h17M12 3.5v17"/></svg></button>
<button class="tool" data-view="3d" title="3D landscape" aria-label="3D landscape"><svg viewBox="0 0 24 24"><path d="m12 3 8 4.5v9L12 21l-8-4.5v-9zM4 7.5l8 4.5 8-4.5M12 12v9"/></svg></button>
<button class="tool" data-view="city" title="City" aria-label="City"><svg viewBox="0 0 24 24"><path d="M3 21h18M5 21V10l4-2v13M9 21V4l6 3v14M15 21v-9l4-1.5V21"/><path d="M11.5 9v.01M11.5 12v.01M11.5 15v.01"/></svg></button>
<button class="tool" id="codeButton" title="Toggle source texture" aria-label="Toggle source texture"><svg viewBox="0 0 24 24"><path d="M4 18 9 6l4 8 2-5 5 9z"/><path d="M4 20h16"/></svg></button>
</div>
<span class="separator"></span>
+167 -77
View File
@@ -1,3 +1,5 @@
import { perspective, lookAt, multiply, transform, invert, orbitPose, direction, ORBIT_FOV } from './camera.js';
const VERTICES = new Float32Array([
// top
0,0,1, 1,0,1, 1,1,1, 0,0,1, 1,1,1, 0,1,1,
@@ -13,6 +15,15 @@ const VERTICES = new Float32Array([
0,1,0, 0,0,0, 1,0,0, 0,1,0, 1,0,0, 1,1,0,
]);
// Instance kinds in the box program.
export const KIND = { building: 0, block: 1, ground: 2 };
const FLOATS_PER_INSTANCE = 16;
// Landscape heights are exaggerated relative to the 1000x680 map so small repositories still read as 3D.
const LANDSCAPE_HEIGHT_SCALE = 420 / 170;
const CITY_FOV = 55 * Math.PI / 180;
export const EYE_HEIGHT = 1.7;
// Shared by every program. 2D is an orthographic map; 3D goes through the same view-projection
// matrix that project() uses in JS, so WebGL, the overlay and picking agree exactly.
const PROJECT = `
@@ -35,18 +46,25 @@ precision highp float;
layout(location=0) in vec3 aUnit;
layout(location=1) in vec4 aRect;
layout(location=2) in vec4 aColorHeight;
layout(location=3) in vec4 aAtlas;
layout(location=4) in vec4 aMeta;
${PROJECT}
out vec4 vColor;
out vec3 vUnit;
out vec2 vWorld;
out vec2 vAtlas;
out vec3 vSize;
flat out int vFace;
flat out ivec2 vKind;
void main(){
float height = max(1.0, aColorHeight.a);
int kind = int(aMeta.x + .5);
float height = kind == 0 && uMode < 1.5 ? max(1.0, aColorHeight.a) : aColorHeight.a;
vec3 world = vec3(aRect.xy + aUnit.xy * aRect.zw, aUnit.z * height);
vColor = vec4(aColorHeight.rgb, 1.0);
vUnit = aUnit;
vWorld = world.xy;
vAtlas = aAtlas.xy + aUnit.xy * aAtlas.zw;
vSize = vec3(aRect.zw, height);
vFace = gl_VertexID / 6;
vKind = ivec2(kind, int(aMeta.w + .5));
gl_Position = projectWorld(world);
}`;
@@ -54,8 +72,10 @@ const FS = `#version 300 es
precision highp float;
in vec4 vColor;
in vec3 vUnit;
in vec2 vWorld;
in vec2 vAtlas;
in vec3 vSize;
flat in int vFace;
flat in ivec2 vKind;
uniform float uMode;
uniform float uAlpha;
uniform sampler2D uCode;
@@ -70,16 +90,25 @@ void main(){
vec3 top = vec3(.055, .067, .094);
vec3 base = uMode < .5 ? mix(top, vColor.rgb, .07) : top;
vec3 stroke = mix(vColor.rgb, vec3(1.0), .12);
if(vFace > 0 && vFace < 5){
if(vKind.x == 2){
outColor = vec4(.03, .036, .052, uAlpha); // asphalt
return;
}
if(vKind.x == 1){
// Blocks are raised pavements tinted by their dominant layer.
base = mix(vec3(.075, .085, .11), vColor.rgb, .07);
stroke = mix(vColor.rgb, base, .55);
} else if(vFace > 0 && vFace < 5){
// Walls: a fixed key light per side and a darker foot make buildings read as solids.
float light = vFace == 1 ? .34 : vFace == 2 ? .2 : vFace == 3 ? .26 : .42;
base = mix(top, vColor.rgb, light) * (.55 + .45 * vUnit.z);
stroke = mix(stroke, base, .45);
} else if(vFace == 0 && uCodeOn > .5){
// Roofs sample the code overview, which is laid out exactly like the 1000x680 world.
vec4 code = texture(uCode, vWorld / vec2(1000.0, 680.0));
// Roofs sample the code overview canvas at this file's rectangle.
vec4 code = texture(uCode, vAtlas);
base = mix(base, code.rgb / max(code.a, .001), code.a * .92);
}
if(vKind.y == 1) stroke = vec3(1.0, .94, .54); // selected
outColor = vec4(mix(stroke, base, border), uAlpha);
}`;
@@ -109,10 +138,11 @@ function shader(gl, type, source) {
return value;
}
import { perspective, lookAt, multiply, transform, orbitPose, ORBIT_FOV } from './camera.js';
// Landscape heights are exaggerated relative to the 1000x680 map so small repositories still read as 3D.
const LANDSCAPE_HEIGHT_SCALE = 420 / 170;
function uniformsOf(gl, prog) {
const out = {}, count = gl.getProgramParameter(prog, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < count; i++) { const name = gl.getActiveUniform(prog, i).name; out[name] = gl.getUniformLocation(prog, name); }
return out;
}
export class LandscapeRenderer {
constructor(canvas) {
@@ -120,8 +150,8 @@ export class LandscapeRenderer {
const gl = canvas.getContext('webgl2', { antialias: true, alpha: true, depth: true, powerPreference: 'high-performance' });
if (!gl) throw new Error('WebGL2 is required');
this.gl = gl;
this.program = program(gl, VS, FS);
this.gridProgram = program(gl, GRID_VS, GRID_FS);
this.program = program(gl, VS, FS); this.uniforms = uniformsOf(gl, this.program);
this.gridProgram = program(gl, GRID_VS, GRID_FS); this.gridUniforms = uniformsOf(gl, this.gridProgram);
const grid = [];
for (let x = -200; x <= 1200; x += 100) grid.push(x, -200, x, 900);
for (let y = -200; y <= 900; y += 100) grid.push(-200, y, 1200, y);
@@ -131,106 +161,166 @@ export class LandscapeRenderer {
this.gridCount = grid.length / 2;
this.codeTexture = gl.createTexture(); this.codeOn = false;
this.anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
this.vao = gl.createVertexArray(); gl.bindVertexArray(this.vao);
const vertices = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vertices); gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
this.instances = gl.createBuffer();
this.cube = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this.cube); gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
this.landscape = this.boxLayer(); this.cityLayer = this.boxLayer();
this.camera = { x: 500, y: 350, zoom: 1, yaw: -.1, pitch: .78, distance: 3.5 };
this.mode = '2d'; this.count = 0;
// City camera: 'heli' orbits a ground target; 'walk' and 'fly' look out from an eye position.
this.city = { view: 'heli', x: 0, y: 0, yaw: -.12, pitch: .8, distance: 500, eye: [0, 0, EYE_HEIGHT], lookYaw: 0, lookPitch: 0 };
this.mode = '2d';
this.resize();
}
boxLayer() {
const gl = this.gl, layer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 };
gl.bindVertexArray(layer.vao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.cube); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, layer.buffer);
for (let i = 0; i < 4; i++) {
gl.enableVertexAttribArray(i + 1); gl.vertexAttribPointer(i + 1, 4, gl.FLOAT, false, FLOATS_PER_INSTANCE * 4, i * 16); gl.vertexAttribDivisor(i + 1, 1);
}
return layer;
}
upload(layer, packed) {
const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, layer.buffer); gl.bufferData(gl.ARRAY_BUFFER, packed, gl.DYNAMIC_DRAW);
layer.count = packed.length / FLOATS_PER_INSTANCE; layer.packed = packed;
}
// Landscape tiles: items carry x,y,w,h (map units), color and height.
setData(items) {
this.items = items;
const packed = new Float32Array(items.length * 8);
for (let i=0;i<items.length;i++) {
const item=items[i], o=i*8;
packed.set([item.x,item.y,item.w,item.h,item.color[0],item.color[1],item.color[2],item.height],o);
const packed = new Float32Array(items.length * FLOATS_PER_INSTANCE);
items.forEach((item, i) => packed.set([item.x, item.y, item.w, item.h, ...item.color, item.height, item.x / 1000, item.y / 680, item.w / 1000, item.h / 680, KIND.building, 0, item.id, 0], i * FLOATS_PER_INSTANCE));
this.upload(this.landscape, packed);
}
// City instances: [{x,y,w,h,height,color,atlas:[u,v,du,dv],kind,id}] in metres.
setCity(instances, bounds) {
const packed = new Float32Array(instances.length * FLOATS_PER_INSTANCE);
instances.forEach((b, i) => packed.set([b.x, b.y, b.w, b.h, ...b.color, b.height, ...(b.atlas || [0, 0, 0, 0]), b.kind, 0, b.id || 0, 0], i * FLOATS_PER_INSTANCE));
this.cityInstances = instances; this.cityBounds = bounds;
this.upload(this.cityLayer, packed);
}
// Highlights one landscape tile and one city building by file id (0 clears).
setSelected(id) {
for (const layer of [this.landscape, this.cityLayer]) {
const packed = layer.packed; if (!packed) continue;
for (let o = 0; o < packed.length; o += FLOATS_PER_INSTANCE) packed[o + 15] = id && packed[o + 12] === KIND.building && packed[o + 14] === id ? 1 : 0;
const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, layer.buffer); gl.bufferSubData(gl.ARRAY_BUFFER, 0, packed);
}
const gl=this.gl; gl.bindVertexArray(this.vao); gl.bindBuffer(gl.ARRAY_BUFFER,this.instances); gl.bufferData(gl.ARRAY_BUFFER,packed,gl.STATIC_DRAW);
gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1,4,gl.FLOAT,false,32,0); gl.vertexAttribDivisor(1,1);
gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2,4,gl.FLOAT,false,32,16); gl.vertexAttribDivisor(2,1);
this.count=items.length;
}
resize() {
// Cache the CSS size once per frame: project() runs tens of thousands of times per frame,
// and reading clientWidth there forces layout queries that dominated 3D frame time.
this.width=Math.max(1,this.canvas.clientWidth); this.height=Math.max(1,this.canvas.clientHeight);
const dpr=Math.min(devicePixelRatio||1,2), w=Math.max(1,Math.floor(this.width*dpr)), h=Math.max(1,Math.floor(this.height*dpr));
if(this.canvas.width!==w||this.canvas.height!==h){this.canvas.width=w;this.canvas.height=h;}
this.dpr=dpr; this.gl.viewport(0,0,w,h); this.updateView();
this.width = Math.max(1, this.canvas.clientWidth); this.height = Math.max(1, this.canvas.clientHeight);
const dpr = Math.min(devicePixelRatio || 1, 2), w = Math.max(1, Math.floor(this.width * dpr)), h = Math.max(1, Math.floor(this.height * dpr));
if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; }
this.dpr = dpr; this.gl.viewport(0, 0, w, h); this.updateView();
}
// Uploads the code overview canvas as the roof texture; call again after it changes.
setCodeTexture(source) {
const gl=this.gl; gl.bindTexture(gl.TEXTURE_2D,this.codeTexture);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL,true); // mipmaps of straight alpha get dark fringes around glyphs
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,source);
const gl = this.gl; gl.bindTexture(gl.TEXTURE_2D, this.codeTexture);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); // mipmaps of straight alpha get dark fringes around glyphs
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
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);
if(this.anisotropy)gl.texParameterf(gl.TEXTURE_2D,this.anisotropy.TEXTURE_MAX_ANISOTROPY_EXT,Math.min(8,gl.getParameter(this.anisotropy.MAX_TEXTURE_MAX_ANISOTROPY_EXT)));
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
if (this.anisotropy) gl.texParameterf(gl.TEXTURE_2D, this.anisotropy.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(this.anisotropy.MAX_TEXTURE_MAX_ANISOTROPY_EXT)));
}
setCameraUniforms(u) {
const gl=this.gl;
gl.uniform2f(u.uCenter,this.camera.x,this.camera.y);
gl.uniform2f(u.uViewport,this.width,this.height);
gl.uniform1f(u.uZoom,this.camera.zoom);
gl.uniform1f(u.uMode,this.mode==='3d'?1:0);
gl.uniformMatrix4fv(u.uViewProj,false,this.viewProj);
gl.uniform1f(u.uHeightScale,this.heightScale);
const gl = this.gl;
gl.uniform2f(u.uCenter, this.camera.x, this.camera.y);
gl.uniform2f(u.uViewport, this.width, this.height);
gl.uniform1f(u.uZoom, this.camera.zoom);
gl.uniform1f(u.uMode, this.mode === '2d' ? 0 : this.mode === '3d' ? 1 : 2);
if (u.uViewProj) gl.uniformMatrix4fv(u.uViewProj, false, this.viewProj);
if (u.uHeightScale) gl.uniform1f(u.uHeightScale, this.heightScale);
}
// Recomputes the 3D view-projection from the camera; called once per frame and before picking.
// Eye and look direction of the current city camera.
cityPose() {
const c = this.city;
if (c.view === 'heli') {
const cy = Math.cos(c.yaw), sy = Math.sin(c.yaw), cp = Math.cos(c.pitch), sp = Math.sin(c.pitch);
const eye = [c.x + sy * cp * c.distance, c.y + cy * cp * c.distance, sp * c.distance];
return { eye, forward: [-sy * cp, -cy * cp, -sp] };
}
return { eye: c.eye, forward: direction(c.lookYaw, c.lookPitch) };
}
// Recomputes the 3D view-projection from the active camera; runs on every resize check.
updateView() {
this.heightScale=LANDSCAPE_HEIGHT_SCALE;
const pose=orbitPose(this.camera);
this.eye=pose.eye;
this.viewProj=multiply(perspective(ORBIT_FOV,this.width/this.height,4,60000),lookAt(pose.eye,pose.target,pose.up));
const aspect = this.width / this.height;
if (this.mode === 'city') {
this.heightScale = 1;
const { eye, forward } = this.cityPose(), c = this.city;
const size = Math.max(this.cityBounds?.width || 1000, this.cityBounds?.height || 1000);
const near = c.view === 'heli' ? Math.max(.5, c.distance * .002) : .1, far = c.view === 'heli' ? c.distance * 3 + size * 2 : size * 2.5 + 2000;
this.eye = eye; this.forward = forward;
this.viewProj = multiply(perspective(CITY_FOV, aspect, near, far), lookAt(eye, [eye[0] + forward[0], eye[1] + forward[1], eye[2] + forward[2]], Math.abs(forward[2]) > .999 ? [0, -1, 0] : [0, 0, 1]));
} else {
this.heightScale = LANDSCAPE_HEIGHT_SCALE;
const pose = orbitPose(this.camera);
this.eye = pose.eye;
this.viewProj = multiply(perspective(ORBIT_FOV, aspect, 4, 60000), lookAt(pose.eye, pose.target, pose.up));
}
this.inverseViewProj = null;
}
render(alpha=1) {
const gl=this.gl; this.resize();
gl.clearColor(0,0,0,0); gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
const names=['uCenter','uViewport','uZoom','uMode','uViewProj','uHeightScale','uAlpha','uCode','uCodeOn'];
this.uniforms||=Object.fromEntries(names.map(n=>[n,gl.getUniformLocation(this.program,n)]));
this.gridUniforms||=Object.fromEntries(names.map(n=>[n,gl.getUniformLocation(this.gridProgram,n)]));
if(this.mode==='3d'){
render(alpha = 1) {
const gl = this.gl; this.resize();
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
if (this.mode === '3d') {
// Ground grid first without writing depth; buildings then cover it where they stand.
gl.useProgram(this.gridProgram); gl.bindVertexArray(this.gridVao); this.setCameraUniforms(this.gridUniforms);
gl.depthMask(false); gl.drawArrays(gl.LINES,0,this.gridCount); gl.depthMask(true);
gl.depthMask(false); gl.drawArrays(gl.LINES, 0, this.gridCount); gl.depthMask(true);
}
const u=this.uniforms;
gl.useProgram(this.program); gl.bindVertexArray(this.vao); this.setCameraUniforms(u);
gl.uniform1f(u.uAlpha,alpha);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,this.codeTexture); gl.uniform1i(u.uCode,0);
gl.uniform1f(u.uCodeOn,this.codeOn&&this.mode==='3d'?1:0);
gl.drawArraysInstanced(gl.TRIANGLES,0,36,this.count);
const u = this.uniforms, layer = this.mode === 'city' ? this.cityLayer : this.landscape;
gl.useProgram(this.program); gl.bindVertexArray(layer.vao); this.setCameraUniforms(u);
gl.uniform1f(u.uAlpha, alpha);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.codeTexture); gl.uniform1i(u.uCode, 0);
gl.uniform1f(u.uCodeOn, this.codeOn && this.mode !== '2d' ? 1 : 0);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, layer.count);
}
// Geometry of an item in the active world: map units for 2D/3D, metres in the city.
geom(item) { return this.mode === 'city' ? item.city || item : item; }
screenRect(item) {
if(this.mode==='2d') return { x:(item.x-this.camera.x)*this.camera.zoom+this.width/2, y:(item.y-this.camera.y)*this.camera.zoom+this.height/2, w:item.w*this.camera.zoom, h:item.h*this.camera.zoom };
let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity;
for(const [x,y] of [[item.x,item.y],[item.x+item.w,item.y],[item.x,item.y+item.h],[item.x+item.w,item.y+item.h]]){
const p=this.project([x,y,item.height]);
if(p.x<x0)x0=p.x; if(p.x>x1)x1=p.x; if(p.y<y0)y0=p.y; if(p.y>y1)y1=p.y;
const g = this.geom(item);
if (this.mode === '2d') return { x: (g.x - this.camera.x) * this.camera.zoom + this.width / 2, y: (g.y - this.camera.y) * this.camera.zoom + this.height / 2, w: g.w * this.camera.zoom, h: g.h * this.camera.zoom };
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const [x, y] of [[g.x, g.y], [g.x + g.w, g.y], [g.x, g.y + g.h], [g.x + g.w, g.y + g.h]]) {
const p = this.project([x, y, g.height]);
if (p.x < x0) x0 = p.x; if (p.x > x1) x1 = p.x; if (p.y < y0) y0 = p.y; if (p.y > y1) y1 = p.y;
}
return {x:x0,y:y0,w:x1-x0,h:y1-y0};
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
// Screen position of a world point; depth is the view distance, and points behind the camera are NaN.
project([x,y,z=0]) {
const [cx,cy,,cw]=transform(this.viewProj,x,y,z*this.heightScale);
if(cw<=1e-3)return {x:NaN,y:NaN,depth:Infinity};
return {x:(cx/cw+1)/2*this.width,y:(1-cy/cw)/2*this.height,depth:cw};
project([x, y, z = 0]) {
const [cx, cy, , cw] = transform(this.viewProj, x, y, z * this.heightScale);
if (cw <= 1e-3) return { x: NaN, y: NaN, depth: Infinity };
return { x: (cx / cw + 1) / 2 * this.width, y: (1 - cy / cw) / 2 * this.height, depth: cw };
}
worldAt(clientX,clientY) {
return {x:this.camera.x+(clientX-this.width/2)/this.camera.zoom,y:this.camera.y+(clientY-this.height/2)/this.camera.zoom};
// World-space ray through a screen point (3D modes), with z in the active height units.
ray(screenX, screenY) {
this.inverseViewProj ||= invert(this.viewProj);
const m = this.inverseViewProj, nx = screenX / this.width * 2 - 1, ny = 1 - screenY / this.height * 2;
const point = z => { const [x, y, w, q] = transform(m, nx, ny, z); return [x / q, y / q, w / q]; };
const a = point(-1), b = point(1), len = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
return { origin: a, dir: [(b[0] - a[0]) / len, (b[1] - a[1]) / len, (b[2] - a[2]) / len] };
}
worldAt(clientX, clientY) {
return { x: this.camera.x + (clientX - this.width / 2) / this.camera.zoom, y: this.camera.y + (clientY - this.height / 2) / this.camera.zoom };
}
}
+7 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { perspective, lookAt, multiply, transform, orbitPose, ORBIT_FOV } from '../web/camera.js';
import { perspective, lookAt, multiply, transform, invert, orbitPose, ORBIT_FOV } from '../web/camera.js';
// The landscape projection before the real camera, kept as the reference.
function legacyProject(camera, [x, y, z], width, height) {
@@ -26,3 +26,9 @@ test('orbit camera reproduces the legacy landscape projection', () => {
}
}
});
test('invert undoes a view-projection', () => {
const m = multiply(perspective(1, 1.5, .1, 1000), lookAt([3, 4, 5], [0, 0, 0]));
const inv = invert(m), id = multiply(m, inv);
for (let i = 0; i < 16; i++) assert.ok(Math.abs(id[i] - (i % 5 === 0 ? 1 : 0)) < 1e-4, `${i}: ${id[i]}`);
});
+61
View File
@@ -0,0 +1,61 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { layoutCity, buildingHeight, spatialIndex, rayBox, pickRay, CITY } from '../web/city.js';
function sampleFiles() {
const files = []; let id = 1;
for (const dir of ['src/core', 'src/ui/widgets', 'tests', 'docs', 'vendor/lib', '']) {
for (let i = 0; i < 12; i++) files.push({ id: id++, path: `${dir ? dir + '/' : ''}file${i}.js`, lines: 20 + (i * 137) % 900, complexity: i * 3 });
}
return files;
}
const overlaps = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
test('every file gets a building inside the city, and buildings never overlap', () => {
const files = sampleFiles(), city = layoutCity(files, 'demo');
assert.equal(city.buildings.size, files.length);
const list = [...city.buildings.values()];
for (const b of list) {
assert.ok(b.w > 0 && b.h > 0 && b.x >= 0 && b.y >= 0 && b.x + b.w <= city.width + 1e-6 && b.y + b.h <= city.height + 1e-6, JSON.stringify(b));
}
for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) assert.ok(!overlaps(list[i], list[j]), `${i} overlaps ${j}`);
});
test('top-level districts are separated by an avenue', () => {
const city = layoutCity(sampleFiles(), 'demo');
const districts = city.blocks.filter(block => block.depth === 1);
assert.ok(districts.length >= 4);
for (let i = 0; i < districts.length; i++) for (let j = i + 1; j < districts.length; j++) {
const a = districts[i], b = districts[j];
const gap = Math.max(b.x - (a.x + a.w), a.x - (b.x + b.w), b.y - (a.y + a.h), a.y - (b.y + b.h));
assert.ok(gap >= CITY.roadWidths[0] * .99 || gap >= Math.min(a.w, a.h, b.w, b.h) * .39, `gap ${gap}`);
}
});
test('the city grows with the codebase and is deterministic', () => {
const small = layoutCity(sampleFiles().slice(0, 10)), large = layoutCity(sampleFiles());
assert.ok(large.width * large.height > small.width * small.height * 3);
assert.deepEqual([...layoutCity(sampleFiles()).buildings], [...large.buildings]);
});
test('complex files are taller', () => {
assert.ok(buildingHeight({ complexity: 200 }) > buildingHeight({ complexity: 3 }));
assert.equal(buildingHeight({}), 6);
});
test('rays hit the nearest box and miss empty space', () => {
const near = { x: 10, y: -1, w: 2, h: 2, height: 5 }, far = { x: 30, y: -1, w: 2, h: 2, height: 50 };
assert.equal(rayBox([0, 0, 1], [1, 0, 0], near), 10);
assert.equal(rayBox([0, 0, 6], [1, 0, 0], near), Infinity);
assert.equal(pickRay([0, 0, 1], [1, 0, 0], [far, near]).entry, near);
assert.equal(pickRay([0, 0, 1], [-1, 0, 0], [far, near]), null);
assert.equal(pickRay([0, 0, 1], [1, 0, 0], [far, near], 5), null);
});
test('spatial index finds boxes near a point', () => {
const a = { x: 0, y: 0, w: 5, h: 5 }, b = { x: 200, y: 200, w: 5, h: 5 };
const index = spatialIndex([a, b]);
assert.deepEqual([...index.near(2, 2, 3)], [a]);
assert.deepEqual([...index.near(100, 100, 3)], []);
});