Route dependents to the selected file with pulsing magenta dashes

Files that import the selected file now get street routes too, from
the same single search (paths cost the same both ways), reversed so
they flow toward the selection. They draw as magenta dashes marching
inward with a pulse travelling along them, dashed on the minimap too.
Ribbon geometry moves into city.js and every ribbon keeps to the right
of its direction of travel, so an import and a dependent sharing a
street run in separate lanes instead of overlapping (tested).
This commit is contained in:
Andy Dixon
2026-07-05 14:33:52 +01:00
parent 630636b3af
commit 8f3a3337b5
5 changed files with 75 additions and 28 deletions
+2 -1
View File
@@ -34,7 +34,8 @@ Configuration:
- `T` or **Tour** flies over the largest districts. **Copy link** shares the repository, view, - `T` or **Tour** flies over the largest districts. **Copy link** shares the repository, view,
camera and selected file as a URL fragment (GitHub repositories only). camera and selected file as a URL fragment (GitHub repositories only).
- Touch: left thumb joystick, right thumb look, pinch to zoom. - Touch: left thumb joystick, right thumb look, pinch to zoom.
- Selecting a file also lays glowing routes along the streets to each file it imports. - Selecting a file also lays glowing routes along the streets: cyan/amber chevrons out to each
file it imports, and pulsing magenta dashes in from each file that imports it, in separate lanes.
- Residents wander the streets (toggle in Settings). A wall with posters encloses the city. - Residents wander the streets (toggle in Settings). A wall with posters encloses the city.
- Signed in with GitHub, files with open code scanning or Dependabot alerts smoke (and burn - Signed in with GitHub, files with open code scanning or Dependabot alerts smoke (and burn
for critical/high) and are wrapped in hazard tape. The GitHub App needs read access to for critical/high) and are wrapped in hazard tape. The GitHub App needs read access to
+19 -10
View File
@@ -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, folderBlades, folderLabel, facadeQuad, heading, tourStops, encodePlace, decodePlace, joystick, buildNavGrid, routesFrom, spawnWanderers, stepWanderers, alertsByPath, burns, tapeQuads, cityWalls, posterQuads, seededRandom, WALL, MOVE } from './city.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, MOVE } from './city.js';
import { LabelAtlas, PosterAtlas } from './labels.js'; import { LabelAtlas, PosterAtlas } from './labels.js';
import { apiFetch, progressEvents } from './api.mjs'; import { apiFetch, progressEvents } from './api.mjs';
@@ -175,7 +175,8 @@ function setCityView(view,at){
if(fromHeli||at){ 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])))}); 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. // With routes showing, walking starts at the selected building's door facing down the first route.
const route=!at&&view==='walk'&&cityRoutes[0]?.points.length>1?cityRoutes[0].points:null; // 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>1?(first.outgoing?first.points:[...first.points].reverse()):null;
const target=at||route?.[0]||[c.x,c.y],spot=collide(target[0],target[1],MOVE.radius*3,collisionIndex); 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; 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); 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);
@@ -386,9 +387,12 @@ function drawMinimap(){
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; 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);}} 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){ for(const route of cityRoutes){
ctx.strokeStyle=route.kind===0?'rgba(54,211,194,.9)':'rgba(247,189,77,.9)';ctx.lineWidth=1.4;ctx.beginPath(); 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(); 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). // Heading arrow: yaw 0 points north (up the map).
ctx.save();ctx.translate(x,y);ctx.rotate(-yaw); 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='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();
@@ -769,21 +773,26 @@ function updateTrails(){
renderer.setTrails(arcs);dirty=true; renderer.setTrails(arcs);dirty=true;
} }
// Walkable routes along the streets from the selected file to each file it imports. // Walkable routes along the streets between the selected file and the files it imports (flowing
// out, cyan/amber chevrons) and the files that import it (flowing in, magenta dashes).
// ponytail: one Dijkstra on the main thread (tens of ms on ~1,700 files); move to a worker if large repos stutter. // ponytail: one Dijkstra on the main thread (tens of ms on ~1,700 files); move to a worker if large repos stutter.
const MAX_ROUTES=40; const MAX_ROUTES=40;
function updateRoutes(){ function updateRoutes(){
const source=selected?.id?layoutById.get(selected.id)?.city:null; const source=selected?.id?layoutById.get(selected.id)?.city:null;
cityRoutes=[]; cityRoutes=[];
if(source&&navGrid){ if(source&&navGrid){
const targets=[],kinds=[],seen=new Set(); const targets=[],meta=[],seen=new Set(),imports={count:0},dependents={count:0};
for(const edge of edgesByFile.get(selected.id)||[]){ for(const edge of edgesByFile.get(selected.id)||[]){
if(edge.from!==selected.id||seen.has(edge.to))continue;seen.add(edge.to); const outgoing=edge.from===selected.id,other=outgoing?edge.to:edge.from,counter=outgoing?imports:dependents,key=`${outgoing}:${other}`;
const target=layoutById.get(edge.to)?.city;if(!target||target===source)continue; if(seen.has(key)||counter.count>=MAX_ROUTES)continue;seen.add(key);
targets.push(target);kinds.push(edge.confidence==='known'?0:1); const target=layoutById.get(other)?.city;if(!target||target===source)continue;
if(targets.length>=MAX_ROUTES)break; targets.push(target);counter.count++;
meta.push({outgoing,kind:outgoing?(edge.confidence==='known'?ROUTE_KIND.knownImport:ROUTE_KIND.inferredImport):ROUTE_KIND.dependent});
} }
if(targets.length)routesFrom(navGrid,source,targets).forEach((points,i)=>{if(points)cityRoutes.push({points,kind:kinds[i],target:targets[i]});}); // Paths cost the same both ways, so one search from the selected building serves both directions.
if(targets.length)routesFrom(navGrid,source,targets).forEach((points,i)=>{
if(points)cityRoutes.push({points:meta[i].outgoing?points:[...points].reverse(),kind:meta[i].kind,outgoing:meta[i].outgoing,target:targets[i]});
});
} }
renderer.setRoutes(cityRoutes);dirty=true; renderer.setRoutes(cityRoutes);dirty=true;
} }
+24
View File
@@ -640,3 +640,27 @@ export function posterQuads(walls, designs, random = Math.random) {
} }
return out; return out;
} }
// Flat ribbon triangles for routes on the ground: per vertex x, y, z, across (-1 left .. 1 right),
// along (metres from the route's start) and kind. Ribbons keep to the right of their direction of
// travel, so an outbound and an inbound route sharing a street run in separate lanes.
export const ROUTE_KIND = { knownImport: 0, inferredImport: 1, dependent: 2 };
export function ribbonVertices(routes, width = 1.3, z = .45, lane = .8) {
const vertices = [];
for (const route of routes) {
let along = 0;
for (let i = 1; i < route.points.length; i++) {
const [ax, ay] = route.points[i - 1], [bx, by] = route.points[i], length = Math.hypot(bx - ax, by - ay);
if (length < 1e-3) continue;
// Right of travel is (-dy, dx) in this left-handed world (see heading()). Each segment also runs
// half a width past both ends so corners overlap instead of leaving notches.
const dx = (bx - ax) / length, dy = (by - ay) / length, rx = -dy, ry = dx;
const ox = rx * lane, oy = ry * lane, ex = dx * width / 2, ey = dy * width / 2, hx = rx * width / 2, hy = ry * width / 2;
const p0 = [ax - ex + ox, ay - ey + oy], p1 = [bx + ex + ox, by + ey + oy], a0 = along - width / 2, a1 = along + length + width / 2;
const v = (p, side, a) => vertices.push(p[0] + hx * side, p[1] + hy * side, z, side, a, route.kind);
v(p0, -1, a0); v(p0, 1, a0); v(p1, 1, a1); v(p0, -1, a0); v(p1, 1, a1); v(p1, -1, a1);
along += length;
}
}
return vertices;
}
+15 -17
View File
@@ -1,4 +1,5 @@
import { perspective, lookAt, multiply, transform, invert, orbitPose, direction, ORBIT_FOV } from './camera.js'; import { perspective, lookAt, multiply, transform, invert, orbitPose, direction, ORBIT_FOV } from './camera.js';
import { ribbonVertices } from './city.js';
const VERTICES = new Float32Array([ const VERTICES = new Float32Array([
// top // top
@@ -255,13 +256,22 @@ uniform float uTime;
uniform float uFogDensity; uniform float uFogDensity;
out vec4 outColor; out vec4 outColor;
void main(){ void main(){
float across = abs(vAcrossAlong.x), fog = exp(-pow(vDepth * uFogDensity, 2.0));
if(vKind == 2){
// Dependents: magenta dashes marching toward the selected file, with a pulse travelling along them.
float dash = fract(vAcrossAlong.y * .16 - uTime * 1.1);
float onDash = smoothstep(0.0, .05, dash) * (1.0 - smoothstep(.5, .56, dash));
float pulse = .55 + .45 * sin(uTime * 5.0 - vAcrossAlong.y * .35);
float edge = 1.0 - smoothstep(.55, .95, across);
outColor = vec4(vec3(1.0, .22, .72) * edge * (.06 + onDash * (.3 + .45 * pulse)) * fog, 0.0);
return;
}
vec3 color = vKind == 0 ? vec3(.21, .83, .76) : vec3(.97, .74, .3); vec3 color = vKind == 0 ? vec3(.21, .83, .76) : vec3(.97, .74, .3);
float across = abs(vAcrossAlong.x);
// Chevron tips lead: the phase grows toward the centre line, and time moves it forward. // Chevron tips lead: the phase grows toward the centre line, and time moves it forward.
float phase = fract(vAcrossAlong.y * .22 + across * .35 - uTime * 1.4); float phase = fract(vAcrossAlong.y * .22 + across * .35 - uTime * 1.4);
float chevron = smoothstep(0.0, .06, phase) * (1.0 - smoothstep(.22, .32, phase)); float chevron = smoothstep(0.0, .06, phase) * (1.0 - smoothstep(.22, .32, phase));
float body = 1.0 - smoothstep(.7, 1.0, across); float body = 1.0 - smoothstep(.7, 1.0, across);
float glow = body * (.16 + .95 * chevron) * exp(-pow(vDepth * uFogDensity, 2.0)); float glow = body * (.16 + .95 * chevron) * fog;
outColor = vec4(color * glow, 0.0); outColor = vec4(color * glow, 0.0);
}`; }`;
const ROUTE_FLOATS = 6; const ROUTE_FLOATS = 6;
@@ -541,25 +551,13 @@ export class LandscapeRenderer {
} }
// routes: [{ points: [[x,y], ...], kind }] drawn on the ground from first point to last. // routes: [{ points: [[x,y], ...], kind }] drawn on the ground from first point to last.
setRoutes(routes, width = 1.6, z = .45) { setRoutes(routes) {
const vertices = []; const vertices = ribbonVertices(routes);
for (const route of routes) {
let along = 0;
for (let i = 1; i < route.points.length; i++) {
const [ax, ay] = route.points[i - 1], [bx, by] = route.points[i], length = Math.hypot(bx - ax, by - ay);
if (length < 1e-3) continue;
// Each segment runs half a width past both ends so corners overlap instead of leaving notches.
const dx = (bx - ax) / length, dy = (by - ay) / length, nx = -dy * width / 2, ny = dx * width / 2, ex = dx * width / 2, ey = dy * width / 2;
const p0 = [ax - ex, ay - ey], p1 = [bx + ex, by + ey], a0 = along - width / 2, a1 = along + length + width / 2;
const v = (p, side, a) => vertices.push(p[0] + nx * side, p[1] + ny * side, z, side, a, route.kind);
v(p0, -1, a0); v(p0, 1, a0); v(p1, 1, a1); v(p0, -1, a0); v(p1, 1, a1); v(p1, -1, a1);
along += length;
}
}
const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, this.routeLayer.buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW); const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, this.routeLayer.buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW);
this.routeLayer.count = vertices.length / ROUTE_FLOATS; this.routeLayer.count = vertices.length / ROUTE_FLOATS;
} }
particleLayer() { particleLayer() {
const gl = this.gl, layer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 }; const gl = this.gl, layer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 };
gl.bindVertexArray(layer.vao); gl.bindVertexArray(layer.vao);
+15
View File
@@ -112,3 +112,18 @@ test('posters are pasted on the city side of the walls, tilted and readable from
} }
assert.ok(new Set(posters.map(p => p.u[2].toFixed(2))).size > 10, 'arbitrary angles'); assert.ok(new Set(posters.map(p => p.u[2].toFixed(2))).size > 10, 'arbitrary angles');
}); });
import { ribbonVertices, heading } from '../web/city.js';
test('route ribbons keep right, so opposite directions on one street use separate lanes', () => {
const east = ribbonVertices([{ points: [[0, 0], [100, 0]], kind: 0 }]);
const west = ribbonVertices([{ points: [[100, 0], [0, 0]], kind: 2 }]);
const ys = vertices => { const out = []; for (let i = 1; i < vertices.length; i += 6) out.push(vertices[i]); return out; };
// Heading east (+x) the walker's right is heading(yaw).right with forward (1, 0): yaw = -PI/2.
const right = heading(-Math.PI / 2).right;
assert.ok(Math.abs(right[0]) < 1e-9 && right[1] > 0, 'right of east is +y here');
assert.ok(ys(east).every(y => y > 0), `eastbound ribbon on +y: ${ys(east)}`);
assert.ok(ys(west).every(y => y < 0), `westbound ribbon on -y: ${ys(west)}`);
const along = []; for (let i = 4; i < east.length; i += 6) along.push(east[i]);
assert.ok(Math.max(...along) > 99 && Math.min(...along) < 0 && east[5] === 0 && west[5] === 2);
});