From 3443c1c65e9f1753e1e656b35d08416c1fa298f2 Mon Sep 17 00:00:00 2001 From: godosa Date: Wed, 7 Oct 2026 00:14:38 +0200 Subject: worldmap-viewer: initial public history --- viewer/js/tiles.js | 162 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 viewer/js/tiles.js (limited to 'viewer/js/tiles.js') diff --git a/viewer/js/tiles.js b/viewer/js/tiles.js new file mode 100644 index 0000000..78bac6d --- /dev/null +++ b/viewer/js/tiles.js @@ -0,0 +1,162 @@ +// tiles.js — deep-zoom tile maths (pure): the scheme shared with map/tiles.py, target zoom, visible tiles, patch meshes +import { R_KM, toVec, project } from './geo.js'; + +export const TILE = 256; +export const MIN_TILE_Z = 5; // below this the base texture is as sharp as the tiles +export const MAX_TILE_Z = 16; +export const MESH_SEGS = 128; // 3D patches: one vertex per server height (map/tiles.py MESH_N = 129) +export const tileSpanDeg = z => 180 / 2 ** z; +export const pxKm = z => (2 * Math.PI * R_KM) / (2 ** (z + 1) * TILE); +export const tileKey = t => `${t.z}/${t.x}/${t.y}`; +export const DETAIL_TILES = 16; // the 3D fine grain repeats every 16 tiles (columns per zoom are a multiple of 16) +export const detailOrigin = t => [(t.x % DETAIL_TILES) * TILE, (t.y % DETAIL_TILES) * TILE]; // texels, small: float32-exact +export const parentTile = t => ({ z: t.z - 1, x: t.x >> 1, y: t.y >> 1 }); + +export function tileOf(lat, lon, z) { + const s = tileSpanDeg(z), nx = 2 ** (z + 1), ny = 2 ** z; + const x = ((Math.floor((lon + 180) / s) % nx) + nx) % nx; + const y = Math.min(ny - 1, Math.max(0, Math.floor((90 - lat) / s))); + return { z, x, y }; +} + +export function tileBounds({ z, x, y }) { + const s = tileSpanDeg(z); + return { west: -180 + x * s, east: -180 + (x + 1) * s, north: 90 - y * s, south: 90 - (y + 1) * s }; +} + +export function targetZoom(kmPerPx) { + if (!(kmPerPx > 0)) return 0; + const z = Math.ceil(Math.log2((2 * Math.PI * R_KM) / (kmPerPx * 2 * TILE)) - 1e-12); + return Math.max(0, Math.min(MAX_TILE_Z, z)); +} + +function ringTiles(t, ring) { // the tile first, then its neighbours (rows clipped at the poles, columns wrapped) + const nx = 2 ** (t.z + 1), ny = 2 ** t.z, out = [t]; + for (let dy = -ring; dy <= ring; dy++) { + const y = t.y + dy; + if (y < 0 || y >= ny) continue; + for (let dx = -ring; dx <= ring; dx++) if (dx || dy) out.push({ z: t.z, x: (((t.x + dx) % nx) + nx) % nx, y }); + } + return out; +} + +export function tilesForPoints(points, z, ring = 1) { + const out = new Map(); + for (const p of points) for (const u of ringTiles(tileOf(p.lat, p.lon, z), ring)) if (!out.has(tileKey(u))) out.set(tileKey(u), u); + return [...out.values()]; +} + +const children = t => [0, 1, 2, 3].map(i => ({ z: t.z + 1, x: 2 * t.x + (i & 1), y: 2 * t.y + (i >> 1) })); + +// A quadtree cut for per-point zooms: every point wants its tile (and ring) at its own zoom. Any tile holding a +// wanted finer tile is split into all four children, so nothing overlaps; a split tile that was itself wanted +// (on screen) has all its children drawn, so nothing it covered goes missing. Order: first wanted first. +export function lodTiles(points, zs, ring = 1) { + const want = new Map(), split = new Set(); + points.forEach((p, i) => { + if (!(zs[i] >= MIN_TILE_Z)) return; + for (const u of ringTiles(tileOf(p.lat, p.lon, zs[i]), ring)) { + const k = tileKey(u); + if (!want.has(k)) want.set(k, { t: u, n: want.size }); + for (let a = parentTile(u); a.z >= MIN_TILE_Z; a = parentTile(a)) split.add(tileKey(a)); + } + }); + const out = [], roots = new Map(); + const visit = (t, cov, n) => { + const k = tileKey(t), w = want.get(k), c = cov || !!w, m = w ? w.n : n; + if (split.has(k)) for (const ch of children(t)) visit(ch, c, m); + else if (c) out.push({ t, n: w ? w.n : n + 0.5 }); + }; + for (const { t } of want.values()) { + let r = t; + while (r.z > MIN_TILE_Z) r = parentTile(r); + roots.set(tileKey(r), r); + } + for (const r of roots.values()) visit(r, false, Infinity); + return out.sort((a, b) => a.n - b.n).map(o => o.t); +} + +function grid(t, segs) { // rows north → south, columns west → east (same winding as geo.sphereMesh) + const b = tileBounds(t), n = segs + 1; + const lat = new Float64Array(n * n), lon = new Float64Array(n * n), uvs = new Float32Array(n * n * 2); + for (let i = 0; i < n; i++) { + for (let j = 0; j < n; j++) { + const k = i * n + j; + lat[k] = b.north - ((b.north - b.south) * i) / segs; + lon[k] = b.west + ((b.east - b.west) * j) / segs; + uvs[2 * k] = j / segs; + uvs[2 * k + 1] = 1 - i / segs; + } + } + const indices = new Uint32Array(segs * segs * 6); + let q = 0; + for (let i = 0; i < segs; i++) { + for (let j = 0; j < segs; j++) { + const a = i * n + j, c = a + n; + indices.set([a, c, c + 1, a, c + 1, a + 1], q); + q += 6; + } + } + return { b, lat, lon, uvs, indices }; +} + +const R_M = R_KM * 1000; + +function edgeRing(segs) { // grid indices around the border, each once, in order (N, E, S, W) + const n = segs + 1, out = []; + for (let j = 0; j < segs; j++) out.push(j); + for (let i = 0; i < segs; i++) out.push(i * n + segs); + for (let j = segs; j > 0; j--) out.push(segs * n + j); + for (let i = segs; i > 0; i--) out.push(i * n); + return out; +} + +export function globePatch(t, segs = 16, heights = null, exag = 1) { // positions relative to the patch centre + const g = grid(t, segs), N = g.lat.length; + const center = toVec((g.b.north + g.b.south) / 2, (g.b.west + g.b.east) / 2); + const edge = heights ? edgeRing(segs) : [], M = N + edge.length; + const spacingM = (((g.b.north - g.b.south) * Math.PI) / 180 / segs) * R_M; + const skirt = heights ? 2 * spacingM * Math.max(1, exag) : 0; // m: hides cracks against neighbours up to 2 zooms coarser + const positions = new Float32Array(M * 3), uvs = new Float32Array(M * 2); + const skirtFlags = heights ? new Float32Array(M).fill(1, N) : undefined; // 1: skirt (the cliff shading skips it) + const put = (k, lat, lon, m) => { + const v = toVec(lat, lon), r = 1 + m / R_M; + for (let i = 0; i < 3; i++) positions[3 * k + i] = v[i] * r - center[i]; + }; + for (let k = 0; k < N; k++) put(k, g.lat[k], g.lon[k], heights ? heights[k] * exag : 0); + uvs.set(g.uvs); + edge.forEach((k, i) => { + put(N + i, g.lat[k], g.lon[k], heights[k] * exag - skirt); + uvs[2 * (N + i)] = g.uvs[2 * k]; + uvs[2 * (N + i) + 1] = g.uvs[2 * k + 1]; + }); + const indices = new Uint32Array(g.indices.length + edge.length * 6); + indices.set(g.indices); + for (let i = 0, q = g.indices.length; i < edge.length; i++, q += 6) { + const a = edge[i], b = edge[(i + 1) % edge.length], a2 = N + i, b2 = N + ((i + 1) % edge.length); + indices.set([a, a2, b2, a, b2, b], q); + } + return { center, positions, uvs, indices, skirt: skirtFlags }; +} + +export function gridHeight(t, heights, lat, lon, segs = MESH_SEGS) { // bilinear height inside a tile's mesh + const b = tileBounds(t), n = segs + 1, s = b.east - b.west; + const fx = ((((lon - b.west) % 360) + 360) % 360 / s) * segs, fy = ((b.north - lat) / (b.north - b.south)) * segs; + const j = Math.min(segs - 1, Math.max(0, Math.floor(fx))), i = Math.min(segs - 1, Math.max(0, Math.floor(fy))); + const u = Math.min(1, Math.max(0, fx - j)), v = Math.min(1, Math.max(0, fy - i)), h = (a, c) => heights[a * n + c]; + return (1 - v) * ((1 - u) * h(i, j) + u * h(i, j + 1)) + v * ((1 - u) * h(i + 1, j) + u * h(i + 1, j + 1)); +} + +export function flatPatch(t, kind, segs = 16, zOff = 0) { + const g = grid(t, segs), N = g.lat.length, D = Math.PI / 180; + const c = project(kind, (g.b.north + g.b.south) / 2, (g.b.west + g.b.east) / 2); + const positions = new Float32Array(N * 3), ll = new Float32Array(N * 2); + for (let k = 0; k < N; k++) { + const p = project(kind, g.lat[k], g.lon[k]); + positions[3 * k] = p.x - c.x; + positions[3 * k + 1] = p.y - c.y; + ll[2 * k] = g.lat[k] * D; + ll[2 * k + 1] = g.lon[k] * D; + } + return { center: [c.x, c.y, zOff], positions, uvs: g.uvs, indices: g.indices, ll }; +} -- cgit