// 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)))); }