// 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<THREE.Texture>, 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))));
}