// @wuys/map · renderers/webgl.js
//
// WebGL2 renderer — GPU-projected country fills with per-vertex flag UVs.
//
// The whole point: the orthographic / equal-earth projection is computed in
// the VERTEX SHADER per-vertex. Each vertex of every triangle carries (lon,
// lat) and (u, v) — flag-local texture coordinates. The GPU interpolates
// (u, v) across each triangle's pixels. As you rotate the globe, vertex
// positions change, but every fragment's UV stays anchored to its vertex's
// geographic coords → the flag is "painted" onto the country geometry. No
// stretch. No drift. No SVG triangle wobble.
//
// One <canvas> behind the SVG renderer (or instead of it, depending on the
// mount path). Per-country flag textures are loaded once at init. Per-frame
// work is just a uniform update + N draw calls (one per visible country).
//
// Limitations of this initial implementation:
//   • Country borders, labels, contested overlays, atmosphere, spotlights
//     are NOT rendered. Only countries + flag fills. Those features stay
//     on the SVG renderer for now; this renderer is for the flag map look.
//   • Hit-testing via document.elementFromPoint won't work (canvas is one
//     element). Hit-test returns null; users that need clicks use SVG.
//   • Each flag is its own texture (one draw call per country). Could be
//     batched into an atlas later if perf needs it.

import * as EE     from "../math/projections/equal-earth.js";
import * as ORTHO  from "../math/projections/orthographic.js";
import { earClip, simplifyToCount } from "../data/triangulate.js";
import { registerRenderer } from "./base.js";

// Cap vertices per polygon BEFORE ear-clipping. earClip is O(n^3) worst-case,
// so detailed country polygons (Canada has rings with 100+ vertices) blow up
// init time to tens of seconds. Simplifying to a bounded vertex count keeps
// init under a second total.
const POLY_VERT_TARGET = 24;

const VERTEX_SHADER = `#version 300 es
precision highp float;

in vec2 aLonLat;       // (lon°, lat°)
in vec2 aUV;           // flag-local (u, v) in [0..1]

out vec2 vUV;
out float vDepth;      // z component for back-hemisphere culling (globe)

uniform float uMode;             // 0 = flat (EE), 1 = globe (ortho)
uniform vec2  uRotation;         // (rotLonRad, rotLatRad)
uniform float uBaseR;             // 450 * zoom (viewBox units)
uniform vec2  uViewCenter;       // (CX, CY) — viewBox center
uniform vec4  uViewBox;          // (vbx, vby, vbw, vbh) — current viewBox

const float PI = 3.14159265358979323846;
const float DEG = PI / 180.0;

// === Equal Earth projection ==================================================
vec2 projectFlat(vec2 lonlat) {
  const float A1 = 1.340264;
  const float A2 = -0.081106;
  const float A3 = 0.000893;
  const float A4 = 0.003796;
  const float M  = 0.86602540378443864676;  // sqrt(3) / 2
  const float X_HALF = 2.7063831;
  const float Y_HALF = 1.3173627;
  const float SCALE  = min(1000.0 / X_HALF, 500.0 / Y_HALF);
  float lonR = lonlat.x * DEG;
  float latR = lonlat.y * DEG;
  float th = asin(M * sin(latR));
  float t2 = th * th;
  float t6 = t2 * t2 * t2;
  float Mp = A1 + 3.0 * A2 * t2 + t6 * (7.0 * A3 + 9.0 * A4 * t2);
  float Mt = A1 + A2 * t2 + t6 * (A3 + A4 * t2);
  float xn = 2.0 * sqrt(3.0) * lonR * cos(th) / (3.0 * Mp);
  float yn = Mt * th;
  return vec2(1000.0 + xn * SCALE, 500.0 - yn * SCALE);
}

// === Orthographic projection =================================================
vec3 projectGlobe(vec2 lonlat) {
  float lonR = lonlat.x * DEG;
  float latR = lonlat.y * DEG;
  float dLam = lonR - uRotation.x;
  float cosLam = cos(dLam);
  float sinLam = sin(dLam);
  float cosPhi = cos(latR);
  float sinPhi = sin(latR);
  float sinPhi0 = sin(uRotation.y);
  float cosPhi0 = cos(uRotation.y);
  float x = cosPhi * sinLam;
  float y = cosPhi0 * sinPhi - sinPhi0 * cosPhi * cosLam;
  float z = sinPhi0 * sinPhi + cosPhi0 * cosPhi * cosLam;
  return vec3(uViewCenter.x + x * uBaseR, uViewCenter.y - y * uBaseR, z);
}

void main() {
  vec2 vb;
  float z;
  if (uMode > 0.5) {
    vec3 p = projectGlobe(aLonLat);
    vb = p.xy;
    z = p.z;
  } else {
    vb = projectFlat(aLonLat);
    z = 1.0;
  }
  // viewBox (vbx, vby, vbw, vbh) → clip space [-1, +1].
  float clipX = ((vb.x - uViewBox.x) / uViewBox.z) * 2.0 - 1.0;
  float clipY = 1.0 - ((vb.y - uViewBox.y) / uViewBox.w) * 2.0;
  // Push back-hemisphere vertices off-screen (z < -0.05) by setting clip z
  // beyond the [-1, +1] range — they get clipped by the GPU.
  gl_Position = vec4(clipX, clipY, 0.0, 1.0);
  vUV = aUV;
  vDepth = z;
}
`;

const FRAGMENT_SHADER = `#version 300 es
precision highp float;
in vec2 vUV;
in float vDepth;
out vec4 fragColor;
uniform sampler2D uFlag;
void main() {
  if (vDepth < -0.05) discard;             // back hemisphere
  fragColor = texture(uFlag, vUV);
}
`;

export function createWebGLRenderer({ state, events, theme, layers } = {}) {
  let canvas = null;
  let target = null;
  let gl = null;
  let program = null;
  let attrLoc = null;
  let uniLoc = null;
  // Ocean SVG (behind canvas) — gives us the same ocean rectangle (flat) and
  // circle (globe) the SVG renderer drew, without re-doing it in shaders.
  // The WebGL canvas is transparent and composites on top.
  let oceanSvg = null;
  let oceanRect = null;
  let oceanCircle = null;
  // Per-country: { vbo, vertexCount, flagTexture, lonMin, lonMax, latMin, latMax, lon, lat }
  const meshes = new Map();
  // Solid-color texture used when flag toggle is OFF (renders countries in
  // theme fallback color instead of flag).
  let solidTex = null;
  let _resizeBound = false;

  function compileShader(type, src) {
    const sh = gl.createShader(type);
    gl.shaderSource(sh, src);
    gl.compileShader(sh);
    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
      const log = gl.getShaderInfoLog(sh);
      gl.deleteShader(sh);
      throw new Error("[wuys/map] WebGL shader compile failed: " + log);
    }
    return sh;
  }

  function linkProgram() {
    const vs = compileShader(gl.VERTEX_SHADER, VERTEX_SHADER);
    const fs = compileShader(gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
    const p = gl.createProgram();
    gl.attachShader(p, vs);
    gl.attachShader(p, fs);
    gl.linkProgram(p);
    if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
      const log = gl.getProgramInfoLog(p);
      gl.deleteProgram(p);
      throw new Error("[wuys/map] WebGL program link failed: " + log);
    }
    return p;
  }

  // Load an image as an ImageBitmap (via fetch → blob → createImageBitmap)
  // and upload to a WebGL2 texture. ImageBitmap doesn't have the same
  // tainted-canvas issue as <img>; it's the cross-browser way to ship
  // images into WebGL textures from file:// without CORS pain.
  // The data: URI fallback handles the special non-ISO flags (already inlined
  // in flags-data.js) — fetch() supports data URIs in every browser.
  const TEX_TIMEOUT_MS = 5000;
  async function loadTexture(src) {
    const timer = new Promise((_, reject) =>
      setTimeout(() => reject(new Error("[wuys/map] flag timed out: " + src.slice(0, 64))), TEX_TIMEOUT_MS));
    const load = (async () => {
      const res = await fetch(src);
      if (!res.ok) throw new Error("HTTP " + res.status + " for " + src.slice(0, 64));
      const blob = await res.blob();
      const bitmap = await createImageBitmap(blob);
      const tex = gl.createTexture();
      gl.bindTexture(gl.TEXTURE_2D, tex);
      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
      gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
      bitmap.close && bitmap.close();
      return tex;
    })();
    return Promise.race([load, timer]);
  }

  // Build the per-country triangle mesh from the already-filtered cm.polys
  // (≤12 significant polygons per country). Each poly's ring is simplified
  // (Douglas-Peucker) to a bounded vertex count before ear-clipping — keeps
  // total init O(countries) instead of O(detailed-vertices^3).
  function buildMesh(cm) {
    if (!cm || !cm.polys || cm.polys.length === 0) return null;
    const flatVerts = [];
    for (const poly of cm.polys) {
      if (!poly.points || poly.points.length < 3) continue;
      const lonR = poly.lonRange;
      const latR = poly.latRange;
      if (lonR <= 0 || latR <= 0) continue;
      const ring = simplifyToCount(poly.points, POLY_VERT_TARGET);
      const triangles = earClip(ring);
      for (const tri of triangles) {
        for (let i = 0; i < 3; i++) {
          const lon = tri[i][0];
          const lat = tri[i][1];
          const u = (lon - poly.lonMin) / lonR;
          const v = (poly.latMax - lat) / latR;
          flatVerts.push(lon, lat, u, v);
        }
      }
    }
    if (flatVerts.length === 0) return null;
    const vbo = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(flatVerts), gl.STATIC_DRAW);
    return {
      vbo,
      vertexCount: flatVerts.length / 4,
      lonMin: cm.lonMin, lonMax: cm.lonMax, latMin: cm.latMin, latMax: cm.latMax,
      lon: cm.lon,
      lat: cm.lat,
      flagTexture: null,
    };
  }

  function sizeCanvas() {
    if (!canvas || !target) return;
    let rw = canvas.clientWidth, rh = canvas.clientHeight;
    if (!rw || !rh) {
      const r = target.getBoundingClientRect();
      rw = r.width; rh = r.height;
    }
    if (!rw || !rh) { rw = 1280; rh = 720; }
    const dpr = window.devicePixelRatio || 1;
    const W = Math.max(1, Math.floor(rw * dpr));
    const H = Math.max(1, Math.floor(rh * dpr));
    if (canvas.width !== W || canvas.height !== H) {
      canvas.width = W;
      canvas.height = H;
    }
    // gl.viewport is set per-frame in patch() to the letterboxed sub-region
    // that matches the SVG ocean's preserveAspectRatio="xMidYMid meet" → both
    // layers render through the exact same pixel space.
  }

  // Compute the letterboxed viewport (sub-rect of the full canvas) for a
  // given viewBox aspect, matching SVG meet behaviour.
  function viewportForViewBox(vbw, vbh) {
    const cw = canvas.width, ch = canvas.height;
    const scale = Math.min(cw / vbw, ch / vbh);
    const w = vbw * scale;
    const h = vbh * scale;
    return { x: Math.floor((cw - w) / 2), y: Math.floor((ch - h) / 2), w: Math.floor(w), h: Math.floor(h) };
  }

  async function init(targetEl) {
    target = targetEl;
    // Ensure positioning context so we can stack ocean SVG + canvas inside.
    const cs = getComputedStyle(target);
    if (cs.position === "static") target.style.position = "relative";

    // Ocean layer (SVG) sits BEHIND the canvas.
    const SVG_NS = "http://www.w3.org/2000/svg";
    oceanSvg = document.createElementNS(SVG_NS, "svg");
    oceanSvg.setAttribute("viewBox", "0 0 2000 1000");
    oceanSvg.setAttribute("preserveAspectRatio", "xMidYMid meet");
    oceanSvg.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;";
    oceanRect = document.createElementNS(SVG_NS, "rect");
    oceanRect.setAttribute("x", "0"); oceanRect.setAttribute("y", "0");
    oceanRect.setAttribute("width", "2000"); oceanRect.setAttribute("height", "1000");
    oceanRect.setAttribute("fill", (theme.ocean && theme.ocean.fill) || "#88a3a8");
    oceanSvg.appendChild(oceanRect);
    oceanCircle = document.createElementNS(SVG_NS, "circle");
    oceanCircle.setAttribute("cx", "1000"); oceanCircle.setAttribute("cy", "500");
    oceanCircle.setAttribute("r", "450");
    oceanCircle.setAttribute("fill", (theme.ocean && theme.ocean.fill) || "#88a3a8");
    oceanCircle.setAttribute("display", "none");
    oceanSvg.appendChild(oceanCircle);
    target.appendChild(oceanSvg);

    canvas = document.createElement("canvas");
    canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block;background:transparent;";
    target.appendChild(canvas);
    gl = canvas.getContext("webgl2", { antialias: true, alpha: true, premultipliedAlpha: false });
    if (!gl) {
      if (oceanSvg && oceanSvg.parentNode) oceanSvg.parentNode.removeChild(oceanSvg);
      if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas);
      oceanSvg = null; canvas = null;
      throw new Error("[wuys/map] WebGL2 context unavailable");
    }
    try {

    program = linkProgram();
    attrLoc = {
      aLonLat: gl.getAttribLocation(program, "aLonLat"),
      aUV:     gl.getAttribLocation(program, "aUV"),
    };
    uniLoc = {
      uMode:       gl.getUniformLocation(program, "uMode"),
      uRotation:   gl.getUniformLocation(program, "uRotation"),
      uBaseR:      gl.getUniformLocation(program, "uBaseR"),
      uViewCenter: gl.getUniformLocation(program, "uViewCenter"),
      uViewBox:    gl.getUniformLocation(program, "uViewBox"),
      uFlag:       gl.getUniformLocation(program, "uFlag"),
    };

      sizeCanvas();
      gl.clearColor(0, 0, 0, 0);
      gl.disable(gl.DEPTH_TEST);
      gl.enable(gl.BLEND);
      gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

      // 1×1 solid-color texture used when flags toggle is OFF (countries
      // render as theme fillFallback color rather than flag image).
      solidTex = gl.createTexture();
      gl.bindTexture(gl.TEXTURE_2D, solidTex);
      const f = (theme.countries && theme.countries.fillFallback) || "#1f1430";
      const rgb = parseHex(f);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
        new Uint8Array([rgb[0], rgb[1], rgb[2], 255]));
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

      const flags = state.data.flags;
      const texturePromises = [];
      let loadedFlags = 0;
      let failedFlags = 0;
      const firstFailures = [];                 // sample the first few real errors
      for (const [iso, cm] of state.data.countries) {
        const mesh = buildMesh(cm);
        if (!mesh) continue;
        meshes.set(iso, mesh);
        const flagSrc = flags && flags.get ? flags.get(iso) : null;
        if (flagSrc) {
          texturePromises.push(
            loadTexture(flagSrc)
              .then((tex) => { mesh.flagTexture = tex; loadedFlags++; })
              .catch((err) => {
                failedFlags++;
                if (firstFailures.length < 3) {
                  firstFailures.push({ iso, src: String(flagSrc).slice(0, 80), error: err && err.message });
                }
              })
          );
        }
      }
      await Promise.all(texturePromises);
      if (failedFlags > 0) {
        console.warn("[wuys/map/webgl] " + failedFlags + " of " +
          (loadedFlags + failedFlags) + " flag textures failed. Sample failures:");
        for (const f of firstFailures) console.warn("  ", f);
      }
      // If absolutely nothing loaded, fall back to SVG (let createMap's
      // outer try/catch take over) rather than show an empty canvas.
      if (loadedFlags === 0 && failedFlags > 0) {
        throw new Error("[wuys/map/webgl] all flag textures failed; aborting init so SVG fallback takes over");
      }

      if (!_resizeBound) {
        _resizeBound = true;
        window.addEventListener("resize", () => { sizeCanvas(); patch(); });
      }
    } catch (e) {
      // Strip canvas so the SVG fallback above us renders unobstructed.
      if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas);
      canvas = null;
      gl = null;
      throw e;
    }
  }

  function patch() {
    if (!gl || !program) return;
    if (!Number.isFinite(state.nav.lon))  { state.nav.lon  = 0; state.nav.tLon  = 0; }
    if (!Number.isFinite(state.nav.lat))  { state.nav.lat  = 0; state.nav.tLat  = 0; }
    if (!Number.isFinite(state.nav.zoom) || state.nav.zoom <= 0) {
      state.nav.zoom = 1; state.nav.tZoom = 1;
    }
    sizeCanvas();

    const mode = state.view.mode;
    const rotLonRad = state.nav.lon * Math.PI / 180;
    const rotLatRad = state.nav.lat * Math.PI / 180;
    const zoom = state.nav.zoom;
    const baseR = 450 * zoom;

    // Viewport / viewBox: mirror what the SVG renderer does (aspect-adaptive
    // viewBox in flat mode, fixed 2000×1000 in globe mode).
    let vbx, vby, vbw, vbh;
    if (mode === "globe") {
      vbx = 0; vby = 0; vbw = 2000; vbh = 1000;
    } else {
      const r = target.getBoundingClientRect();
      const aspect = (r.width && r.height) ? r.width / r.height : 2;
      let viewW, viewH, offX, offY;
      if (aspect > 2) {
        viewW = 1000 * aspect; viewH = 1000;
        offX = (viewW - 2000) / 2; offY = 0;
      } else {
        viewW = 2000; viewH = 2000 / aspect;
        offX = 0; offY = (viewH - 1000) / 2;
      }
      const w = viewW / zoom;
      const h = viewH / zoom;
      const [px, py] = EE.project(state.nav.lon, state.nav.lat);
      const cx = Math.max(w / 2 - offX, Math.min(2000 + offX - w / 2, px));
      const cy = Math.max(h / 2 - offY, Math.min(1000 + offY - h / 2, py));
      vbx = cx - w / 2; vby = cy - h / 2; vbw = w; vbh = h;
    }

    // Toggle ocean shape under the canvas.
    if (oceanRect && oceanCircle) {
      if (mode === "globe") {
        oceanRect.setAttribute("display", "none");
        oceanCircle.removeAttribute("display");
        oceanCircle.setAttribute("r", String(baseR));
      } else {
        oceanCircle.setAttribute("display", "none");
        oceanRect.removeAttribute("display");
      }
      if (oceanSvg) oceanSvg.setAttribute("viewBox", vbx + " " + vby + " " + vbw + " " + vbh);
    }

    // Set the WebGL viewport to the letterboxed sub-region of the canvas
    // that matches the SVG ocean's preserveAspectRatio="xMidYMid meet".
    // Without this, the country meshes render through a stretched clip
    // space while the ocean SVG renders aspect-preserved → mismatched.
    const vp = viewportForViewBox(vbw, vbh);
    gl.viewport(vp.x, vp.y, vp.w, vp.h);

    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.useProgram(program);
    gl.uniform1f(uniLoc.uMode, mode === "globe" ? 1.0 : 0.0);
    gl.uniform2f(uniLoc.uRotation, rotLonRad, rotLatRad);
    gl.uniform1f(uniLoc.uBaseR, baseR);
    gl.uniform2f(uniLoc.uViewCenter, 1000.0, 500.0);
    gl.uniform4f(uniLoc.uViewBox, vbx, vby, vbw, vbh);
    gl.uniform1i(uniLoc.uFlag, 0);

    gl.activeTexture(gl.TEXTURE0);

    const showFlags = state.theme.flagsVisible === true;
    const sinRotPhi = Math.sin(rotLatRad);
    const cosRotPhi = Math.cos(rotLatRad);

    for (const [, mesh] of meshes) {
      if (mode === "globe") {
        const phi = mesh.lat * Math.PI / 180;
        const lam = mesh.lon * Math.PI / 180;
        const cosDist = Math.sin(phi) * sinRotPhi +
                        Math.cos(phi) * cosRotPhi * Math.cos(lam - rotLonRad);
        if (cosDist < -0.3) continue;
      }
      const tex = (showFlags && mesh.flagTexture) ? mesh.flagTexture : solidTex;
      if (!tex) continue;
      gl.bindTexture(gl.TEXTURE_2D, tex);
      gl.bindBuffer(gl.ARRAY_BUFFER, mesh.vbo);
      gl.enableVertexAttribArray(attrLoc.aLonLat);
      gl.vertexAttribPointer(attrLoc.aLonLat, 2, gl.FLOAT, false, 16, 0);
      gl.enableVertexAttribArray(attrLoc.aUV);
      gl.vertexAttribPointer(attrLoc.aUV, 2, gl.FLOAT, false, 16, 8);
      gl.drawArrays(gl.TRIANGLES, 0, mesh.vertexCount);
    }
  }

  function parseHex(hex) {
    const s = String(hex).replace("#", "");
    if (s.length === 3) {
      return [parseInt(s[0]+s[0], 16), parseInt(s[1]+s[1], 16), parseInt(s[2]+s[2], 16)];
    }
    return [parseInt(s.slice(0,2),16), parseInt(s.slice(2,4),16), parseInt(s.slice(4,6),16)];
  }

  function destroy() {
    if (gl) {
      for (const [, mesh] of meshes) {
        if (mesh.vbo) gl.deleteBuffer(mesh.vbo);
        if (mesh.flagTexture) gl.deleteTexture(mesh.flagTexture);
      }
      if (solidTex) gl.deleteTexture(solidTex);
      if (program) gl.deleteProgram(program);
    }
    meshes.clear();
    solidTex = null;
    if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas);
    if (oceanSvg && oceanSvg.parentNode) oceanSvg.parentNode.removeChild(oceanSvg);
    canvas = null; gl = null; program = null;
    oceanSvg = null; oceanRect = null; oceanCircle = null;
  }

  function worldToScreen({ lon, lat }) {
    const mode = state.view.mode;
    const rect = canvas ? canvas.getBoundingClientRect() : { width: 0, height: 0 };
    let vb;
    if (mode === "flat") {
      const [x, y] = EE.project(lon, lat);
      vb = { x, y };
    } else {
      const p = ORTHO.project(lon, lat, { rotLon: state.nav.lon, rotLat: state.nav.lat, zoom: state.nav.zoom });
      vb = { x: p.vbx, y: p.vby };
    }
    // Approximate: assume canvas covers full target. Map viewBox 0..2000 / 0..1000 to canvas.
    return { x: (vb.x / 2000) * rect.width, y: (vb.y / 1000) * rect.height };
  }

  function screenToWorld({ x, y }) {
    const mode = state.view.mode;
    const rect = canvas ? canvas.getBoundingClientRect() : { width: 1, height: 1 };
    const vbx = (x / rect.width) * 2000;
    const vby = (y / rect.height) * 1000;
    if (mode === "flat") return EE.unproject(vbx, vby);
    return ORTHO.unproject(vbx, vby, { rotLon: state.nav.lon, rotLat: state.nav.lat, zoom: state.nav.zoom });
  }

  function viewportToWorld(dxPx, dyPx) {
    const c = worldToScreen({ lon: state.nav.lon, lat: state.nav.lat });
    const t = screenToWorld({ x: c.x + dxPx, y: c.y + dyPx });
    return { dLon: t.lon - state.nav.lon, dLat: t.lat - state.nav.lat };
  }

  function getViewport() {
    const mode = state.view.mode;
    if (mode === "flat") {
      // Approximate from current rotation/zoom.
      const span = 180 / state.nav.zoom;
      return {
        lonMin: state.nav.lon - span, lonMax: state.nav.lon + span,
        latMin: state.nav.lat - span / 2, latMax: state.nav.lat + span / 2,
      };
    }
    return { lonMin: state.nav.lon - 90, lonMax: state.nav.lon + 90, latMin: state.nav.lat - 90, latMax: state.nav.lat + 90 };
  }

  function hitTest() { return null; }
  function applyTheme() {
    if (target && theme.background) target.style.background = theme.background;
    // Update ocean fill if the theme changed.
    if (oceanRect && theme.ocean && theme.ocean.fill) oceanRect.setAttribute("fill", theme.ocean.fill);
    if (oceanCircle && theme.ocean && theme.ocean.fill) oceanCircle.setAttribute("fill", theme.ocean.fill);
    // Update solid-color texture for flags-off rendering.
    if (gl && solidTex) {
      const f = (theme.countries && theme.countries.fillFallback) || "#1f1430";
      const rgb = parseHex(f);
      gl.bindTexture(gl.TEXTURE_2D, solidTex);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
        new Uint8Array([rgb[0], rgb[1], rgb[2], 255]));
    }
  }

  return {
    init: (...a) => Promise.resolve(init(...a)),
    patch,
    destroy,
    hitTest,
    worldToScreen,
    screenToWorld,
    viewportToWorld,
    getViewport,
    applyTheme,
    getCanvas: () => canvas,
  };
}

registerRenderer("webgl", (args) => createWebGLRenderer(args));
