plotwren / app.js
CyberMax-tools's picture
Deploy plotwren
2c94890 verified
Raw History Blame Contribute Delete
23.5 kB
// Plotwren town map: a hand-drawn (code-drawn) pixel town on one <canvas>. No libraries, ~20 KB, works with touch,
// mouse and keyboard. Data: /api/town (live plots), falling back to the house plots when offline or on a static mirror.
// © 2026 CyberMax. All rights reserved.
import { WORLD, TIERS, FEATURED, DISTRICTS, ROADS, WATER, FOUNTAIN, PLOTS, YARD, usd } from './town.js';
const SITE = 'https://plotwren.cybermaxtools.com';
const ON_SITE = /plotwren\.cybermaxtools\.com$|plotwren\.cybermax-tools\.workers\.dev$|^localhost$|^127\.0\.0\.1$/.test(location.hostname);
const BASE = ON_SITE ? '' : SITE; // the static mirror (Hugging Face) reads the live town and links to the real checkout
const SRC = new URLSearchParams(location.search).get('s') || (ON_SITE ? 'map' : 'mirror');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const cv = document.getElementById('town');
const cx = cv.getContext('2d');
const panel = document.getElementById('panel');
const state = { plots: {}, house: [], loaded: false, checkout: true };
const cam = { x: 0, y: 0, s: 1 };
const imgs = new Map();
let hover = null, selected = null, t0 = performance.now();
// ---------------------------------------------------------------- deterministic noise
function rng(seed) { let s = seed >>> 0; return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296); }
const hash = (str) => [...str].reduce((h, c) => (Math.imul(h ^ c.charCodeAt(0), 16777619) >>> 0), 2166136261);
const inRect = (x, y, r, m = 0) => x >= r.x - m && x < r.x + r.w + m && y >= r.y - m && y < r.y + r.h + m;
// ---------------------------------------------------------------- static background (drawn once)
const PAL = { grass: '#8fd16a', grass2: '#7cc35a', grass3: '#a3dc7e', road: '#6b7280', road2: '#5b616d', line: '#f8e16c', walk: '#d6cfc2', walk2: '#c7bfb0', water: '#4aa3df', water2: '#7cc4f0', dirt: '#c99a5b', dirt2: '#b5864a', ink: '#2b2d42', paper: '#fff8e7', trunk: '#7a4a24', leaf: '#3f8f3a', leaf2: '#55a84b' };
const bg = document.createElement('canvas');
bg.width = WORLD.w; bg.height = WORLD.h;
function drawBackground() {
const g = bg.getContext('2d');
const r = rng(7);
g.fillStyle = PAL.grass; g.fillRect(0, 0, WORLD.w, WORLD.h);
for (let i = 0; i < 2600; i++) { g.fillStyle = r() < 0.5 ? PAL.grass2 : PAL.grass3; g.fillRect((r() * WORLD.w) | 0, (r() * WORLD.h) | 0, 2, 2); }
// sidewalks around roads, then the roads
for (const rd of ROADS) { g.fillStyle = PAL.walk; g.fillRect(rd.x - 4, rd.y - 4, rd.w + 8, rd.h + 8); }
for (const rd of ROADS) {
g.fillStyle = PAL.road; g.fillRect(rd.x, rd.y, rd.w, rd.h);
g.fillStyle = PAL.road2; for (let i = 0; i < (rd.w * rd.h) / 60; i++) g.fillRect(rd.x + ((r() * rd.w) | 0), rd.y + ((r() * rd.h) | 0), 2, 2);
g.fillStyle = PAL.line;
if (rd.w > rd.h) for (let x = rd.x + 6; x < rd.x + rd.w - 10; x += 24) g.fillRect(x, rd.y + rd.h / 2 - 1, 12, 2);
else for (let y = rd.y + 6; y < rd.y + rd.h - 10; y += 24) g.fillRect(rd.x + rd.w / 2 - 1, y, 2, 12);
}
// water + a little pier
g.fillStyle = PAL.water; g.fillRect(WATER.x, WATER.y, WATER.w, WATER.h);
g.fillStyle = '#e9d8a6'; g.fillRect(0, WATER.y - 10, WORLD.w, 10);
g.fillStyle = PAL.trunk; g.fillRect(744, WATER.y - 10, 48, 54);
for (let y = WATER.y - 10; y < WATER.y + 44; y += 6) { g.fillStyle = '#5e3a1c'; g.fillRect(744, y, 48, 1); }
// fountain on the square
const f = FOUNTAIN;
g.fillStyle = '#9aa3ad'; g.fillRect(f.x + 4, f.y + 4, f.w - 8, f.h - 8);
g.fillStyle = PAL.water; g.fillRect(f.x + 8, f.y + 8, f.w - 16, f.h - 16);
g.fillStyle = '#e5e7eb'; g.fillRect(f.x + 20, f.y + 16, 8, 16);
// trees and flowers on empty grass
const blocked = (x, y) => ROADS.some((rd) => inRect(x, y, rd, 10)) || inRect(x, y, WATER, 14) || [...PLOTS, ...YARD].some((p) => inRect(x, y, p, 8)) || inRect(x, y, FOUNTAIN, 14);
for (let i = 0; i < 700; i++) {
const x = (r() * (WORLD.w - 20)) | 0, y = (r() * (WORLD.h - 90)) | 0;
if (blocked(x, y) || blocked(x + 14, y + 18)) continue;
if (r() < 0.55) tree(g, x, y, r() < 0.3); else flower(g, x, y, ['#ff5d8f', '#f3a712', '#ffffff', '#7b2cbf'][(r() * 4) | 0]);
}
// district plaques
label(g, DISTRICTS.yard, 72, 44); label(g, DISTRICTS.street, 72, 172); label(g, DISTRICTS.boulevard, 72, 380);
label(g, DISTRICTS.square, 640, 324); label(g, DISTRICTS.market, 72, 652); label(g, DISTRICTS.market, 840, 652); label(g, DISTRICTS.street, 840, 172); label(g, DISTRICTS.boulevard, 912, 380);
}
function tree(g, x, y, round) {
g.fillStyle = 'rgba(0,0,0,.15)'; g.fillRect(x + 2, y + 16, 14, 4);
g.fillStyle = PAL.trunk; g.fillRect(x + 7, y + 11, 4, 8);
g.fillStyle = PAL.leaf; round ? (g.fillRect(x + 2, y + 2, 14, 10), g.fillRect(x + 4, y, 10, 14)) : (g.fillRect(x + 5, y, 8, 4), g.fillRect(x + 3, y + 4, 12, 4), g.fillRect(x + 1, y + 8, 16, 4));
g.fillStyle = PAL.leaf2; g.fillRect(x + 5, y + 3, 4, 3);
}
function flower(g, x, y, c) { g.fillStyle = '#3f8f3a'; g.fillRect(x + 2, y + 3, 1, 3); g.fillStyle = c; g.fillRect(x + 1, y + 1, 3, 3); g.fillStyle = '#f8e16c'; g.fillRect(x + 2, y + 2, 1, 1); }
function label(g, text, x, y) {
g.font = 'bold 9px ui-monospace, Menlo, Consolas, monospace';
const w = Math.ceil(g.measureText(text).width) + 8;
g.fillStyle = PAL.ink; g.fillRect(x, y - 9, w, 12);
g.fillStyle = '#fff'; g.fillText(text, x + 4, y);
}
// ---------------------------------------------------------------- plots
function logoImg(src) {
if (!src) return null;
let im = imgs.get(src);
if (!im) { im = new Image(); im.decoding = 'async'; im.src = src.startsWith('/') ? BASE + src : src; im.onload = () => draw(); imgs.set(src, im); }
return im.complete && im.naturalWidth ? im : null;
}
function signBox(g, x, y, w, h, colour, live) {
g.fillStyle = PAL.ink; g.fillRect(x - 1, y - 1, w + 2, h + 2);
g.fillStyle = PAL.paper; g.fillRect(x, y, w, h);
const im = logoImg(live.logo);
const s = Math.min(w, h) - 2;
if (im) { g.imageSmoothingEnabled = true; g.drawImage(im, x + (w - s) / 2, y + (h - s) / 2, s, s); g.imageSmoothingEnabled = false; }
else {
g.fillStyle = colour; g.fillRect(x + 1, y + 1, w - 2, h - 2);
g.fillStyle = '#fff'; g.font = `bold ${Math.max(6, (s * 0.7) | 0)}px ui-monospace, monospace`; g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillText((live.name || '?').slice(0, 1).toUpperCase(), x + w / 2, y + h / 2 + 1); g.textAlign = 'start'; g.textBaseline = 'alphabetic';
}
}
function drawPlot(g, p, info, now) {
const { x, y, w, h } = p;
const tier = p.tier;
g.fillStyle = 'rgba(0,0,0,.12)'; g.fillRect(x + 2, y + h - 2, w, 4);
if (!info || info.state === 'free') { // empty lot with a for-sale sign
g.fillStyle = PAL.dirt; g.fillRect(x, y, w, h);
g.fillStyle = PAL.dirt2; for (let i = 2; i < w; i += 6) g.fillRect(x + i, y + ((i * 7) % (h - 2)), 2, 2);
g.strokeStyle = '#8a6a3c'; g.setLineDash([2, 2]); g.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); g.setLineDash([]);
const sx = x + w / 2 - 7, sy = y + h / 2 - 9;
g.fillStyle = PAL.trunk; g.fillRect(sx + 6, sy + 8, 2, 10);
g.fillStyle = { stall: '#29bf12', shop: '#2e86ab', billboard: '#f3a712', landmark: '#e4572e' }[tier]; g.fillRect(sx, sy, 14, 9);
g.fillStyle = '#fff'; g.fillRect(sx + 3, sy + 4, 8, 1); g.fillRect(sx + 6, sy + 2, 2, 5);
return;
}
if (info.state === 'reserved' || info.state === 'sold') { // claimed, content coming: scaffolding
g.fillStyle = '#d9c7a3'; g.fillRect(x, y, w, h);
g.strokeStyle = '#a0763c'; g.lineWidth = 1;
for (let i = 0; i <= w; i += 8) { g.beginPath(); g.moveTo(x + i + 0.5, y); g.lineTo(x + i + 0.5, y + h); g.stroke(); }
for (let j = 0; j <= h; j += 8) { g.beginPath(); g.moveTo(x, y + j + 0.5); g.lineTo(x + w, y + j + 0.5); g.stroke(); }
g.fillStyle = '#f3a712'; g.fillRect(x + 2, y + h - 6, w - 4, 3);
return;
}
const c = info.colour || '#2e86ab';
if (tier === 'stall') {
g.fillStyle = '#c7a26b'; g.fillRect(x + 2, y + 14, w - 4, h - 16);
for (let i = 0; i < w - 4; i += 6) { g.fillStyle = (i / 6) % 2 ? '#fff' : c; g.fillRect(x + 2 + i, y + 6, 6, 8); }
g.fillStyle = PAL.ink; g.fillRect(x + 2, y + 14, w - 4, 1);
signBox(g, x + 9, y + 17, 14, 12, c, info);
} else if (tier === 'shop' || tier === 'house') {
g.fillStyle = '#efe3cc'; g.fillRect(x + 3, y + 14, w - 6, h - 16);
g.fillStyle = c; g.fillRect(x, y + 6, w, 10); g.fillRect(x + 4, y + 2, w - 8, 4);
g.fillStyle = 'rgba(0,0,0,.2)'; g.fillRect(x, y + 15, w, 1);
g.fillStyle = '#7fc8f8'; g.fillRect(x + 6, y + h - 14, 10, 8); g.fillRect(x + w - 16, y + h - 14, 10, 8);
g.fillStyle = PAL.ink; g.fillRect(x + w / 2 - 4, y + h - 14, 8, 12);
signBox(g, x + w / 2 - 12, y + 17, 24, 16, c, info);
if (tier === 'house') { g.fillStyle = PAL.ink; g.fillRect(x + w - 18, y, 18, 9); g.fillStyle = '#fff'; g.font = 'bold 7px ui-monospace, monospace'; g.fillText('CMX', x + w - 17, y + 7); }
} else if (tier === 'billboard') {
g.fillStyle = '#5e3a1c'; g.fillRect(x + 12, y + 34, 4, 14); g.fillRect(x + w - 16, y + 34, 4, 14);
g.fillStyle = PAL.ink; g.fillRect(x, y, w, 36);
g.fillStyle = c; g.fillRect(x + 2, y + 2, w - 4, 32);
signBox(g, x + 5, y + 5, 26, 26, c, info);
g.fillStyle = '#fff'; g.font = 'bold 7px ui-monospace, monospace';
const nm = (info.name || '').slice(0, 8);
g.fillText(nm, x + 34, y + 16); g.font = '6px ui-monospace, monospace'; g.fillText((info.name || '').slice(8, 16), x + 34, y + 25);
} else if (tier === 'landmark') {
g.fillStyle = '#e9e4da'; g.fillRect(x + 14, y + 10, w - 28, h - 12);
g.fillStyle = c; g.fillRect(x + 10, y + 4, w - 20, 10); g.fillRect(x + 24, y, w - 48, 4);
g.fillStyle = '#7fc8f8';
for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) g.fillRect(x + 20 + k * 15, y + 54 + r * 9, 8, 5);
g.fillStyle = PAL.ink; g.fillRect(x + w / 2 - 6, y + h - 14, 12, 12);
signBox(g, x + w / 2 - 18, y + 16, 36, 34, c, info);
}
if (info.featured) {
const a = reduced ? 0.9 : 0.55 + 0.45 * Math.sin(now / 300);
g.strokeStyle = `rgba(255, 214, 10, ${a})`; g.lineWidth = 3; g.strokeRect(x - 3, y - 3, w + 6, h + 6); g.lineWidth = 1;
}
}
// ---------------------------------------------------------------- walkers (decoration only)
const walkers = Array.from({ length: reduced ? 0 : 26 }, (_, i) => {
const r = rng(100 + i), rd = ROADS[i % ROADS.length], horiz = rd.w > rd.h;
return { rd, horiz, p: r(), v: (0.012 + r() * 0.02) * (r() < 0.5 ? 1 : -1), lane: r(), c: ['#e4572e', '#2e86ab', '#7b2cbf', '#29bf12', '#f3a712', '#ff5d8f'][i % 6] };
});
function drawWalkers(g, now) {
for (const w of walkers) {
const len = w.horiz ? w.rd.w : w.rd.h;
const pos = (((w.p * len + (now / 1000) * w.v * 600) % len) + len) % len;
const x = w.horiz ? w.rd.x + pos : w.rd.x - 3 + (w.lane < 0.5 ? 0 : w.rd.w + 2);
const y = w.horiz ? w.rd.y - 7 + (w.lane < 0.5 ? 0 : w.rd.h + 6) : w.rd.y + pos;
const step = ((now / 180) | 0) % 2;
g.fillStyle = '#f2c79b'; g.fillRect(x, y, 3, 3);
g.fillStyle = w.c; g.fillRect(x - 1, y + 3, 5, 4);
g.fillStyle = PAL.ink; g.fillRect(x - 1 + step, y + 7, 1, 2); g.fillRect(x + 2 - step, y + 7, 1, 2);
}
}
// ---------------------------------------------------------------- camera + drawing
function fit() {
const r = cv.getBoundingClientRect();
const dpr = Math.min(devicePixelRatio || 1, 2);
cv.width = Math.round(r.width * dpr); cv.height = Math.round(r.height * dpr);
const s = Math.max(r.width / WORLD.w, r.height / WORLD.h) * (r.width < 700 ? 1.6 : 1.05);
cam.s = s; cam.min = Math.min(r.width / WORLD.w, r.height / WORLD.h); cam.max = 6;
cam.x = WORLD.w / 2 - r.width / s / 2; cam.y = 420 - r.height / s / 2;
clamp(); draw();
}
function clamp() {
const r = cv.getBoundingClientRect();
const vw = r.width / cam.s, vh = r.height / cam.s;
cam.x = vw >= WORLD.w ? (WORLD.w - vw) / 2 : Math.max(0, Math.min(WORLD.w - vw, cam.x));
cam.y = vh >= WORLD.h ? (WORLD.h - vh) / 2 : Math.max(0, Math.min(WORLD.h - vh, cam.y));
}
function infoOf(p) {
if (p.tier === 'house') return p.live;
return state.plots[p.id] || { state: 'free' };
}
const HOUSE_PLOTS = () => state.house.map((h) => ({ ...YARD.find((y) => y.id === h.id), tier: 'house', live: { state: 'live', ...h } }));
let raf = 0;
function draw() {
if (raf) return;
raf = requestAnimationFrame(frame);
}
function frame(now) {
raf = 0;
const dpr = cv.width / cv.getBoundingClientRect().width;
cx.setTransform(dpr * cam.s, 0, 0, dpr * cam.s, -cam.x * dpr * cam.s, -cam.y * dpr * cam.s);
cx.imageSmoothingEnabled = false;
cx.drawImage(bg, 0, 0);
if (!reduced) { // water shimmer
cx.fillStyle = PAL.water2;
for (let i = 0; i < 40; i++) { const x = (i * 97 + now / 40) % WORLD.w; cx.fillRect(x, WATER.y + 8 + ((i * 13) % 44), 10, 2); }
}
for (const p of PLOTS) drawPlot(cx, p, state.plots[p.id], now);
for (const h of HOUSE_PLOTS()) drawPlot(cx, h, h.live, now);
drawWalkers(cx, now);
const hl = (p, col) => { if (!p) return; cx.strokeStyle = col; cx.lineWidth = 2 / cam.s; cx.strokeRect(p.x - 1, p.y - 1, p.w + 2, p.h + 2); };
hl(hover, 'rgba(255,255,255,.9)'); hl(selected, '#ffd60a');
if (cam.s >= 2.2) { // names under live signs when zoomed in
cx.font = 'bold 6px ui-monospace, monospace'; cx.textAlign = 'center';
for (const p of [...PLOTS, ...HOUSE_PLOTS()]) {
const i = infoOf(p);
if (i?.state !== 'live' || p.tier === 'billboard') continue;
const t = (i.name || '').slice(0, 14), tw = cx.measureText(t).width + 4;
cx.fillStyle = 'rgba(43,45,66,.85)'; cx.fillRect(p.x + p.w / 2 - tw / 2, p.y + p.h + 1, tw, 8);
cx.fillStyle = '#fff'; cx.fillText(t, p.x + p.w / 2, p.y + p.h + 7);
}
cx.textAlign = 'start';
}
if (!reduced && !document.hidden && visible) draw();
}
let visible = true;
new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) draw(); }).observe(cv);
document.addEventListener('visibilitychange', () => { if (!document.hidden) draw(); });
// ---------------------------------------------------------------- input: drag, pinch, wheel, tap, keyboard
const pts = new Map();
let drag = null, moved = 0, pinch = null;
const toWorld = (cxp, cyp) => { const r = cv.getBoundingClientRect(); return { x: cam.x + (cxp - r.left) / cam.s, y: cam.y + (cyp - r.top) / cam.s }; };
const hit = (w) => [...HOUSE_PLOTS(), ...PLOTS].find((p) => inRect(w.x, w.y, p, 2)) || null;
function zoomAt(f, cxp, cyp) {
const before = toWorld(cxp, cyp);
cam.s = Math.max(cam.min, Math.min(cam.max, cam.s * f));
const r = cv.getBoundingClientRect();
cam.x = before.x - (cxp - r.left) / cam.s; cam.y = before.y - (cyp - r.top) / cam.s;
clamp(); draw();
}
cv.addEventListener('pointerdown', (e) => {
cv.setPointerCapture(e.pointerId);
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 1) { drag = { x: e.clientX, y: e.clientY, cx: cam.x, cy: cam.y }; moved = 0; }
if (pts.size === 2) { const [a, b] = [...pts.values()]; pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), s: cam.s }; moved = 99; }
});
cv.addEventListener('pointermove', (e) => {
if (pts.has(e.pointerId)) pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pinch && pts.size === 2) {
const [a, b] = [...pts.values()];
const f = (pinch.s * Math.hypot(a.x - b.x, a.y - b.y) / pinch.d) / cam.s;
zoomAt(f, (a.x + b.x) / 2, (a.y + b.y) / 2);
return;
}
if (drag && pts.size === 1) {
moved = Math.max(moved, Math.hypot(e.clientX - drag.x, e.clientY - drag.y));
cam.x = drag.cx - (e.clientX - drag.x) / cam.s; cam.y = drag.cy - (e.clientY - drag.y) / cam.s;
clamp(); draw();
return;
}
if (e.pointerType === 'mouse') {
const h = hit(toWorld(e.clientX, e.clientY));
if (h !== hover) { hover = h; cv.style.cursor = h ? 'pointer' : 'grab'; draw(); }
}
});
const up = (e) => {
pts.delete(e.pointerId);
if (pts.size < 2) pinch = null;
if (pts.size === 0) {
if (drag && moved < 6) { const p = hit(toWorld(e.clientX, e.clientY)); select(p); }
drag = null;
}
};
cv.addEventListener('pointerup', up);
cv.addEventListener('pointercancel', (e) => { pts.delete(e.pointerId); drag = null; pinch = null; });
cv.addEventListener('wheel', (e) => { e.preventDefault(); zoomAt(Math.exp(-e.deltaY * 0.0015), e.clientX, e.clientY); }, { passive: false });
document.getElementById('zin').onclick = () => { const r = cv.getBoundingClientRect(); zoomAt(1.4, r.left + r.width / 2, r.top + r.height / 2); };
document.getElementById('zout').onclick = () => { const r = cv.getBoundingClientRect(); zoomAt(1 / 1.4, r.left + r.width / 2, r.top + r.height / 2); };
cv.addEventListener('keydown', (e) => {
const step = 40 / cam.s, k = e.key;
if (k === 'ArrowLeft') cam.x -= step; else if (k === 'ArrowRight') cam.x += step; else if (k === 'ArrowUp') cam.y -= step; else if (k === 'ArrowDown') cam.y += step;
else if (k === '+' || k === '=') return zoomAt(1.25, ...centre()); else if (k === '-') return zoomAt(0.8, ...centre()); else return;
e.preventDefault(); clamp(); draw();
});
const centre = () => { const r = cv.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; };
function focus(p) {
const r = cv.getBoundingClientRect();
cam.s = Math.max(cam.s, r.width < 700 ? 1.8 : 2);
cam.x = p.x + p.w / 2 - r.width / cam.s / 2; cam.y = p.y + p.h / 2 - r.height / cam.s / 2.6;
clamp(); draw();
}
// ---------------------------------------------------------------- panel
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
function host(u) { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } }
function select(p) {
selected = p; draw();
if (!p) { panel.hidden = true; return; }
const i = infoOf(p);
const tier = p.tier === 'house' ? null : TIERS[p.tier];
const where = p.tier === 'house' ? DISTRICTS.yard : DISTRICTS[p.district];
let html;
if (i.state === 'live') {
const logo = i.logo ? `<img src="${esc(i.logo.startsWith('/') ? BASE + i.logo : i.logo)}" alt="" width="56" height="56">` : `<span class="mono" style="background:${esc(i.colour)}">${esc((i.name || '?')[0])}</span>`;
html = `<div class="ph">${logo}<div><h3>${esc(i.name)}</h3><p>${esc(i.pitch)}</p></div></div>
<p class="meta">Plot ${esc(p.id)} · ${esc(tier ? tier.name : 'CyberMax house plot')} · ${esc(where)}${p.tier === 'house' ? ' · <b>one of CyberMax\'s own products</b>' : ''}${i.featured ? ' · <b class="feat">Featured</b>' : ''}</p>
<p class="row"><a class="btn" href="${BASE}/go/${esc(p.id)}" rel="nofollow ugc" target="_blank">Visit ${esc(host(i.url))}</a><a class="btn ghost" href="${BASE}/p/${esc(p.id)}">Share page</a></p>`;
} else if (i.state === 'reserved' || i.state === 'sold') {
html = `<h3>Plot ${esc(p.id)}: ${esc(tier.name)}</h3><p>${i.state === 'sold' ? 'Claimed. The owner is putting up their sign.' : 'Someone is checking out on this plot right now. Try a neighbour.'}</p><p class="meta">${esc(where)}</p>`;
} else {
const can = state.checkout;
html = `<h3>Plot ${esc(p.id)}: ${esc(tier.name)} · <span class="price">${usd(tier.price)}</span> <small>paid once</small></h3><p>${esc(tier.blurb)}</p>
<p class="meta">${esc(where)} · your logo, name, one-line pitch and link · share page + badge · real visit counts</p>
<p class="row">${can ? `<a class="btn" id="claim" href="${BASE}/buy/${esc(p.id)}?s=${encodeURIComponent(SRC)}">Claim plot ${esc(p.id)} for ${usd(tier.price)}</a>` : '<span class="btn disabled">Checkout opens soon</span>'}<a class="btn ghost" href="${BASE}/p/${esc(p.id)}">Plot page</a></p>
<p class="fine">Fixed price, no bidding, nobody can take it over. 14-day fix-or-refund if it doesn't work as described. <a href="${BASE}/terms/">Terms</a></p>`;
}
panel.innerHTML = `<button class="x" aria-label="Close" onclick="this.parentElement.hidden=true">×</button>${html}`;
panel.hidden = false;
history.replaceState(null, '', `?plot=${p.id}${location.hash}`);
}
// ---------------------------------------------------------------- lists below the map (featured strip, directory, stock)
function lists() {
const live = [...PLOTS.map((p) => ({ p, i: state.plots[p.id] })).filter((x) => x.i?.state === 'live'), ...HOUSE_PLOTS().map((p) => ({ p, i: p.live }))];
live.sort((a, b) => (b.i.featured ? 1 : 0) - (a.i.featured ? 1 : 0) || (b.i.since || 0) - (a.i.since || 0));
const card = ({ p, i }) => `<li><button type="button" data-plot="${esc(p.id)}">${i.logo ? `<img src="${esc(i.logo.startsWith('/') ? BASE + i.logo : i.logo)}" alt="" width="28" height="28" loading="lazy">` : `<span class="mono" style="background:${esc(i.colour)}">${esc((i.name || '?')[0])}</span>`}<span><b>${esc(i.name)}</b><small>${esc(i.pitch)}</small></span>${p.tier === 'house' ? '<em>CyberMax</em>' : i.featured ? '<em class="feat">Featured</em>' : ''}</button></li>`;
const dir = document.getElementById('dir');
dir.innerHTML = live.map(card).join('');
const feat = live.filter((x) => x.i.featured);
document.getElementById('featured').innerHTML = feat.length ? feat.map(card).join('') : `<li class="empty">No featured makers yet. Own a plot? Feature it for ${usd(FEATURED.price)}/month.</li>`;
for (const t of Object.keys(TIERS)) {
const left = PLOTS.filter((p) => p.tier === t && !state.plots[p.id]).length;
const el = document.querySelector(`[data-left="${t}"]`);
if (el) el.textContent = `${left} of ${PLOTS.filter((p) => p.tier === t).length} free`;
}
const sold = Object.values(state.plots).filter((x) => x.state === 'live' || x.state === 'sold').length;
document.getElementById('count').textContent = sold ? `${sold} plot${sold === 1 ? '' : 's'} claimed so far` : 'Opening week: every plot is free to claim';
}
document.addEventListener('click', (e) => {
const b = e.target.closest('[data-plot]');
if (!b) return;
const p = [...HOUSE_PLOTS(), ...PLOTS].find((x) => x.id === b.dataset.plot);
if (p) { cv.scrollIntoView({ behavior: reduced ? 'auto' : 'smooth', block: 'center' }); focus(p); select(p); }
});
document.querySelectorAll('[data-pick]').forEach((b) => b.addEventListener('click', () => { // "pick a free X for me"
const free = PLOTS.filter((p) => p.tier === b.dataset.pick && !state.plots[p.id]);
if (!free.length) return;
const p = free[(Math.random() * Math.min(free.length, 6)) | 0];
cv.scrollIntoView({ behavior: reduced || innerWidth < 700 ? 'auto' : 'smooth', block: 'center' }); focus(p); select(p);
}));
// ---------------------------------------------------------------- boot
async function load() {
try {
const r = await fetch(`${BASE}/api/town`, { cache: 'no-store' });
if (!r.ok) throw new Error(r.status);
const j = await r.json();
state.plots = j.plots || {}; state.house = j.house || []; state.checkout = j.checkout !== false;
} catch {
try { const j = await (await fetch('./house.json')).json(); state.house = j.house; } catch { /* map still works */ }
}
state.loaded = true;
lists(); draw();
const q = new URLSearchParams(location.search).get('plot');
const p = q && [...HOUSE_PLOTS(), ...PLOTS].find((x) => x.id === q);
if (p) { focus(p); select(p); }
}
drawBackground();
addEventListener('resize', () => { fit(); });
fit();
load();
window.__plotwren = { state, cam, select: (id) => select([...HOUSE_PLOTS(), ...PLOTS].find((x) => x.id === id)) }; // for tests and the demo recorder