| … | … | @@ -49,14 +49,31 @@ canvas{position:absolute;inset:0;width:100%;height:100%;display:block} |
| 49 | 49 | |
| 50 | 50 | <script> |
| 51 | 51 | const TILE_PX = 256; // must match the server's tile_px |
| 52 | // Must match server.py TILE_VERSION. Tile PNGs are browser-cacheable for an |
| 53 | // hour, so without this in the URL a shading or apron change is invisible until |
| 54 | // the cache expires -- the page keeps drawing images from a build that is gone. |
| 55 | let TILE_VERSION = 3; |
| 52 | 56 | const M_PER_PX = 30; // model native |
| 53 | 57 | const TILE_M = TILE_PX * M_PER_PX; // 7.68 km per tile |
| 54 | 58 | |
| 55 | 59 | const cv = document.getElementById("cv"), ctx = cv.getContext("2d", {alpha:false}); |
| 56 | 60 | const $ = id => document.getElementById(id); |
| 57 | 61 | |
| 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}; |
| 62 | // AXES, and getting this wrong is what made the terrain "not line up". |
| 63 | // |
| 64 | // A tile is fetched as fetch(i1 = tx*n, j1 = ty*n) and stored as array[i, j]. |
| 65 | // Image.fromarray maps axis 0 -> rows -> screen Y. So: |
| 66 | // |
| 67 | // tx indexes i -> SCREEN Y (north-south) |
| 68 | // ty indexes j -> SCREEN X (east-west) |
| 69 | // |
| 70 | // The first version put tx on screen X, which lays every tile out transposed |
| 71 | // relative to its own content. Measured: joins stepped 6.86x an interior pixel. |
| 72 | // With the mapping below, 0.82x -- indistinguishable. The DATA was always |
| 73 | // exact (0.00 m across 108 overlapping pairs); this was pure bookkeeping. |
| 74 | // |
| 75 | // view.x is world j (columns, screen X); view.y is world i (rows, screen Y). |
| 76 | let view = {x: 2048 + 128, y: 128, scale: 0.25}; |
| 60 | 77 | const imgs = new Map(); // "tx,ty" -> Image (loaded) or "pending" |
| 61 | 78 | let tileState = {}; // "tx,ty" -> cached|disk|queued|busy|missing|error |
| 62 | 79 | let stats = {}; |
| … | … | @@ -74,11 +91,12 @@ addEventListener("resize", resize); |
| 74 | 91 | function visibleTiles() { |
| 75 | 92 | const w = innerWidth / view.scale, h = innerHeight / view.scale; |
| 76 | 93 | 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); |
| 94 | // screen X spans world j -> ty ; screen Y spans world i -> tx |
| 95 | const tj0 = Math.floor(x0 / TILE_PX), tj1 = Math.floor((x0 + w) / TILE_PX); |
| 96 | const ti0 = Math.floor(y0 / TILE_PX), ti1 = Math.floor((y0 + h) / TILE_PX); |
| 79 | 97 | const out = []; |
| 80 | | for (let ty = t0y; ty <= t1y; ty++) |
| 81 | | for (let tx = t0x; tx <= t1x; tx++) out.push([tx, ty]); |
| 98 | for (let tx = ti0; tx <= ti1; tx++) |
| 99 | for (let ty = tj0; ty <= tj1; ty++) out.push([tx, ty]); |
| 82 | 100 | return out; |
| 83 | 101 | } |
| 84 | 102 | |
| … | … | @@ -88,7 +106,7 @@ function tileImage(tx, ty) { |
| 88 | 106 | const im = new Image(); |
| 89 | 107 | im.onload = () => { imgs.set(k, im); dirty = true; }; |
| 90 | 108 | im.onerror = () => { imgs.delete(k); }; // 204 = not ready yet |
| 91 | | im.src = `/api/tile.png?tx=${tx}&ty=${ty}&view=shaded-height`; |
| 109 | im.src = `/api/tile.png?tx=${tx}&ty=${ty}&view=shaded-height&v=${TILE_VERSION}`; |
| 92 | 110 | imgs.set(k, "pending"); |
| 93 | 111 | return "pending"; |
| 94 | 112 | } |
| … | … | @@ -101,8 +119,8 @@ function draw() { |
| 101 | 119 | const showGrid = $("grid").checked; |
| 102 | 120 | |
| 103 | 121 | 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; |
| 122 | const sx = cxs + (ty * TILE_PX - view.x) * s; // ty = j = east-west |
| 123 | const sy = cys + (tx * TILE_PX - view.y) * s; // tx = i = north-south |
| 106 | 124 | const im = tileImage(tx, ty); |
| 107 | 125 | if (im && im !== "pending") { |
| 108 | 126 | ctx.drawImage(im, sx, sy, size + 1, size + 1); |
| … | … | @@ -171,11 +189,12 @@ async function poll() { |
| 171 | 189 | for (let dy = -rings; dy <= rings; dy++) |
| 172 | 190 | for (let dx = -rings; dx <= rings; dx++) want.add((tx+dx) + "," + (ty+dy)); |
| 173 | 191 | } |
| 174 | | const ctx_ = [Math.floor(view.x / TILE_PX), Math.floor(view.y / TILE_PX)]; |
| 192 | // centre expressed as (tx, ty) = (i, j), matching the server's ordering |
| 193 | const ctx_ = [Math.floor(view.y / TILE_PX), Math.floor(view.x / TILE_PX)]; |
| 175 | 194 | // Sort by distance from the view centre and ask only for the nearest few. |
| 176 | 195 | const sorted = [...want].map(k => { |
| 177 | | const [x, y] = k.split(",").map(Number); |
| 178 | | return {k, d: (x - ctx_[0]) ** 2 + (y - ctx_[1]) ** 2}; |
| 196 | const [tx, ty] = k.split(",").map(Number); |
| 197 | return {k, d: (tx - ctx_[0]) ** 2 + (ty - ctx_[1]) ** 2}; |
| 179 | 198 | }).sort((a, b) => a.d - b.d); |
| 180 | 199 | const list = sorted.map(o => o.k).join(";"); // state for all |
| 181 | 200 | const ask = sorted.slice(0, MAX_QUEUE).map(o => o.k).join(";"); // generate a few |
| … | … | @@ -187,6 +206,11 @@ async function poll() { |
| 187 | 206 | const r = await fetch(`/api/tiles/state?tiles=${encodeURIComponent(list)}`); |
| 188 | 207 | const j = await r.json(); |
| 189 | 208 | tileState = j.state; stats = j.stats; |
| 209 | if (j.tile_version && j.tile_version !== TILE_VERSION) { |
| 210 | // The server re-rendered tiles differently. Drop every cached image |
| 211 | // rather than showing a mix of two builds. |
| 212 | TILE_VERSION = j.tile_version; imgs.clear(); dirty = true; |
| 213 | } |
| 190 | 214 | // retry any tile that has become available since we last failed on it |
| 191 | 215 | for (const [k, st] of Object.entries(tileState)) |
| 192 | 216 | if ((st === "cached" || st === "disk") && !imgs.has(k)) { |
| … | … | @@ -207,12 +231,12 @@ $("clearq").onclick = () => { |
| 207 | 231 | function hud() { |
| 208 | 232 | const s = stats || {}; |
| 209 | 233 | const km = (TILE_PX * M_PER_PX / 1000).toFixed(2); |
| 210 | | const wx = (view.x * M_PER_PX / 1000).toFixed(1); |
| 211 | | const wy = (view.y * M_PER_PX / 1000).toFixed(1); |
| 234 | const wx = (view.x * M_PER_PX / 1000).toFixed(1); // j, east-west |
| 235 | const wy = (view.y * M_PER_PX / 1000).toFixed(1); // i, north-south |
| 212 | 236 | const span = (innerWidth / view.scale * M_PER_PX / 1000).toFixed(1); |
| 213 | 237 | $("stats").innerHTML = |
| 214 | 238 | `<span class="a">INFINITE DIFFUSION — STREAMING</span> |
| 215 | | <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> |
| 239 | <span class="k">centre </span>${wx} , ${wy} km <span class="k">(tile</span> ${Math.floor(view.y/TILE_PX)},${Math.floor(view.x/TILE_PX)}<span class="k">)</span> |
| 216 | 240 | <span class="k">view </span>${span} km across <span class="k">tile</span> ${km} km |
| 217 | 241 | <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>' : ''} |
| 218 | 242 | |
| … | … | @@ -249,7 +273,8 @@ $("zoom").addEventListener("input", () => setZoom(+$("zoom").value)); |
| 249 | 273 | $("pre").addEventListener("input", () => { $("v_pre").textContent = $("pre").value; }); |
| 250 | 274 | $("grid").addEventListener("change", () => { dirty = true; }); |
| 251 | 275 | $("go0").onclick = () => { view.x = 0; view.y = 0; dirty = true; poll(); }; |
| 252 | | $("gomtn").onclick = () => { view.x = 128; view.y = 2176; dirty = true; poll(); }; |
| 276 | // the mountain region is i=0, j=2048 -> screen y=128, x=2176 |
| 277 | $("gomtn").onclick = () => { view.x = 2176; view.y = 128; dirty = true; poll(); }; |
| 253 | 278 | |
| 254 | 279 | resize(); setZoom(-2); hud(); poll(); |
| 255 | 280 | </script> |