Spaces:
Running
Running
File size: 23,535 Bytes
2c94890 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 | // 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|