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/layers.js | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 viewer/js/layers.js (limited to 'viewer/js/layers.js') diff --git a/viewer/js/layers.js b/viewer/js/layers.js new file mode 100644 index 0000000..c0ab662 --- /dev/null +++ b/viewer/js/layers.js @@ -0,0 +1,84 @@ +// layers.js — layer textures (fetched on first use, a few kept on the GPU) +import * as THREE from 'three'; +import { el } from './ui.js'; + +const KEEP = 4; // base + over-layer + 2 recent; an 8192×4096 texture is ≈180 MB of GPU memory with mipmaps + +export class LayerStore { + constructor(layers, maxTextureSize, anisotropy = 1, load = null) { + this.layers = layers; + this.loadImpl = load; // tests inject a loader; the browser uses #load + this.pinned = new Set(); // ids wanted right now (base + over): never evicted + this.shown = new Set(); // ids drawn right now (may lag the wanted ones while they load): never evicted + this.max = maxTextureSize; + this.anisotropy = anisotropy; + this.cache = new Map(); // id → Promise, least recently used first + } + + get(id) { + return this.layers.find(l => l.id === id); + } + + pin(ids) { + this.pinned = new Set(ids.filter(Boolean)); + } + + showing(ids) { + this.shown = new Set(ids.filter(Boolean)); + } + + dispose(keep = []) { // another era's store took over: free the GPU memory, except textures still drawn + for (const q of this.cache.values()) q.then(t => { if (!keep.includes(t)) t.dispose(); }, () => {}); + this.cache.clear(); + } + + texture(id) { + let p = this.cache.get(id); + if (p) this.cache.delete(id); + else p = (this.loadImpl ?? (l => this.#load(l)))(this.get(id)).catch(e => { this.cache.delete(id); throw e; }); + this.cache.set(id, p); + for (const [old, q] of [...this.cache]) { // oldest first; textures on screen stay + if (this.cache.size <= KEEP) break; + if (this.pinned.has(old) || this.shown.has(old) || old === id) continue; + this.cache.delete(old); + q.then(t => t.dispose(), () => {}); + } + return p; + } + + async #load(layer) { + const img = await new THREE.ImageLoader().loadAsync(layer.file) + .catch(() => { throw new Error(`could not load ${layer.file}`); }); + let src = img; + if (img.width > this.max) { // GPU limit: downsample to 4096 wide + const w = Math.min(4096, this.max); + src = document.createElement('canvas'); + src.width = w; + src.height = w / 2; + src.getContext('2d').drawImage(img, 0, 0, w, w / 2); + } + const tex = new THREE.Texture(src); + tex.colorSpace = THREE.NoColorSpace; // shaders pass the JPEG's sRGB bytes straight through + tex.anisotropy = this.anisotropy; + tex.needsUpdate = true; + return tex; + } +} + +const UNIT_LABEL = { degC: '°C' }; // layers.json keeps the build's unit codes +const fmtTick = v => (Math.abs(v) >= 100 ? Math.round(v).toLocaleString('en-US') : String(+v.toFixed(2))); + +export function renderLegend(box, layer) { + const L = layer.legend; + box.hidden = !L; + if (!L) return box.replaceChildren(); + const title = el('div', { class: 'muted' }, layer.name); + if (L.type === 'categorical') { + return box.replaceChildren(title, ...L.items.map(it => + el('div', {}, el('span', { class: 'sw', style: `background:${it.color}` }), it.name))); + } + const lo = L.stops[0][0], hi = L.stops.at(-1)[0]; + const grad = L.stops.map(([v, c]) => `${c} ${(((v - lo) / (hi - lo)) * 100).toFixed(1)}%`).join(', '); + box.replaceChildren(title, el('div', { class: 'bar', style: `background: linear-gradient(to right, ${grad})` }), + el('div', { class: 'ticks' }, el('span', {}, fmtTick(lo)), el('span', {}, UNIT_LABEL[L.unit] ?? L.unit), el('span', {}, fmtTick(hi)))); +} -- cgit