diff --git a/web/app.js b/web/app.js index 6ea4091..921db9e 100644 --- a/web/app.js +++ b/web/app.js @@ -1,5 +1,5 @@ import { LandscapeRenderer, KIND } from './renderer.js'; -import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, bladeSigns, facadeQuad, heading, tourStops, encodePlace, decodePlace, joystick, MOVE } from './city.js'; +import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, folderBlades, folderLabel, facadeQuad, heading, tourStops, encodePlace, decodePlace, joystick, MOVE } from './city.js'; import { LabelAtlas } from './labels.js'; import { apiFetch, progressEvents } from './api.mjs'; @@ -46,7 +46,7 @@ let searchHits=[]; let expandedCoverageLayer=null; let coverageActiveIndex=-1; let cameraAnimation=null; -let cityModel=null,cityEntries=[],cityIndex=null,cityFitted=false,cityDistricts=[]; +let cityModel=null,cityEntries=[],cityIndex=null,cityFitted=false,cityDistricts=[],cityFolders=[]; let currentSnapshot=null; let currentName=''; let currentMetric='lines'; @@ -128,6 +128,10 @@ function computeCity(){ 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); + // 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))})); renderer.setCity(instances,{width:cityModel.width,height:cityModel.height}); @@ -801,10 +805,15 @@ function buildSignQuads(signs,focus,radius,eye,heli,yaw){ add(flatQuad(b.x+b.w/2,b.y+b.h/2,b.height+.08,width,width/entry.aspect),entry);roofs++; if(!heli&&dSIGN_LIMITS.wallRadius)continue; + const entry=label(text,'street');if(!entry)break; + for(const quad of folderBlades(block,buildings,eye,entry.aspect,bladeClearance))add(quad,entry); + } // Street names on the northern kerb of nearby blocks. for(const block of cityModel.blocks){ if(block.depth<2||block.depth>3||Math.hypot(block.x+block.w/2-focus[0],block.y+block.h/2-focus[1])>radius)continue; diff --git a/web/city.js b/web/city.js index a6f5dc0..e38dabd 100644 --- a/web/city.js +++ b/web/city.js @@ -315,25 +315,62 @@ export function readableFrom(quad, eye) { return n[0] * (eye[0] - quad.c[0]) + n[1] * (eye[1] - quad.c[1]) + n[2] * (eye[2] - quad.c[2]) < 0; } -// Blade signs stick out from the wall like shop signs, so names read while walking alongside. -// Each blade is two back-to-back faces; only the one readable from the viewer's side is drawn. -// clearance(x, y, direction) returns the free distance outward, so blades never pierce a neighbour. -export function bladeSigns(b, eye, aspect, clearance, height = .7, z = 4.2) { - const out = [], inset = .15; - for (const wall of walls(b)) { - if ((eye[0] - wall.c[0]) * wall.n[0] + (eye[1] - wall.c[1]) * wall.n[1] <= 0) continue; - const count = Math.max(1, Math.floor(wall.width / 24)), segment = wall.width / count, along = [wall.n[1], -wall.n[0]]; - const signZ = Math.max(2.6, Math.min(z, b.height - height)); - for (let i = 0; i < count; i++) { - // Blades sit a third of the way into each segment so they don't overlap the flat wall sign. - const offset = (i + .3) * segment - wall.width / 2, x = wall.c[0] + along[0] * offset, y = wall.c[1] + along[1] * offset; - const room = clearance ? clearance(x, y, wall.n) - inset - .5 : Infinity; - const width = Math.min(3.2, aspect * height, room); - if (width < 1.2) continue; - const c = [x + wall.n[0] * (inset + width / 2), y + wall.n[1] * (inset + width / 2), signZ]; - const u = [wall.n[0] * width / 2, wall.n[1] * width / 2, 0], v = [0, 0, width / aspect / 2]; - out.push({ c, u, v }, { c, u: u.map(value => -value), v }); +// One blade: two back-to-back faces sticking out of a wall at `offset` metres along it from its centre. +// clearance(x, y, direction) is the free distance outward; blades shorten to it or are skipped. +function blade(wall, offset, aspect, clearance, z, height = .7, maxWidth = 5.5) { + const inset = .15, along = [wall.n[1], -wall.n[0]]; + const x = wall.c[0] + along[0] * offset, y = wall.c[1] + along[1] * offset; + const room = clearance ? clearance(x, y, wall.n) - inset - .5 : Infinity; + const width = Math.min(maxWidth, aspect * height, room); + if (width < 1.2) return []; + const c = [x + wall.n[0] * (inset + width / 2), y + wall.n[1] * (inset + width / 2), z]; + const u = [wall.n[0] * width / 2, wall.n[1] * width / 2, 0], v = [0, 0, width / aspect / 2]; + return [{ c, u, v }, { c, u: u.map(value => -value), v }]; +} + +// Street-corner signs naming a folder. For each corner of the folder's block, the building nearest +// that corner gets a blade on each of its two walls facing the corner's streets, near the corner end, +// so an intersection shows every neighbouring folder on its own side. Walls set back further than +// `edge` metres from the block boundary face an inner courtyard, not a street, and are skipped. +export function folderBlades(block, buildings, eye, aspect, clearance, edge = 4.5) { + const out = []; + if (!buildings.length) return out; + const corners = [ + { x: block.x, y: block.y, normals: [[0, -1], [-1, 0]] }, + { x: block.x + block.w, y: block.y, normals: [[0, -1], [1, 0]] }, + { x: block.x, y: block.y + block.h, normals: [[0, 1], [-1, 0]] }, + { x: block.x + block.w, y: block.y + block.h, normals: [[0, 1], [1, 0]] }, + ]; + const seen = new Set(); + for (const corner of corners) { + let nearest = null, best = Infinity; + for (const b of buildings) { + const d = Math.hypot(Math.max(b.x - corner.x, 0, corner.x - b.x - b.w), Math.max(b.y - corner.y, 0, corner.y - b.y - b.h)); + if (d < best) { best = d; nearest = b; } + } + for (const n of corner.normals) { + const wall = walls(nearest).find(w => w.n[0] === n[0] && w.n[1] === n[1]); + const key = `${buildings.indexOf(nearest)}:${n}:${corner.x}:${corner.y}`; + if (seen.has(key)) continue; + seen.add(key); + // Distance from this wall to the block boundary on the same side. + const setback = n[0] ? Math.abs((n[0] > 0 ? block.x + block.w : block.x) - wall.c[0]) : Math.abs((n[1] > 0 ? block.y + block.h : block.y) - wall.c[1]); + if (setback > edge) continue; + if (eye && (eye[0] - wall.c[0]) * n[0] + (eye[1] - wall.c[1]) * n[1] <= 0) continue; + const along = [n[1], -n[0]], toCorner = Math.sign((corner.x - wall.c[0]) * along[0] + (corner.y - wall.c[1]) * along[1]) || 1; + const offset = toCorner * Math.max(0, wall.width / 2 - Math.min(1.5, wall.width * .2)); + out.push(...blade(wall, offset, aspect, clearance, Math.max(2.6, Math.min(4.2, nearest.height - .8)))); } } return out; } + +// Folder path for a street sign: the whole path when short, otherwise its tail after an ellipsis. +export function folderLabel(path, root, max = 34) { + if (!path) return root; + if (path.length <= max) return path; + const parts = path.split('/'); + let label = parts.pop(); + while (parts.length && label.length + parts[parts.length - 1].length + 3 <= max) label = `${parts.pop()}/${label}`; + return `…/${label}`; +} diff --git a/web/labels.js b/web/labels.js index 3a84896..18df92a 100644 --- a/web/labels.js +++ b/web/labels.js @@ -19,6 +19,8 @@ const STYLES = { // Name plates on roofs and walls. plate: { font: '600 30px Inter, system-ui, sans-serif', text: '#f1f3f8', fill: 'rgba(10,12,18,.9)', border: 'rgba(255,255,255,.14)' }, selected: { font: '700 30px Inter, system-ui, sans-serif', text: '#1a1405', fill: 'rgba(255,236,140,.96)', border: 'rgba(255,255,255,.5)' }, + // Folder blades at street corners, in street-sign green. + street: { font: '600 30px Inter, system-ui, sans-serif', text: '#f4fff9', fill: 'rgba(14,112,76,.96)', border: 'rgba(255,255,255,.55)' }, // District and street names painted on the ground. ground: { font: '700 34px Inter, system-ui, sans-serif', text: 'rgba(205,198,255,.9)' }, }; diff --git a/webtest/labels.test.mjs b/webtest/labels.test.mjs index ddee603..39e682a 100644 --- a/webtest/labels.test.mjs +++ b/webtest/labels.test.mjs @@ -41,23 +41,42 @@ test('facade covers the most squarely facing wall within the building', () => { assert.deepEqual(flatQuad(1, 2, 3, 4, 2), { c: [1, 2, 3], u: [2, 0, 0], v: [0, -1, 0] }); }); -import { bladeSigns, readableFrom } from '../web/city.js'; +import { folderBlades, folderLabel, readableFrom } from '../web/city.js'; -test('blade signs project from the wall and exactly one face reads from either side', () => { - const south = { x: 0, y: 0, w: 20, h: 10, height: 30 }; - const blades = bladeSigns(south, [5, 14, 1.7], 4); - assert.equal(blades.length, 2, 'one blade (two faces) on the south wall only'); - const [a, b] = blades; - assert.ok(Math.abs(a.u[0]) < 1e-9 && a.u[1] > 0 && a.c[1] > 10, 'perpendicular to the wall, outside it'); - for (const eye of [[-5, 14, 1.7], [25, 14, 1.7]]) { - assert.equal(readableFrom(a, eye) + readableFrom(b, eye), 1, `one readable face from ${eye}`); +// A 2x2 grid of buildings inside a block whose kerb is 2.5 m from the boundary. +const block = { x: 0, y: 0, w: 50, h: 50 }; +const grid = [ + { x: 2.5, y: 2.5, w: 20, h: 20, height: 30 }, { x: 27.5, y: 2.5, w: 20, h: 20, height: 30 }, + { x: 2.5, y: 27.5, w: 20, h: 20, height: 30 }, { x: 27.5, y: 27.5, w: 20, h: 20, height: 30 }, +]; + +test('folder blades sit at every block corner, on street-facing walls, pointing outward', () => { + const blades = folderBlades(block, grid, null, 4); + assert.equal(blades.length, 16, 'two walls per corner, two faces per blade'); + for (let i = 0; i < blades.length; i += 2) { + const [a, b] = [blades[i], blades[i + 1]]; + const outward = [Math.sign(a.u[0]), Math.sign(a.u[1])]; + const corner = [a.c[0] < 25 ? 0 : 50, a.c[1] < 25 ? 0 : 50]; + assert.ok(Math.hypot(a.c[0] - corner[0], a.c[1] - corner[1]) < 6, `near its corner: ${a.c}`); + assert.ok(outward[0] ? (outward[0] > 0) === (a.c[0] > 25) : (outward[1] > 0) === (a.c[1] > 25), 'projects into the street'); + for (const eye of [[a.c[0] + a.v[2] * 0 + (a.u[1] ? 10 : 0), a.c[1] + (a.u[0] ? 10 : 0), 2], [a.c[0] - (a.u[1] ? 10 : 0), a.c[1] - (a.u[0] ? 10 : 0), 2]]) { + assert.equal(readableFrom(a, eye) + readableFrom(b, eye), 1); + } } +}); + +test('courtyard walls, hidden walls and cramped gaps get no blades', () => { + const inner = [{ x: 10, y: 10, w: 30, h: 30, height: 30 }]; + assert.equal(folderBlades(block, inner, null, 4).length, 0, 'set back from the street'); + const northOnly = folderBlades(block, grid, [25, -40, 1.7], 4); + assert.ok(northOnly.length === 4 && northOnly.every(q => q.c[1] < 2.5), 'only walls facing the viewer'); + assert.equal(folderBlades(block, grid, null, 4, () => 1).length, 0); assert.ok(readableFrom(flatQuad(0, 0, 0, 4, 1), [0, 0, 50]), 'ground labels read from above'); }); -test('blades shrink to the free space and are skipped when a neighbour is too close', () => { - const b = { x: 0, y: 0, w: 20, h: 10, height: 30 }; - assert.ok(bladeSigns(b, [5, 30, 1.7], 10, () => 2.5)[0].u[1] * 2 <= 2.5 - .65 + 1e-9); - assert.equal(bladeSigns(b, [5, 30, 1.7], 10, () => 1).length, 0); - assert.equal(bladeSigns({ x: 0, y: 0, w: 100, h: 10, height: 30 }, [50, 30, 1.7], 4).length, 8, 'repeats along long walls'); +test('folder labels keep the tail of long paths', () => { + assert.equal(folderLabel('', 'prometheus'), 'prometheus'); + assert.equal(folderLabel('tsdb/chunks', 'x'), 'tsdb/chunks'); + const long = folderLabel('web/ui/react-app/src/pages/graph/components', 'x'); + assert.ok(long.startsWith('…/') && long.endsWith('graph/components') && long.length <= 34 + 2, long); });