Show folder paths on blade signs at street corners
Blades named the file they were attached to. They now act as street signs: for each corner of a folder's block, the building nearest that corner gets a green blade on each wall facing the corner's two streets, near the corner end, reading the folder path (shortened from the left when long; the repository name at the root). Neighbouring blocks each sign their own corner, so an intersection shows a different folder on every side. Courtyard walls set back from the street are skipped, and blades still shorten or disappear when a neighbour is too close. File names remain on the flat wall and roof signs.
This commit is contained in:
+12
-3
@@ -1,5 +1,5 @@
|
|||||||
import { LandscapeRenderer, KIND } from './renderer.js';
|
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 { LabelAtlas } from './labels.js';
|
||||||
import { apiFetch, progressEvents } from './api.mjs';
|
import { apiFetch, progressEvents } from './api.mjs';
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ let searchHits=[];
|
|||||||
let expandedCoverageLayer=null;
|
let expandedCoverageLayer=null;
|
||||||
let coverageActiveIndex=-1;
|
let coverageActiveIndex=-1;
|
||||||
let cameraAnimation=null;
|
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 currentSnapshot=null;
|
||||||
let currentName='';
|
let currentName='';
|
||||||
let currentMetric='lines';
|
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)});
|
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);
|
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};};
|
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))}));
|
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});
|
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++;
|
add(flatQuad(b.x+b.w/2,b.y+b.h/2,b.height+.08,width,width/entry.aspect),entry);roofs++;
|
||||||
if(!heli&&d<SIGN_LIMITS.wallRadius&&walls<SIGN_LIMITS.walls){
|
if(!heli&&d<SIGN_LIMITS.wallRadius&&walls<SIGN_LIMITS.walls){
|
||||||
for(const quad of wallSigns(b,eye,entry.aspect))add(quad,entry);
|
for(const quad of wallSigns(b,eye,entry.aspect))add(quad,entry);
|
||||||
for(const quad of bladeSigns(b,eye,entry.aspect,bladeClearance))add(quad,entry);
|
|
||||||
walls++;
|
walls++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Folder names on blades at the street corners of nearby blocks, one per side of each intersection.
|
||||||
|
if(!heli)for(const {block,buildings,label:text} of cityFolders){
|
||||||
|
if(Math.max(Math.abs(block.x+block.w/2-focus[0])-block.w/2,Math.abs(block.y+block.h/2-focus[1])-block.h/2)>SIGN_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.
|
// Street names on the northern kerb of nearby blocks.
|
||||||
for(const block of cityModel.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;
|
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;
|
||||||
|
|||||||
+55
-18
@@ -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;
|
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.
|
// One blade: two back-to-back faces sticking out of a wall at `offset` metres along it from its centre.
|
||||||
// Each blade is two back-to-back faces; only the one readable from the viewer's side is drawn.
|
// clearance(x, y, direction) is the free distance outward; blades shorten to it or are skipped.
|
||||||
// clearance(x, y, direction) returns the free distance outward, so blades never pierce a neighbour.
|
function blade(wall, offset, aspect, clearance, z, height = .7, maxWidth = 5.5) {
|
||||||
export function bladeSigns(b, eye, aspect, clearance, height = .7, z = 4.2) {
|
const inset = .15, along = [wall.n[1], -wall.n[0]];
|
||||||
const out = [], inset = .15;
|
const x = wall.c[0] + along[0] * offset, y = wall.c[1] + along[1] * offset;
|
||||||
for (const wall of walls(b)) {
|
const room = clearance ? clearance(x, y, wall.n) - inset - .5 : Infinity;
|
||||||
if ((eye[0] - wall.c[0]) * wall.n[0] + (eye[1] - wall.c[1]) * wall.n[1] <= 0) continue;
|
const width = Math.min(maxWidth, aspect * height, room);
|
||||||
const count = Math.max(1, Math.floor(wall.width / 24)), segment = wall.width / count, along = [wall.n[1], -wall.n[0]];
|
if (width < 1.2) return [];
|
||||||
const signZ = Math.max(2.6, Math.min(z, b.height - height));
|
const c = [x + wall.n[0] * (inset + width / 2), y + wall.n[1] * (inset + width / 2), z];
|
||||||
for (let i = 0; i < count; i++) {
|
const u = [wall.n[0] * width / 2, wall.n[1] * width / 2, 0], v = [0, 0, width / aspect / 2];
|
||||||
// Blades sit a third of the way into each segment so they don't overlap the flat wall sign.
|
return [{ c, u, v }, { c, u: u.map(value => -value), v }];
|
||||||
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);
|
// Street-corner signs naming a folder. For each corner of the folder's block, the building nearest
|
||||||
if (width < 1.2) continue;
|
// that corner gets a blade on each of its two walls facing the corner's streets, near the corner end,
|
||||||
const c = [x + wall.n[0] * (inset + width / 2), y + wall.n[1] * (inset + width / 2), signZ];
|
// so an intersection shows every neighbouring folder on its own side. Walls set back further than
|
||||||
const u = [wall.n[0] * width / 2, wall.n[1] * width / 2, 0], v = [0, 0, width / aspect / 2];
|
// `edge` metres from the block boundary face an inner courtyard, not a street, and are skipped.
|
||||||
out.push({ c, u, v }, { c, u: u.map(value => -value), v });
|
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;
|
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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const STYLES = {
|
|||||||
// Name plates on roofs and walls.
|
// 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)' },
|
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)' },
|
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.
|
// District and street names painted on the ground.
|
||||||
ground: { font: '700 34px Inter, system-ui, sans-serif', text: 'rgba(205,198,255,.9)' },
|
ground: { font: '700 34px Inter, system-ui, sans-serif', text: 'rgba(205,198,255,.9)' },
|
||||||
};
|
};
|
||||||
|
|||||||
+33
-14
@@ -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] });
|
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', () => {
|
// A 2x2 grid of buildings inside a block whose kerb is 2.5 m from the boundary.
|
||||||
const south = { x: 0, y: 0, w: 20, h: 10, height: 30 };
|
const block = { x: 0, y: 0, w: 50, h: 50 };
|
||||||
const blades = bladeSigns(south, [5, 14, 1.7], 4);
|
const grid = [
|
||||||
assert.equal(blades.length, 2, 'one blade (two faces) on the south wall only');
|
{ x: 2.5, y: 2.5, w: 20, h: 20, height: 30 }, { x: 27.5, y: 2.5, w: 20, h: 20, height: 30 },
|
||||||
const [a, b] = blades;
|
{ x: 2.5, y: 27.5, w: 20, h: 20, height: 30 }, { x: 27.5, y: 27.5, w: 20, h: 20, height: 30 },
|
||||||
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}`);
|
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');
|
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', () => {
|
test('folder labels keep the tail of long paths', () => {
|
||||||
const b = { x: 0, y: 0, w: 20, h: 10, height: 30 };
|
assert.equal(folderLabel('', 'prometheus'), 'prometheus');
|
||||||
assert.ok(bladeSigns(b, [5, 30, 1.7], 10, () => 2.5)[0].u[1] * 2 <= 2.5 - .65 + 1e-9);
|
assert.equal(folderLabel('tsdb/chunks', 'x'), 'tsdb/chunks');
|
||||||
assert.equal(bladeSigns(b, [5, 30, 1.7], 10, () => 1).length, 0);
|
const long = folderLabel('web/ui/react-app/src/pages/graph/components', 'x');
|
||||||
assert.equal(bladeSigns({ x: 0, y: 0, w: 100, h: 10, height: 30 }, [50, 30, 1.7], 4).length, 8, 'repeats along long walls');
|
assert.ok(long.startsWith('…/') && long.endsWith('graph/components') && long.length <= 34 + 2, long);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user