PROCITY/web/map.html
m3ultra acf734a1e5 Lane A round 12: v3.0-alpha gig layer (?gigs=1) — venue, plan.gigs, band names, closing-time debt
Publishes the gig interface the whole round hangs off (A runs first). Everything is
behind ?gigs=1 as a POST-HOC augmentation (gigs.js withGigs, applied by the selector),
so generatePlan is untouched and all v2 goldens stay frozen (synthetic 0x3fa36874, osm
melbourne 0x34cfdec0, katoomba 0x0f652510). Source-agnostic (synthetic + osm).

- Venue: one 'pub' per town — a spine-end plain shop converted in place (keeps lot
  id/geometry, enterable), shop.venue=true, never the openLate landmark. Registry gains
  a weight-0 'pub' type (interior:'band_room' for Lane C) → never auto-placed → base
  plan byte-identical.
- plan.gigs: 7 nightly {gigId,venueShopId,bandName,genreKey:'pubrock',night,startSeg:5,
  endSeg:5,cover}; cover ~half free / half $2-$10 (F charges at the door).
- Band names: bandName(seed) generator (90s pub-rock) + OPTIONAL web/assets/custom_bands.json
  drop-in (priority into the pool; absent → pure generator, no fetch under ?noassets).
- plan.posters: ~6 seeded {id,gigId,x,z,ry} for Lane B.
- Closing-time debt paid: isOpen(shopOrHours,hour) exported (half-open law as code, D consumes).
- map.html?gigs=1: venue ring + poster pins + gig panel line. Flags-off unchanged.

Self-check ALL GREEN 3074/3074 (gigSuite ×2 sources, flags-off≡base, gig golden
0xa6ae5a5e). qa.sh --strict GREEN 6/6. Verified in-browser both modes. CITY_SPEC v3
section + LANE_A_NOTES handshake for C/D/B/F. Atomic pathspec commit (shared-tree race).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:33:53 +10:00

370 lines
18 KiB
HTML
Raw Permalink 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 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;
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';
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, { town: TOWN, gigs: GIGS, customBands }); 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`;
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){} }
regen(bootSeed);
});
</script>
</body>
</html>