/**
 * SHADES — the face. Lane A owns this file.
 *
 * Everything the game already simulates is invisible to a stranger without this:
 * loads, the gust warning, whether the garden is actually getting hit. So the
 * rule here is that the HUD only ever READS. It takes no decisions, owns no
 * state worth keeping, and if you deleted it the sim would run identically —
 * which is also why it can be this chatty without anything drifting.
 *
 * Two things earn their place as world-anchored rather than screen-anchored:
 * the corner load bars (a number in a corner of the screen can't tell you WHICH
 * shackle is about to go, and that's the whole "…the shackle, I knew about the
 * shackle" moment) and the repair prompt, which Lane D already owns.
 */

import * as THREE from '../vendor/three.module.js';
import { STORM_LEN } from './contracts.js';
import { forecastLines } from './weather.js';

const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const kmh = (ms) => ms * 3.6;

// Load bar colours: green → amber → red. The amber band is deliberately wide;
// a corner at 60% in a lull is one gust from 100% and the player should feel
// that before the bar turns red on them.
const BAR_OK = 0x6ee06e, BAR_WARN = 0xffc24a, BAR_HOT = 0xff5b4a, BAR_DEAD = 0x7a2f2f;

const CSS = `
#hud { position:fixed; inset:0; pointer-events:none; z-index:10;
  font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea;
  text-shadow:0 1px 3px #000a; user-select:none; }
#hud .panel { position:absolute; background:#0d1418d9; border:1px solid #2c3a44;
  border-radius:6px; padding:8px 10px; }
#hud-top { top:10px; left:50%; transform:translateX(-50%); text-align:center; min-width:280px; }
#hud-wind { font-size:15px; font-weight:700; letter-spacing:.04em; }
#hud-clock { color:#8ba0ad; }
#hud-gust { position:absolute; top:78px; left:50%; transform:translateX(-50%);
  font:700 20px/1 ui-monospace,Menlo,monospace; letter-spacing:.16em; color:#ffd27a;
  background:#3a2a0ce0; border:1px solid #7a5a1a; border-radius:5px; padding:8px 16px;
  opacity:0; transition:opacity .12s; }
#hud-gust.on { opacity:1; }
#hud-garden { bottom:12px; left:12px; min-width:210px; }
#hud-bar { height:9px; background:#00000066; border-radius:5px; overflow:hidden; margin-top:5px; }
#hud-bar i { display:block; height:100%; width:100%; background:#6ee06e; transition:width .2s,background .3s; }
#hud-carry { bottom:12px; right:12px; text-align:right; }
#hud-events { position:absolute; bottom:70px; left:12px; max-width:420px; }
#hud-events div { color:#ffd27a; margin-top:2px; }
#hud-help { position:absolute; bottom:12px; left:50%; transform:translateX(-50%);
  color:#93a6b2; opacity:.85; white-space:nowrap; }

#hud-card { position:fixed; inset:0; z-index:30; display:none; place-items:center;
  background:#060a0dc4; backdrop-filter:blur(3px);
  font:13px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea; }
#hud-card.on { display:grid; }
#hud-card .card { background:#0d1418; border:1px solid #2f3f4a; border-radius:9px;
  padding:22px 26px; min-width:440px; max-width:620px; pointer-events:auto; }
#hud-card h1 { margin:0 0 2px; font-size:19px; letter-spacing:.16em; color:#fff; }
#hud-card h2 { margin:0 0 16px; font-size:12px; font-weight:400; color:#8ba0ad; letter-spacing:.04em; }
#hud-card .row { display:flex; justify-content:space-between; gap:18px; padding:3px 0;
  border-bottom:1px solid #1c262d; }
#hud-card .row b { font-weight:700; color:#fff; }
#hud-card .storm { display:block; width:100%; text-align:left; margin:7px 0; padding:10px 12px;
  background:#121c23; border:1px solid #2f3f4a; border-radius:6px; color:#dde5ea; cursor:pointer;
  font:inherit; transition:border-color .15s,background .15s; }
#hud-card .storm:hover { border-color:#7ee0ff; background:#16242c; }
#hud-card .storm .name { font-weight:700; color:#fff; letter-spacing:.08em; }
#hud-card .storm .stat { color:#8ba0ad; }
#hud-card .go { margin-top:16px; padding:9px 18px; background:#1d3d2a; border:1px solid #3f7a52;
  border-radius:6px; color:#a8f0b8; cursor:pointer; font:inherit; font-weight:700; letter-spacing:.1em; }
#hud-card .go:hover { background:#255033; }
#hud-card .verdict { margin:14px 0 0; padding:10px 12px; border-radius:6px; font-weight:700;
  letter-spacing:.05em; }
#hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; }
#hud-card .verdict.lose { background:#3a1618; border:1px solid #7d2b2b; color:#ff8f86; }

/* --- the week ---------------------------------------------------------- */
#hud-card .pips { letter-spacing:.5em; font-size:15px; margin:-6px 0 10px; color:#3f5561; }
#hud-card .pip.now { color:#7ee0ff; }
#hud-card .pip.held { color:#7fce6a; }
#hud-card .pip.lost { color:#ff8f86; }
#hud-card .ledger { margin-top:14px; padding-top:10px; border-top:1px solid #24343d; }
#hud-card .ledger .row b { color:#a8f0b8; }
#hud-card .ledger .row.bad b { color:#ff8f86; }
#hud-card .ledger .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
#hud-card .ledger .row.total b { color:#fff; }

/* E's diptych. The art is the card; the words sit in the left third they left
   clear for exactly this. */
#hud-card .endcard { background-size:cover; background-position:center right;
  max-width:none; width:min(92vw,900px); min-height:min(70vh,520px);
  display:flex; align-items:center; padding:0; border:1px solid #24343d; }
#hud-card .endcard .endtext { width:min(46%,380px); padding:28px 26px;
  background:linear-gradient(to right, rgba(9,14,18,.92) 65%, rgba(9,14,18,0)); }
#hud-card .endcard h1 { font-size:26px; }
#hud-card .endcard .kicker { margin:16px 0 0; color:#8ba0ad; font-style:italic; line-height:1.5; }

/* --- dawn (Lane E, pasted from their THREADS note) ---------------------- */
/* The screen blend is the point: it lifts the black storm scene into morning
   without touching the card on top. The 2.2s ease matters more than the
   colours. (No backticks in here — this whole block is a JS template literal.) */
#dawn { position:fixed; inset:0; pointer-events:none; opacity:0; z-index:5;
        transition:opacity 2.2s ease; mix-blend-mode:screen;
        background:linear-gradient(to top,
          rgba(255,178,102,.30) 0%,    /* the sun that finally turned up */
          rgba(255,150,96,.16) 22%,
          rgba(120,132,168,.10) 55%,
          rgba(38,48,84,.16) 100%); }  /* night still up there */
#dawn.on { opacity:1; }
`;

/**
 * @param {object} d
 * @param {THREE.Scene} d.scene
 * @param {THREE.Camera} d.camera
 * @param {object} d.game     the phase machine
 * @param {object} d.world
 * @param {object} d.wind     the router
 * @param {object} d.player
 * @param {object} d.rig      SailRig
 * @param {object} d.garden   {hp}
 * @param {() => object} d.getSky      skyfx is rebuilt per storm, so read it late
 * @param {() => number} d.getWindTime
 * @param {{text:string,t:number}[]} d.events
 */
export function createHud(d) {
  const style = document.createElement('style');
  style.textContent = CSS;
  document.head.appendChild(style);

  const root = document.createElement('div');
  root.id = 'hud';
  root.innerHTML = `
    <div class="panel" id="hud-top">
      <div id="hud-wind">--</div>
      <div id="hud-clock">--</div>
    </div>
    <div id="hud-gust">GUST INCOMING</div>
    <div class="panel" id="hud-garden">
      <div><span id="hud-garden-label">GARDEN</span> <b id="hud-garden-pct">100%</b></div>
      <div id="hud-bar"><i></i></div>
      <div id="hud-shade" style="color:#8ba0ad"></div>
    </div>
    <div class="panel" id="hud-carry" style="display:none"></div>
    <div id="hud-events"></div>
    <div id="hud-help"></div>
  `;
  document.body.appendChild(root);

  const card = document.createElement('div');
  card.id = 'hud-card';
  document.body.appendChild(card);

  // The morning. Sits UNDER the card and over the frozen storm scene — see the
  // `screen` blend in the CSS, which lifts the black night into dawn without
  // touching the card on top of it. Lane E shipped this as ten lines rather than
  // a ninth texture nobody loads, and they were right.
  const dawn = document.createElement('div');
  dawn.id = 'dawn';
  document.body.appendChild(dawn);

  const $ = (id) => root.querySelector(id);
  const elWind = $('#hud-wind'), elClock = $('#hud-clock'), elGust = $('#hud-gust');
  const elPct = $('#hud-garden-pct'), elBar = $('#hud-bar i'), elShade = $('#hud-shade');
  const elGardenLabel = $('#hud-garden-label');
  const elCarry = $('#hud-carry'), elEvents = $('#hud-events'), elHelp = $('#hud-help');

  // --- world-anchored corner load bars ------------------------------------
  // A bar per corner, floating at the corner it describes. Geometry rather than
  // a redrawn canvas: the fill is a scaled quad, so animating it is free and a
  // flogging corner's bar can track it at 60 Hz without touching a texture.
  const barGroup = new THREE.Group();
  barGroup.visible = false;
  d.scene.add(barGroup);

  const quad = new THREE.PlaneGeometry(1, 1);
  quad.translate(0.5, 0, 0);              // pivot on the left edge, so scale.x grows rightward
  const BAR_W = 0.9, BAR_H = 0.1;

  const bars = [];
  function ensureBars(n) {
    while (bars.length < n) {
      const holder = new THREE.Group();
      const bg = new THREE.Mesh(quad, new THREE.MeshBasicMaterial({
        color: 0x0a1016, transparent: true, opacity: 0.75, depthTest: false,
      }));
      bg.scale.set(BAR_W, BAR_H, 1);
      bg.position.x = -BAR_W / 2;
      const fill = new THREE.Mesh(quad, new THREE.MeshBasicMaterial({
        color: BAR_OK, depthTest: false,
      }));
      fill.position.x = -BAR_W / 2;
      fill.scale.set(0.001, BAR_H * 0.72, 1);
      const label = makeLabel();
      label.position.y = 0.19;
      holder.add(bg, fill, label);
      holder.renderOrder = 999;
      barGroup.add(holder);
      bars.push({ holder, fill, label, lastText: '' });
    }
    for (let i = 0; i < bars.length; i++) bars[i].holder.visible = i < n;
  }

  function makeLabel() {
    const c = document.createElement('canvas');
    c.width = 256; c.height = 64;
    const tex = new THREE.CanvasTexture(c);
    const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }));
    sp.scale.set(1.5, 0.375, 1);
    sp.userData.ctx = c.getContext('2d');
    sp.userData.tex = tex;
    return sp;
  }

  function drawLabel(sprite, text, color) {
    const g = sprite.userData.ctx;
    g.clearRect(0, 0, 256, 64);
    g.font = 'bold 30px ui-monospace, Menlo, monospace';
    g.textAlign = 'center';
    g.textBaseline = 'middle';
    g.lineWidth = 6;
    g.strokeStyle = '#0a1016';
    g.strokeText(text, 128, 32);
    g.fillStyle = color;
    g.fillText(text, 128, 32);
    sprite.userData.tex.needsUpdate = true;
  }

  // Labels are the only per-frame texture cost here, so they redraw on a slow
  // tick and only when the shown value actually changes.
  let labelT = 0;

  const _p = new THREE.Vector3();
  const _q = new THREE.Quaternion();
  const _cam = new THREE.Vector3();

  // --- helpers ------------------------------------------------------------
  const barColor = (frac, broken) => (broken ? BAR_DEAD : frac > 0.85 ? BAR_HOT : frac > 0.55 ? BAR_WARN : BAR_OK);

  function cornerPos(i) {
    if (d.rig.cornerPos) return d.rig.cornerPos(i);
    const c = d.rig.corners[i];
    return c?.anchor?.pos ?? null;
  }

  const hud = {
    /** Set false while a card is up — the storm HUD shouldn't peer through it. */
    setVisible(on) { root.style.display = on ? '' : 'none'; },

    setHelp(text) { elHelp.textContent = text; },

    /**
     * @param {number} dt
     * @param {number} t  wind/storm time
     */
    update(dt, t) {
      const phase = d.game.phase;
      const storm = phase === 'storm';

      // --- wind + clock ---
      const speed = d.wind.speedAt(d.player.pos, t);
      elWind.textContent = `${speed.toFixed(1)} m/s   ${kmh(speed).toFixed(0)} km/h`;
      elWind.style.color = speed > 25 ? '#ff8f86' : speed > 15 ? '#ffc24a' : '#dde5ea';
      elClock.textContent = storm
        ? `STORM   ${Math.max(0, STORM_LEN - d.game.phaseT).toFixed(0)}s left`
        : phase.toUpperCase();

      // --- gust telegraph ---
      // The contract promises >=1.2 s of warning; this is where the player
      // actually gets to spend it.
      const tel = storm ? d.wind.gustTelegraph(t) : null;
      elGust.classList.toggle('on', !!tel);
      if (tel) elGust.textContent = `GUST INCOMING  ${tel.eta.toFixed(1)}s  ·  ${kmh(tel.power).toFixed(0)} km/h`;

      // --- garden ---
      const hp = d.garden.hp;
      elPct.textContent = `${hp.toFixed(0)}%`;
      elBar.style.width = `${clamp01(hp / 100) * 100}%`;
      elBar.style.background = hp > 66 ? '#6ee06e' : hp > 33 ? '#ffc24a' : '#ff5b4a';
      // Decision 7: rain shadow is what matters at night, sun coverage by day.
      // Showing whichever is load-bearing right now stops the number being a
      // fact about nothing.
      const sky = d.getSky?.();
      if (storm && sky?.rainShadowOver) {
        const dry = sky.rainShadowOver(d.world.gardenBed);
        elGardenLabel.textContent = 'GARDEN';
        elShade.textContent = `${(dry * 100).toFixed(0)}% kept dry by the sail`;
      } else {
        const shade = d.rig.rigged ? d.rig.coverageOver(d.world.gardenBed, d.world.sunDir, d.world.heightAt) : 0;
        elGardenLabel.textContent = 'GARDEN';
        elShade.textContent = d.rig.rigged ? `${(shade * 100).toFixed(0)}% in shade` : 'no sail rigged';
      }

      // --- carried item ---
      const carrying = d.player.carrying;
      elCarry.style.display = carrying ? '' : 'none';
      if (carrying) elCarry.textContent = `carrying: ${carrying}`;

      // --- event ticker ---
      const live = d.events.filter((e) => t - e.t < 6 || e.t > t);
      elEvents.innerHTML = live.slice(-4).map((e) => `<div>${e.text}</div>`).join('');

      // --- corner bars ---
      barGroup.visible = d.rig.rigged && (storm || phase === 'prep');
      if (!barGroup.visible) return;

      ensureBars(d.rig.corners.length);
      labelT += dt;
      const redraw = labelT >= 0.12;
      if (redraw) labelT = 0;
      d.camera.getWorldQuaternion(_q);
      d.camera.getWorldPosition(_cam);

      for (let i = 0; i < d.rig.corners.length; i++) {
        const c = d.rig.corners[i];
        const b = bars[i];
        const p = cornerPos(i);
        if (!p) { b.holder.visible = false; continue; }

        _p.set(p.x, p.y, p.z);
        b.holder.position.copy(_p);
        b.holder.position.y += 0.45;
        b.holder.quaternion.copy(_q);

        // Hold roughly constant screen size. A house corner is ~18 m away when
        // you're standing at the posts, and at that range a world-sized bar is
        // a few unreadable pixels — which defeats the entire point of putting it
        // out there rather than in a corner of the screen. Clamped so it doesn't
        // balloon when you walk right up to a corner to repair it.
        const dist = _p.distanceTo(_cam);
        b.holder.scale.setScalar(Math.max(0.75, Math.min(3.2, dist / 7)));

        const frac = clamp01((c.load || 0) / c.hw.rating);
        b.fill.scale.x = Math.max(0.001, (c.broken ? 1 : frac) * BAR_W);
        b.fill.material.color.setHex(barColor(frac, c.broken));

        if (redraw) {
          const text = c.broken
            ? `${c.anchorId.toUpperCase()} BLOWN`
            : `${c.anchorId.toUpperCase()} ${((c.load || 0) / 1000).toFixed(1)}/${(c.hw.rating / 1000).toFixed(1)}kN`;
          if (text !== b.lastText) {
            b.lastText = text;
            drawLabel(b.label, text, c.broken ? '#ff8f86' : frac > 0.85 ? '#ffb1ab' : '#dde5ea');
          }
        }
      }
    },

    // --- cards ------------------------------------------------------------

    /**
     * The forecast. Its job is to sell the dread and, incidentally, to be the
     * difficulty select for free — three authored storms already bracket the
     * range, so letting the player pick one IS the choice.
     *
     * @param {{key:string, def:object}[]} storms
     * @param {(key:string) => void} onPick
     */
showForecast({ key, def, site }, wk, onGo) {
      // Integrator merge (Sprint 8): A's week card shape + C's forecastLines —
      // MEASURED numbers, banded by lead (the old inline estimate read 30 m/s
      // for a storm that really gusts 32.3). Tonight is lead 0 = exact; when
      // the week wants to show tomorrow's card, pass its lead and it hedges.
      const f = forecastLines(def, 0);
      const change = (def.events ?? []).find((e) => e.type === 'windchange');
      const night = (def.sky?.darkness ?? 0) > 0.6;
      const hail = !!def.hail;

      // The ladder, as pips. You can see what you've survived and how far is
      // left — which is most of what makes night four frightening.
      const pips = Array.from({ length: wk.nights }, (_, i) => {
        const done = i < wk.night - 1;
        const now = i === wk.night - 1;
        const held = done && wk.log[i]?.won;
        return `<span class="pip ${now ? 'now' : done ? (held ? 'held' : 'lost') : ''}">${
          now ? '◆' : done ? (held ? '●' : '○') : '·'}</span>`;
      }).join('');

      // A different yard announces itself — the corner block is somewhere you've
      // never rigged, and that's most of what makes night three land.
      const siteLine = site
        ? `<div class="stat" style="color:#7ee0ff">${site.name}${site.blurb ? ` — ${site.blurb}` : ''}</div>`
        : '';

      card.innerHTML = `<div class="card">
        <h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
        <div class="pips">${pips}</div>
        <h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? '  ·  NIGHT' : ''}</h2>
        ${siteLine}
        <div class="stat">${f.wind}</div>
        <div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
        ${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
        <div class="row" style="margin-top:14px"><span>in the bank</span><b>$${wk.bank}</b></div>
        <div class="stat" style="color:#8ba0ad;margin-top:10px">
          ${hail
            ? 'Hail is what kills a garden, and cloth stops hail. Get the sail over the bed.'
            : 'Nothing tonight can hurt the garden. Learn the anchors.'}
        </div>
        ${change ? `<div class="stat" style="color:#8ba0ad">
          A change means the corners that were slack all storm are the loaded ones after it.
        </div>` : ''}
        <button class="go">RIG IT</button>
      </div>`;
      card.classList.add('on');
      hud.setVisible(false);
      card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onGo(); });
    },

    /**
     * E's dawn tint, pasted from their THREADS note. The 2.2 s ease is the point,
     * not the colours: the storm ends, the light comes up, and THEN you're told
     * how you did. Sell the survival before the scoreboard.
     */
    setDawn(on) { dawn.classList.toggle('on', !!on); },

    /**
     * The end of the week, either way. E's diptych is on disk and their words are
     * in THREADS — I took the primaries verbatim; the trade talking, no triumph.
     *
     * @param {object} s week.js settlement
     * @param {() => void} onRestart
     */
    showEndCard(s, onRestart) {
      const won = s.outcome === 'win';
      const art = won ? 'card_win.jpg' : 'card_gameover.jpg';

      // The words scale with what was actually held — reaching night five is not
      // the same as saving five gardens, and the end card is the last thing the
      // game says. E's primary copy for a clean week, E's own alternate for a
      // scraped one, and a third for the ending nobody knew existed until the
      // economy was measured: solvent, with the gardens dead behind you.
      const COPY = {
        clean: ['THE WEEK HELD', "Five nights. Everything's still where you put it.",
          "Nobody thanks you for the storm that did nothing. That's the job."],
        scraped: ['FIVE NIGHTS, FIVE MORNINGS', 'You made the least wrong call five times running.',
          'Some of it held. You know which bits didn\'t.'],
        solvent: ['STILL TRADING', 'The books balanced. The garden didn\'t.',
          'You can be paid all week and still have nothing to point at.'],
        gameover: ['OFF THE JOB', 'Broke, with the week still running.',
          'The wind never sent an invoice. Everyone else did.'],
      };
      const [head, sub, kick] = COPY[won ? (s.grade ?? 'clean') : 'gameover'];
      const nights = s.night;

      card.innerHTML = `<div class="card endcard ${won ? 'win' : 'lose'}"
           style="background-image:url(./models/textures/${art})">
        <div class="endtext">
          <h1>${head}</h1>
          <h2>${sub}</h2>
          <div class="row"><span>nights survived</span><b>${nights}/5</b></div>
          <div class="row"><span>gardens held</span><b>${s.held ?? 0}/5</b></div>
          <div class="row"><span>in the bank</span><b>$${s.bankAfter}</b></div>
          <div class="kicker">${kick}</div>
          <button class="go">${won ? 'ANOTHER WEEK' : 'TRY AGAIN'}</button>
        </div>
      </div>`;
      card.classList.add('on');
      hud.setVisible(false);
      card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onRestart(); });
    },

    /**
     * @param {object} r  {hp, cornersLost, cornersTotal, bill, collateral, budgetLeft, win}
     * @param {() => void} onAgain
     */
    showAftermath(r, onAgain) {
      const w = r.week;
      const rows = [
        ['garden', `${r.hp.toFixed(0)}%`],
        ['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
        ['what got through', r.hailBlocked],
        ['hardware lost', r.bill ? `$${r.bill}` : 'none'],
        ['collateral', r.collateral.length ? r.collateral.map((c) => `${c.what} ($${c.cost})`).join(', ') : 'none'],
      ].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');

      // The money, itemised. A settlement you can't read is a number you can't
      // argue with, and arguing with it is how you learn the shop.
      const money = w ? `
        <div class="ledger">
          <div class="row"><span>${w.won ? 'fee' : `fee (night lost — ${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
          <div class="row"><span>garden bonus</span><b>+$${w.bonus}</b></div>
          <div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
          ${w.collateral ? `<div class="row bad"><span>collateral</span><b>−$${w.collateral}</b></div>` : ''}
          <div class="row"><span>spent on the rig</span><b>−$${w.spent}</b></div>
          <div class="row total"><span>in the bank</span><b>$${w.bankBefore} → $${w.bankAfter}</b></div>
        </div>` : '';

      const next = !w ? 'PLAY AGAIN'
        : w.outcome === 'continue' ? `NIGHT ${w.night + 1} →`
        : w.outcome === 'win' ? 'SEE THE WEEK'
        : 'SEE THE DAMAGE';

      card.innerHTML = `<div class="card">
        <h1>MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}</h1>
        <h2>${r.subtitle}</h2>
        ${rows}
        ${money}
        <div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
        <button class="go">${next}</button>
      </div>`;
      card.classList.add('on');
      hud.setVisible(false);
      card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onAgain(); });
    },

    hideCard() {
      card.classList.remove('on');
      card.innerHTML = '';
      hud.setVisible(true);
    },

    get cardOpen() { return card.classList.contains('on'); },

    dispose() {
      root.remove(); card.remove(); style.remove();
      d.scene.remove(barGroup);
      quad.dispose();
      for (const b of bars) {
        b.fill.material.dispose();
        b.label.material.map?.dispose();
        b.label.material.dispose();
      }
    },
  };

  return hud;
}
