// Plotwren town map: a hand-drawn (code-drawn) pixel town on one . 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 ? `` : `${esc((i.name || '?')[0])}`; html = `
${logo}

${esc(i.name)}

${esc(i.pitch)}

Plot ${esc(p.id)} · ${esc(tier ? tier.name : 'CyberMax house plot')} · ${esc(where)}${p.tier === 'house' ? ' · one of CyberMax\'s own products' : ''}${i.featured ? ' · Featured' : ''}

Visit ${esc(host(i.url))}Share page

`; } else if (i.state === 'reserved' || i.state === 'sold') { html = `

Plot ${esc(p.id)}: ${esc(tier.name)}

${i.state === 'sold' ? 'Claimed. The owner is putting up their sign.' : 'Someone is checking out on this plot right now. Try a neighbour.'}

${esc(where)}

`; } else { const can = state.checkout; html = `

Plot ${esc(p.id)}: ${esc(tier.name)} · ${usd(tier.price)} paid once

${esc(tier.blurb)}

${esc(where)} · your logo, name, one-line pitch and link · share page + badge · real visit counts

${can ? `Claim plot ${esc(p.id)} for ${usd(tier.price)}` : 'Checkout opens soon'}Plot page

Fixed price, no bidding, nobody can take it over. 14-day fix-or-refund if it doesn't work as described. Terms

`; } panel.innerHTML = `${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 }) => `
  • `; 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('') : `
  • No featured makers yet. Own a plot? Feature it for ${usd(FEATURED.price)}/month.
  • `; 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