From a3f9ef2d9dca681d4353ef3c330a4bb6b1ea0a67 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sat, 27 Jun 2026 16:21:48 +0100 Subject: [PATCH] Set buildings with security alerts on fire and tape them off After a GitHub snapshot loads, the viewer's code scanning and Dependabot alerts are fetched and grouped by file. In the city, affected buildings smoke, and those with a critical or high alert also burn: GPU particles whose age derives from time and seed, so emitters upload once. Hazard tape runs diagonally across every wall, yellow DEPENDABOT ALERT rising and red CODE SCANNING ALERT falling, crossing as an X when a file has both; the tape tiles a striped label cut to the wall's diagonal. The minimap marks alert buildings, the coverage panel shows the alert count (or why none could be loaded, e.g. signed out or no permission), and a selected file lists its alerts with severity and GitHub links. --- web/app.js | 54 +++++++++++++++++++++-- web/city.js | 47 ++++++++++++++++++++ web/labels.js | 11 ++++- web/renderer.js | 97 ++++++++++++++++++++++++++++++++++++++++- web/styles.css | 10 +++++ webtest/labels.test.mjs | 29 ++++++++++++ 6 files changed, 240 insertions(+), 8 deletions(-) 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])=>``).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 coverageClick a section
${coverageRows}
${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 coverageClick a section
${coverageRows}
${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'); +});