| … | … | @@ -41,6 +41,18 @@ canvas{position:absolute;inset:0;width:100%;height:100%;display:block} |
| 41 | 41 | <input type="range" id="pre" min="0" max="3" step="1" value="1"></label> |
| 42 | 42 | <label><input type="checkbox" id="grid" checked style="width:auto"> tile grid + state</label> |
| 43 | 43 | <hr> |
| 44 | <label style="display:block"><input type="checkbox" id="biome" style="width:auto"> | |
| 45 | backdrop: terrain class</label> | |
| 46 | <div id="legend" hidden style="font-family:'IBM Plex Mono',monospace;font-size:10px; | |
| 47 | line-height:1.7;color:var(--dim);margin:6px 0 2px"> | |
| 48 | <span style="color:#6b8c54">■</span> plains <60 m<br> | |
| 49 | <span style="color:#8ca852">■</span> rolling 60–180 m<br> | |
| 50 | <span style="color:#b8a859">■</span> hill country 180–400 m<br> | |
| 51 | <span style="color:#bf8052">■</span> rugged 400–800 m<br> | |
| 52 | <span style="color:#e0dbe6">■</span> alpine >800 m<br> | |
| 53 | <span style="color:#5b6b8c">■</span> sea<br> | |
| 54 | <span style="color:#6b7a80">relief over a 23 km window</span> | |
| 55 | </div> | |
| 44 | 56 | <button id="go0">origin (0,0)</button> |
| 45 | 57 | <button id="gomtn">the mountain region</button> |
| 46 | 58 | <button id="clearq">stop prefetching</button> |
| … | … | @@ -200,7 +212,9 @@ async function ensureCoarse() { |
| 200 | 212 | const im = new Image(); |
| 201 | 213 | im.onload = () => { coarseImg = im; coarseBox = box; coarsePending = false; dirty = true; }; |
| 202 | 214 | im.onerror = () => { coarsePending = false; }; |
| 203 | im.src = `/api/coarse.png?ci1=${ci1}&cj1=${cj1}&ci2=${ci2}&cj2=${cj2}&v=${TILE_VERSION}`; | |
| 215 | const cview = $("biome").checked ? "terrain-class" : "shaded-height"; | |
| 216 | im.src = `/api/coarse.png?ci1=${ci1}&cj1=${cj1}&ci2=${ci2}&cj2=${cj2}` + | |
| 217 | `&view=${cview}&v=${TILE_VERSION}`; | |
| 204 | 218 | } |
| 205 | 219 | |
| 206 | 220 | async function poll() { |
| … | … | @@ -324,6 +338,11 @@ function setZoom(z) { |
| 324 | 338 | $("zoom").addEventListener("input", () => setZoom(+$("zoom").value)); |
| 325 | 339 | $("pre").addEventListener("input", () => { $("v_pre").textContent = $("pre").value; }); |
| 326 | 340 | $("grid").addEventListener("change", () => { dirty = true; }); |
| 341 | $("biome").addEventListener("change", () => { | |
| 342 | $("legend").hidden = !$("biome").checked; | |
| 343 | coarseImg = null; coarseBox = null; coarsePending = false; // force a refetch | |
| 344 | ensureCoarse(); dirty = true; | |
| 345 | }); | |
| 327 | 346 | $("go0").onclick = () => { view.x = 0; view.y = 0; dirty = true; poll(); }; |
| 328 | 347 | // the mountain region is i=0, j=2048 -> screen y=128, x=2176 |
| 329 | 348 | $("gomtn").onclick = () => { view.x = 2176; view.y = 128; dirty = true; poll(); }; |
| … | … | @@ -58,6 +58,51 @@ def coarse_to_metres(c0): |
| 58 | 58 | return np.sign(c0) * c0 * c0 |
| 59 | 59 | |
| 60 | 60 | |
| 61 | # Terrain classes by LOCAL RELIEF over a 23 km window (3 coarse px), which is | |
| 62 | # what separates rolling hills from ranges far better than elevation does. | |
| 63 | # Measured over 4.6 M km2 of this seed: 4.0% plains, 21.9% rolling, 42.1% hill | |
| 64 | # country, 28.2% rugged, 3.8% alpine, with peaks to 6407 m. The world already | |
| 65 | # contains the whole range; it just had no way to be found. | |
| 66 | TERRAIN_CLASSES = [ | |
| 67 | ("plains", 0, 60, (0.42, 0.55, 0.33)), | |
| 68 | ("rolling", 60, 180, (0.55, 0.66, 0.32)), | |
| 69 | ("hill country",180, 400, (0.72, 0.66, 0.35)), | |
| 70 | ("rugged", 400, 800, (0.75, 0.50, 0.32)), | |
| 71 | ("alpine", 800, 1e18, (0.88, 0.86, 0.90)), | |
| 72 | ] | |
| 73 | ||
| 74 | ||
| 75 | def local_relief(elev, win=3): | |
| 76 | """Std of coarse elevation in a win x win window, edge-padded.""" | |
| 77 | r = win // 2 | |
| 78 | p = np.pad(elev, r, mode="edge") | |
| 79 | acc = np.zeros_like(elev); acc2 = np.zeros_like(elev) | |
| 80 | for dy in range(win): | |
| 81 | for dx in range(win): | |
| 82 | b = p[dy:dy + elev.shape[0], dx:dx + elev.shape[1]] | |
| 83 | acc += b; acc2 += b * b | |
| 84 | n = win * win | |
| 85 | return np.sqrt(np.maximum(acc2 / n - (acc / n) ** 2, 0.0)) | |
| 86 | ||
| 87 | ||
| 88 | def colourise_terrain_class(elev): | |
| 89 | """Colour by terrain class so a region can be FOUND, not just rendered.""" | |
| 90 | rel = local_relief(elev) | |
| 91 | rgb = np.zeros(elev.shape + (3,)) | |
| 92 | for _nm, lo, hi, col in TERRAIN_CLASSES: | |
| 93 | m = (rel >= lo) & (rel < hi) | |
| 94 | for k in range(3): | |
| 95 | rgb[..., k] = np.where(m, col[k], rgb[..., k]) | |
| 96 | # shade it so the classes read as terrain rather than a choropleth | |
| 97 | sh = hillshade(elev, 7680.0) | |
| 98 | rgb *= (0.45 + 0.55 * sh[..., None]) | |
| 99 | sea = elev <= 0 | |
| 100 | d = np.clip(-elev / 6000.0, 0, 1) | |
| 101 | for k, c in enumerate((0.10, 0.16, 0.34)): | |
| 102 | rgb[..., k] = np.where(sea, c * (1 - 0.45 * d), rgb[..., k]) | |
| 103 | return (np.clip(rgb, 0, 1) * 255).astype(np.uint8) | |
| 104 | ||
| 105 | ||
| 61 | 106 | def tiles(): |
| 62 | 107 | global _tiles |
| 63 | 108 | if _tiles is None: |
| … | … | @@ -564,8 +609,11 @@ def api_coarse(): |
| 564 | 609 | elev = coarse_to_metres(a[..., 0].astype(np.float64)) |
| 565 | 610 | vmin = float(request.args.get("vmin", -4000)) |
| 566 | 611 | vmax = float(request.args.get("vmax", 4000)) |
| 567 | rgb = colourise_array(elev, 7680.0, request.args.get("view", "shaded-height"), | |
| 568 | vmin, vmax) | |
| 612 | view = request.args.get("view", "shaded-height") | |
| 613 | if view == "terrain-class": | |
| 614 | rgb = colourise_terrain_class(elev) | |
| 615 | else: | |
| 616 | rgb = colourise_array(elev, 7680.0, view, vmin, vmax) | |
| 569 | 617 | resp = Response(to_png(rgb), mimetype="image/png") |
| 570 | 618 | resp.headers["Cache-Control"] = "public, max-age=3600" |
| 571 | 619 | resp.headers["X-Height"] = str(h); resp.headers["X-Width"] = str(w) |