// PROCITY Lane B — minimap.js // The `map` mode: a 2D top-down town directory drawn from the CityPlan (streets + lots coloured by // shop type + a live player dot/heading). This is the shell's own lightweight map; Lane A's // map.html debug view grows into the richer directory later (Lane F reconciles). // // [R39 — v9 Layer 2, THE FOG] `createMinimap(plan, known, opts)`. // // known === null ⇒ EXACTLY the pre-R39 map: every lot, every street, full-plan extent, no labels, // no cache. That is what ?classic=1 / ?game=0 / ?fog=0 hand it, so THIS FILE // CONTAINS NO FLAG TEST — the classic guarantee is by construction, the same // shape ground.js gets from townCharacter(null) (index.html:178-185). // known !== null ⇒ the map draws only what has been walked past (discovery.js writes the store, // save.js keeps it) AND FRAMES ITSELF TO IT. The framing is the point, not a // bonus: the full-plan transform renders a shop rectangle at 0.83×1.11 px on // bowral_real and 1.42×1.89 on katoomba_real — the 21 towns that are supposed to // be 21 mental maps have a map you cannot read. The same line that hides what you // don't know makes legible what you do. // // Zero draws, zero triangles, in both arms: this is a 2D canvas over the WebGL canvas and // `hud.js:361`'s `renderer.info.render.calls` never sees it. Measured, not asserted — see §39. // // The static layer is CACHED to an offscreen canvas in the fog arm and re-rendered only when the // discovery version or the (quantised) frame box changes. The null arm keeps re-filling every lot per // frame exactly as it always has: identical work, identical pixels, nothing new to be wrong. const TYPE_COLOR = { record: '#8a5aa8', opshop: '#8a7a5a', toy: '#c86aa0', book: '#7a6a3a', video: '#4a6ab0', pawn: '#c4a028', milkbar: '#c4863a', dept: '#5a6a7a', stall: '#5a8a6a', house: '#6b5a48', anchor: '#5a6a7a', }; const MIN_SPAN_M = 240; // the tightest frame: a 200 m window + the transform's own 40 m margin const FRAME_Q = 25; // frame box quantisation (m) — walking inside what you know re-renders nothing const MAX_LABELS = 40; export function createMinimap(plan, known = null, opts = {}) { let addresses = opts.addresses || null; // Lane A's createAddresses(): streetOf / localityOf const wrap = document.createElement('div'); wrap.id = 'pc-map'; wrap.style.cssText = 'position:fixed;inset:0;z-index:20;display:none;background:#141712;' + 'align-items:center;justify-content:center;flex-direction:column;font:13px -apple-system,sans-serif;color:#e8e0d0'; const canvas = document.createElement('canvas'); canvas.width = 720; canvas.height = 720; canvas.style.cssText = 'background:#1b1e17;border-radius:10px;max-width:92vw;max-height:80vh;box-shadow:0 8px 40px #000'; const cap = document.createElement('div'); cap.style.cssText = 'margin-top:12px;opacity:.8'; cap.textContent = `${plan.name} — seed ${plan.citySeed} · press M to close`; wrap.append(canvas, cap); document.body.appendChild(wrap); const ctx = canvas.getContext('2d'); // world→canvas transform (fit plan extent with margin) let minX = -60, maxX = 60, minZ = -60, maxZ = 60; for (const n of plan.streets.nodes) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minZ = Math.min(minZ, n.z); maxZ = Math.max(maxZ, n.z); } for (const l of plan.lots) { minX = Math.min(minX, l.x); maxX = Math.max(maxX, l.x); minZ = Math.min(minZ, l.z); maxZ = Math.max(maxZ, l.z); } const pad = 24, W = canvas.width, H = canvas.height; const span = Math.max(maxX - minX, maxZ - minZ) + 40; const cxw = (minX + maxX) / 2, czw = (minZ + maxZ) / 2; const sc = (W - pad * 2) / span; const tx = (x) => W / 2 + (x - cxw) * sc; const tz = (z) => H / 2 + (z - czw) * sc; const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n])); const shopByLot = new Map(plan.shops.map((s) => [s.lot, s])); function drawStatic() { ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#1b1e17'; ctx.fillRect(0, 0, W, H); // streets for (const e of plan.streets.edges) { const a = nodes.get(e.a), b = nodes.get(e.b); ctx.strokeStyle = '#3a3f34'; ctx.lineWidth = Math.max(3, e.width * sc); ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(tx(a.x), tz(a.z)); ctx.lineTo(tx(b.x), tz(b.z)); ctx.stroke(); } // lots for (const l of plan.lots) { const s = shopByLot.get(l.id); const col = TYPE_COLOR[s ? s.type : l.use] || '#666'; ctx.save(); ctx.translate(tx(l.x), tz(l.z)); ctx.rotate(-(l.ry || 0)); ctx.fillStyle = col; ctx.fillRect(-l.w * sc / 2, -l.d * sc / 2, l.w * sc, l.d * sc); // front-edge tick (toward the street) ctx.fillStyle = 'rgba(255,255,255,.5)'; ctx.fillRect(-l.w * sc / 2, l.d * sc / 2 - 2, l.w * sc, 2); ctx.restore(); } } // ── the fog arm ──────────────────────────────────────────────────────────────────────────────── // Everything below is dead code when `known` is null: `draw()` branches once, at the top. const off = known ? document.createElement('canvas') : null; if (off) { off.width = W; off.height = H; } const og = off ? off.getContext('2d') : null; const lotById = known ? new Map(plan.lots.map((l) => [l.id, l])) : null; const shopById = known ? new Map(plan.shops.map((s) => [s.id, s])) : null; let cacheKey = null, lastCounts = null; let T = { sc, tx, tz }; // the live transform (full-extent until something is known) // The extent of what is KNOWN, recomputed only when the ledger changes (the player is unioned in // per frame below — that part is two comparisons, not a walk of the ledger). // // SHOPFRONTS ONLY, DELIBERATELY. Including walked EDGES here was my first cut and it threw the // framing away: an edge is drawn as the whole node-to-node line, and the synthetic's main street is // one 400 m edge, so stepping onto it at spawn expanded the frame to 340 m before the player knew a // single shop. Framing on the shops you have actually stood in front of (plus where you are standing) // keeps the fresh-boot frame at the 240 m floor — 21×41 px on the synthetic, 29×39 on a real town — // and a walked street that runs off the edge of the frame is simply clipped by the canvas, which is // what a map does. let kb = { v: -1, x0: Infinity, x1: -Infinity, z0: Infinity, z1: -Infinity }; function refreshKnownExtent() { let x0 = Infinity, x1 = -Infinity, z0 = Infinity, z1 = -Infinity; const eat = (x, z) => { if (x < x0) x0 = x; if (x > x1) x1 = x; if (z < z0) z0 = z; if (z > z1) z1 = z; }; for (const id of known.shopIds()) { const s = shopById.get(id), l = s && lotById.get(s.lot); if (l) { const r = Math.max(l.w, l.d) / 2; eat(l.x - r, l.z - r); eat(l.x + r, l.z + r); } } kb = { v: known.version(), x0, x1, z0, z1 }; } function knownBox(px, pz) { if (kb.v !== known.version()) refreshKnownExtent(); // the player is ALWAYS on the map — a frame you can walk off is a frame that lies let x0 = Math.min(kb.x0, px - 20), x1 = Math.max(kb.x1, px + 20); let z0 = Math.min(kb.z0, pz - 20), z1 = Math.max(kb.z1, pz + 20); // quantise so ordinary walking inside what you know never invalidates the cache x0 = Math.floor(x0 / FRAME_Q) * FRAME_Q; z0 = Math.floor(z0 / FRAME_Q) * FRAME_Q; x1 = Math.ceil(x1 / FRAME_Q) * FRAME_Q; z1 = Math.ceil(z1 / FRAME_Q) * FRAME_Q; // same +40 margin the full-extent transform uses, clamped: never tighter than a 200 m window, // never wider than the whole plan (fully explored ⇒ exactly today's overview, which is the point // at which today's overview is the right map). let s2 = Math.max(x1 - x0, z1 - z0) + 40; s2 = Math.max(MIN_SPAN_M, Math.min(span, s2)); return { cx: (x0 + x1) / 2, cz: (z0 + z1) / 2, span: s2, x0, x1, z0, z1 }; } function paintFog(box) { const s = (W - pad * 2) / box.span; const fx = (x) => W / 2 + (x - box.cx) * s; const fz = (z) => H / 2 + (z - box.cz) * s; T = { sc: s, tx: fx, tz: fz }; og.clearRect(0, 0, W, H); og.fillStyle = '#1b1e17'; og.fillRect(0, 0, W, H); // streets you have walked const drawn = []; for (const e of plan.streets.edges) { if (!known.hasEdge(e.id)) continue; const a = nodes.get(e.a), b = nodes.get(e.b); if (!a || !b) continue; og.strokeStyle = '#3a3f34'; og.lineWidth = Math.max(3, e.width * s); og.lineCap = 'round'; og.beginPath(); og.moveTo(fx(a.x), fz(a.z)); og.lineTo(fx(b.x), fz(b.z)); og.stroke(); drawn.push({ e, a, b }); } // lots: a shop is drawn once you have walked past IT; a house/yard/infill rides the street it // fronts (you saw the houses when you walked the road, and no house is a secret worth keeping). for (const l of plan.lots) { const sh = shopByLot.get(l.id); if (sh ? !known.hasShop(sh.id) : !known.hasEdge(l.frontEdge)) continue; const col = TYPE_COLOR[sh ? sh.type : l.use] || '#666'; og.save(); og.translate(fx(l.x), fz(l.z)); og.rotate(-(l.ry || 0)); og.fillStyle = col; og.fillRect(-l.w * s / 2, -l.d * s / 2, l.w * s, l.d * s); og.fillStyle = 'rgba(255,255,255,.5)'; og.fillRect(-l.w * s / 2, l.d * s / 2 - 2, l.w * s, 2); og.restore(); } paintLabels(og, drawn, s, fx, fz); paintScale(og, s); } // Lane A's address layer, consumed WITHOUT branching on town type (§39.1's explicit guarantee): // we ask a street for its name and a shop for its locality label, draw whatever answers, and a town // that answers one way rather than the other is not a case we test for. function paintLabels(g, drawn, s, fx, fz) { if (!addresses) return; const seen = new Set(); const out = []; if (typeof addresses.streetOf === 'function') { const byName = new Map(); for (const d of drawn) { let name = null; try { name = addresses.streetOf(d.e.id); } catch (err) { return; } if (!name) continue; const len = Math.hypot(d.b.x - d.a.x, d.b.z - d.a.z); const prev = byName.get(name); if (!prev || len > prev.len) byName.set(name, { d, len }); } for (const [name, v] of byName) out.push({ text: name, kind: 'edge', d: v.d }); } if (typeof addresses.localityOf === 'function') { const byLabel = new Map(); for (const id of known.shopIds()) { let loc = null; try { loc = addresses.localityOf(id); } catch (err) { break; } const text = loc && (loc.street || loc.label); if (!text) continue; const sh = shopById.get(id), l = sh && lotById.get(sh.lot); if (!l) continue; let b = byLabel.get(text); if (!b) byLabel.set(text, b = { x: 0, z: 0, n: 0 }); b.x += l.x; b.z += l.z; b.n++; } for (const [text, b] of byLabel) out.push({ text, kind: 'spot', x: b.x / b.n, z: b.z / b.n }); } g.font = '11px -apple-system, Segoe UI, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.lineWidth = 3; g.strokeStyle = 'rgba(10,12,8,.85)'; let n = 0; for (const it of out) { if (n >= MAX_LABELS) break; const key = it.text.toLowerCase(); if (seen.has(key)) continue; const w = g.measureText(it.text).width; g.save(); if (it.kind === 'edge') { const ax = fx(it.d.a.x), az = fz(it.d.a.z), bx = fx(it.d.b.x), bz = fz(it.d.b.z); if (Math.hypot(bx - ax, bz - az) < w + 10) { g.restore(); continue; } let ang = Math.atan2(bz - az, bx - ax); if (ang > Math.PI / 2 || ang < -Math.PI / 2) ang += Math.PI; // never upside down g.translate((ax + bx) / 2, (az + bz) / 2); g.rotate(ang); } else { g.translate(fx(it.x), fz(it.z) - 10); } g.fillStyle = '#d9d2be'; g.strokeText(it.text, 0, 0); g.fillText(it.text, 0, 0); g.restore(); seen.add(key); n++; } } const SCALE_STEPS = [10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000]; function paintScale(g, s) { const want = (W - pad * 2) / s / 5; // ~a fifth of the window let m = SCALE_STEPS[0]; for (const v of SCALE_STEPS) if (v <= want) m = v; const px = m * s; const x = pad, y = H - pad; g.strokeStyle = 'rgba(232,224,208,.7)'; g.lineWidth = 2; g.beginPath(); g.moveTo(x, y); g.lineTo(x + px, y); g.moveTo(x, y - 4); g.lineTo(x, y + 4); g.moveTo(x + px, y - 4); g.lineTo(x + px, y + 4); g.stroke(); g.font = '11px -apple-system, Segoe UI, sans-serif'; g.textAlign = 'left'; g.textBaseline = 'bottom'; g.fillStyle = 'rgba(232,224,208,.8)'; g.fillText(`${m} m`, x + 2, y - 6); } // "segments", not "streets": `c.edges` counts plan edges, and a real street is many of them. When // Lane A's address layer is wired we can say how many STREETS that is, so we do — same field, two // suppliers, no town-type test (39.1's contract). function refreshCaption(c) { let streets = null; if (addresses && typeof addresses.streetOf === 'function') { const names = new Set(); for (const id of known.edgeIds()) { let n = null; try { n = addresses.streetOf(id); } catch (e) { names.clear(); break; } if (n) names.add(n); } if (names.size) streets = names.size; } cap.textContent = `${plan.name} — seed ${plan.citySeed} · ${c.shops} shop${c.shops === 1 ? '' : 's'} known · ` + (streets != null ? `${streets} street${streets === 1 ? '' : 's'}` : `${c.edges} street segment${c.edges === 1 ? '' : 's'}`) + ` walked · press M to close`; } function draw(playerPos, fwd) { if (!known) drawStatic(); else { const box = knownBox(playerPos.x, playerPos.z); const v = known.version(); const key = `${v}|${box.x0},${box.x1},${box.z0},${box.z1}`; if (key !== cacheKey) { paintFog(box); cacheKey = key; const c = known.counts(); if (!lastCounts || c.shops !== lastCounts.shops || c.edges !== lastCounts.edges) { refreshCaption(c); lastCounts = c; } } ctx.clearRect(0, 0, W, H); ctx.drawImage(off, 0, 0); } const x = T.tx(playerPos.x), z = T.tz(playerPos.z); // heading (world +X→right, +Z→down on the map) ctx.strokeStyle = '#ffd75e'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x, z); ctx.lineTo(x + fwd.x * 16, z + fwd.z * 16); ctx.stroke(); ctx.fillStyle = '#ffd75e'; ctx.beginPath(); ctx.arc(x, z, 6, 0, Math.PI * 2); ctx.fill(); } return { draw, setVisible: (v) => { wrap.style.display = v ? 'flex' : 'none'; }, dispose: () => wrap.remove(), // [R39] Lane A's address layer, injectable — the shell wires it at boot when citygen exports // createAddresses, and it can land mid-session without a reload. setAddresses(a) { addresses = a || null; cacheKey = null; lastCounts = null; }, // what the map is actually showing, for the gates: metres across, and the px a shop lot renders at get view() { const med = (arr) => { const b = arr.slice().sort((p, q) => p - q); return b.length ? b[b.length >> 1] : 0; }; const shopLots = plan.shops.map((s) => plan.lots.find((l) => l.id === s.lot)).filter(Boolean); const w = med(shopLots.map((l) => l.w)), d = med(shopLots.map((l) => l.d)); return { fog: !!known, spanFull: span, span: (W - pad * 2) / T.sc, scale: T.sc, lotPx: { w: +(w * T.sc).toFixed(2), d: +(d * T.sc).toFixed(2) }, lotPxFull: { w: +(w * sc).toFixed(2), d: +(d * sc).toFixed(2) }, labels: !!addresses }; }, }; }