Spaces:
Running
Running
Download app.js from CyberMax-tools/plotwren: direct link, hf CLI and curl.
- Browser
- Download file 23.5 kB
-
https://huggingface.co/spaces/CyberMax-tools/plotwren/resolve/main/app.js
- Command line
-
hf download hf://spaces/CyberMax-tools/plotwren/app.js
-
curl -L -o app.js https://huggingface.co/spaces/CyberMax-tools/plotwren/resolve/main/app.js
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 | |