PROCITY/web/map.html
m3ultra 34f7dae397 Lane A R39 39.1 (close): map.html says the street name out loud; CITY_SPEC + A-progress §39
map.html (Lane A's reference tool) can now load E's REAL town caches — until this round it could
only render the three checked-in fixtures, so it could never show a real street. Adds the address
line to the panel and `label · side` to the shop tooltip.

Verified in-browser (no-store server, both suppliers, ONE line of code, no town-type branch):
· ?plansrc=osm&town=castlemaine_real → "address: ways · 841/858 edges named · 22/22 shops ·
  141 names"; tooltip "XXXX Antiques — Elizabeth Street · south side", "Maxi IGA — Forest Street ·
  north side". Matches the Node measurement shop-for-shop.
· default synthetic → "address: district · 493/493 shops labelled by district"; tooltip
  "Nev's Pawn & Loan — the warehouse fringe · north side".

CITY_SPEC: the address layer documented as DERIVED data, with the schema rule that follows from it —
no street name, district label or address ever becomes a CityPlan field. `plan_osm` still discards
`rd.name` deliberately; the names are recovered downstream because plan nodes ARE snapped projected
way points. The only thing the lift publishes is `norm.shift`, into opts.report, never the plan.

A-progress §39: the round, the two departures from the synthesis with their numbers, the gate table
with the breakage that fires each arm, and the tolerance finding (correctness saturates at 6 m).

selfcheck 157,647/157,647 ALL GREEN · fingerprint 0x5f76e76 unchanged · zero re-pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:39:59 +10:00

392 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PROCITY — plan debugger (Lane A)</title>
<style>
:root{
--bg:#14110d; --panel:#1c1813; --ink:#efe6d2; --dim:#a99a80; --line:#2c2519;
--road:#3a3020; --roadmain:#5a4a2e; --roadarc:#4a3a52; --roadlane:#2a241a;
--block:#241d14; --accent:#e0a44a;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);
font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:hidden}
#wrap{display:flex;height:100%}
#stage{flex:1;position:relative}
canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:crosshair}
#side{width:270px;background:var(--panel);border-left:1px solid var(--line);
padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:12px}
h1{font-size:15px;margin:0;letter-spacing:.06em;color:var(--accent)}
h1 small{display:block;color:var(--dim);font-size:11px;letter-spacing:.02em;margin-top:2px}
.row{display:flex;gap:6px;align-items:center}
input,button{font:inherit;background:#0e0b07;color:var(--ink);border:1px solid var(--line);
border-radius:6px;padding:6px 8px}
input{flex:1;min-width:0}
button{cursor:pointer;background:#2a2216}
button:hover{background:#37301f;border-color:var(--accent)}
button.primary{background:var(--accent);color:#1a1206;border-color:var(--accent);font-weight:700}
.stat{color:var(--dim)} .stat b{color:var(--ink);font-weight:700}
.legend{display:grid;grid-template-columns:auto 1fr;gap:3px 8px;align-items:center;font-size:11px}
.sw{width:12px;height:12px;border-radius:3px;border:1px solid #0006}
.muted{color:var(--dim);font-size:11px}
#tip{position:absolute;pointer-events:none;background:#000c;border:1px solid var(--accent);
color:var(--ink);padding:5px 8px;border-radius:6px;font-size:12px;max-width:240px;
transform:translate(10px,10px);display:none;z-index:5;white-space:nowrap}
#tip .t{color:var(--accent);font-weight:700}
#tip .s{color:var(--dim);font-size:11px}
hr{border:0;border-top:1px solid var(--line);margin:2px 0}
.toggles{display:grid;grid-template-columns:1fr 1fr;gap:4px}
label.chk{display:flex;gap:5px;align-items:center;font-size:11px;color:var(--dim);cursor:pointer}
</style>
</head>
<body>
<div id="wrap">
<div id="stage">
<canvas id="c"></canvas>
<div id="tip"></div>
</div>
<aside id="side">
<h1>PROCITY<small>plan debugger · Lane A</small></h1>
<div class="row">
<input id="seed" value="20261990" inputmode="numeric" title="city seed (uint32)">
<button id="regen" class="primary">regen</button>
</div>
<div class="row">
<button id="rand" style="flex:1">🎲 random</button>
<button id="export" style="flex:1">⤓ JSON</button>
</div>
<div id="name" class="stat"></div>
<div id="stats" class="stat"></div>
<hr>
<div class="muted">layers</div>
<div class="toggles">
<label class="chk"><input type="checkbox" id="tChunks"> chunk grid</label>
<label class="chk"><input type="checkbox" id="tBlocks" checked> blocks</label>
<label class="chk"><input type="checkbox" id="tLots" checked> lots</label>
<label class="chk"><input type="checkbox" id="tShops" checked> shop dots</label>
<label class="chk"><input type="checkbox" id="tLabels"> all labels</label>
<label class="chk"><input type="checkbox" id="tNodes"> nodes</label>
</div>
<hr>
<div class="muted">shop types</div>
<div class="legend" id="legend"></div>
<hr>
<div class="muted">lot use</div>
<div class="legend" id="legendUse"></div>
<hr>
<div class="muted" style="line-height:1.6">
drag to pan · scroll to zoom · hover a shop for its name.<br>
This is Lane A's debug view — it grows into the in-game map.
</div>
</aside>
</div>
<!-- No importmap / no THREE: this debug page is pure Canvas 2D (CITY_SPEC allows the map page to
use THREE, but Lane A's 2D map doesn't need it). It imports only Lane A's pure-data modules. -->
<script type="module">
import { generatePlanFor, chunkIndex, CHUNK, createAddresses, registerTownCache, getTownCache } from './js/citygen/index.js';
// plan source: default 'synthetic'; ?plansrc=osm renders a real-data fixture town, ?town=<key> picks it.
const _q = new URLSearchParams(location.search);
const PLANSRC = _q.get('plansrc') === 'osm' ? 'osm' : 'synthetic';
const TOWN = _q.get('town') || undefined;
const GIGS = _q.get('gigs') === '1'; // v3 gig layer: venue + nightly gigs + posters (?gigs=1)
let customBands = []; // John's OPTIONAL band drop-in (loaded async at boot when ?gigs=1)
import { SHOP_TYPES, SHOP_TYPE_IDS } from './js/core/registry.js';
// ── palette ─────────────────────────────────────────────────────────────────────────
const TYPE_COLOR = {
record:'#ff5470', opshop:'#5fbf74', toy:'#ffcf3f', book:'#c98b3b', video:'#ffe14d',
pawn:'#c0c0c0', milkbar:'#ff6b6b', dept:'#e07b9a', stall:'#c8a24a', pub:'#e8b73a',
};
const USE_COLOR = {
shop:'#6b5a3a', anchor:'#8a4a63', house:'#3f5a4a', yard:'#2e3a2a', stall:'#7a6a3a', infill:'#4a4436',
};
const EDGE_STYLE = { // c = bare CSS custom-prop name OR hex — getcss() resolves either (never a var() string,
main:{c:'--roadmain', w:1}, side:{c:'#4a3f28', w:1}, lane:{c:'#2a241a', w:1}, arcade:{c:'#6a4a72', w:1}, // which canvas can't parse)
};
// ── state ───────────────────────────────────────────────────────────────────────────
const cv = document.getElementById('c'), ctx = cv.getContext('2d');
let plan = null, idx = null, DPR = Math.min(devicePixelRatio||1, 2);
const view = { x:0, z:0, scale:0.6 }; // world→screen: screen = (world - viewcenter)*scale + canvascenter
let hover = null;
let addr = null; // ROUND39 39.1 — the address layer for the current plan (rebuilt in regen)
const $ = id => document.getElementById(id);
const cssPx = () => ({ w: cv.clientWidth, h: cv.clientHeight });
function resize(){
const {w,h} = cssPx();
cv.width = w*DPR; cv.height = h*DPR;
draw();
}
function fitView(){
const {w,h} = cssPx();
const s = plan.size;
view.x = 0; view.z = 0;
view.scale = Math.min(w/(s.w*1.08), h/(s.d*1.08));
}
const w2s = (x,z) => {
const {w,h} = cssPx();
return [ (x-view.x)*view.scale + w/2, (z-view.z)*view.scale + h/2 ];
};
const s2w = (sx,sz) => {
const {w,h} = cssPx();
return [ (sx-w/2)/view.scale + view.x, (sz-h/2)/view.scale + view.z ];
};
// ── draw ──────────────────────────────────────────────────────────────────────────
function draw(){
if(!plan) return;
const {w,h}=cssPx();
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.clearRect(0,0,w,h);
ctx.fillStyle = getcss('--bg'); ctx.fillRect(0,0,w,h);
// town bounds
const s=plan.size;
const [bx0,bz0]=w2s(-s.w/2,-s.d/2), [bx1,bz1]=w2s(s.w/2,s.d/2);
ctx.fillStyle='#0e0b07'; ctx.fillRect(bx0,bz0,bx1-bx0,bz1-bz0);
ctx.strokeStyle=getcss('--line'); ctx.lineWidth=1; ctx.strokeRect(bx0,bz0,bx1-bx0,bz1-bz0);
if ($('tChunks').checked) drawChunks();
if ($('tBlocks').checked) drawBlocks();
drawStreets();
if ($('tLots').checked) drawLots();
if ($('tShops').checked) drawShops();
if (GIGS) drawGigs();
if ($('tNodes').checked) drawNodes();
if (hover) drawHoverHi();
drawCompass();
}
let _drawQueued=false;
function scheduleDraw(){ if(_drawQueued) return; _drawQueued=true; requestAnimationFrame(()=>{ _drawQueued=false; draw(); }); }
function getcss(v){ return getComputedStyle(document.documentElement).getPropertyValue(v).trim() || v; }
function drawChunks(){
const s=plan.size, c=CHUNK;
ctx.strokeStyle='#ffffff10'; ctx.lineWidth=1;
for(let x=-s.w/2; x<=s.w/2; x+=c){ const [sx0,sy0]=w2s(x,-s.d/2),[sx1,sy1]=w2s(x,s.d/2);
ctx.beginPath();ctx.moveTo(sx0,sy0);ctx.lineTo(sx1,sy1);ctx.stroke(); }
for(let z=-s.d/2; z<=s.d/2; z+=c){ const [sx0,sy0]=w2s(-s.w/2,z),[sx1,sy1]=w2s(s.w/2,z);
ctx.beginPath();ctx.moveTo(sx0,sy0);ctx.lineTo(sx1,sy1);ctx.stroke(); }
}
function drawBlocks(){
for(const b of plan.blocks){
ctx.beginPath();
b.poly.forEach((p,i)=>{ const [sx,sy]=w2s(p[0],p[1]); i?ctx.lineTo(sx,sy):ctx.moveTo(sx,sy); });
ctx.closePath();
ctx.fillStyle = DISTRICT_FILL[b.kind] || '#241d1440';
ctx.fill();
ctx.strokeStyle='#00000030'; ctx.lineWidth=1; ctx.stroke();
}
}
const DISTRICT_FILL = {
mainstreet:'#2c2414aa', backstreets:'#241f16aa', arcade:'#2e2338aa',
market:'#2c2a16aa', warehouse:'#20211caa', residential:'#1c2620aa',
};
function drawStreets(){
const nById = new Map(plan.streets.nodes.map(n=>[n.id,n]));
// widen roads with the edge width, scaled
for(const e of plan.streets.edges){
const a=nById.get(e.a), b=nById.get(e.b); if(!a||!b) continue;
const [ax,ay]=w2s(a.x,a.z), [bx,by]=w2s(b.x,b.z);
const st = EDGE_STYLE[e.kind]||EDGE_STYLE.side;
ctx.strokeStyle = getcss(st.c);
ctx.lineWidth = Math.max(1, e.width*view.scale);
ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(ax,ay);ctx.lineTo(bx,by);ctx.stroke();
}
}
function lotQuad(l){
const cs=Math.cos(l.ry), sn=Math.sin(l.ry), hw=l.w/2, hd=l.d/2;
return [[-hw,-hd],[hw,-hd],[hw,hd],[-hw,hd]].map(([ox,oz])=>[l.x+ox*cs+oz*sn, l.z-ox*sn+oz*cs]);
}
function drawLots(){
for(const l of plan.lots){
const q=lotQuad(l);
ctx.beginPath();
q.forEach((p,i)=>{ const [sx,sy]=w2s(p[0],p[1]); i?ctx.lineTo(sx,sy):ctx.moveTo(sx,sy); });
ctx.closePath();
ctx.fillStyle=USE_COLOR[l.use]||'#333'; ctx.fill();
ctx.strokeStyle='#0008'; ctx.lineWidth=.5; ctx.stroke();
}
}
let _sbl=null; // shop-by-lot cache, module-level (NEVER attached to plan — export must stay schema-clean)
function shopByLot(){ return _sbl || (_sbl=new Map(plan.shops.map(s=>[s.lot,s]))); }
function drawShops(){
const sbl=shopByLot();
const lById=new Map(plan.lots.map(l=>[l.id,l]));
const showLabels=$('tLabels').checked;
ctx.textAlign='center'; ctx.textBaseline='middle';
for(const sh of plan.shops){
const l=lById.get(sh.lot); if(!l) continue;
const [sx,sy]=w2s(l.x,l.z);
const rr=Math.max(2, Math.min(6, l.w*view.scale*0.35));
ctx.beginPath();ctx.arc(sx,sy,rr,0,7); ctx.fillStyle=TYPE_COLOR[sh.type]||'#fff'; ctx.fill();
ctx.lineWidth=1; ctx.strokeStyle='#0009'; ctx.stroke();
if(showLabels && view.scale>0.8){
ctx.font='9px ui-monospace,monospace'; ctx.fillStyle='#efe6d2cc';
ctx.fillText(sh.sign, sx, sy-rr-6);
}
}
}
// v3 gig overlay (?gigs=1): poster markers + a ring/label on the venue.
function drawGigs(){
if(!plan.gigs || !plan.gigs.length) return;
ctx.fillStyle='#ffd23f';
for(const p of (plan.posters||[])){ const [sx,sy]=w2s(p.x,p.z); // little poster pins
ctx.beginPath(); ctx.moveTo(sx,sy-4); ctx.lineTo(sx+3,sy+3); ctx.lineTo(sx-3,sy+3); ctx.closePath(); ctx.fill(); }
const venue=plan.shops.find(s=>s.venue); if(!venue) return;
const l=plan.lots.find(x=>x.id===venue.lot); if(!l) return;
const [sx,sy]=w2s(l.x,l.z);
ctx.beginPath(); ctx.arc(sx,sy,10,0,7); ctx.strokeStyle='#ffd23f'; ctx.lineWidth=2; ctx.stroke();
ctx.font='bold 10px ui-monospace,monospace'; ctx.fillStyle='#ffd23f'; ctx.textAlign='center';
ctx.fillText('♪ '+venue.sign, sx, sy-15);
}
function drawNodes(){
ctx.fillStyle=getcss('--accent');
for(const n of plan.streets.nodes){ const [sx,sy]=w2s(n.x,n.z);
ctx.beginPath();ctx.arc(sx,sy,2,0,7);ctx.fill(); }
}
function drawHoverHi(){
const l=hover.lot; const q=lotQuad(l);
ctx.beginPath(); q.forEach((p,i)=>{const [sx,sy]=w2s(p[0],p[1]); i?ctx.lineTo(sx,sy):ctx.moveTo(sx,sy);});
ctx.closePath(); ctx.strokeStyle=getcss('--accent'); ctx.lineWidth=2; ctx.stroke();
}
function drawCompass(){
const {w}=cssPx(); const x=w-38, y=34;
ctx.strokeStyle=getcss('--dim'); ctx.fillStyle=getcss('--dim'); ctx.lineWidth=1.5;
ctx.beginPath();ctx.moveTo(x,y+12);ctx.lineTo(x,y-12);ctx.stroke();
ctx.beginPath();ctx.moveTo(x,y-12);ctx.lineTo(x-3,y-7);ctx.lineTo(x+3,y-7);ctx.closePath();ctx.fill();
ctx.font='10px monospace';ctx.textAlign='center';ctx.fillText('N',x,y-16);
}
// ── picking ─────────────────────────────────────────────────────────────────────────
function pick(sx,sz){
const [wx,wz]=s2w(sx,sz);
const sbl=shopByLot();
let best=null, bestD=Infinity;
for(const l of plan.lots){
const d=(l.x-wx)**2+(l.z-wz)**2;
if(d<bestD && d < Math.max(l.w,l.d)**2){ bestD=d; best=l; }
}
if(!best) return null;
return { lot:best, shop:sbl.get(best.id)||null };
}
// ── interaction ───────────────────────────────────────────────────────────────────
let drag=null;
cv.addEventListener('mousedown', e=>{ drag={x:e.clientX,y:e.clientY,vx:view.x,vz:view.z}; });
addEventListener('mouseup', ()=> drag=null);
addEventListener('mousemove', e=>{
const rect=cv.getBoundingClientRect(); const sx=e.clientX-rect.left, sy=e.clientY-rect.top;
if(drag){ view.x=drag.vx-(e.clientX-drag.x)/view.scale; view.z=drag.vz-(e.clientY-drag.y)/view.scale; scheduleDraw(); return; }
if(sx<0||sy<0||sx>rect.width||sy>rect.height){ hideTip(); return; }
hover=pick(sx,sy);
const tip=$('tip');
if(hover){
cv.style.cursor='pointer';
const l=hover.lot, s=hover.shop;
tip.style.display='block'; tip.style.left=sx+'px'; tip.style.top=sy+'px';
// ONE consumer contract: print `label`. It is the street name on a real town and the district
// phrase on the synthetic, and this line does not know or care which.
const loc = s ? addr.localityOf(s.id) : null;
const where = loc && loc.label ? `<div class="s" style="color:var(--accent)">${esc(loc.label)}${loc.side?` · ${loc.side} side`:''}</div>` : '';
tip.innerHTML = s
? `<div class="t">${esc(s.name)}</div>${where}<div class="s">${s.type} · ${SHOP_TYPES[s.type]?.label||''} · ${s.storeys}fl · ${pad(s.hours[0])}${pad(s.hours[1])}</div><div class="s">${s.facadeSkin}</div>`
: `<div class="t">${l.use} lot</div><div class="s">${l.w}×${l.d}m · block ${l.block}</div>`;
scheduleDraw();
} else { hideTip(); }
});
function hideTip(){ if(hover){hover=null;scheduleDraw();} $('tip').style.display='none'; cv.style.cursor='crosshair'; }
cv.addEventListener('wheel', e=>{
e.preventDefault();
const rect=cv.getBoundingClientRect(); const sx=e.clientX-rect.left, sy=e.clientY-rect.top;
const [wx,wz]=s2w(sx,sy);
const f=Math.exp(-e.deltaY*0.0012);
view.scale=Math.max(0.05, Math.min(8, view.scale*f));
const [nx,nz]=s2w(sx,sy); view.x+=wx-nx; view.z+=wz-nz;
scheduleDraw();
}, {passive:false});
const esc=s=>String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
const pad=h=>String(h).padStart(2,'0')+':00';
// ── panel ─────────────────────────────────────────────────────────────────────────
function regen(seed){
const s = (seed>>>0);
plan = generatePlanFor(s, PLANSRC, { town: TOWN, gigs: GIGS, customBands }); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan
// ROUND39 39.1 — THE ADDRESS LAYER on the reference map. Same two-arg call every consumer makes,
// and NO branch on town type: a registered real cache supplies street names, everything else
// supplies district labels, and the tooltip prints `label` either way.
addr = createAddresses(plan, TOWN ? getTownCache(TOWN) : null);
$('seed').value = s;
$('name').innerHTML = `<b style="color:var(--accent);font-size:14px">${esc(plan.name)}</b>`;
const nShop=plan.shops.length, byType={};
for(const sh of plan.shops) byType[sh.type]=(byType[sh.type]||0)+1;
$('stats').innerHTML =
`<b>${plan.streets.edges.length}</b> streets · <b>${plan.blocks.length}</b> blocks<br>`+
`<b>${plan.lots.length}</b> lots · <b>${nShop}</b> shops · <b>${Object.keys(idx.chunks).length}</b> chunks`;
{ const a = addr.stats();
$('stats').innerHTML += `<br><span class="muted">address: <b>${a.supplier}</b> · `+
(a.supplier === 'ways'
? `${a.edgesNamed}/${a.edges} edges named · <b>${a.shopsWithStreet}/${a.shops}</b> shops · ${a.distinctStreets} names`
: `${a.shopsLabelled}/${a.shops} shops labelled by district`)+`</span>`; }
if (GIGS && plan.gigs && plan.gigs.length){
const venue=plan.shops.find(s=>s.venue), g0=plan.gigs[0];
$('stats').innerHTML += `<br><b style="color:#ffd23f">♪ ${esc(venue?.name||'venue')}</b><br>`+
`tonight: <b>${esc(g0.bandName)}</b> · ${g0.cover?('$'+g0.cover+' cover'):'FREE'}<br>`+
`<span class="muted">${plan.gigs.length} nights · ${(plan.posters||[]).length} posters`+
`${customBands.length?` · ${customBands.length} custom bands`:''}</span>`;
}
fitView(); draw(); buildLegend(byType);
}
function buildLegend(byType){
const L=$('legend'); L.innerHTML='';
for(const t of SHOP_TYPE_IDS){
const sw=document.createElement('span'); sw.className='sw'; sw.style.background=TYPE_COLOR[t];
const lb=document.createElement('span'); lb.textContent=`${t} (${byType?.[t]||0})`;
L.append(sw,lb);
}
const U=$('legendUse'); U.innerHTML='';
for(const u of Object.keys(USE_COLOR)){
const sw=document.createElement('span'); sw.className='sw'; sw.style.background=USE_COLOR[u];
const lb=document.createElement('span'); lb.textContent=u;
U.append(sw,lb);
}
}
$('regen').onclick=()=>regen(parseInt($('seed').value,10)||0);
$('seed').addEventListener('keydown',e=>{ if(e.key==='Enter') $('regen').click(); });
$('rand').onclick=()=>regen((Math.random()*4294967295)>>>0); // UI-only randomness; generation stays seeded
$('export').onclick=()=>{
const blob=new Blob([JSON.stringify(plan,null,0)],{type:'application/json'});
const a=document.createElement('a'); a.href=URL.createObjectURL(blob);
a.download=`procity-${plan.citySeed}.json`; a.click(); URL.revokeObjectURL(a.href);
};
for(const id of ['tChunks','tBlocks','tLots','tShops','tLabels','tNodes']) $(id).onchange=draw;
addEventListener('resize', resize);
// boot — seed can come from ?seed=… (Lane F / screenshot harness).
// rAF ensures the flex layout has sized the canvas before fitView reads its dimensions.
const q=new URLSearchParams(location.search);
const bootSeed=parseInt(q.get('seed')||$('seed').value,10)||20261990;
requestAnimationFrame(async ()=>{
resize();
// OPTIONAL band drop-in: load John's names for the debug view (graceful if the file is absent).
if (GIGS){ try { const r=await fetch('./assets/custom_bands.json'); if(r.ok){ const j=await r.json(); if(Array.isArray(j.bands)) customBands=j.bands; } } catch(e){} }
// ROUND39 39.1 — load E's REAL town cache when one exists for ?town=<key>, exactly the way the
// shell does (web/index.html:125-129). Until now this page could only render the three checked-in
// fixtures, so it could never show a real street. Graceful: a missing cache falls straight through
// to the fixture path, which is what melbourne/katoomba/silverton do.
if (PLANSRC === 'osm' && TOWN){
try { const r = await fetch(`./assets/towns/${TOWN}.json`);
if (r.ok) registerTownCache(TOWN, await r.json()); } catch(e){}
}
regen(bootSeed);
});
</script>
</body>
</html>