diff --git a/web/app.js b/web/app.js
index 0af25dc..368b5c3 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, 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, MOVE } from './city.js';
import { LabelAtlas } from './labels.js';
import { apiFetch, progressEvents } from './api.mjs';
@@ -129,6 +129,7 @@ function computeCity(){
}
cityIndex=spatialIndex(cityEntries);
navGrid=buildNavGrid(cityEntries,cityModel.blocks,cityModel);
+ updateFires();
spawnResidents();
// Each folder's own buildings (not its subfolders'), for street-corner signs.
const byDirectory=new Map();
@@ -366,6 +367,7 @@ function buildMinimap(){
const ctx=canvas.getContext('2d'),{scale,ox,oy}=minimapTransform();ctx.scale(dpr,dpr);
ctx.fillStyle='rgba(141,125,255,.08)';
for(const block of cityModel.blocks)if(block.depth===1)ctx.fillRect(ox+block.x*scale,oy+block.y*scale,block.w*scale,block.h*scale);
+ for(const item of layoutItems){const b=item.city;if(!b||!fileAlerts.has(item.path))continue;ctx.fillStyle='#ff4d3d';ctx.beginPath();ctx.arc(ox+(b.x+b.w/2)*scale,oy+(b.y+b.h/2)*scale,2.6,0,Math.PI*2);ctx.fill();}
for(const item of layoutItems){const b=item.city;if(!b)continue;ctx.fillStyle=`rgba(${item.color.map(v=>Math.round(v*255)).join(',')},.85)`;ctx.fillRect(ox+b.x*scale,oy+b.y*scale,Math.max(.6,b.w*scale),Math.max(.6,b.h*scale));}
minimapBase=canvas;
}
@@ -432,7 +434,7 @@ function applySnapshot(snapshot){
computeLayout();fitScene();fitCity();updateStats(snapshot);renderInspector();renderResults([]);renderHistory();
$('#breadcrumbText').textContent=currentName;$('#emptyState').hidden=files.length>0;$('#welcome').hidden=true;
currentRepoUrl=snapshot.source==='github'?lastGithubUrl.replace(/\/+$/,'').replace(/\.git$/,''):'';
- endTour();applyPendingPlace();
+ endTour();applyPendingPlace();loadAlerts();
}
function fitScene(){
@@ -597,7 +599,7 @@ function renderInspector(){
hint.hidden=false;hint.textContent=expandedCoverageLayer?'Click a file or use ↑ and ↓ to move around the map':'Select an entity on the map';
const coverageRows=Object.entries(layerLabels).map(([key,label])=>`${label} ${format(sceneStats.layers.get(key)||0)} ${expandedCoverageLayer===key?'−':'+'} `).join('');
const expandedLabel=layerLabels[expandedCoverageLayer];
- content.innerHTML=`
Coverage · ${escapeHtml(currentName)}
${format(files.length)} indexed files known links ${format(sceneStats.known)} inferred links ${format(sceneStats.inferred)} definitions ${format(sceneStats.definitions)} source lines ${format(sceneStats.totalLines)}
Semantic coverage Click a section
${expandedCoverageLayer?`${escapeHtml(expandedLabel)} Click or ↑ ↓ to navigate
`:''}`;
+ content.innerHTML=`Coverage · ${escapeHtml(currentName)}
${format(files.length)} indexed files known links ${format(sceneStats.known)} inferred links ${format(sceneStats.inferred)} definitions ${format(sceneStats.definitions)} source lines ${format(sceneStats.totalLines)} security alerts ${alertSummary()}
Semantic coverage Click a section
${expandedCoverageLayer?`${escapeHtml(expandedLabel)} Click or ↑ ↓ to navigate
`:''}`;
content.querySelectorAll('[data-coverage-layer]').forEach(row=>row.addEventListener('click',()=>toggleCoverageLayer(row.dataset.coverageLayer)));
if(expandedCoverageLayer)mountCoverageList(expandedCoverageLayer);
return;
@@ -606,7 +608,7 @@ function renderInspector(){
const connections=(edgesByFile.get(selected.id)||[]).map(edge=>({edge,file:fileById.get(edge.from===selected.id?edge.to:edge.from)})).filter(item=>item.file);
const related=connections.slice(0,30);
const sourceStatus=selected.sourceLoading?'Loading complete file…':selected.sourceError?'Preview only':`${format(selected.lines)} lines`;
- content.innerHTML=`${escapeHtml(selected.path)}
${escapeHtml(selected.name)} language ${escapeHtml(selected.language)} semantic layer ${escapeHtml(layerLabels[selected.layer]||'Other')} source lines ${format(selected.lines)} definitions ${format(selected.symbols.length)} complexity ${format(selected.complexity)} connections ${format(connections.length)}
${related.length?`Relationships · ${related.length}
${related.map(({edge,file})=>`${escapeHtml(file.path)} ${edge.confidence}
`).join('')}`:'No indexed file connections. External dependencies and unresolved references are not shown.
'}Source ${sourceStatus}
`;
+ content.innerHTML=`${escapeHtml(selected.path)}
${escapeHtml(selected.name)} language ${escapeHtml(selected.language)} semantic layer ${escapeHtml(layerLabels[selected.layer]||'Other')} source lines ${format(selected.lines)} definitions ${format(selected.symbols.length)} complexity ${format(selected.complexity)} connections ${format(connections.length)}
${alertSection(selected)}${related.length?`Relationships · ${related.length}
${related.map(({edge,file})=>`${escapeHtml(file.path)} ${edge.confidence}
`).join('')}`:'No indexed file connections. External dependencies and unresolved references are not shown.
'}Source ${sourceStatus}
`;
content.querySelectorAll('.relation[data-id]').forEach(row=>row.addEventListener('click',()=>selectFile(fileById.get(Number(row.dataset.id)))));
mountSourceViewer(selected);
}
@@ -792,6 +794,41 @@ function updateResidents(seconds){
renderer.setWanderers(residentBuffer);
}
+// ---- Security alerts from GitHub: burning buildings, hazard tape and inspector details ----
+let fileAlerts=new Map(),alertState={loading:false,reason:'',statuses:null};
+async function loadAlerts(){
+ fileAlerts=new Map();alertState={loading:!!currentRepoUrl,reason:currentRepoUrl?'':'not-github',statuses:null};
+ updateFires();
+ if(!currentRepoUrl||!currentSnapshot)return renderInspector();
+ const snapshotId=currentSnapshot;
+ try{
+ const response=await apiFetch(apiUrl(`/api/snapshots/${snapshotId}/alerts`));
+ const payload=await response.json();if(currentSnapshot!==snapshotId)return;
+ fileAlerts=alertsByPath(payload);
+ alertState={loading:false,reason:payload.reason||'',statuses:payload.available?{codeScanning:payload.codeScanning.status,dependabot:payload.dependabot.status}:null};
+ }catch{alertState={loading:false,reason:'unavailable',statuses:null};}
+ updateFires();if(cityModel)buildMinimap();signState.key='';renderInspector();dirty=true;
+}
+function updateFires(){
+ if(!cityModel)return;
+ const emitters=[];
+ for(const b of cityEntries){const alerts=fileAlerts.get(b.file.path);if(alerts)emitters.push({x:b.x+b.w/2,y:b.y+b.h/2,z:b.height,radius:Math.min(b.w,b.h)/2,burning:burns(alerts.worst)});}
+ renderer.setFires(emitters);
+}
+function alertSummary(){
+ if(alertState.loading)return 'loading…';
+ if(alertState.reason==='signin')return 'sign in to GitHub';
+ if(alertState.reason)return '—';
+ const count=[...fileAlerts.values()].reduce((n,a)=>n+a.codeScanning.length+a.dependabot.length,0);
+ const blocked=Object.entries(alertState.statuses||{}).filter(([,status])=>status!=='ok').map(([kind])=>kind==='dependabot'?'Dependabot':'code scanning');
+ return `${format(count)} in ${format(fileAlerts.size)} files${blocked.length?` (no ${blocked.join(' or ')} access)`:''}`;
+}
+function alertSection(file){
+ const alerts=fileAlerts.get(file.path);if(!alerts)return '';
+ const rows=[...alerts.codeScanning.map(a=>({...a,kind:'Code scanning'})),...alerts.dependabot.map(a=>({...a,kind:'Dependabot'}))];
+ return `Security alerts · ${rows.length}
${rows.map(a=>`${escapeHtml(a.severity)} ${escapeHtml(a.title)} ${a.kind}${a.line?` · line ${a.line}`:''} `).join('')}`;
+}
+
// Search hits become light columns visible across the city.
function updateBeacons(){
if(!cityModel){renderer.setBeacons([]);return;}
@@ -849,6 +886,15 @@ function buildSignQuads(signs,focus,radius,eye,heli,yaw){
walls++;
}
}
+ // Hazard tape on every building with security alerts, wherever it is in the city.
+ for(const b of cityEntries){
+ const alerts=fileAlerts.get(b.file.path);if(!alerts)continue;
+ for(const [kind,text,style,direction] of [['dependabot','DEPENDABOT ALERT','tapeDependabot',1],['codeScanning','CODE SCANNING ALERT','tapeCodeScanning',-1]]){
+ if(!alerts[kind].length)continue;
+ const entry=label(text,style);if(!entry)continue;
+ for(const quad of tapeQuads(b,entry.aspect,direction))add(quad,{...entry,du:entry.du*quad.fraction});
+ }
+ }
// 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;
diff --git a/web/city.js b/web/city.js
index 669666b..e37cbcb 100644
--- a/web/city.js
+++ b/web/city.js
@@ -553,3 +553,50 @@ export function stepWanderers(all, grid, dt, random = Math.random) {
export function seededRandom(seed) {
return () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
}
+
+// ---- Security alerts: burning buildings and hazard tape ----
+
+const SEVERITY_RANK = { low: 1, medium: 2, high: 3, critical: 4 };
+
+// Groups an /alerts response by file path: { codeScanning: [], dependabot: [], worst }.
+export function alertsByPath(response) {
+ const byPath = new Map();
+ if (!response?.available) return byPath;
+ for (const [kind, set] of [['codeScanning', response.codeScanning], ['dependabot', response.dependabot]]) {
+ for (const alert of set?.alerts || []) {
+ if (!alert.path) continue;
+ const entry = byPath.get(alert.path) || { codeScanning: [], dependabot: [], worst: 'low' };
+ entry[kind].push(alert);
+ if ((SEVERITY_RANK[alert.severity] || 0) > SEVERITY_RANK[entry.worst]) entry.worst = alert.severity;
+ byPath.set(alert.path, entry);
+ }
+ }
+ return byPath;
+}
+
+// Critical and high alerts set a building alight; medium and low only smoulder.
+export const burns = severity => (SEVERITY_RANK[severity] || 0) >= SEVERITY_RANK.high;
+
+// Hazard tape running diagonally across every wall, as segments tiling a repeated label.
+// direction 1 rises left to right (Dependabot), -1 falls (code scanning), so both together form an X.
+// Each segment is a quad plus `fraction`, the share of the label it shows (the last one is cut short).
+export function tapeQuads(b, aspect, direction = 1) {
+ const out = [], tape = Math.max(1.8, Math.min(5, Math.min(b.w, b.h) * .14)), offset = .1 + (direction > 0 ? 0 : .02);
+ for (const wall of walls(b)) {
+ const width = wall.width, height = b.height, length = Math.hypot(width, height);
+ if (width < 3) continue;
+ const right = [wall.n[1], -wall.n[0]], dx = width / length, dz = direction * height / length, segment = tape * aspect;
+ const startAlong = -width / 2, startZ = direction > 0 ? 0 : height;
+ for (let s = 0; s < length; s += segment) {
+ const piece = Math.min(segment, length - s), mid = s + piece / 2;
+ const along = startAlong + dx * mid, z = startZ + dz * mid;
+ out.push({
+ c: [wall.c[0] + right[0] * along + wall.n[0] * offset, wall.c[1] + right[1] * along + wall.n[1] * offset, z],
+ u: [right[0] * dx * piece / 2, right[1] * dx * piece / 2, dz * piece / 2],
+ v: [-right[0] * dz * tape / 2, -right[1] * dz * tape / 2, dx * tape / 2],
+ fraction: piece / segment,
+ });
+ }
+ }
+ return out;
+}
diff --git a/web/labels.js b/web/labels.js
index 18df92a..8a9944f 100644
--- a/web/labels.js
+++ b/web/labels.js
@@ -21,6 +21,9 @@ const STYLES = {
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)' },
+ // Hazard tape: square-cut, edge to edge, with warning stripes where the repeated label joins.
+ tapeDependabot: { font: '800 28px Inter, system-ui, sans-serif', text: '#141414', fill: '#f5c518', stripe: '#141414', tape: true },
+ tapeCodeScanning: { font: '800 28px Inter, system-ui, sans-serif', text: '#ffffff', fill: '#d7263d', stripe: '#ffffff', tape: true },
// District and street names painted on the ground.
ground: { font: '700 34px Inter, system-ui, sans-serif', text: 'rgba(205,198,255,.9)' },
};
@@ -49,12 +52,16 @@ export class LabelAtlas {
if (cached) return cached;
const s = STYLES[style], ctx = this.ctx, h = this.packer.rowHeight;
ctx.font = s.font;
- const pad = s.fill ? 14 : 4, width = Math.ceil(Math.min(ctx.measureText(text).width + pad * 2, 900));
+ const stripes = s.tape ? 44 : 0, pad = s.tape ? 12 : s.fill ? 14 : 4, width = Math.ceil(Math.min(ctx.measureText(text).width + pad * 2 + stripes, 900));
const slot = this.packer.add(width);
if (!slot) return null;
ctx.save();
ctx.beginPath(); ctx.rect(slot.x, slot.y, slot.w, slot.h); ctx.clip();
- if (s.fill) {
+ if (s.tape) {
+ ctx.fillStyle = s.fill; ctx.fillRect(slot.x, slot.y + 4, slot.w, h - 8);
+ ctx.fillStyle = s.stripe;
+ for (let i = 0; i < 3; i++) { const x = slot.x + slot.w - stripes + 4 + i * 14; ctx.beginPath(); ctx.moveTo(x, slot.y + h - 4); ctx.lineTo(x + 7, slot.y + h - 4); ctx.lineTo(x + 19, slot.y + 4); ctx.lineTo(x + 12, slot.y + 4); ctx.closePath(); ctx.fill(); }
+ } else if (s.fill) {
ctx.fillStyle = s.fill; ctx.strokeStyle = s.border; ctx.lineWidth = 2;
ctx.beginPath(); ctx.roundRect(slot.x + 1, slot.y + 3, slot.w - 2, h - 6, 12); ctx.fill(); ctx.stroke();
}
diff --git a/web/renderer.js b/web/renderer.js
index 5103c0c..d39c105 100644
--- a/web/renderer.js
+++ b/web/renderer.js
@@ -254,6 +254,62 @@ void main(){
}`;
const ROUTE_FLOATS = 6;
+// Fire and smoke over buildings with security alerts. Particles are stateless: each one's age is
+// derived from time and its seed in the vertex shader, so the CPU only uploads emitters once.
+const FIRE_VS = `#version 300 es
+precision highp float;
+layout(location=0) in vec2 aCorner;
+layout(location=1) in vec4 aEmitter; // roof centre x, y, z and footprint radius
+layout(location=2) in vec3 aSeedKind; // seed, kind (0 smoke, 1 flame), emitter scale
+uniform mat4 uViewProj;
+uniform vec3 uRight;
+uniform vec3 uUp;
+uniform float uTime;
+out vec2 vCorner;
+out float vAge;
+flat out int vKind;
+out float vDepth;
+float hash(float n){ return fract(sin(n * 91.3458) * 47453.5453); }
+void main(){
+ float seed = aSeedKind.x, radius = aEmitter.w, scale = aSeedKind.z;
+ vKind = int(aSeedKind.y + .5);
+ float rate = vKind == 1 ? .9 + hash(seed) * .5 : .09 + hash(seed) * .05;
+ float age = fract(uTime * rate + hash(seed + 1.7));
+ vAge = age;
+ float angle = hash(seed + 3.1) * 6.2832, spread = sqrt(hash(seed + 5.3)) * radius * (vKind == 1 ? .75 : .45);
+ vec3 base = aEmitter.xyz + vec3(cos(angle) * spread, sin(angle) * spread, 0.0);
+ vec3 rise = vKind == 1
+ ? vec3(0.0, 0.0, age * (3.0 + radius * .45))
+ : vec3(vec2(1.0, .55) * age * age * (18.0 + radius), age * (28.0 + radius * 2.5)) + vec3(sin(age * 7.0 + seed) * 1.5, cos(age * 5.0 + seed) * 1.5, 0.0);
+ float size = (vKind == 1 ? mix(1.6, .4, age) * (.7 + radius * .08) : mix(2.0, 9.0, age) * (.6 + radius * .05)) * scale;
+ vCorner = aCorner * 2.0 - 1.0;
+ vec3 world = base + rise * scale + (uRight * vCorner.x + uUp * vCorner.y) * size;
+ gl_Position = uViewProj * vec4(world, 1.0);
+ vDepth = gl_Position.w;
+}`;
+
+const FIRE_FS = `#version 300 es
+precision highp float;
+in vec2 vCorner;
+in float vAge;
+flat in int vKind;
+in float vDepth;
+uniform float uFogDensity;
+out vec4 outColor;
+void main(){
+ float r = length(vCorner);
+ if(r > 1.0) discard;
+ float soft = pow(1.0 - r, 1.5), fog = exp(-pow(vDepth * uFogDensity, 2.0));
+ if(vKind == 1){
+ vec3 color = mix(vec3(1.0, .92, .55), mix(vec3(1.0, .45, .08), vec3(.7, .08, .02), smoothstep(.4, 1.0, vAge)), smoothstep(0.0, .45, vAge));
+ outColor = vec4(color * soft * (1.0 - vAge) * 1.4 * fog, 0.0); // additive glow
+ } else {
+ float alpha = soft * .5 * smoothstep(0.0, .12, vAge) * (1.0 - vAge) * fog;
+ outColor = vec4(vec3(.16, .15, .15) * alpha, alpha); // premultiplied dark smoke
+ }
+}`;
+const FIRE_FLOATS = 7;
+
// Residents: camera-facing sprites drawn procedurally as glowing stick figures with a walk cycle.
const WANDERER_VS = `#version 300 es
precision highp float;
@@ -370,6 +426,8 @@ export class LandscapeRenderer {
[[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.routeProgram = program(gl, ROUTE_VS, ROUTE_FS); this.routeUniforms = uniformsOf(gl, this.routeProgram);
+ this.fireProgram = program(gl, FIRE_VS, FIRE_FS); this.fireUniforms = uniformsOf(gl, this.fireProgram);
+ this.smokeLayer = this.particleLayer(); this.flameLayer = this.particleLayer();
this.wandererProgram = program(gl, WANDERER_VS, WANDERER_FS); this.wandererUniforms = uniformsOf(gl, this.wandererProgram);
this.wandererLayer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 };
gl.bindVertexArray(this.wandererLayer.vao);
@@ -480,6 +538,31 @@ export class LandscapeRenderer {
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);
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.corners); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
+ gl.bindBuffer(gl.ARRAY_BUFFER, layer.buffer);
+ gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, FIRE_FLOATS * 4, 0); gl.vertexAttribDivisor(1, 1);
+ gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 3, gl.FLOAT, false, FIRE_FLOATS * 4, 16); gl.vertexAttribDivisor(2, 1);
+ return layer;
+ }
+
+ // emitters: [{ x, y, z, radius, burning }] — every emitter smokes, burning ones also carry flames.
+ setFires(emitters) {
+ const smoke = [], flames = [];
+ emitters.forEach((e, index) => {
+ const radius = Math.max(2, e.radius), smokeCount = Math.round(Math.min(60, 18 + radius * 1.5)), flameCount = Math.round(Math.min(70, 16 + radius * 2));
+ for (let i = 0; i < smokeCount; i++) smoke.push(e.x, e.y, e.z, radius, index * 131 + i * 7.13, 0, 1);
+ if (e.burning) for (let i = 0; i < flameCount; i++) flames.push(e.x, e.y, e.z, radius, index * 97 + i * 3.71 + .5, 1, 1);
+ });
+ const gl = this.gl;
+ for (const [layer, data] of [[this.smokeLayer, smoke], [this.flameLayer, flames]]) {
+ gl.bindBuffer(gl.ARRAY_BUFFER, layer.buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW);
+ layer.count = data.length / FIRE_FLOATS;
+ }
+ }
+
// Resident positions as packed [x, y, phase, seed] per figure; called every frame while they walk.
setWanderers(packed) {
const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, this.wandererLayer.buffer); gl.bufferData(gl.ARRAY_BUFFER, packed, gl.DYNAMIC_DRAW);
@@ -493,7 +576,7 @@ export class LandscapeRenderer {
this.upload(this.beaconLayer, packed);
}
- get animating() { return this.mode === 'city' && (this.trailLayer.count > 0 || this.routeLayer.count > 0 || this.wandererLayer.count > 0); }
+ get animating() { return this.mode === 'city' && (this.trailLayer.count > 0 || this.routeLayer.count > 0 || this.wandererLayer.count > 0 || this.smokeLayer.count > 0); }
syncAtlas() {
const gl = this.gl, atlas = this.atlas; if (!atlas) return;
@@ -602,7 +685,8 @@ export class LandscapeRenderer {
const size = Math.max(this.cityBounds?.width || 1000, this.cityBounds?.height || 1000);
const near = c.view === 'heli' ? Math.max(.5, c.distance * .002) : .1, far = c.view === 'heli' ? c.distance * 3 + size * 2 : size * 2.5 + 2000;
this.eye = eye; this.forward = forward;
- this.viewProj = multiply(perspective(CITY_FOV, aspect, near, far), lookAt(eye, [eye[0] + forward[0], eye[1] + forward[1], eye[2] + forward[2]], Math.abs(forward[2]) > .999 ? [0, -1, 0] : [0, 0, 1]));
+ this.view = lookAt(eye, [eye[0] + forward[0], eye[1] + forward[1], eye[2] + forward[2]], Math.abs(forward[2]) > .999 ? [0, -1, 0] : [0, 0, 1]);
+ this.viewProj = multiply(perspective(CITY_FOV, aspect, near, far), this.view);
} else {
this.heightScale = LANDSCAPE_HEIGHT_SCALE;
const pose = orbitPose(this.camera);
@@ -642,6 +726,15 @@ export class LandscapeRenderer {
// 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.smokeLayer.count) {
+ // Camera-facing particles: the view matrix rows are the camera's right and up axes.
+ const f = this.fireUniforms, v = this.view, c = this.city;
+ gl.useProgram(this.fireProgram);
+ gl.uniformMatrix4fv(f.uViewProj, false, this.viewProj); gl.uniform3f(f.uRight, v[0], v[4], v[8]); gl.uniform3f(f.uUp, v[1], v[5], v[9]);
+ gl.uniform1f(f.uTime, performance.now() / 1000); gl.uniform1f(f.uFogDensity, fog * .6);
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.bindVertexArray(this.smokeLayer.vao); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, this.smokeLayer.count);
+ gl.blendFunc(gl.ONE, gl.ONE); if (this.flameLayer.count) { gl.bindVertexArray(this.flameLayer.vao); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, this.flameLayer.count); }
+ }
if (this.wandererLayer.count) {
const w = this.wandererUniforms, f = this.forward, len = Math.hypot(f[0], f[1]) || 1, c = this.city;
gl.useProgram(this.wandererProgram); gl.bindVertexArray(this.wandererLayer.vao);
diff --git a/web/styles.css b/web/styles.css
index 9242fa7..d5f1c74 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -203,6 +203,16 @@ kbd { font: 500 10px/1.5 var(--mono); border: 1px solid var(--line-strong); bord
.relation i { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-2); box-shadow: 0 0 8px rgba(54,211,194,.6); }
.relation span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #cdd3df; }
.relation small { color: var(--dim); font-size: 10px; }
+.alert-row { display: grid; grid-template-columns: auto minmax(0,1fr); gap: 10px; align-items: center; margin: 0 -8px; padding: 7px 8px; border-radius: 9px; color: inherit; text-decoration: none; }
+.alert-row:hover { background: var(--hover); }
+.alert-row em { padding: 2px 7px; border-radius: 999px; font: 700 9.5px var(--mono); font-style: normal; text-transform: uppercase; letter-spacing: .05em; }
+.alert-row strong, .alert-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.alert-row strong { color: var(--text); font-weight: 500; }
+.alert-row small { color: var(--dim); font-size: 10.5px; }
+.severity-critical { color: #fff; background: #d7263d; }
+.severity-high { color: #1b0d00; background: #ff8a3d; }
+.severity-medium { color: #1b1400; background: #f5c518; }
+.severity-low { color: #cfd5e2; background: rgba(255,255,255,.12); }
.coverage-title, .coverage-list-head, .source-title { display: flex; align-items: baseline; justify-content: space-between; }
.coverage-title small, .coverage-list-head small, .source-title small { color: var(--dim); font-size: 10px; font-weight: 400; text-transform: none; letter-spacing: 0; }
.coverage-menu { display: grid; gap: 2px; }
diff --git a/webtest/labels.test.mjs b/webtest/labels.test.mjs
index 39e682a..8ee028d 100644
--- a/webtest/labels.test.mjs
+++ b/webtest/labels.test.mjs
@@ -80,3 +80,32 @@ test('folder labels keep the tail of long paths', () => {
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);
});
+
+import { alertsByPath, burns, tapeQuads } from '../web/city.js';
+
+test('alerts group by file with the worst severity', () => {
+ const byPath = alertsByPath({ available: true, codeScanning: { alerts: [{ path: 'a.go', severity: 'medium' }, { path: 'a.go', severity: 'critical' }] }, dependabot: { alerts: [{ path: 'go.mod', severity: 'low' }, { path: '', severity: 'high' }] } });
+ assert.equal(byPath.size, 2);
+ assert.equal(byPath.get('a.go').worst, 'critical');
+ assert.equal(byPath.get('a.go').codeScanning.length, 2);
+ assert.equal(byPath.get('go.mod').dependabot.length, 1);
+ assert.equal(alertsByPath({ available: false, reason: 'signin' }).size, 0);
+ assert.ok(burns('high') && burns('critical') && !burns('medium') && !burns(undefined));
+});
+
+test('hazard tape crosses each wall corner to corner, readable from outside', () => {
+ const b = { x: 0, y: 0, w: 20, h: 10, height: 40 };
+ for (const direction of [1, -1]) {
+ const quads = tapeQuads(b, 6, direction);
+ const south = quads.filter(q => q.c[1] > 10);
+ const length = south.reduce((sum, q) => sum + Math.hypot(...q.u) * 2, 0);
+ assert.ok(Math.abs(length - Math.hypot(20, 40)) < 1e-6, `covers the diagonal: ${length}`);
+ const [first, last] = [south[0], south.at(-1)];
+ assert.ok(direction > 0 ? first.c[2] < last.c[2] : first.c[2] > last.c[2], 'runs the right way');
+ assert.ok(south.every(q => q.c[2] >= 0 && q.c[2] <= 40 && q.c[0] >= 0 && q.c[0] <= 20));
+ assert.ok(south.every(q => readableFrom(q, [10, 60, 20])) && south.every(q => !readableFrom(q, [10, 5, 20])));
+ assert.ok(south.at(-1).fraction <= 1 && south.slice(0, -1).every(q => q.fraction === 1));
+ assert.equal(new Set(quads.map(q => `${Math.sign(q.c[0] - 10)},${Math.sign(q.c[1] - 5)}`)).size >= 4, true, 'all four walls');
+ }
+ assert.equal(tapeQuads({ x: 0, y: 0, w: 2, h: 2, height: 30 }, 6).length, 0, 'too narrow for tape');
+});