<!-- SOLUV · part 444 -->
ep
  const order = [...groups.entries()].sort((a, b) => b[1].length - a[1].length);
  let avenueW = 0;
  if (order.length && order[0][1].length >= 24) {
    const [bid, g] = order[0], take = g.splice(0, Math.min(g.length, 96));
    const nAv = take.length > 48 ? 2 : 1, perRow = Math.ceil(take.length / (2 * nAv));
    let y0 = -(keepOuter + 160), maxLen = 0;
    for (let a = 0; a < nAv; a++) {
      const ax = (a - (nAv - 1) / 2) * 260, rows = [take.slice(a * 2 * perRow, a * 2 * perRow + perRow), take.slice(a * 2 * perRow + perRow, (a + 1) * 2 * perRow)];
      rows.forEach((row, side) => { let y = y0; for (const p of row) { y -= (p.w + GAP) / 2; place(p, ax + (side ? 55 : -55), y, side ? -1 : 1, 0); y -= (p.w + GAP) / 2; } maxLen = Math.max(maxLen, y0 - y); });
      avenues.push({ x0: ax - 70, x1: ax + 70, y0, y1: y0 - maxLen });
    }
    avenueW = nAv * 260 + 140;
    Object.assign(books[bid], { lab: true, cx: 0, cy: y0 - maxLen / 2, r: Math.max(140, maxLen / 2), lz: Math.max(...take.map(p => p.h)) + 40 });
    extent = Math.max(extent, -(y0 - maxLen));
    if (!g.length) order.shift(); else order.sort((a, b) => b[1].length - a[1].length);
  }
  // tower courts round an outer circle (skipping the avenue's south sector)
  const towers = order.filter(([, g]) => g.length).map(([bid, g]) => {
    const arc = g.reduce((a, p) => a + p.w + GAP, 0);
    // estimate radius: first ring holds up to 900 units of arc, then pairs add 160
    let r = Math.max(40, Math.min(arc, 900) / (2 * Math.PI)), left = arc, R = r;
    for (let k = 0; left > 0; k++) { left -= 2 * Math.PI * R; if (left > 0) R += k % 2 === 0 ? 40 : 120; }
    return { bid, g, R: R + D };
  });
  if (towers.length) {
    const maxR = Math.max(...towers.map(t => t.R)), need = towers.reduce((a, t) => a + 2 * t.R + 180, 0);
    let Rc = keepOuter + 220 + maxR, skip = 0;
    for (let it = 0; it < 4; it++) { skip = avenueW ? Math.min(2.2, (avenueW + 200) / Rc + 0.25) : 0; Rc = Math.max(keepOuter + 220 + maxR, need / (2 * Math.PI - skip)); }
    const span = 2 * Math.PI - skip; let a = -Math.PI / 2 + skip / 2;
    // big towers alternate sides so the outer circle stays balanced
    for (const t of towers) {
      const da = (2 * t.R + 180) / need * span, am = a + da / 2; a += da;
      const cx = Math.cos(am) * Rc, cy = Math.sin(am) * Rc;
      const out = ringCourt(t.g, cx, cy, 900, 40);
      Object.assign(books[t.bid], { lab: true, cx, cy, r: out, lz: Math.max(...t.g.map(p => p.h)) + 40 });
      extent = Math.max(extent, Rc + out);
    }
  }
  return { cx: 0, cy: 0, radius: extent, rings, avenues };
}

// Routing: '/soluv' = the shared 2000-block map with every wall; '/soluv/N' = the same map, flown to wall N (room id N).
// Works from a real path (server rewrite /soluv/* -> this page), from '#/soluv/N', or from '?room=N' (local static server).
export function parseRoute(loc = location) {
  const m = (loc.pathname + ' ' + loc.hash).match(/\/soluv\/(\d+)/) || loc.search.match(/[?&]room=(\d+)/);
  return m ? Math.max(1, parseInt(m[1], 10)) : 0;
}
export async function mountWordSky(container, opts = {}) {
  const dataBase = new URL(opts.data || './data/', document.baseURI);
  const t0 = performance.now();
  const stats = { tIndexMs: 0, tFirstFrameMs: 0, tVisibleTilesMs: 0, tAllTilesMs: 0, indexBytes: 0, tileBytes: 0, tilesLoaded: 0, tilesTotal: 0,
    wordsLoaded: 0, wordsTotal: 0, messagesLoaded: 0, glyphs: 0, maxGlyphs: 0, labels: 0, droppedForBudget: 0, frames: 0, atlasGlyphs: 0 };

  /* fonts (vendored, OFL) */
  try {
    const faces = [new FontFace(FONT_FAMILY, `url(${new URL('vendor/roboto-condensed-latin-400-normal.woff2', MODULE_BASE)})`, { weight: '400' }),
      new FontFace(FONT_FAMILY, `url(${new URL('vendor/roboto-condensed-latin-700-normal.woff2', MODULE_BASE)})`, { weight: '700' }),
      new FontFace(OLD_FAMILY, `url(${new URL('vendor/old-standard-tt-latin-700-normal.woff2', MODULE_BASE)})`, { weight: '700' })];
    await Promise.all(faces.map(f => f.load().then(ff => document.fonts.add(ff))));
  } catch (e) { console.warn('word-sky: font load failed, using fallback', e); }

  /* DOM */
  container.classList.add('ws-root');
  injectStyle();
  if (getComputedStyle(container).position === 'static') container.style.position = 'relative';
  if (!container.getBoundingClientRect().height) container.style.minHeight = '480px';
  const wrap = document.createElement('div'); wrap.className = 'ws-wrap';
  wrap.innerHTML = `
    <canvas class="ws-canvas"></canvas>
    <form class="ws-search" autocomplete="off"><input class="ws-q" type="search" enterkeyhint="search" placeholder="Search a word or a speaker" aria-label="Search words or speakers"><button class="ws-btn ws-go" type="submit" aria-label="Search">Go</button><button class="ws-btn ws-next" type="button" aria-label="Next match" disabled>&#9654;</button></form>
    <div class="ws-status" aria-live="polite"></div>
    <div class="ws-ctrls"><button class="ws-btn" data-a="in