// living-lab-lib.jsx — chalkboard primitives for "The Living Lab" (Evidence Engine).
// Visual DNA reused from the WMGF cover language (cover2.jsx): slate ground,
// chalk hand-lettering, grain displacement. Every element supports a `draw`
// progress (0..1) so the scene can be told like a professor drawing it out —
// borders stroke on, lettering writes in, interiors settle after.
//
// COLOUR ROLES (locked, two highways of information):
//   white  = the highway lines (gray/white multi-line)
//   blue   = evidence            (basic → oriented, top highway, runs right)
//   green  = cleared for spending (oriented → applied on top; oriented → basic on bottom)
//   purple = innovation           (applied → oriented, bottom highway, runs left;
//                                  also the colour of the solution progress bars)
//   gold   = the thesis           (thesis box + WMI/GEM nodes + thought clouds)
const { clamp } = window;

// ── palette ──────────────────────────────────────────────────────────────────
const LAB = {
  ground:  '#1d2421',
  panel:   '#222b27',
  panel2:  '#26312c',
  chalk:   '#e9e3d3',
  blue:    '#5db0ff', blueHi:  '#c2e2ff',
  green:   '#48d178', greenHi: '#bff5cf',
  purple:  '#a78bfa', purpleHi:'#ded3ff',
  gold:    '#f2c233', goldDeco:'#c9a55a', goldDeep:'#8a6624',
  dust:    '#ffffff',
  marker:  "'Permanent Marker','Bradley Hand','Comic Sans MS',cursive",
};

// ── deterministic noise + easing helpers ────────────────────────────────────
function rng(s) { const x = Math.sin(s * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); }
const frac = (v) => v - Math.floor(v);
const lerp = (a, b, t) => a + (b - a) * t;
const seg = (t, a, b, ease) => { const u = clamp((t - a) / (b - a || 1e-6), 0, 1); return ease ? ease(u) : u; };
// stroke draw-on via normalised pathLength — works on any path
const dashDraw = (draw) => draw < 0.999 ? { pathLength: 1, strokeDasharray: 1, strokeDashoffset: 1 - draw } : {};

// ── hand-lettering (adapted from cover2 HandText) ───────────────────────────
// `reveal` (0..1) writes the text in letter by letter, like a hand on a board.
const CAPW = { ' ':0.30, I:0.30, J:0.44, L:0.50, T:0.56, F:0.54, E:0.56,
  M:0.94, W:0.98, R:0.66, A:0.70, O:0.76, D:0.72, N:0.72, H:0.72, C:0.72,
  P:0.62, S:0.60, U:0.70, Y:0.64, B:0.62, G:0.76, V:0.66, '-':0.40, '·':0.4, '?':0.56 };
function cw(ch) { if (ch === ' ') return 0.30; const u = ch.toUpperCase();
  const b = CAPW[u] != null ? CAPW[u] : 0.64; return ch === u ? b : b * 0.8; }

function HandText({ text, x, y, size, color, filter, anchor = 'middle',
                   seed = 1, jy = 0.04, jr = 4, js = 0.05, spacing = 1, opacity = 1, reveal = 1 }) {
  if (reveal <= 0.001) return null;
  const chars = [...String(text)];
  const adv = chars.map((c) => size * cw(c) * spacing);
  const total = adv.reduce((a, b) => a + b, 0);
  const startX = anchor === 'middle' ? x - total / 2 : anchor === 'end' ? x - total : x;
  let cx = startX; const nodes = [];
  chars.forEach((c, i) => {
    const s1 = rng(seed + i * 1.7), s2 = rng(seed * 2.3 + i * 3.1), s3 = rng(seed * 3.7 + i * 0.7);
    const dy = (s1 - 0.5) * 2 * size * jy;
    const rot = (s2 - 0.5) * 2 * jr;
    const ds = (s3 - 0.5) * 2 * size * js;
    const gx = cx + adv[i] / 2;
    const lo = clamp(reveal * chars.length - i, 0, 1);   // letter-by-letter
    if (c !== ' ' && lo > 0.01) nodes.push(
      <text key={i} x={gx} y={y + dy} fontSize={size + ds} fill={color} textAnchor="middle" opacity={lo}
            transform={`rotate(${rot} ${gx} ${y + dy})`} style={{ fontFamily: LAB.marker }}>{c}</text>);
    cx += adv[i];
  });
  return <g filter={filter} opacity={opacity}>{nodes}</g>;
}

// ── geometry ────────────────────────────────────────────────────────────────
function makeLine(x0, y0, x1, y1, { amp = 3, seed = 1, steps, bow = 0 } = {}) {
  const dist = Math.hypot(x1 - x0, y1 - y0);
  const n = steps || Math.max(6, Math.round(dist / 46));
  const ang = Math.atan2(y1 - y0, x1 - x0) + Math.PI / 2;
  const pts = [];
  for (let i = 0; i <= n; i++) {
    const u = i / n;
    const arc = Math.sin(u * Math.PI) * bow;
    const j = (rng(seed + i * 1.7) - 0.5) * 2 * amp * Math.sin(u * Math.PI) + arc;
    pts.push({ x: x0 + (x1 - x0) * u + Math.cos(ang) * j, y: y0 + (y1 - y0) * u + Math.sin(ang) * j });
  }
  return pts;
}
function ptsToPath(pts) { return 'M ' + pts.map((p) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' L '); }
function polylineLen(pts) { let L = 0; for (let i = 1; i < pts.length; i++) L += Math.hypot(pts[i].x - pts[i-1].x, pts[i].y - pts[i-1].y); return L; }
function sampleLine(pts, u) {
  const f = clamp(u, 0, 1) * (pts.length - 1), i = Math.floor(f), t = f - i;
  const a = pts[Math.min(i, pts.length - 1)], b = pts[Math.min(i + 1, pts.length - 1)];
  return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}
function cloudPath(w, h, seed = 1) {
  const rx = w / 2, ry = h / 2, bumps = 9; let d = '';
  for (let i = 0; i <= bumps; i++) {
    const a = (i / bumps) * Math.PI * 2 - Math.PI / 2;
    const rr = 1 + (rng(seed + i * 2.3) - 0.5) * 0.16;
    const x = Math.cos(a) * rx * rr, y = Math.sin(a) * ry * rr;
    if (i === 0) { d = `M ${x.toFixed(1)} ${y.toFixed(1)}`; continue; }
    const pa = ((i - 0.5) / bumps) * Math.PI * 2 - Math.PI / 2;
    const bulge = 1.22 + (rng(seed + i) - 0.5) * 0.1;
    d += ` Q ${(Math.cos(pa) * rx * bulge).toFixed(1)} ${(Math.sin(pa) * ry * bulge).toFixed(1)} ${x.toFixed(1)} ${y.toFixed(1)}`;
  }
  return d + ' Z';
}
function boxPath(w, h, r = 14, seed = 1, jit = 1.6) {
  const x = -w / 2, y = -h / 2;
  const j = (k) => (rng(seed + k) - 0.5) * jit;
  return `M ${x + r} ${y + j(1)}
    L ${x + w - r} ${y + j(2)} Q ${x + w} ${y} ${x + w + j(3)} ${y + r}
    L ${x + w + j(4)} ${y + h - r} Q ${x + w} ${y + h} ${x + w - r} ${y + h + j(5)}
    L ${x + r} ${y + h + j(6)} Q ${x} ${y + h} ${x + j(7)} ${y + h - r}
    L ${x + j(8)} ${y + r} Q ${x} ${y} ${x + r} ${y + j(1)} Z`;
}

// ── filter / gradient defs ──────────────────────────────────────────────────
function LabDefs({ grit = 1.5 }) {
  const pulseGrad = (id, c, hi) => (
    <radialGradient id={id} cx="50%" cy="50%" r="50%">
      <stop offset="0%" stopColor={hi} stopOpacity="1" />
      <stop offset="34%" stopColor={c} stopOpacity="0.95" />
      <stop offset="100%" stopColor={c} stopOpacity="0" />
    </radialGradient>
  );
  return (
    <defs>
      <filter id="ll-chalk" x="-14%" y="-14%" width="128%" height="128%">
        <feTurbulence type="fractalNoise" baseFrequency="0.013 0.016" numOctaves="2" seed="4" result="w" />
        <feDisplacementMap in="SourceGraphic" in2="w" scale={grit} xChannelSelector="R" yChannelSelector="G" result="d" />
        <feGaussianBlur in="d" stdDeviation="0.4" result="soft" />
        <feComponentTransfer in="soft" result="halo"><feFuncA type="linear" slope="0.5" /></feComponentTransfer>
        <feTurbulence type="fractalNoise" baseFrequency="0.7" numOctaves="3" seed="6" result="g" />
        <feColorMatrix in="g" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1.5 -0.26" result="ga" />
        <feComposite in="d" in2="ga" operator="in" result="tex" />
        <feMerge><feMergeNode in="halo" /><feMergeNode in="tex" /></feMerge>
      </filter>
      <filter id="ll-dust" x="0" y="0" width="100%" height="100%">
        <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed="3" stitchTiles="stitch" />
        <feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.05 0" />
      </filter>
      <radialGradient id="ll-vign" cx="50%" cy="46%" r="72%">
        <stop offset="60%" stopColor="#000" stopOpacity="0" />
        <stop offset="100%" stopColor="#000" stopOpacity="0.34" />
      </radialGradient>
      {pulseGrad('ll-pb', LAB.blue, LAB.blueHi)}
      {pulseGrad('ll-pg', LAB.green, LAB.greenHi)}
      {pulseGrad('ll-pp', LAB.purple, LAB.purpleHi)}
      <radialGradient id="ll-goldcore" cx="50%" cy="45%" r="60%">
        <stop offset="0%" stopColor="#fff0c4" stopOpacity="1" />
        <stop offset="45%" stopColor={LAB.gold} stopOpacity="0.9" />
        <stop offset="100%" stopColor={LAB.goldDeep} stopOpacity="0" />
      </radialGradient>
      <radialGradient id="ll-whitecore" cx="50%" cy="45%" r="60%">
        <stop offset="0%" stopColor="#ffffff" stopOpacity="1" />
        <stop offset="45%" stopColor={LAB.chalk} stopOpacity="0.92" />
        <stop offset="100%" stopColor={LAB.chalk} stopOpacity="0" />
      </radialGradient>
      <filter id="ll-soft" x="-60%" y="-60%" width="220%" height="220%">
        <feGaussianBlur stdDeviation="5" />
      </filter>
    </defs>
  );
}

// ── flow orb (rides the highways only — never inside a frame) ───────────────
function Pulse({ x, y, o = 1, r = 6.5, kind = 'blue' }) {
  if (o <= 0.01) return null;
  const grad = kind === 'green' ? 'll-pg' : kind === 'purple' ? 'll-pp' : 'll-pb';
  const core = kind === 'green' ? LAB.greenHi : kind === 'purple' ? LAB.purpleHi : LAB.blueHi;
  return (
    <g opacity={o}>
      <circle cx={x} cy={y} r={r} fill={`url(#${grad})`} />
      <circle cx={x} cy={y} r={r * 0.34} fill={core} />
    </g>
  );
}

// ── chalk line (draw-on) ────────────────────────────────────────────────────
function Line({ pts, w = 1.25, color = LAB.chalk, opacity = 0.34, draw = 1 }) {
  const len = React.useMemo(() => polylineLen(pts), [pts]);
  if (draw <= 0.001) return null;
  return (
    <path d={ptsToPath(pts)} fill="none" stroke={color} strokeWidth={w} strokeLinecap="round"
          strokeLinejoin="round" strokeOpacity={opacity}
          strokeDasharray={draw < 0.999 ? len : undefined}
          strokeDashoffset={draw < 0.999 ? len * (1 - draw) : 0} />
  );
}

// ── zone frame — border strokes on, coloured glow settles after ─────────────
function ZoneFrame({ x, y, w, h, seed = 1, draw = 1, glow = LAB.chalk }) {
  if (draw <= 0.001) return null;
  const cx = x + w / 2, cy = y + h / 2, d = boxPath(w, h, 22, seed, 2.6);
  const halo = seg(draw, 0.6, 1);
  return (
    <g>
      {halo > 0.01 ? (
        <g transform={`translate(${cx} ${cy})`} filter="url(#ll-soft)" opacity={halo}>
          <path d={d} fill="none" stroke={glow} strokeWidth="5" strokeOpacity="0.42" />
        </g>
      ) : null}
      <g transform={`translate(${cx} ${cy})`} filter="url(#ll-chalk)">
        <path d={d} fill={glow} fillOpacity={0.02 * halo} stroke={glow} strokeWidth="2.2"
              strokeOpacity="0.72" {...dashDraw(draw)} />
      </g>
    </g>
  );
}

// ── agent workstation cell — sketched on, then the screen comes alive ───────
function AgentCell({ w = 148, h = 90, flicker = 1, cursor = 1, label, showLabel = true, seed = 1, draw = 1 }) {
  if (draw <= 0.001) return null;
  const sx = -w / 2, sy = -h / 2;
  const inner = seg(draw, 0.5, 1);
  return (
    <g>
      <g filter="url(#ll-chalk)">
        <path d={boxPath(w, h, 12, seed)} fill={LAB.panel} fillOpacity={0.9 * inner}
              stroke={LAB.chalk} strokeWidth="1.6" strokeOpacity="0.6" {...dashDraw(seg(draw, 0, 0.62))} />
        <g opacity={inner}>
          <rect x={sx + 20} y={sy + 17} width={w - 64} height={h - 38} rx="4"
                fill="#182320" stroke={LAB.chalk} strokeWidth="1.3" strokeOpacity={0.5 * flicker} />
          <circle cx={sx + 30} cy={sy + 27} r="2.4" fill={LAB.chalk} opacity={0.55 * flicker} />
          <line x1={sx + 28} y1={sy + 39} x2={sx + w - 56} y2={sy + 39} stroke={LAB.chalk} strokeWidth="1.4" strokeOpacity={0.42 * flicker} />
          <line x1={sx + 28} y1={sy + 49} x2={sx + w - 68} y2={sy + 49} stroke={LAB.chalk} strokeWidth="1.4" strokeOpacity={0.34 * flicker} />
          <line x1={sx + 28} y1={sy + 59} x2={sx + w - 62} y2={sy + 59} stroke={LAB.chalk} strokeWidth="1.4" strokeOpacity={0.3 * flicker} />
          <rect x={sx + 28} y={sy + 66} width="8" height="3.4" fill={LAB.chalk} opacity={cursor * 0.7} />
          <circle cx={sx + w - 21} cy={sy + h / 2 - 8} r="8" fill="none" stroke={LAB.chalk} strokeWidth="1.5" strokeOpacity="0.6" />
          <path d={`M ${sx + w - 33} ${sy + h - 13} q 12 -18 24 0`} fill="none" stroke={LAB.chalk} strokeWidth="1.5" strokeOpacity="0.6" />
        </g>
      </g>
      {showLabel && label ? (
        <HandText text={label} x={0} y={h / 2 + 21} size={13} color={LAB.chalk} anchor="middle"
                  filter="url(#ll-chalk)" seed={seed + 5} jr={2} opacity={0.5} reveal={seg(draw, 0.55, 0.95)} />
      ) : null}
    </g>
  );
}

// ── solution cell — purple innovation bar; green border-glow when fed ───────
function SolutionCell({ w = 190, h = 92, label, showLabel = true, bright = 0, progress = 0, seed = 1, draw = 1 }) {
  if (draw <= 0.001) return null;
  const bw = w - 60;
  const inner = seg(draw, 0.5, 1);
  return (
    <g>
      {bright > 0.02 ? (
        <path d={boxPath(w + 8, h + 8, 14, seed)} fill="none" stroke={LAB.green}
              strokeWidth="2" strokeOpacity={bright * 0.5} filter="url(#ll-soft)" />
      ) : null}
      <g filter="url(#ll-chalk)">
        <path d={boxPath(w, h, 12, seed)} fill={LAB.panel} fillOpacity={0.9 * inner}
              stroke={LAB.chalk} strokeWidth="1.6" strokeOpacity={0.5 + bright * 0.35}
              {...dashDraw(seg(draw, 0, 0.62))} />
        <g opacity={inner}>
          <line x1={-bw / 2} y1={h / 2 - 18} x2={bw / 2} y2={h / 2 - 18} stroke={LAB.chalk} strokeWidth="2.4" strokeOpacity="0.2" />
          {progress > 0.01 ? (
            <line x1={-bw / 2} y1={h / 2 - 18} x2={-bw / 2 + bw * clamp(progress, 0, 1)} y2={h / 2 - 18}
                  stroke={LAB.purple} strokeWidth="2.6" strokeOpacity="0.9" strokeLinecap="round" />
          ) : null}
        </g>
      </g>
      {showLabel && label ? (
        <HandText text={label} x={0} y={-2} size={17} color={LAB.chalk} anchor="middle"
                  filter="url(#ll-chalk)" seed={seed + 3} jr={2} opacity={0.9} reveal={seg(draw, 0.45, 0.95)} />
      ) : null}
    </g>
  );
}

// ── the thesis box (gold, live) — drawn on, core lights when complete ───────
function ThesisBox({ w = 170, h = 140, label, showLabel = true, coreAngle = 0, glow = 1, solid = 1, seed = 7, draw = 1 }) {
  if (draw <= 0.001) return null;
  const lit = seg(draw, 0.62, 1);
  return (
    <g>
      <path d={boxPath(w + 16, h + 16, 18, seed)} fill={LAB.gold} fillOpacity={0.05 * glow * lit}
            stroke={LAB.gold} strokeWidth="3" strokeOpacity={0.28 * glow * lit} filter="url(#ll-soft)" />
      <g filter="url(#ll-chalk)" opacity={solid}>
        <path d={boxPath(w, h, 15, seed)} fill={LAB.panel2} fillOpacity={0.9 * seg(draw, 0.4, 0.9)}
              stroke={LAB.gold} strokeWidth="2.4" strokeOpacity="0.92" {...dashDraw(seg(draw, 0, 0.6))} />
        <path d={boxPath(w - 16, h - 16, 12, seed + 1)} fill="none" stroke={LAB.goldDeco} strokeWidth="1.2"
              strokeOpacity={0.4 * seg(draw, 0.55, 0.85)} />
      </g>
      <g transform={`rotate(${coreAngle})`} opacity={glow * lit}>
        <circle cx="0" cy="0" r="20" fill="url(#ll-goldcore)" />
        {[0, 60, 120].map((a) => (
          <line key={a} x1="0" y1="0" x2={Math.cos(a * Math.PI / 180) * 15} y2={Math.sin(a * Math.PI / 180) * 15}
                stroke={LAB.gold} strokeWidth="2" strokeOpacity="0.85" transform={`rotate(${a})`} />
        ))}
        <circle cx="0" cy="0" r="4.4" fill="#fff3cf" />
      </g>
      {showLabel && label ? (
        <HandText text={label} x={0} y={h / 2 - 30} size={27} color={LAB.gold} anchor="middle"
                  filter="url(#ll-chalk)" seed={seed + 9} jr={2} opacity={solid} reveal={seg(draw, 0.6, 1)} />
      ) : null}
    </g>
  );
}

// ── the forming thought (next thesis) ───────────────────────────────────────
function ThoughtCloud({ w = 210, h = 116, label, showLabel = true, seed = 3, spark = 1, reveal = 1 }) {
  return (
    <g>
      <path d={cloudPath(w, h, seed)} fill={LAB.chalk} fillOpacity="0.03"
            stroke={LAB.chalk} strokeWidth="2" strokeOpacity="0.72" strokeDasharray="3 7"
            strokeLinecap="round" filter="url(#ll-chalk)" />
      <circle cx="0" cy="-2" r="9" fill="url(#ll-whitecore)" opacity={spark} />
      <circle cx="0" cy="-2" r="2.6" fill="#ffffff" opacity={spark} />
      {showLabel && label ? (
        <HandText text={label} x={0} y={7} size={26} color={LAB.chalk} anchor="middle"
                  filter="url(#ll-chalk)" seed={seed + 4} jr={2} opacity={0.95} reveal={reveal} />
      ) : null}
    </g>
  );
}

function TRCard({ w = 54, h = 72, tab = 'TR', seed = 1 }) {
  return (
    <g filter="url(#ll-chalk)">
      <path d={boxPath(w, h, 5, seed)} fill={LAB.panel2} stroke={LAB.chalk} strokeWidth="1.5" strokeOpacity="0.72" />
      <rect x={-w/2 + 6} y={-h/2 + 7} width={w - 12} height="12" rx="2" fill={LAB.gold} fillOpacity="0.16"
            stroke={LAB.gold} strokeWidth="1" strokeOpacity="0.55" />
      <line x1={-w/2 + 8} y1={-h/2 + 30} x2={w/2 - 8} y2={-h/2 + 30} stroke={LAB.chalk} strokeWidth="1.2" strokeOpacity="0.35" />
      <line x1={-w/2 + 8} y1={-h/2 + 40} x2={w/2 - 12} y2={-h/2 + 40} stroke={LAB.chalk} strokeWidth="1.2" strokeOpacity="0.3" />
      <line x1={-w/2 + 8} y1={-h/2 + 50} x2={w/2 - 9} y2={-h/2 + 50} stroke={LAB.chalk} strokeWidth="1.2" strokeOpacity="0.26" />
      <HandText text={tab} x={0} y={-h/2 + 17} size={9} color={LAB.gold} anchor="middle" seed={seed + 2} jr={1} />
    </g>
  );
}

// ── layout ──────────────────────────────────────────────────────────────────
const FRAMES = {
  left:  { x: 54,   y: 210, w: 452, h: 728 },
  mid:   { x: 706,  y: 210, w: 508, h: 728 },
  right: { x: 1414, y: 210, w: 452, h: 728 },
};
const CENTER = { x: 960, y: 560 };
// two highways of information (each a 3-line gray set, orbs on the middle line)
// two clearly separated highways — a top road (runs right) and a bottom road
// (runs left) with a wide median between them; the thesis box bridges both.
const HIGHWAY = { topYs: [476, 490, 504], botYs: [616, 630, 644], tlx: 872, trx: 1048 };
const LABEL_Y = 194;
const THOUGHT_Y = 326;
const WMI_SMALL = { x: 960, y: 796 };

const FOUNDATION = { x: 1440, y: 806, w: 400, h: 104 };
const SOLUTIONS = [
  { id: 'DLP spec', x: 1538, y: 858, w: 190, h: 66, group: 'PROTOCOL', era: 'foundation', maxBar: 0.82 },
  { id: 'DLP-SDK',  x: 1742, y: 858, w: 190, h: 66, group: 'PROTOCOL', era: 'foundation', maxBar: 0.78 },
  { id: 'GrantsProQR',   x: 1538, y: 656, w: 196, h: 74, group: 'SOLUTIONS', era: 'reports', maxBar: 0.66 },
  { id: 'ResearchProAI', x: 1742, y: 656, w: 196, h: 74, group: 'SOLUTIONS', era: 'reports', maxBar: 0.6 },
  { id: 'WMGF',          x: 1538, y: 558, w: 196, h: 74, group: 'SOLUTIONS', era: 'reports', maxBar: 0.7 },
  { id: 'AIRA',          x: 1742, y: 558, w: 196, h: 74, group: 'SOLUTIONS', era: 'reports', maxBar: 0.55 },
  { id: 'ProtoLex', x: 1538, y: 460, w: 196, h: 74, group: 'SOLUTIONS', era: 'gem', maxBar: 0.16 },
  { id: 'Loom',     x: 1742, y: 460, w: 196, h: 74, group: 'SOLUTIONS', era: 'gem', maxBar: 0.1 },
  { id: 'WMFE',     x: 1640, y: 360, w: 196, h: 74, group: 'SOLUTIONS', era: 'gem', maxBar: 0.14 },
].map((s, i) => ({ ...s, i }));

const GROUPS = [
  { id: 'SOLUTIONS', x: 1640, y: 302, era: 'reports' },
  { id: 'PROTOCOL',  x: 1640, y: 792, era: 'foundation' },
];

const TR_IDS = ['TR-D-001', 'TR-A-001', 'TR-A-002', 'TR-A-003', 'TR-A-004', 'TR-L-001'];

function layoutAgents(n) {
  n = clamp(Math.round(n), 3, 9);
  const rows = Math.ceil(n / 2);
  const step = 116;
  const startY = CENTER.y - ((rows - 1) * step) / 2;
  const out = [];
  for (let i = 0; i < n; i++) {
    const col = i % 2, row = Math.floor(i / 2);
    const jx = (rng(i * 3.1) - 0.5) * 18, jy = (rng(i * 5.7) - 0.5) * 14;
    const x = (col === 0 ? 150 : 300) + jx;
    const y = startY + row * step + (col === 1 ? step / 2 : 0) + jy;
    out.push({ x, y, i });
  }
  return out;
}

Object.assign(window, {
  LAB, rng, frac, lerp, seg, dashDraw,
  HandText, makeLine, ptsToPath, polylineLen, sampleLine, cloudPath, boxPath,
  LabDefs, Pulse, Line, ZoneFrame, AgentCell, SolutionCell, ThesisBox, ThoughtCloud, TRCard,
  FRAMES, CENTER, HIGHWAY, LABEL_Y, THOUGHT_Y, WMI_SMALL, FOUNDATION, SOLUTIONS, GROUPS, TR_IDS, layoutAgents,
});
