diff --git a/web/app.js b/web/app.js index 0a86591..5f12770 100644 --- a/web/app.js +++ b/web/app.js @@ -123,12 +123,14 @@ function computeCity(){ 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}); + // More definitions light more windows. + const symbols=item.symbolCount??item.symbols?.length??0; + 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); cityDistricts=cityModel.blocks.filter(block=>block.depth===1).map(block=>({block,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}); - buildMinimap();updateCityHud();signState.key='';facadeState={entry:null,texture:null};renderer.setFacade(null); + buildMinimap();updateCityHud();updateTrails();updateBeacons();signState.key='';facadeState={entry:null,texture:null};renderer.setFacade(null); } // ---- City navigation: helicopter, walk and fly cameras, HUD, crosshair and minimap ---- @@ -463,7 +465,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; - renderer.setSelected(file?.id||0);renderInspector();if(file)loadFileDetails(file);dirty=true; + renderer.setSelected(file?.id||0);updateTrails();renderInspector();if(file)loadFileDetails(file);dirty=true; } function renderInspector(){ @@ -525,7 +527,7 @@ function mountCoverageList(layer){ } function renderResults(hits){ - searchHits=hits;$('#resultBadge').textContent=hits.length||'';$('#resultSummary').textContent=hits.length?`${hits.length} matching files and definitions`:'Type in the filter to search files and symbols.'; + searchHits=hits;updateBeacons();$('#resultBadge').textContent=hits.length||'';$('#resultSummary').textContent=hits.length?`${hits.length} matching files and definitions`:'Type in the filter to search files and symbols.'; $('#resultList').innerHTML=hits.map(hit=>`
${escapeHtml(hit.name)}${escapeHtml(hit.path)}${hit.line?` · ${hit.line}`:''}
`).join(''); $('#resultList').querySelectorAll('.result-row').forEach(row=>row.addEventListener('click',()=>{const file=fileById.get(Number(row.dataset.id));if(file){selectFile(file);focusFile(file);} }));dirty=true; } @@ -621,6 +623,29 @@ function drawCityOverlay(){ if(showCode){fillCodeOverview();uploadCodeOverview();} } +// Glowing arcs from the selected building to every file it imports or is imported by. +function updateTrails(){ + const origin=selected?.id?layoutById.get(selected.id)?.city:null; + if(!origin||!cityModel){renderer.setTrails([]);return;} + const top=b=>[b.x+b.w/2,b.y+b.h/2,b.height],arcs=[],seen=new Set(); + for(const edge of edgesByFile.get(selected.id)||[]){ + const key=`${edge.from}>${edge.to}`;if(seen.has(key))continue;seen.add(key); + const from=layoutById.get(edge.from)?.city,to=layoutById.get(edge.to)?.city;if(!from||!to||from===to)continue; + const a=top(from),b=top(to),lift=12+Math.hypot(b[0]-a[0],b[1]-a[1])*.35,points=[]; + for(let i=0;i<=28;i++){const t=i/28;points.push([a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t+Math.sin(Math.PI*t)*lift]);} + arcs.push({points,kind:edge.confidence==='known'?0:1}); + } + renderer.setTrails(arcs);dirty=true; +} + +// Search hits become light columns visible across the city. +function updateBeacons(){ + if(!cityModel){renderer.setBeacons([]);return;} + const ids=new Set(searchHits.map(hit=>hit.entityId??hit.id)),beams=[]; + for(const id of ids){const b=layoutById.get(id)?.city;if(!b)continue;const w=Math.max(3,Math.min(b.w,b.h)*.5);beams.push({x:b.x+b.w/2-w/2,y:b.y+b.h/2-w/2,w,h:w,height:b.height+Math.max(250,cityModel.width*.4),color:[.95,.8,.3]});} + renderer.setBeacons(beams);dirty=true; +} + // ---- In-world text: ground names, roof plates, wall signs and the facade of the targeted file ---- const labelAtlas=new LabelAtlas(); let signState={key:'',at:0}; @@ -649,7 +674,8 @@ function buildSignQuads(signs,focus,radius,eye,heli,yaw){ const label=(text,style)=>{if(signs.full)return null;const entry=labelAtlas.get(text,style);if(!entry)signs.full=true;return entry;}; const add=(quad,uv)=>signs.quads.push({...quad,uv}),right=heading(yaw).right; // District names float above their tallest tower, turned toward the camera like skyline signs. - for(const district of cityDistricts){ + // District names are for orientation from a distance; close in they would fill the view. + for(const district of heli&&renderer.city.distance<350?[]:cityDistricts){ const entry=label(district.block.node.name,'plate');if(!entry)continue; const b=district.block,width=Math.min(Math.max(b.w,b.h)*.5,Math.max(40,district.top*1.2)),height=width/entry.aspect; add({c:[b.x+b.w/2,b.y+b.h/2,district.top+height/2+8],u:[right[0]*width/2,right[1]*width/2,0],v:[0,0,height/2]},entry); @@ -819,6 +845,7 @@ function animate(now){ 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(renderer.animating)dirty=true; if(dirty){ // The overlay fills the code overview, so draw it before the GPU pass that samples it. renderer.resize();drawOverlay();renderer.codeOn=showCode;renderer.render();dirty=codeTexturesPending; diff --git a/web/renderer.js b/web/renderer.js index 6229508..81a2928 100644 --- a/web/renderer.js +++ b/web/renderer.js @@ -16,7 +16,7 @@ const VERTICES = new Float32Array([ ]); // Instance kinds in the box program. -export const KIND = { building: 0, block: 1, ground: 2 }; +export const KIND = { building: 0, block: 1, ground: 2, beacon: 3 }; const FLOATS_PER_INSTANCE = 16; // Landscape heights are exaggerated relative to the 1000x680 map so small repositories still read as 3D. @@ -53,8 +53,10 @@ out vec4 vColor; out vec3 vUnit; out vec2 vAtlas; out vec3 vSize; +out float vDepth; flat out int vFace; flat out ivec2 vKind; +flat out vec2 vLit; void main(){ int kind = int(aMeta.x + .5); float height = kind == 0 && uMode < 1.5 ? max(1.0, aColorHeight.a) : aColorHeight.a; @@ -65,7 +67,9 @@ void main(){ vSize = vec3(aRect.zw, height); vFace = gl_VertexID / 6; vKind = ivec2(kind, int(aMeta.w + .5)); + vLit = aMeta.yz; gl_Position = projectWorld(world); + vDepth = gl_Position.w; }`; const FS = `#version 300 es @@ -74,14 +78,30 @@ in vec4 vColor; in vec3 vUnit; in vec2 vAtlas; in vec3 vSize; +in float vDepth; flat in int vFace; flat in ivec2 vKind; +flat in vec2 vLit; uniform float uMode; uniform float uAlpha; uniform sampler2D uCode; uniform float uCodeOn; +uniform float uFogDensity; +uniform vec3 uFogColor; out vec4 outColor; +float hash(vec3 p){ return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453); } +vec3 fog(vec3 color){ + float amount = 1.0 - exp(-pow(vDepth * uFogDensity, 2.0)); + return mix(color, uFogColor, amount); +} void main(){ + if(vKind.x == 3){ + // Search beacon: a light column fading upward, open at the top. + if(vFace == 0 || vFace == 5) discard; + float fade = pow(1.0 - vUnit.z, 1.6); + outColor = vec4(vColor.rgb * fade * .55, 0.0); + return; + } if(uMode < .5 && vFace > 0) discard; vec2 face = vFace == 0 || vFace == 5 ? vUnit.xy : vFace < 3 ? vUnit.xz : vUnit.yz; float edge = min(min(face.x, 1.0-face.x), min(face.y, 1.0-face.y)); @@ -91,9 +111,11 @@ void main(){ vec3 base = uMode < .5 ? mix(top, vColor.rgb, .07) : top; vec3 stroke = mix(vColor.rgb, vec3(1.0), .12); if(vKind.x == 2){ - outColor = vec4(.03, .036, .052, uAlpha); // asphalt + outColor = vec4(fog(vec3(.03, .036, .052)), uAlpha); // asphalt return; } + bool city = uMode > 1.5; + if(city) stroke = mix(vColor.rgb, vec3(1.0), .3) * 1.2; // neon edges at night if(vKind.x == 1){ // Blocks are raised pavements tinted by their dominant layer. base = mix(vec3(.075, .085, .11), vColor.rgb, .07); @@ -102,14 +124,26 @@ void main(){ // 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); + stroke = mix(stroke, base, city ? .25 : .45); + if(city){ + // Windows: a grid in metres; the lit share follows the file's definition count. + float wide = vFace < 3 ? vSize.x : vSize.y; + vec2 metres = vec2((vFace < 3 ? vUnit.x : vUnit.y) * wide, vUnit.z * vSize.z); + vec2 cell = floor(metres / vec2(3.2, 3.5)), local = fract(metres / vec2(3.2, 3.5)); + float pane = step(.22, local.x) * step(local.x, .78) * step(.3, local.y) * step(local.y, .78); + pane *= step(1.0, cell.y) * step(metres.y, vSize.z - 2.0) * step(1.6, metres.x) * step(metres.x, wide - 1.6); + bool lit = hash(vec3(cell, vLit.y + float(vFace) * 17.0)) < vLit.x; + vec3 glass = lit ? mix(vec3(1.0, .82, .52), vColor.rgb, .35) * (.8 + .4 * hash(vec3(cell.yx, vLit.y))) : base * .55; + base = mix(base, glass, pane); + } } else if(vFace == 0 && uCodeOn > .5){ // 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); + vec3 color = mix(stroke, base, border); + outColor = vec4(city ? fog(color) : color, uAlpha); }`; const GRID_VS = `#version 300 es @@ -133,23 +167,61 @@ layout(location=3) in vec3 aAxisV; layout(location=4) in vec4 aUv; uniform mat4 uViewProj; out vec2 vUv; +out float vDepth; void main(){ vec3 world = aCenter + aAxisU * (aCorner.x * 2.0 - 1.0) + aAxisV * (aCorner.y * 2.0 - 1.0); vUv = aUv.xy + vec2(aCorner.x, 1.0 - aCorner.y) * aUv.zw; gl_Position = uViewProj * vec4(world, 1.0); + vDepth = gl_Position.w; }`; +const SIGN_FLOATS = 13; + +// Import trails: screen-space ribbons along arcs, with dashes flowing from importer to imported. +const TRAIL_VS = `#version 300 es +precision highp float; +layout(location=0) in vec3 aPos; +layout(location=1) in vec3 aOther; +layout(location=2) in vec3 aSideTKind; +uniform mat4 uViewProj; +uniform vec2 uViewport; +out float vT; +flat out int vKind; +void main(){ + vec4 a = uViewProj * vec4(aPos, 1.0), b = uViewProj * vec4(aOther, 1.0); + vT = aSideTKind.y; vKind = int(aSideTKind.z + .5); + if(a.w < .2 || b.w < .2){ gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; } // behind the camera + vec2 dir = normalize((b.xy / b.w - a.xy / a.w) * uViewport + 1e-6); + vec2 offset = vec2(-dir.y, dir.x) * aSideTKind.x * 2.5 / uViewport; + gl_Position = a + vec4(offset * a.w, 0.0, 0.0); +}`; + +const TRAIL_FS = `#version 300 es +precision highp float; +in float vT; +flat in int vKind; +uniform float uTime; +out vec4 outColor; +void main(){ + vec3 color = vKind == 0 ? vec3(.21, .83, .76) : vec3(.97, .74, .3); + float dash = fract(vT * 14.0 - uTime * .9); + float glow = .35 + .65 * smoothstep(.0, .25, dash) * (1.0 - smoothstep(.55, .8, dash)); + if(vKind == 1 && dash > .6) discard; // inferred links are dashed + outColor = vec4(color * glow, 0.0); +}`; +const TRAIL_FLOATS = 9; const SIGN_FS = `#version 300 es precision highp float; in vec2 vUv; +in float vDepth; uniform sampler2D uTexture; +uniform float uFogDensity; out vec4 outColor; void main(){ vec4 color = texture(uTexture, vUv); if(color.a < .02) discard; - outColor = color; + outColor = color * exp(-pow(vDepth * uFogDensity, 2.0)); // premultiplied, so fading toward clear }`; -const SIGN_FLOATS = 13; function program(gl, vertex, fragment) { const value = gl.createProgram(); @@ -195,6 +267,12 @@ export class LandscapeRenderer { this.corners = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this.corners); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW); this.signLayer = this.quadLayer(); this.facadeLayer = this.quadLayer(); this.atlasTexture = this.texture(); this.facadeTexture = this.texture(); this.atlasGeneration = -1; + this.trailProgram = program(gl, TRAIL_VS, TRAIL_FS); this.trailUniforms = uniformsOf(gl, this.trailProgram); + this.trailLayer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 }; + gl.bindVertexArray(this.trailLayer.vao); gl.bindBuffer(gl.ARRAY_BUFFER, this.trailLayer.buffer); + [[0, 0], [1, 12], [2, 24]].forEach(([index, offset]) => { gl.enableVertexAttribArray(index); gl.vertexAttribPointer(index, 3, gl.FLOAT, false, TRAIL_FLOATS * 4, offset); }); + this.beaconLayer = this.boxLayer(); + this.time = 0; this.camera = { x: 500, y: 350, zoom: 1, yaw: -.1, pitch: .78, distance: 3.5 }; // 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, ex: 0, ey: 0, ez: EYE_HEIGHT, lookYaw: 0, lookPitch: 0 }; @@ -260,6 +338,31 @@ export class LandscapeRenderer { this.facadeLayer.count = 1; } + // arcs: [{ points: [[x,y,z], ...], kind: 0 known | 1 inferred }] ordered from importer to imported. + setTrails(arcs) { + const vertices = []; + for (const arc of arcs) { + const n = arc.points.length - 1; + for (let i = 0; i < n; i++) { + const a = arc.points[i], b = arc.points[i + 1], ta = i / n, tb = (i + 1) / n; + // Two triangles per segment; the far end looks back at the near end, so its side flips. + vertices.push(...a, ...b, 1, ta, arc.kind, ...a, ...b, -1, ta, arc.kind, ...b, ...a, -1, tb, arc.kind); + vertices.push(...a, ...b, -1, ta, arc.kind, ...b, ...a, 1, tb, arc.kind, ...b, ...a, -1, tb, arc.kind); + } + } + const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, this.trailLayer.buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW); + this.trailLayer.count = vertices.length / TRAIL_FLOATS; + } + + // beams: [{ x, y, w, h, height, color }] rising from search hits. + setBeacons(beams) { + const packed = new Float32Array(beams.length * FLOATS_PER_INSTANCE); + beams.forEach((b, i) => packed.set([b.x, b.y, b.w, b.h, ...b.color, b.height, 0, 0, 0, 0, KIND.beacon, 0, 0, 0], i * FLOATS_PER_INSTANCE)); + this.upload(this.beaconLayer, packed); + } + + get animating() { return this.mode === 'city' && this.trailLayer.count > 0; } + syncAtlas() { const gl = this.gl, atlas = this.atlas; if (!atlas) return; gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); @@ -280,7 +383,7 @@ export class LandscapeRenderer { if (!layer.count) return; const gl = this.gl, u = this.signUniforms; gl.useProgram(this.signProgram); gl.bindVertexArray(layer.vao); - gl.uniformMatrix4fv(u.uViewProj, false, this.viewProj); + gl.uniformMatrix4fv(u.uViewProj, false, this.viewProj); gl.uniform1f(u.uFogDensity, this.fogDensity()); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture); gl.uniform1i(u.uTexture, 0); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, layer.count); } @@ -301,7 +404,7 @@ export class LandscapeRenderer { // 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)); + 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, b.lit || 0, b.id || 0, 0], i * FLOATS_PER_INSTANCE)); this.cityInstances = instances; this.cityBounds = bounds; this.upload(this.cityLayer, packed); } @@ -377,9 +480,19 @@ export class LandscapeRenderer { this.inverseViewProj = null; } + // Night fog: thick enough to give depth, thin enough to keep the city legible from the air. + fogDensity() { + if (this.mode !== 'city') return 0; + const c = this.city, size = Math.max(this.cityBounds?.width || 1000, this.cityBounds?.height || 1000); + if (c.view === 'heli') return 1.1 / (c.distance * 2.4 + size * .8); + return 1 / (c.view === 'walk' ? 520 : 700 + c.ez * 5); + } + 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); + const fogColor = [.027, .031, .05], fog = this.fogDensity(); + if (this.mode === 'city') gl.clearColor(...fogColor, 1); else 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. @@ -391,8 +504,19 @@ export class LandscapeRenderer { 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.uniform1f(u.uFogDensity, fog); gl.uniform3f(u.uFogColor, ...fogColor); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, layer.count); if (this.mode === 'city') { + // Light effects add colour and never occlude: additive blending without depth writes. + gl.depthMask(false); gl.blendFunc(gl.ONE, gl.ONE); + if (this.beaconLayer.count) { gl.bindVertexArray(this.beaconLayer.vao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, this.beaconLayer.count); } + if (this.trailLayer.count) { + const t = this.trailUniforms; + gl.useProgram(this.trailProgram); gl.bindVertexArray(this.trailLayer.vao); + gl.uniformMatrix4fv(t.uViewProj, false, this.viewProj); gl.uniform2f(t.uViewport, this.width, this.height); gl.uniform1f(t.uTime, performance.now() / 1000); + gl.drawArrays(gl.TRIANGLES, 0, this.trailLayer.count); + } + gl.depthMask(true); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // Signs sit on or just off surfaces: test depth, don't write it, and pull them forward a touch. this.syncAtlas(); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false);