| … | … | @@ -0,0 +1,238 @@ |
| 1 | <!doctype html> |
| 2 | <meta charset="utf-8"> |
| 3 | <title>Infinite Terrain — Explore</title> |
| 4 | <style> |
| 5 | :root{ |
| 6 | --bg:#0f141a; --panel:#171d24; --edge:#26313a; --edge2:#354350; |
| 7 | --ink:#e3e9e6; --dim:#8b9aa1; --accent:#74c2cd; --warn:#d7a45c; --ok:#77c39a; |
| 8 | font-family:"IBM Plex Sans",system-ui,sans-serif; |
| 9 | } |
| 10 | *{box-sizing:border-box} |
| 11 | html,body{margin:0;height:100%;overflow:hidden;background:var(--bg);color:var(--ink);font-size:13px} |
| 12 | #map{position:absolute;inset:0;cursor:grab;touch-action:none} |
| 13 | #map:active{cursor:grabbing} |
| 14 | canvas{position:absolute;inset:0;width:100%;height:100%;display:block} |
| 15 | |
| 16 | .hud{position:absolute;background:var(--panel);border:1px solid var(--edge); |
| 17 | border-radius:4px;padding:11px 13px;font-family:"IBM Plex Mono",ui-monospace,monospace; |
| 18 | font-size:11px;line-height:1.65;white-space:pre;pointer-events:none; |
| 19 | font-variant-numeric:tabular-nums} |
| 20 | #stats{top:12px;left:12px} |
| 21 | #help{bottom:12px;left:12px;color:var(--dim);font-size:10.5px} |
| 22 | #ctl{top:12px;right:12px;pointer-events:auto;white-space:normal;width:214px} |
| 23 | #ctl label{display:grid;grid-template-columns:1fr auto;gap:2px 8px;margin:7px 0; |
| 24 | font-family:"IBM Plex Sans",sans-serif;font-size:11.5px;align-items:center} |
| 25 | #ctl input[type=range]{grid-column:1/-1;width:100%;accent-color:var(--accent)} |
| 26 | #ctl .v{color:var(--accent);font-family:"IBM Plex Mono",monospace} |
| 27 | #ctl button{width:100%;margin-top:7px;background:#22303a;color:var(--ink); |
| 28 | border:1px solid var(--edge2);border-radius:3px;padding:5px; |
| 29 | font:inherit;font-size:11px;cursor:pointer} |
| 30 | #ctl button:hover{border-color:var(--accent)} |
| 31 | #ctl hr{border:0;border-top:1px solid var(--edge);margin:11px 0} |
| 32 | .k{color:var(--dim)} .g{color:var(--ok)} .w{color:var(--warn)} .a{color:var(--accent)} |
| 33 | </style> |
| 34 | |
| 35 | <div id="map"><canvas id="cv"></canvas></div> |
| 36 | <div class="hud" id="stats"></div> |
| 37 | <div class="hud" id="ctl"> |
| 38 | <label>zoom <span class="v" id="v_zoom">1.00</span> |
| 39 | <input type="range" id="zoom" min="-3" max="1.6" step="0.05" value="0"></label> |
| 40 | <label>prefetch rings <span class="v" id="v_pre">1</span> |
| 41 | <input type="range" id="pre" min="0" max="3" step="1" value="1"></label> |
| 42 | <label><input type="checkbox" id="grid" checked style="width:auto"> tile grid + state</label> |
| 43 | <hr> |
| 44 | <button id="go0">origin (0,0)</button> |
| 45 | <button id="gomtn">the mountain region</button> |
| 46 | <button id="clearq">stop prefetching</button> |
| 47 | </div> |
| 48 | <div class="hud" id="help">drag to pan · wheel to zoom · one tile generates at a time, by design</div> |
| 49 | |
| 50 | <script> |
| 51 | const TILE_PX = 256; // must match the server's tile_px |
| 52 | const M_PER_PX = 30; // model native |
| 53 | const TILE_M = TILE_PX * M_PER_PX; // 7.68 km per tile |
| 54 | |
| 55 | const cv = document.getElementById("cv"), ctx = cv.getContext("2d", {alpha:false}); |
| 56 | const $ = id => document.getElementById(id); |
| 57 | |
| 58 | // view: world position (in model px) at screen centre, and scale (screen px per model px) |
| 59 | let view = {x: 0, y: 2048 * 1 + 128, scale: 0.25}; |
| 60 | const imgs = new Map(); // "tx,ty" -> Image (loaded) or "pending" |
| 61 | let tileState = {}; // "tx,ty" -> cached|disk|queued|busy|missing|error |
| 62 | let stats = {}; |
| 63 | let dirty = true; |
| 64 | |
| 65 | function resize() { |
| 66 | const r = devicePixelRatio || 1; |
| 67 | cv.width = innerWidth * r; cv.height = innerHeight * r; |
| 68 | ctx.setTransform(r, 0, 0, r, 0, 0); |
| 69 | ctx.imageSmoothingEnabled = false; |
| 70 | dirty = true; |
| 71 | } |
| 72 | addEventListener("resize", resize); |
| 73 | |
| 74 | function visibleTiles() { |
| 75 | const w = innerWidth / view.scale, h = innerHeight / view.scale; |
| 76 | const x0 = view.x - w/2, y0 = view.y - h/2; |
| 77 | const t0x = Math.floor(x0 / TILE_PX), t1x = Math.floor((x0 + w) / TILE_PX); |
| 78 | const t0y = Math.floor(y0 / TILE_PX), t1y = Math.floor((y0 + h) / TILE_PX); |
| 79 | const out = []; |
| 80 | for (let ty = t0y; ty <= t1y; ty++) |
| 81 | for (let tx = t0x; tx <= t1x; tx++) out.push([tx, ty]); |
| 82 | return out; |
| 83 | } |
| 84 | |
| 85 | function tileImage(tx, ty) { |
| 86 | const k = tx + "," + ty; |
| 87 | if (imgs.has(k)) return imgs.get(k); |
| 88 | const im = new Image(); |
| 89 | im.onload = () => { imgs.set(k, im); dirty = true; }; |
| 90 | im.onerror = () => { imgs.delete(k); }; // 204 = not ready yet |
| 91 | im.src = `/api/tile.png?tx=${tx}&ty=${ty}&view=shaded-height`; |
| 92 | imgs.set(k, "pending"); |
| 93 | return "pending"; |
| 94 | } |
| 95 | |
| 96 | function draw() { |
| 97 | ctx.fillStyle = "#0d1114"; |
| 98 | ctx.fillRect(0, 0, innerWidth, innerHeight); |
| 99 | const s = view.scale, size = TILE_PX * s; |
| 100 | const cxs = innerWidth/2, cys = innerHeight/2; |
| 101 | const showGrid = $("grid").checked; |
| 102 | |
| 103 | for (const [tx, ty] of visibleTiles()) { |
| 104 | const sx = cxs + (tx * TILE_PX - view.x) * s; |
| 105 | const sy = cys + (ty * TILE_PX - view.y) * s; |
| 106 | const im = tileImage(tx, ty); |
| 107 | if (im && im !== "pending") { |
| 108 | ctx.drawImage(im, sx, sy, size + 1, size + 1); |
| 109 | } else { |
| 110 | const st = tileState[tx + "," + ty] || "missing"; |
| 111 | ctx.fillStyle = st === "busy" ? "#1d3038" : "#141a20"; |
| 112 | ctx.fillRect(sx, sy, size, size); |
| 113 | if (size < 48) { // below the request threshold: say so, do not pretend |
| 114 | ctx.fillStyle = "#243039"; |
| 115 | ctx.fillRect(sx + 1, sy + 1, size - 2, size - 2); |
| 116 | } |
| 117 | } |
| 118 | if (showGrid) { |
| 119 | const st = tileState[tx + "," + ty] || "missing"; |
| 120 | ctx.strokeStyle = st === "busy" ? "#74c2cd" |
| 121 | : st === "queued" ? "#d7a45c" |
| 122 | : (st === "cached" || st === "disk") ? "#2b3b45" : "#3a2b2b"; |
| 123 | ctx.lineWidth = st === "busy" ? 2 : 1; |
| 124 | ctx.strokeRect(sx + 0.5, sy + 0.5, size - 1, size - 1); |
| 125 | if (size > 90) { |
| 126 | ctx.fillStyle = "#8b9aa1"; |
| 127 | ctx.font = "10px 'IBM Plex Mono', monospace"; |
| 128 | ctx.fillText(`${tx},${ty}`, sx + 6, sy + 14); |
| 129 | if (st === "busy" || st === "queued") ctx.fillText(st, sx + 6, sy + 27); |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | dirty = false; |
| 134 | } |
| 135 | |
| 136 | function frame() { if (dirty) draw(); requestAnimationFrame(frame); } |
| 137 | requestAnimationFrame(frame); |
| 138 | |
| 139 | // ---- prefetch: ask for what is visible, plus N rings, centred on the view ---- |
| 140 | async function poll() { |
| 141 | const vis = visibleTiles(); |
| 142 | const rings = +$("pre").value; |
| 143 | // DO NOT queue what you cannot see the detail of. At low zoom a 7.68 km tile |
| 144 | // lands on ~40 screen px, so generating 30 m/px for it is pure waste -- and |
| 145 | // the queue explodes (343 deep on first load). Below MIN_PX the tile is drawn |
| 146 | // as a placeholder and never requested; that band is exactly where the coarse |
| 147 | // stage (7.7 km/px, cheap) belongs, and is the next thing to wire in. |
| 148 | const MIN_PX = 48; |
| 149 | const onScreen = TILE_PX * view.scale; |
| 150 | if (onScreen < MIN_PX) { |
| 151 | try { |
| 152 | const r = await fetch(`/api/tiles/state?tiles=`); |
| 153 | stats = (await r.json()).stats; hud(); |
| 154 | } catch (e) {} |
| 155 | return; |
| 156 | } |
| 157 | const want = new Set(vis.map(t => t.join(","))); |
| 158 | if (rings > 0) { |
| 159 | for (const [tx, ty] of vis) |
| 160 | for (let dy = -rings; dy <= rings; dy++) |
| 161 | for (let dx = -rings; dx <= rings; dx++) want.add((tx+dx) + "," + (ty+dy)); |
| 162 | } |
| 163 | const list = [...want].join(";"); |
| 164 | const ctx_ = [Math.floor(view.x / TILE_PX), Math.floor(view.y / TILE_PX)]; |
| 165 | try { |
| 166 | if (!STOPPED) { |
| 167 | await fetch(`/api/tiles/request?tiles=${encodeURIComponent(list)}` + |
| 168 | `&cx=${ctx_[0]}&cy=${ctx_[1]}`); |
| 169 | } |
| 170 | const r = await fetch(`/api/tiles/state?tiles=${encodeURIComponent(list)}`); |
| 171 | const j = await r.json(); |
| 172 | tileState = j.state; stats = j.stats; |
| 173 | // retry any tile that has become available since we last failed on it |
| 174 | for (const [k, st] of Object.entries(tileState)) |
| 175 | if ((st === "cached" || st === "disk") && !imgs.has(k)) { |
| 176 | const [tx, ty] = k.split(",").map(Number); tileImage(tx, ty); |
| 177 | } |
| 178 | dirty = true; |
| 179 | hud(); |
| 180 | } catch (e) { /* keep polling */ } |
| 181 | } |
| 182 | setInterval(poll, 900); |
| 183 | |
| 184 | let STOPPED = false; |
| 185 | $("clearq").onclick = () => { |
| 186 | STOPPED = !STOPPED; |
| 187 | $("clearq").textContent = STOPPED ? "resume prefetching" : "stop prefetching"; |
| 188 | }; |
| 189 | |
| 190 | function hud() { |
| 191 | const s = stats || {}; |
| 192 | const km = (TILE_PX * M_PER_PX / 1000).toFixed(2); |
| 193 | const wx = (view.x * M_PER_PX / 1000).toFixed(1); |
| 194 | const wy = (view.y * M_PER_PX / 1000).toFixed(1); |
| 195 | const span = (innerWidth / view.scale * M_PER_PX / 1000).toFixed(1); |
| 196 | $("stats").innerHTML = |
| 197 | `<span class="a">INFINITE DIFFUSION — STREAMING</span> |
| 198 | <span class="k">centre </span>${wx} , ${wy} km <span class="k">(tile</span> ${Math.floor(view.x/TILE_PX)},${Math.floor(view.y/TILE_PX)}<span class="k">)</span> |
| 199 | <span class="k">view </span>${span} km across <span class="k">tile</span> ${km} km |
| 200 | <span class="k">visible </span>${visibleTiles().length} tiles <span class="k">on-screen</span> ${(TILE_PX*view.scale).toFixed(0)} px${TILE_PX*view.scale < 48 ? ' <span class="w">— too small, not requesting</span>' : ''} |
| 201 | |
| 202 | <span class="k">generated </span>${s.generated||0} tiles <span class="k">=</span> ${(s.km2||0).toFixed(0)} km² |
| 203 | <span class="k">avg gen </span>${(s.avg_gen_s||0).toFixed(2)} s/tile ${(s.km2_per_s||0).toFixed(1)} km²/s |
| 204 | <span class="k">queue </span><span class="${(s.queue||0)>0?'w':'k'}">${s.queue||0}</span> <span class="k">busy</span> <span class="a">${(s.busy||[]).join(" ")||"—"}</span> |
| 205 | <span class="k">cache </span>${s.disk_tiles||0} on disk, ${s.mem_tiles||0} in memory |
| 206 | <span class="k">hits </span>${s.from_disk||0} disk, ${s.from_mem||0} mem${(s.errors||0)?`\n<span class="w">errors ${s.errors}</span>`:""}`; |
| 207 | } |
| 208 | |
| 209 | // ---- interaction ----------------------------------------------------------- |
| 210 | let down = false, lx = 0, ly = 0; |
| 211 | $("map").addEventListener("pointerdown", e => { down = true; lx = e.clientX; ly = e.clientY; |
| 212 | $("map").setPointerCapture(e.pointerId); }); |
| 213 | $("map").addEventListener("pointermove", e => { |
| 214 | if (!down) return; |
| 215 | view.x -= (e.clientX - lx) / view.scale; |
| 216 | view.y -= (e.clientY - ly) / view.scale; |
| 217 | lx = e.clientX; ly = e.clientY; dirty = true; hud(); |
| 218 | }); |
| 219 | $("map").addEventListener("pointerup", () => { down = false; poll(); }); |
| 220 | $("map").addEventListener("wheel", e => { |
| 221 | e.preventDefault(); |
| 222 | const z = Math.max(-3, Math.min(1.6, Math.log2(view.scale) - Math.sign(e.deltaY) * 0.15)); |
| 223 | $("zoom").value = z; setZoom(z); |
| 224 | }, {passive:false}); |
| 225 | |
| 226 | function setZoom(z) { |
| 227 | view.scale = Math.pow(2, z); |
| 228 | $("v_zoom").textContent = view.scale.toFixed(3); |
| 229 | dirty = true; hud(); |
| 230 | } |
| 231 | $("zoom").addEventListener("input", () => setZoom(+$("zoom").value)); |
| 232 | $("pre").addEventListener("input", () => { $("v_pre").textContent = $("pre").value; }); |
| 233 | $("grid").addEventListener("change", () => { dirty = true; }); |
| 234 | $("go0").onclick = () => { view.x = 0; view.y = 0; dirty = true; poll(); }; |
| 235 | $("gomtn").onclick = () => { view.x = 128; view.y = 2176; dirty = true; poll(); }; |
| 236 | |
| 237 | resize(); setZoom(-2); hud(); poll(); |
| 238 | </script> |