Give residents identities and an ID card
Clicking a resident (or aiming the walk/fly crosshair at one and pressing E) selects them: they stop walking, glow pink with a wider halo, get a name tag above their head, and the sidebar shows a resident ID card instead of code. The card has a drawn head-and-shoulders portrait (eyes and hair in their colours), name, sex, age, occupation and employer, eye colour, hair colour, blood type, where they were last seen, an ID number and a barcode. people.js generates identities deterministically from the repository name and resident index using nymshift's England/Wales names and taxonomy tables, so the same resident is always the same person. Picking projects each figure to the screen and ignores residents hidden behind buildings. Tested for stability, table membership and sex/first-name consistency.
This commit is contained in:
@@ -58,6 +58,7 @@ Plain ES modules, no framework, no build step.
|
||||
| `camera.js` | Matrix maths: perspective, lookAt, inverse, orbit pose |
|
||||
| `city.js` | Pure city logic, unit tested: layout, collision, navigation grid and routing, sign placement, tape, walls, posters, residents, place links |
|
||||
| `labels.js` | Text atlas for signs (shelf-packed canvas) and the poster atlas |
|
||||
| `people.js` | Residents' identities, generated deterministically from the repository name and resident index, plus the ID card portrait |
|
||||
| `api.mjs` | `fetch` wrapper and SSE-over-fetch parser |
|
||||
|
||||
### Views
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ scale, so a large repository is a large city.
|
||||
| **Cyan chevrons** on the road | Route to a file the selected file imports (**amber** if the import was inferred) |
|
||||
| **Pulsing magenta dashes** on the road | Route from a file that imports the selected file |
|
||||
| **Arcs** in the sky | The same import relationships, as the crow flies |
|
||||
| **Green figures** | Residents. They don't mean anything; they just live there |
|
||||
| **Green figures** | Residents. Click one (or aim the crosshair and press `E`) and they stop, glow pink with their name overhead, and the sidebar shows their resident ID card: name, sex, age, occupation, employer, eye and hair colour, blood type. The same resident is always the same person. |
|
||||
| **Wall with posters** | The city limits, with a few facts about the codebase pasted on |
|
||||
|
||||
## Getting around
|
||||
|
||||
+81
-2
@@ -1,6 +1,7 @@
|
||||
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, ROUTE_KIND, enterBuilding, MOVE } from './city.js';
|
||||
import { LabelAtlas, PosterAtlas } from './labels.js';
|
||||
import { personFor, residentSeed, drawPortrait } from './people.js';
|
||||
import { apiFetch, progressEvents } from './api.mjs';
|
||||
|
||||
const configuredBackend=window.CODENAVIGATOR_CONFIG?.backendUrl?.trim()||'';
|
||||
@@ -232,6 +233,8 @@ document.addEventListener('mousemove',event=>{if(document.pointerLockElement===g
|
||||
document.addEventListener('pointerlockchange',()=>{if(!document.pointerLockElement)heldKeys.clear();updateCityHud();});
|
||||
|
||||
function inspectCityTarget(openPanel){
|
||||
const resident=pickResident(renderer.width/2,renderer.height/2);
|
||||
if(resident){selectResident(resident);if(openPanel&&document.pointerLockElement)document.exitPointerLock();return;}
|
||||
if(!cityTargetEntry)return;
|
||||
selectFile(cityTargetEntry.file);
|
||||
if(openPanel){switchTab('inspector');if(document.pointerLockElement)document.exitPointerLock();}
|
||||
@@ -635,6 +638,7 @@ function mountSourceViewer(file){
|
||||
}
|
||||
|
||||
function selectFile(file,addHistory=true){
|
||||
deselectResident();
|
||||
selected=file||null;
|
||||
if(!file)coverageActiveIndex=-1;
|
||||
if(file&&addHistory){history=history.filter(item=>item.id!==file.id);history.unshift(file);history=history.slice(0,30);renderHistory();}
|
||||
@@ -645,6 +649,11 @@ function selectFile(file,addHistory=true){
|
||||
function renderInspector(){
|
||||
const hint=$('#inspectHint'),content=$('#inspectContent');
|
||||
if(!files.length){hint.hidden=false;hint.textContent='Open a codebase to get going.';content.innerHTML='';return;}
|
||||
if(selectedResident){
|
||||
hint.hidden=true;content.innerHTML=residentCard(selectedResident);
|
||||
drawPortrait($('#residentPortrait').getContext('2d'),personOf(selectedResident),240,290);
|
||||
return;
|
||||
}
|
||||
if(!selected){
|
||||
hint.hidden=false;hint.textContent=expandedCoverageLayer?'Click a file or use ↑ and ↓ to move around the map':'Click anything on the map. Go on.';
|
||||
const coverageRows=Object.entries(layerLabels).map(([key,label])=>`<button class="coverage-row${expandedCoverageLayer===key?' active':''}" type="button" data-coverage-layer="${key}" aria-expanded="${expandedCoverageLayer===key}"><i style="background:${palettes[paletteIndex][key]}"></i><span>${label}</span><small>${format(sceneStats.layers.get(key)||0)}</small><b aria-hidden="true">${expandedCoverageLayer===key?'−':'+'}</b></button>`).join('');
|
||||
@@ -758,7 +767,7 @@ function drawOverlay(){
|
||||
resizeOverlay();const ctx=overlayCtx,w=overlay.clientWidth,h=overlay.clientHeight;ctx.clearRect(0,0,w,h);ctx.save();ctx.font='600 11px "Space Mono", ui-monospace, monospace';ctx.textBaseline='top';
|
||||
codeTexturesPending=false;
|
||||
codeTextureDeadline=performance.now()+5;
|
||||
if(renderer.mode==='city'){visibleScreenItems=[];drawCityOverlay();ctx.restore();return;}
|
||||
if(renderer.mode==='city'){visibleScreenItems=[];drawCityOverlay();drawResidentTag(ctx);ctx.restore();return;}
|
||||
const hitIds=new Set(searchHits.map(hit=>hit.entityId??hit.id)),occupied=[],visible=visibleLayout(w,h);visibleScreenItems=visible;
|
||||
let labelCount=0;
|
||||
if(showCode&&renderer.mode==='2d'){
|
||||
@@ -791,6 +800,12 @@ function drawOverlay(){
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawResidentTag(ctx){
|
||||
const w=selectedResident;if(!w)return;
|
||||
const head=renderer.project([w.x,w.y,1.55*renderer.residentScale()]);
|
||||
if(!Number.isFinite(head.x)||head.x<0||head.y<0||head.x>renderer.width||head.y>renderer.height)return;
|
||||
drawChip(ctx,personOf(w).name,head.x,head.y-24,200,'#ff5ea8',null,true,'center');
|
||||
}
|
||||
function drawCityOverlay(){
|
||||
if(cityModel){updateCityReadouts();updateCitySigns();if(showCode)updateFacade();else renderer.setFacade(null);}
|
||||
// The facade gets this frame's text budget before the background roof atlas.
|
||||
@@ -869,8 +884,72 @@ function pastePosters(){
|
||||
// Residents: roughly one per 2,500 m² of city, capped so large repos stay cheap to simulate.
|
||||
function spawnResidents(){
|
||||
wanderers=showResidents&&navGrid?spawnWanderers(navGrid,Math.max(40,Math.min(600,Math.round(cityModel.width*cityModel.height/2500))),Math.random,cityModel):[];
|
||||
// Each resident's identity comes from the repository and their index, so resident #17 of a city is
|
||||
// always the same person. The shader matches the selection against the float32 seed it receives.
|
||||
wanderers.forEach((w,index)=>{w.index=index;w.seed=Math.fround(residentSeed(currentName,index)/4294967296);});
|
||||
if(selectedResident)deselectResident();
|
||||
if(!wanderers.length)renderer.setWanderers(new Float32Array(0));
|
||||
}
|
||||
|
||||
// ---- Selecting a resident: they stop to pose, get a name tag, and the sidebar shows their ID card ----
|
||||
let selectedResident=null;
|
||||
const personCache=new Map();
|
||||
function personOf(w){
|
||||
const key=`${currentName}#${w.index}`;
|
||||
if(!personCache.has(key))personCache.set(key,personFor(residentSeed(currentName,w.index)));
|
||||
return personCache.get(key);
|
||||
}
|
||||
function selectResident(w){
|
||||
selectFile(null,false);
|
||||
selectedResident=w;w.resumePause=w.pause;w.pause=Infinity;
|
||||
renderer.selectedResidentSeed=w.seed;
|
||||
$('#breadcrumbText').textContent=`${currentName} › resident › ${personOf(w).name}`;
|
||||
switchTab('inspector');renderInspector();dirty=true;
|
||||
}
|
||||
function deselectResident(){
|
||||
if(!selectedResident)return;
|
||||
selectedResident.pause=Math.min(1,selectedResident.resumePause||0)||.5;
|
||||
selectedResident=null;renderer.selectedResidentSeed=-1;dirty=true;
|
||||
}
|
||||
// The resident nearest a screen point whose figure is under it and not hidden behind a building.
|
||||
function pickResident(x,y){
|
||||
if(renderer.mode!=='city'||!wanderers.length)return null;
|
||||
const scale=renderer.residentScale(),{origin,dir}=renderer.ray(x,y),wall=pickRay(origin,dir,cityEntries)?.distance??Infinity;
|
||||
let best=null,bestScore=Infinity;
|
||||
for(const w of wanderers){
|
||||
const foot=renderer.project([w.x,w.y,0]),head=renderer.project([w.x,w.y,1.3*scale]);
|
||||
if(!Number.isFinite(foot.x)||!Number.isFinite(head.x))continue;
|
||||
const height=Math.max(8,foot.y-head.y),radius=Math.max(10,height*.45),cx=(foot.x+head.x)/2,cy=(foot.y+head.y)/2;
|
||||
const dx=(x-cx)/radius,dy=(y-cy)/(height*.6+4),score=dx*dx+dy*dy;
|
||||
if(score>1||score>=bestScore)continue;
|
||||
if(Math.hypot(w.x-origin[0],w.y-origin[1],.65*scale-origin[2])>wall+1)continue;
|
||||
best=w;bestScore=score;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function residentCard(w){
|
||||
const person=personOf(w),block=blockAt(cityModel.blocks,w.x,w.y);
|
||||
const district=block?block.node.path:currentName;
|
||||
const bars=[...person.id].map((char,i)=>`<i style="width:${1+(char.charCodeAt(0)*7+i)%4}px"></i>`).join('');
|
||||
return `<div class="id-card" aria-label="Resident ID card">
|
||||
<div class="id-card-head"><span>City of ${escapeHtml(currentName)}</span><b>RESIDENT ID</b></div>
|
||||
<div class="id-card-body">
|
||||
<canvas class="id-card-portrait" id="residentPortrait" width="240" height="290" aria-label="Portrait of ${escapeHtml(person.name)}"></canvas>
|
||||
<div class="id-card-fields">
|
||||
<small>Name</small><strong>${escapeHtml(person.name)}</strong>
|
||||
<div class="id-card-grid">
|
||||
<span><small>Sex</small>${person.sex}</span><span><small>Age</small>${person.age}</span>
|
||||
<span><small>Blood</small>${person.bloodType}</span><span><small>Eyes</small>${person.eyeColour}</span>
|
||||
<span class="wide"><small>Hair</small>${person.hairColour}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="id-card-job"><small>Occupation</small><strong>${escapeHtml(person.occupation)}</strong><span>at ${escapeHtml(person.employer)}</span></div>
|
||||
<div class="id-card-foot"><span><small>Last seen</small>${escapeHtml(district)}</span><span class="id-card-number">${person.id}</span></div>
|
||||
<div class="id-card-barcode" aria-hidden="true">${bars}${bars}</div>
|
||||
</div>
|
||||
<p class="panel-hint id-card-note">Not a real person. Probably.</p>`;
|
||||
}
|
||||
let residentBuffer=new Float32Array(0);
|
||||
function updateResidents(seconds){
|
||||
stepWanderers(wanderers,navGrid,seconds);
|
||||
@@ -1208,7 +1287,7 @@ function finishPointer(event){
|
||||
if(event.pointerType==='touch')endTouch(event);
|
||||
if(!pointer.down||event.pointerId!==pointer.id)return;
|
||||
viewport.classList.remove('dragging','panning','orbiting');
|
||||
if(event.type==='pointerup'&&event.button===0&&Math.hypot(event.clientX-pointer.startX,event.clientY-pointer.startY)<4){const rect=viewport.getBoundingClientRect();selectFile(pickAt(event.clientX-rect.left,event.clientY-rect.top));}
|
||||
if(event.type==='pointerup'&&event.button===0&&Math.hypot(event.clientX-pointer.startX,event.clientY-pointer.startY)<4){const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,resident=pickResident(x,y);if(resident)selectResident(resident);else selectFile(pickAt(x,y));}
|
||||
pointer.down=false;pointer.id=null;
|
||||
}
|
||||
viewport.addEventListener('pointerup',finishPointer);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Residents' identities. Everything is derived from a seed, so the same resident is always the same
|
||||
// person. Name data from nymshift's England/Wales name set; occupations and the rest from its taxonomy.
|
||||
import { seededRandom } from './city.js';
|
||||
|
||||
export const NAMES = {
|
||||
male: ['Oliver', 'George', 'Arthur', 'Noah', 'Muhammad', 'Leo', 'Oscar', 'Harry', 'Archie', 'Jack', 'Henry', 'Charlie', 'Freddie', 'Theodore', 'Thomas', 'Finley', 'Alfie', 'Jacob', 'William', 'Isaac', 'Joshua', 'Alexander', 'James', 'Lucas', 'Edward'],
|
||||
female: ['Olivia', 'Amelia', 'Isla', 'Ava', 'Ivy', 'Freya', 'Lily', 'Florence', 'Mia', 'Willow', 'Rosie', 'Sophia', 'Isabella', 'Grace', 'Daisy', 'Sienna', 'Poppy', 'Elsie', 'Emily', 'Ella', 'Evelyn', 'Phoebe', 'Sophie', 'Evie', 'Charlotte'],
|
||||
surnames: ['Smith', 'Jones', 'Taylor', 'Brown', 'Williams', 'Wilson', 'Johnson', 'Davies', 'Patel', 'Robinson', 'Wright', 'Thompson', 'Evans', 'Walker', 'White', 'Roberts', 'Green', 'Hall', 'Wood', 'Jackson', 'Clarke', 'Turner', 'Phillips', 'Hill', 'Moore'],
|
||||
};
|
||||
|
||||
export const OCCUPATIONS = ['Account manager', 'Architect', 'Barista', 'Biomedical engineer', 'Bookkeeper', 'Chef', 'Civil engineer', 'Customer support analyst', 'Data analyst', 'Dental hygienist', 'Electrician', 'Event planner', 'Financial controller', 'Graphic designer', 'HR coordinator', 'Industrial designer', 'IT support specialist', 'Journalist', 'Legal assistant', 'Logistics planner', 'Marketing strategist', 'Mechanical technician', 'Nurse practitioner', 'Operations manager', 'Paramedic', 'Pharmacist', 'Product manager', 'Project coordinator', 'QA tester', 'Real estate agent', 'Research assistant', 'Sales consultant', 'School teacher', 'Software developer', 'Supply chain analyst', 'Technical writer', 'UX researcher', 'Veterinary nurse', 'Warehouse supervisor', 'Web developer'];
|
||||
export const EMPLOYERS = ['Aster Labs', 'Blueforge Systems', 'Cedar & Finch', 'Copperline Logistics', 'DatumWorks', 'Echelon Foods', 'Fieldstone Medical', 'Granite Retail Group', 'Harborlight Studio', 'Ironbridge Finance', 'Juniper Analytics', 'Kestrel Energy', 'Lumen Care', 'Meridian Trading', 'Northstar Digital', 'Oakwell Design', 'Praxis Mobility', 'Quartz Security', 'Redwood Clinics', 'Silverline Media', 'Tandem Robotics', 'Umbra Textiles', 'Vector Harbor', 'Westfield Services', 'Zenith Components'];
|
||||
export const BLOOD_TYPES = ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-'];
|
||||
export const EYE_COLOURS = ['Amber', 'Blue', 'Brown', 'Grey', 'Green', 'Hazel'];
|
||||
export const HAIR_COLOURS = ['Black', 'Blond', 'Brown', 'Dark brown', 'Grey', 'Red'];
|
||||
|
||||
// Swatches for drawing the portrait.
|
||||
export const EYE_SWATCH = { Amber: '#ffb000', Blue: '#4aa3ff', Brown: '#7a4a22', Grey: '#9aa3ad', Green: '#3fbf5f', Hazel: '#9a7a3a' };
|
||||
export const HAIR_SWATCH = { Black: '#151515', Blond: '#f2d16b', Brown: '#6b4423', 'Dark brown': '#3b2615', Grey: '#b9b9b9', Red: '#c4442a' };
|
||||
|
||||
// Stable 32-bit seed for a resident: the repository name and the resident's index.
|
||||
export function residentSeed(repo, index) {
|
||||
let hash = 2166136261;
|
||||
for (const char of `${repo}#${index}`) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function personFor(seed) {
|
||||
const random = seededRandom(seed), pick = list => list[Math.floor(random() * list.length)];
|
||||
const sex = random() < .5 ? 'Male' : 'Female';
|
||||
return {
|
||||
id: `CN-${seed.toString(16).toUpperCase().padStart(8, '0')}`,
|
||||
sex,
|
||||
firstName: pick(sex === 'Male' ? NAMES.male : NAMES.female),
|
||||
surname: pick(NAMES.surnames),
|
||||
age: 18 + Math.floor(random() * 63),
|
||||
occupation: pick(OCCUPATIONS),
|
||||
employer: pick(EMPLOYERS),
|
||||
eyeColour: pick(EYE_COLOURS),
|
||||
hairColour: pick(HAIR_COLOURS),
|
||||
bloodType: pick(BLOOD_TYPES),
|
||||
get name() { return `${this.firstName} ${this.surname}`; },
|
||||
};
|
||||
}
|
||||
|
||||
// Head-and-shoulders portrait in the residents' glowing stick-figure style.
|
||||
export function drawPortrait(ctx, person, width, height) {
|
||||
const green = '#52ff61', cx = width / 2, headY = height * .42, head = width * .2;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.fillStyle = '#0d1a0f'; ctx.fillRect(0, 0, width, height);
|
||||
ctx.strokeStyle = 'rgba(82,255,97,.08)'; ctx.lineWidth = 1;
|
||||
for (let y = 4; y < height; y += 6) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); } // scanlines
|
||||
ctx.save();
|
||||
ctx.shadowColor = green; ctx.shadowBlur = 14; ctx.strokeStyle = green; ctx.lineCap = 'round';
|
||||
// Shoulders and neck.
|
||||
ctx.lineWidth = width * .075;
|
||||
ctx.beginPath(); ctx.moveTo(cx - width * .36, height * 1.02); ctx.quadraticCurveTo(cx - width * .3, height * .78, cx, height * .76); ctx.quadraticCurveTo(cx + width * .3, height * .78, cx + width * .36, height * 1.02); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(cx, headY + head); ctx.lineTo(cx, height * .76); ctx.stroke();
|
||||
// Head.
|
||||
ctx.fillStyle = green; ctx.beginPath(); ctx.arc(cx, headY, head, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
// Hair: a cap over the top of the head.
|
||||
ctx.fillStyle = HAIR_SWATCH[person.hairColour] || '#333';
|
||||
ctx.beginPath(); ctx.arc(cx, headY, head * 1.04, Math.PI * 1.05, Math.PI * 1.95); ctx.closePath(); ctx.fill();
|
||||
if (person.sex === 'Female') { ctx.fillRect(cx - head * 1.04, headY - head * .2, head * .3, head * 1.2); ctx.fillRect(cx + head * .74, headY - head * .2, head * .3, head * 1.2); }
|
||||
// Eyes and a small, slightly unimpressed mouth.
|
||||
ctx.fillStyle = EYE_SWATCH[person.eyeColour] || '#000';
|
||||
for (const side of [-1, 1]) { ctx.beginPath(); ctx.arc(cx + side * head * .36, headY + head * .08, head * .13, 0, Math.PI * 2); ctx.fill(); }
|
||||
ctx.strokeStyle = '#0d1a0f'; ctx.lineWidth = head * .1; ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(cx - head * .25, headY + head * .5); ctx.lineTo(cx + head * .25, headY + head * .46); ctx.stroke();
|
||||
}
|
||||
+13
-1
@@ -364,6 +364,7 @@ in float vDepth;
|
||||
flat in float vSeed;
|
||||
uniform float uTime;
|
||||
uniform float uFogDensity;
|
||||
uniform float uSelectedSeed;
|
||||
out vec4 outColor;
|
||||
float segment(vec2 p, vec2 a, vec2 b){ vec2 pa = p - a, ba = b - a; return length(pa - ba * clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0)); }
|
||||
void main(){
|
||||
@@ -381,6 +382,11 @@ void main(){
|
||||
float flicker = .85 + .15 * sin(uTime * 7.0 + vSeed * 40.0);
|
||||
vec3 green = vec3(.32, 1.0, .38) * flicker;
|
||||
float fog = exp(-pow(vDepth * uFogDensity, 2.0));
|
||||
if(abs(vSeed - uSelectedSeed) < 1e-7){
|
||||
// The selected resident glows pink with a wider halo.
|
||||
green = vec3(1.0, .37, .66);
|
||||
halo = exp(-max(d, 0.0) * 7.0) * .6;
|
||||
}
|
||||
if(body + halo < .01) discard;
|
||||
outColor = vec4(green * (body + halo) * fog, 0.0);
|
||||
}`;
|
||||
@@ -599,6 +605,12 @@ export class LandscapeRenderer {
|
||||
this.upload(this.beaconLayer, packed);
|
||||
}
|
||||
|
||||
// Residents are drawn larger from the air so crowds stay visible; picking uses the same scale.
|
||||
residentScale() {
|
||||
const c = this.city;
|
||||
return c.view === 'heli' ? Math.min(6, Math.max(1, c.distance / 250)) : c.view === 'fly' ? Math.min(4, Math.max(1, c.ez / 120)) : 1;
|
||||
}
|
||||
|
||||
get animating() { return this.mode === 'city' && (this.trailLayer.count > 0 || this.routeLayer.count > 0 || this.wandererLayer.count > 0 || this.smokeLayer.count > 0); }
|
||||
|
||||
syncAtlas() {
|
||||
@@ -777,7 +789,7 @@ export class LandscapeRenderer {
|
||||
gl.useProgram(this.wandererProgram); gl.bindVertexArray(this.wandererLayer.vao);
|
||||
gl.uniformMatrix4fv(w.uViewProj, false, this.viewProj); gl.uniform2f(w.uRight, -f[1] / len, f[0] / len);
|
||||
// From the air they'd be sub-pixel; grow them so crowds read as drifting green specks.
|
||||
gl.uniform1f(w.uScale, c.view === 'heli' ? Math.min(6, Math.max(1, c.distance / 250)) : c.view === 'fly' ? Math.min(4, Math.max(1, c.ez / 120)) : 1);
|
||||
gl.uniform1f(w.uScale, this.residentScale()); gl.uniform1f(w.uSelectedSeed, this.selectedResidentSeed ?? -1);
|
||||
gl.uniform1f(w.uTime, performance.now() / 1000); gl.uniform1f(w.uFogDensity, fog);
|
||||
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, this.wandererLayer.count);
|
||||
}
|
||||
|
||||
@@ -160,6 +160,28 @@ kbd { display: inline-block; min-width: 18px; padding: 0 4px; border: 2px solid
|
||||
.severity-high { color: var(--black); background: var(--tang); }
|
||||
.severity-medium { color: var(--black); background: var(--yellow); }
|
||||
.severity-low { color: var(--black); background: var(--muted); }
|
||||
/* Resident ID card: laminated cream card, lime header, holographic sheen, barcode. */
|
||||
.id-card { position: relative; overflow: hidden; margin: 4px 2px 0; border: 2px solid var(--black); border-radius: 12px; background: #f3ecdc; color: #15130f; box-shadow: 5px 5px 0 var(--black); transform: rotate(-1.2deg); animation: plonk .25s ease-out; }
|
||||
.id-card::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(115deg, transparent 30%, rgba(92,200,255,.18) 42%, rgba(255,94,168,.18) 50%, rgba(200,241,53,.18) 58%, transparent 70%); }
|
||||
.id-card small { display: block; color: #6b6352; font: 700 8.5px var(--mono); text-transform: uppercase; letter-spacing: .08em; }
|
||||
.id-card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 7px 12px; border-bottom: 2px solid var(--black); background: var(--lime); }
|
||||
.id-card-head span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 700 10px var(--mono); text-transform: uppercase; }
|
||||
.id-card-head b { flex-shrink: 0; font: 400 13px var(--display); }
|
||||
.id-card-body { display: grid; grid-template-columns: 96px minmax(0,1fr); gap: 12px; padding: 12px; }
|
||||
.id-card-portrait { width: 96px; height: 116px; border: 2px solid var(--black); border-radius: 6px; box-shadow: 2px 2px 0 var(--black); }
|
||||
.id-card-fields strong { display: block; margin: 1px 0 8px; font: 400 17px/1.1 var(--display); overflow-wrap: anywhere; }
|
||||
.id-card-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 10px; }
|
||||
.id-card-grid span { font: 700 12px var(--mono); }
|
||||
.id-card-grid .wide { grid-column: 1 / -1; }
|
||||
.id-card-job { margin: 0 12px; padding: 8px 10px; border: 2px dashed #b9ae95; border-radius: 6px; }
|
||||
.id-card-job strong { display: block; font: 700 14px var(--body); }
|
||||
.id-card-job span { color: #4a4436; font-size: 12px; }
|
||||
.id-card-foot { display: flex; align-items: end; justify-content: space-between; gap: 10px; padding: 10px 12px 4px; font: 700 11px var(--mono); }
|
||||
.id-card-foot > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.id-card-number { flex-shrink: 0; padding: 1px 6px; border-radius: 3px; background: #15130f; color: var(--lime); }
|
||||
.id-card-barcode { display: flex; gap: 2px; height: 26px; margin: 6px 12px 12px; overflow: hidden; }
|
||||
.id-card-barcode i { flex-shrink: 0; height: 100%; background: #15130f; }
|
||||
.id-card-note { margin-top: 14px; font: italic 11px var(--mono); text-align: center; }
|
||||
.coverage-title, .coverage-list-head, .source-title { display: flex; align-items: center; justify-content: flex-start; }
|
||||
.coverage-title small, .coverage-list-head small, .source-title small { margin-left: auto; }
|
||||
.coverage-title small, .coverage-list-head small, .source-title small { color: var(--dim); font: 10px var(--mono); text-transform: none; letter-spacing: 0; }
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { personFor, residentSeed, NAMES, OCCUPATIONS, EMPLOYERS, BLOOD_TYPES, EYE_COLOURS, HAIR_COLOURS } from '../web/people.js';
|
||||
|
||||
test('the same resident is always the same person', () => {
|
||||
const seed = residentSeed('prometheus', 17);
|
||||
assert.equal(seed, residentSeed('prometheus', 17));
|
||||
assert.deepEqual({ ...personFor(seed) }, { ...personFor(seed) });
|
||||
assert.equal(personFor(seed).name, personFor(seed).name);
|
||||
assert.notEqual(residentSeed('prometheus', 17), residentSeed('prometheus', 18));
|
||||
assert.notEqual(residentSeed('prometheus', 17), residentSeed('grafana', 17));
|
||||
});
|
||||
|
||||
test('identities come from the lookup tables and are internally consistent', () => {
|
||||
const sexes = new Set(), names = new Set();
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const p = personFor(residentSeed('repo', i));
|
||||
sexes.add(p.sex); names.add(p.name);
|
||||
assert.ok(['Male', 'Female'].includes(p.sex));
|
||||
assert.ok((p.sex === 'Male' ? NAMES.male : NAMES.female).includes(p.firstName), `${p.firstName} matches ${p.sex}`);
|
||||
assert.ok(NAMES.surnames.includes(p.surname) && OCCUPATIONS.includes(p.occupation) && EMPLOYERS.includes(p.employer));
|
||||
assert.ok(BLOOD_TYPES.includes(p.bloodType) && EYE_COLOURS.includes(p.eyeColour) && HAIR_COLOURS.includes(p.hairColour));
|
||||
assert.ok(Number.isInteger(p.age) && p.age >= 18 && p.age <= 80);
|
||||
assert.match(p.id, /^CN-[0-9A-F]{8}$/);
|
||||
}
|
||||
assert.equal(sexes.size, 2);
|
||||
assert.ok(names.size > 300, `plenty of variety (${names.size} names)`);
|
||||
});
|
||||
Reference in New Issue
Block a user