diff --git a/README.md b/README.md index 303c82c..fe18076 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ Configuration: - `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). - 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. - 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 diff --git a/web/app.js b/web/app.js index 60c5aa7..c98bcbd 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, 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 { apiFetch, progressEvents } from './api.mjs'; @@ -175,7 +175,8 @@ function setCityView(view,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])))}); // 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 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); @@ -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; 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){ - 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(); } + ctx.setLineDash([]); // Heading arrow: yaw 0 points north (up the map). ctx.save();ctx.translate(x,y);ctx.rotate(-yaw); ctx.fillStyle='rgba(54,211,194,.18)';ctx.beginPath();ctx.moveTo(0,0);ctx.arc(0,0,26,-Math.PI/2-.5,-Math.PI/2+.5);ctx.closePath();ctx.fill(); @@ -769,21 +773,26 @@ function updateTrails(){ 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. const MAX_ROUTES=40; function updateRoutes(){ const source=selected?.id?layoutById.get(selected.id)?.city:null; cityRoutes=[]; 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)||[]){ - if(edge.from!==selected.id||seen.has(edge.to))continue;seen.add(edge.to); - const target=layoutById.get(edge.to)?.city;if(!target||target===source)continue; - targets.push(target);kinds.push(edge.confidence==='known'?0:1); - if(targets.length>=MAX_ROUTES)break; + const outgoing=edge.from===selected.id,other=outgoing?edge.to:edge.from,counter=outgoing?imports:dependents,key=`${outgoing}:${other}`; + if(seen.has(key)||counter.count>=MAX_ROUTES)continue;seen.add(key); + const target=layoutById.get(other)?.city;if(!target||target===source)continue; + 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; } diff --git a/web/city.js b/web/city.js index 750bc7a..eba15eb 100644 --- a/web/city.js +++ b/web/city.js @@ -640,3 +640,27 @@ export function posterQuads(walls, designs, random = Math.random) { } 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; +} diff --git a/web/renderer.js b/web/renderer.js index c9db5cb..c9a3ae1 100644 --- a/web/renderer.js +++ b/web/renderer.js @@ -1,4 +1,5 @@ import { perspective, lookAt, multiply, transform, invert, orbitPose, direction, ORBIT_FOV } from './camera.js'; +import { ribbonVertices } from './city.js'; const VERTICES = new Float32Array([ // top @@ -255,13 +256,22 @@ uniform float uTime; uniform float uFogDensity; out vec4 outColor; 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); - float across = abs(vAcrossAlong.x); // 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 chevron = smoothstep(0.0, .06, phase) * (1.0 - smoothstep(.22, .32, phase)); 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); }`; 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. - setRoutes(routes, width = 1.6, z = .45) { - 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; - // 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; - } - } + setRoutes(routes) { + const vertices = ribbonVertices(routes); 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; } + particleLayer() { const gl = this.gl, layer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 }; gl.bindVertexArray(layer.vao); diff --git a/webtest/nav.test.mjs b/webtest/nav.test.mjs index 4978cc8..f79c122 100644 --- a/webtest/nav.test.mjs +++ b/webtest/nav.test.mjs @@ -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'); }); + +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); +});