PROCITY/web/map.html
m3ultra 52eb1090ef Lane A round 6: second plan source ?plansrc=osm (real-data OSM import) — full seam
The carried-over OSM plan source (no-showed R4–R5) lands complete, invariants green — a
second CityPlan producer behind the same contract, so B–F consume it unchanged.

- osm_fixture.js: 95 REAL inner-Melbourne secondhand/charity/music/book/toy/antique shops,
  extracted deterministically from thriftgod city_source.json (Overpass cache), imported as a
  module ⇒ ZERO network.
- plan_osm.js: generatePlanOSM(seed) — project lat/lon→metres, bin shops into latitude-band
  avenues off a spine (real row + real E–W order preserved), march into a valid non-overlapping
  CityPlan, centre on origin, report true 362×486 bbox, real shop names, one openLate video.
- index.js: generatePlanFor(seed, source) selector — default 'synthetic' BYTE-IDENTICAL
  (golden 0x3fa36874 untouched); 'osm' → the importer. generatePlanOSM exported for F.
- selfcheck.js: parameterized by source — OSM runs the full structural invariant suite
  (frontEdge/facing/no-overlap/chunk-coverage/finite/JSON/determinism/openLate) + its own
  golden 0x34cfdec0; synthetic-only brief checks skipped. ALL GREEN 1362/1362.
- map.html: ?plansrc=osm renders it (reference wiring for F). Screenshot in docs/shots/laneA/.
- CITY_SPEC: plan-source note; LANE_A_NOTES R6: seam + goldens + shape-diffs for F's gate (F2).

Honest cut: street geometry regularized (real row+order drive it, exact metres do not);
name-parody deferred. qa.sh --strict GREEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:47:38 +10:00

340 lines
16 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 } from './js/citygen/index.js';
// plan source: default 'synthetic'; ?plansrc=osm renders the real-data fixture town.
const PLANSRC = new URLSearchParams(location.search).get('plansrc') === 'osm' ? 'osm' : 'synthetic';
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',
};
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;
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 ($('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);
}
}
}
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';
tip.innerHTML = s
? `<div class="t">${esc(s.name)}</div><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); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan
$('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`;
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(()=>{ resize(); regen(bootSeed); });
</script>
</body>
</html>