worldmap-viewer

git clone https://git.godosa.eu/worldmap-viewer

master

raw · 3502 bytes

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