0
Skip to Content
AH DESIGN WORKS
AH DESIGN WORKS
About
Architecture
Design
Photography
<?>
Expositions
AH DESIGN WORKS
AH DESIGN WORKS
About
Architecture
Design
Photography
<?>
Expositions
About
Architecture
Design
Photography
<?>
Expositions
<script>

(() => {

***

Best viewed on a desktop or laptop device,
this page reinterprets my videos as 
shifting ASCII fields that react to 
light, colour, and gestures; hovering, 
clicking, or dragging.

***

Beneath the code, the original footage
remains visible like a digital ruin,
fractured but present. 

***

As the system maps colours across clips
and responds to your movements, it 
creates an ongoing dialogue between 
you and the work.

<script>
(() => {

  /* ---------- CSS ---------- */
  const style = document.createElement("style");
  style.textContent = `
    .ascii-wrap{
      position:relative;
      width:100%;
      max-width:1000px;
      margin:0 auto;
      background:black;
      overflow:hidden;
      aspect-ratio:16/9;
      touch-action:none;
      cursor:pointer;
    }
    .ascii-video{
      position:absolute; inset:0;
      width:100%; height:100%;
      object-fit:contain;
      background:black;
      opacity:0;
      pointer-events:none;
    }
    .flash-canvas,.ascii-canvas{
      position:absolute; inset:0;
      width:100%; height:100%;
      pointer-events:none;
      background:transparent;
    }
    .flash-canvas{ z-index:1; }
    .ascii-canvas{ z-index:2; }

    .ascii-link-canvas{
      position:fixed; inset:0;
      width:100vw; height:100vh;
      pointer-events:none;
      z-index:2147483647;
      background:transparent;
    }
  `;
  document.head.appendChild(style);

  /* ---------- GLOBALS ---------- */
  const instances = [];
  let linkCanvas, lctx;
  let currentPairs = [];
  let linkUntil = 0;

  const MATCH_DIST = 170;
  const MATCH_HOLD_MS = 1200;
  const SAMPLE_GRID = 24;

  // +0.5s spacing between match events
  const COOLDOWN_MS = 500;
  let cooldownUntil = 0;

  // cycle: off 2s, on 4s, off 1s
  const CYCLE = [
    { dur: 2000, on: false },
    { dur: 4000, on: true  },
    { dur: 1000, on: false }
  ];
  let cycleStart = performance.now();
  let cycleIndex = 0;

  function cycleState(now){
    let t = now - cycleStart;
    while (t > CYCLE[cycleIndex].dur){
      t -= CYCLE[cycleIndex].dur;
      cycleIndex = (cycleIndex + 1) % CYCLE.length;
      cycleStart = now - t;
    }
    return CYCLE[cycleIndex].on;
  }

  // cursor / touch tracking (screen coords)
  let cursor = { x: 0, y: 0, active: false };

  function setCursor(x,y){
    cursor.x = x;
    cursor.y = y;
    cursor.active = true;
  }

  window.addEventListener("pointermove", e => setCursor(e.clientX, e.clientY), { passive:true });
  window.addEventListener("pointerdown", e => setCursor(e.clientX, e.clientY), { passive:true });
  window.addEventListener("pointerleave", () => cursor.active=false, { passive:true });

  window.addEventListener("touchstart", e=>{
    if(e.touches && e.touches[0]) setCursor(e.touches[0].clientX, e.touches[0].clientY);
  }, { passive:true });
  window.addEventListener("touchmove", e=>{
    if(e.touches && e.touches[0]) setCursor(e.touches[0].clientX, e.touches[0].clientY);
  }, { passive:true });
  window.addEventListener("touchend", ()=>{ cursor.active=false; }, { passive:true });

  /* ---------- LINK CANVAS ---------- */
  function ensureLinkCanvas(){
    if(linkCanvas || !document.body) return;
    linkCanvas = document.createElement("canvas");
    linkCanvas.className = "ascii-link-canvas";
    document.body.appendChild(linkCanvas);
    lctx = linkCanvas.getContext("2d");

    function resize(){
      linkCanvas.width = window.innerWidth;
      linkCanvas.height = window.innerHeight;

      lctx.setLineDash([9,9]);
      lctx.lineWidth = 1.5;
      lctx.shadowColor = "rgba(255,255,255,0.45)";
      lctx.shadowBlur  = 4;
      lctx.font = "11px monospace";
      lctx.textBaseline = "top";
    }
    resize();
    window.addEventListener("resize", resize);
  }

  /* ---------- INIT ONE BLOCK ---------- */
  function initOne(wrap){
    if(wrap.dataset.asciiInit==="1") return;
    wrap.dataset.asciiInit="1";

    const video = wrap.querySelector(".ascii-video");
    const flashCanvas = wrap.querySelector(".flash-canvas");
    const asciiCanvas = wrap.querySelector(".ascii-canvas");
    if(!video || !flashCanvas || !asciiCanvas) return;

    const chars = " .:-=+*#%@";
    const sampleCanvas = document.createElement("canvas");
    const sctx = sampleCanvas.getContext("2d",{willReadFrequently:true});
    const fctx = flashCanvas.getContext("2d");
    const actx = asciiCanvas.getContext("2d");

    const BLACK_CUTOFF = 38;

    let vis={w:0,h:0,left:0,top:0};
    let visReady=false;

    const colsBase=75;
    let cols=colsBase;
    const fps=22;
    let lastFrame=0;

    let baseGlint=0.03;
    let glintStrength=baseGlint;
    let isPointerDown=false;

    let halo={active:false,x:0,y:0,r:120,strength:1};
    let flash={alpha:0,blocks:[],drift:0,vx:0,jitter:0};

    function setAspectAndFit(){
      if(!video.videoWidth||!video.videoHeight) return;

      wrap.style.aspectRatio=`${video.videoWidth}/${video.videoHeight}`;
      const r=wrap.getBoundingClientRect();
      const vAR=video.videoWidth/video.videoHeight;
      const wAR=r.width/r.height;

      if(wAR>vAR){
        vis.h=r.height;
        vis.w=r.height*vAR;
        vis.left=(r.width-vis.w)/2;
        vis.top=0;
      }else{
        vis.w=r.width;
        vis.h=r.width/vAR;
        vis.left=0;
        vis.top=(r.height-vis.h)/2;
      }

      [flashCanvas,asciiCanvas].forEach(c=>{
        c.style.left=vis.left+"px";
        c.style.top=vis.top+"px";
        c.style.width=vis.w+"px";
        c.style.height=vis.h+"px";
        c.width=Math.floor(vis.w);
        c.height=Math.floor(vis.h);
      });

      actx.imageSmoothingEnabled=false;
      fctx.imageSmoothingEnabled=false;
      sctx.imageSmoothingEnabled=false;

      halo.r = Math.max(85, vis.w * 0.16);
      visReady=vis.w>10 && vis.h>10;
    }

    video.addEventListener("loadedmetadata", setAspectAndFit);
    video.addEventListener("canplay", setAspectAndFit);
    window.addEventListener("resize", setAspectAndFit);

    function computeScrollBoost(){
      const rect=wrap.getBoundingClientRect();
      const vh=window.innerHeight||800;
      const topNorm=Math.min(Math.max(1-rect.top/vh,0),1);
      return topNorm*topNorm*0.12;
    }

    function render(t){
      if(t-lastFrame<1000/fps){ requestAnimationFrame(render); return; }
      lastFrame=t;

      if(video.readyState<2 || !visReady){
        requestAnimationFrame(render); return;
      }

      /* ----- FLASHES (under ASCII) ----- */
      fctx.clearRect(0,0,flashCanvas.width,flashCanvas.height);
      if(flash.alpha>0 && flash.blocks.length){
        fctx.save(); fctx.globalAlpha=flash.alpha;

        flash.drift+=flash.vx;
        if(Math.random()<0.06) flash.vx*=-1;
        if(Math.random()<0.04) flash.jitter=(Math.random()-0.5)*0.08;
        flash.jitter*=0.85;

        for(const b of flash.blocks){
          const wob=b.wobble*Math.sin(t*0.01+b.phase);
          let driftX=flash.drift+wob+flash.jitter;
          driftX=Math.max(-b.left,Math.min(1-(b.left+b.w),driftX));

          const sx=(b.left+driftX)*video.videoWidth;
          const sy=b.top*video.videoHeight;
          const sw=b.w*video.videoWidth;
          const sh=b.h*video.videoHeight;

          const dx=(b.left+driftX)*vis.w;
          const dy=b.top*vis.h;
          const dw=b.w*vis.w;
          const dh=b.h*vis.h;

          fctx.drawImage(video,sx,sy,sw,sh,dx,dy,dw,dh);
        }
        fctx.restore();
        flash.alpha*=0.97;
        if(flash.alpha<0.03) flash.alpha=0;
      }

      /* ----- ASCII ----- */
      glintStrength=baseGlint+computeScrollBoost();
      const cellW=vis.w/cols;
      const cellH=cellW*2.0;
      const rows=Math.floor(vis.h/cellH);

      sampleCanvas.width=cols;
      sampleCanvas.height=rows;
      sctx.drawImage(video,0,0,cols,rows);

      let data;
      try{ data=sctx.getImageData(0,0,cols,rows).data; }
      catch{
        actx.clearRect(0,0,asciiCanvas.width,asciiCanvas.height);
        actx.fillStyle="white";
        actx.fillText("CORS blocked. Allow your domain in Cloudinary.",10,30);
        requestAnimationFrame(render); return;
      }

      actx.clearRect(0,0,asciiCanvas.width,asciiCanvas.height);
      actx.save(); actx.globalAlpha=0.30; actx.fillStyle="black";
      actx.fillRect(0,0,asciiCanvas.width,asciiCanvas.height);
      actx.restore();

      actx.font=`${cellH}px monospace`;
      actx.textBaseline="top";

      let p=0;
      for(let y=0;y<rows;y++){
        for(let x=0;x<cols;x++){
          const r=data[p], g=data[p+1], b=data[p+2];
          const bright=(r+g+b)/3;
          const idx=Math.floor((bright/255)*(chars.length-1));
          let ch=chars[idx];

          const isBlack=bright<BLACK_CUTOFF;

          const maxc=Math.max(r,g,b), minc=Math.min(r,g,b);
          const sat=maxc===0?0:(maxc-minc)/maxc;

          const cx=x*cellW+cellW*0.5;
          const cy=y*cellH+cellH*0.5;

          let haloStrength=0;
          if(!isBlack && halo.active){
            const dxh=cx-halo.x, dyh=cy-halo.y;
            const d=Math.sqrt(dxh*dxh+dyh*dyh);
            if(d<halo.r) haloStrength=(1-d/halo.r)*halo.strength;
          }

          if(haloStrength>0 && Math.random()<haloStrength*0.85){
            ch=chars[Math.floor(Math.random()*chars.length)];
          }

          const jitterX = haloStrength>0
            ? (Math.sin(t*0.024+cy*0.05)*haloStrength*cellW*1.1
               + (Math.random()-0.5)*haloStrength*cellW*0.9)
            : 0;

          const glintProb=(isBlack?0:glintStrength)+haloStrength*0.55;
          const isGlint=!isBlack && sat>0.25 && bright>55 && Math.random()<glintProb;

          let gray=Math.floor(195+(bright/255)*60);
          let rr=gray, gg=gray, bb=gray;

          if(isGlint){
            const boost=1.35+haloStrength*2.2;
            rr=Math.min(255,r*boost);
            gg=Math.min(255,g*boost);
            bb=Math.min(255,b*boost);

            const punch=18+haloStrength*55;
            rr=Math.min(255,rr+punch);
            gg=Math.min(255,gg+punch);
            bb=Math.min(255,bb+punch);
          }

          actx.fillStyle=`rgb(${rr|0},${gg|0},${bb|0})`;
          actx.fillText(ch,x*cellW+jitterX,y*cellH);

          p+=4;
        }
      }

      requestAnimationFrame(render);
    }

    requestAnimationFrame(render);

    /* ----- HARDENED AUTOPLAY ----- */
    video.muted = true;
    video.setAttribute("muted", "");
    video.setAttribute("playsinline", "");
    video.setAttribute("autoplay", "");
    video.setAttribute("loop", "");

    const tryPlay = () => {
      const p = video.play();
      if (p && typeof p.catch === "function") p.catch(()=>{});
    };
    tryPlay();
    setTimeout(tryPlay, 600);
    setTimeout(tryPlay, 1600);

    const unlock = () => { tryPlay(); };
    window.addEventListener("pointerdown", unlock, { once:true });
    window.addEventListener("touchstart", unlock, { once:true });
    window.addEventListener("keydown", unlock, { once:true });

    document.addEventListener("visibilitychange", () => {
      if (!document.hidden) tryPlay();
    });

    /* ----- TEARS avg ~38.5% ----- */
    function buildIrregularTears(){
      const targetArea=0.385;
      const blocks=[];
      let areaLeft=targetArea;

      const bands=4+Math.floor(Math.random()*6);
      for(let i=0;i<bands;i++){
        const hBand=0.018+Math.random()*0.075;
        const segs=2+Math.floor(Math.random()*5);
        const top=Math.random()*(1-hBand);

        for(let s=0;s<segs;s++){
          if(areaLeft<=0) break;
          let wSeg=0.10+Math.random()*0.40;
          const desired=areaLeft/hBand;
          wSeg=Math.min(wSeg,desired*(0.55+Math.random()*0.9));
          wSeg=Math.min(Math.max(wSeg,0.07),0.95);
          const left=Math.random()*(1-wSeg);

          blocks.push({
            left, top, w:wSeg, h:hBand,
            wobble:0.01+Math.random()*0.03,
            phase:Math.random()*Math.PI*2
          });
          areaLeft-=wSeg*hBand;
        }
      }

      const chips=3+Math.floor(Math.random()*5);
      for(let c=0;c<chips;c++){
        const w=0.035+Math.random()*0.10;
        const h=0.012+Math.random()*0.05;
        blocks.push({
          left:Math.random()*(1-w),
          top:Math.random()*(1-h),
          w,h,
          wobble:0.006+Math.random()*0.025,
          phase:Math.random()*Math.PI*2
        });
      }
      return blocks;
    }

    function doFlashBurst(dramatic=false){
      const hits=dramatic?3:(1+Math.floor(Math.random()*2));
      let i=0;
      function one(){
        flash.blocks=buildIrregularTears();
        flash.alpha=dramatic?1.0:0.95;
        flash.drift=0;
        flash.vx=(Math.random()-0.5)*(dramatic?0.05:0.02);
        const onDur=dramatic?220:(70+Math.random()*220);
        setTimeout(()=>{
          i++;
          if(i<hits){
            const gap=dramatic?120:(120+Math.random()*320);
            setTimeout(one,gap);
          }
        },onDur);
      }
      one();
    }

    /* ----- FULL-FRAME FLASH OF RAW VIDEO ----- */
    function doFullFrameFlash(){
      // one block covering entire frame
      flash.blocks = [{
        left: 0,
        top: 0,
        w: 1,
        h: 1,
        wobble: 0,
        phase: 0
      }];
      flash.alpha = 0.95;   // strong but still under ASCII
      flash.drift = 0;
      flash.vx = 0;
      flash.jitter = 0;

      // now ~0.25–0.45s
      setTimeout(()=>{
        flash.alpha = 0;
        flash.blocks = [];
      }, 250 + Math.random()*200);
    }

    (function flashLoop(){
      const wait=4000+Math.random()*9000;
      setTimeout(()=>{ doFlashBurst(false); flashLoop(); },wait);
    })();

    /* ----- LOOP: TRIGGER FULL-FRAME FLASH EVERY 4–8s ----- */
    (function fullFlashLoop(){
      const wait = 4000 + Math.random()*4000; // 4–8s
      setTimeout(()=>{
        doFullFrameFlash();
        fullFlashLoop();
      }, wait);
    })();

    /* ----- INTERACTION ----- */
    function toHaloCoords(clientX,clientY){
      const rect=wrap.getBoundingClientRect();
      const x=clientX-rect.left-vis.left;
      const y=clientY-rect.top-vis.top;
      halo.x=Math.min(Math.max(x,0),vis.w);
      halo.y=Math.min(Math.max(y,0),vis.h);
    }

    wrap.addEventListener("pointermove",(e)=>{
      toHaloCoords(e.clientX,e.clientY);
      halo.active=true;
      halo.strength=isPointerDown?1.3:1.0;
    });
    wrap.addEventListener("pointerleave",()=>halo.active=false);

    wrap.addEventListener("click",(e)=>{
      e.preventDefault(); doFlashBurst(true);
    });

    wrap.addEventListener("pointerdown",(e)=>{
      e.preventDefault();
      isPointerDown=true;
      try{ wrap.setPointerCapture(e.pointerId); }catch{}
      toHaloCoords(e.clientX,e.clientY);
      const rect=wrap.getBoundingClientRect();
      const nx=Math.min(Math.max((e.clientX-rect.left)/rect.width,0),1);
      cols=Math.round(40+nx*90);
    });

    wrap.addEventListener("pointerup",(e)=>{
      isPointerDown=false;
      try{ wrap.releasePointerCapture(e.pointerId); }catch{}
      cols=colsBase;
      halo.strength=1.0;
    });

    instances.push({
      wrap, video,
      dominantRGB:null,
      dominantPos:null,
      isReady:()=>{
        const r=wrap.getBoundingClientRect();
        return r.width>10 && r.height>10;
      },
      mapNormToScreen:(xNorm, yNorm)=>{
        const r=wrap.getBoundingClientRect();
        const x = r.left + vis.left + xNorm * vis.w;
        const y = r.top  + vis.top  + yNorm * vis.h;
        return {x,y};
      }
    });
  }

  /* ---------- RGB dominant sampling WITH POSITION ---------- */
  const tinyC = document.createElement("canvas");
  const tinyCtx = tinyC.getContext("2d", { willReadFrequently:true });

  function sampleDominantRGBWithPos(inst){
    const v = inst.video;
    if (v.readyState < 2) return null;

    tinyC.width = SAMPLE_GRID;
    tinyC.height = SAMPLE_GRID;
    tinyCtx.drawImage(v, 0, 0, SAMPLE_GRID, SAMPLE_GRID);

    let d;
    try { d = tinyCtx.getImageData(0,0,SAMPLE_GRID,SAMPLE_GRID).data; }
    catch { return null; }

    let rs=0, gs=0, bs=0, n=0;
    for (let i=0; i<d.length; i+=4){
      const r=d[i], g=d[i+1], b=d[i+2];
      const bright=(r+g+b)/3;
      if (bright < 30) continue;
      rs+=r; gs+=g; bs+=b; n++;
    }
    if (!n) return null;

    const mean = { r: rs/n, g: gs/n, b: bs/n };

    let bestDist = 1e9, bestPx=SAMPLE_GRID/2, bestPy=SAMPLE_GRID/2;
    for (let py=0; py<SAMPLE_GRID; py++){
      for (let px=0; px<SAMPLE_GRID; px++){
        const i = (py*SAMPLE_GRID + px)*4;
        const r=d[i], g=d[i+1], b=d[i+2];
        const bright=(r+g+b)/3;
        if (bright < 30) continue;

        const dr=r-mean.r, dg=g-mean.g, db=b-mean.b;
        const dist = dr*dr + dg*dg + db*db;

        if (dist < bestDist){
          bestDist = dist;
          bestPx = px; bestPy = py;
        }
      }
    }

    return {
      mean,
      pos:{
        xNorm: bestPx/(SAMPLE_GRID-1),
        yNorm: bestPy/(SAMPLE_GRID-1)
      }
    };
  }

  function colorDist(A,B){
    const dr=A.r-B.r, dg=A.g-B.g, db=A.b-B.b;
    return Math.sqrt(dr*dr + dg*dg + db*db);
  }

  function checkMatches(){
    const now = performance.now();
    if (now < cooldownUntil) return;

    const ready = instances.filter(i => i.isReady());
    if (ready.length < 2) return;

    ready.forEach(inst=>{
      const out = sampleDominantRGBWithPos(inst);
      if (out){
        inst.dominantRGB = out.mean;
        inst.dominantPos = out.pos;
      }
    });

    const matches = [];
    for (let i=0; i<ready.length; i++){
      for (let j=i+1; j<ready.length; j++){
        const A = ready[i].dominantRGB;
        const B = ready[j].dominantRGB;
        const Ap = ready[i].dominantPos;
        const Bp = ready[j].dominantPos;
        if (!A || !B || !Ap || !Bp) continue;

        if (colorDist(A,B) < MATCH_DIST){
          const aPoint = ready[i].mapNormToScreen(Ap.xNorm, Ap.yNorm);
          const bPoint = ready[j].mapNormToScreen(Bp.xNorm, Bp.yNorm);

          const arcSign = Math.random() < 0.5 ? -1 : 1;
          const arcMag  = 0.12 + Math.random()*0.28;
          const arcBase = Math.random()*Math.PI*2;

          matches.push({
            ax:aPoint.x, ay:aPoint.y,
            bx:bPoint.x, by:bPoint.y,
            arcSign, arcMag, arcBase
          });
        }
      }
    }

    if (matches.length){
      linkUntil = now + MATCH_HOLD_MS;
      currentPairs = matches;
      cooldownUntil = now + MATCH_HOLD_MS + COOLDOWN_MS;
    } else if (now > linkUntil){
      currentPairs = [];
      linkUntil = 0;
    }
  }

  /* ---------- helpers for tags ---------- */
  function drawTag(text, x, y){
    const padX = 4, padY = 2;
    const w = lctx.measureText(text).width;
    const h = 12;
    lctx.fillStyle = "rgba(255,0,0,0.95)";
    lctx.fillRect(x, y, w + padX*2, h + padY*2);
    lctx.fillStyle = "white";
    lctx.fillText(text, x + padX, y + padY);
  }

  function drawLinks(){
    if(!lctx) return;
    const now=performance.now();
    lctx.clearRect(0,0,linkCanvas.width,linkCanvas.height);

    const active = cycleState(now);
    if(!active || now>linkUntil || !currentPairs.length) return;

    const pulse = 0.7 + 0.3*Math.sin(now*0.012);

    lctx.save();
    lctx.strokeStyle=`rgba(255,255,255,${pulse})`;
    lctx.setLineDash([9,9]);
    lctx.lineWidth = 1.5;
    lctx.font = "11px monospace";

    // draw main lines + endpoints + endpoint tags
    currentPairs.forEach(p=>{
      const {ax,ay,bx,by} = p;
      if(!isFinite(ax+ay+bx+by)) return;

      const dx = bx-ax, dy = by-ay;
      const len = Math.sqrt(dx*dx + dy*dy) || 1;

      const nx = -dy/len, ny = dx/len;
      const wobble = Math.sin(now*0.004 + p.arcBase) * 0.6;
      const bend = len * p.arcMag * p.arcSign * (1 + wobble);

      const cx = (ax+bx)/2 + nx*bend;
      const cy = (ay+by)/2 + ny*bend;

      lctx.beginPath();
      lctx.moveTo(ax,ay);
      lctx.quadraticCurveTo(cx,cy,bx,by);
      lctx.stroke();

      // red endpoint circles
      const r0 = 4.5;
      lctx.setLineDash([]);
      lctx.fillStyle = "rgba(255,0,0,0.95)";
      lctx.beginPath(); lctx.arc(ax,ay,r0,0,Math.PI*2); lctx.fill();
      lctx.beginPath(); lctx.arc(bx,by,r0,0,Math.PI*2); lctx.fill();

      // endpoint tags
      const offset = 7;
      drawTag(`(${ax.toFixed(0)},${ay.toFixed(0)})`, ax + offset, ay + offset);
      drawTag(`(${bx.toFixed(0)},${by.toFixed(0)})`, bx + offset, by + offset);

      lctx.setLineDash([9,9]); // restore dashes
    });

    // extra line to cursor + cursor tag
    if(cursor.active){
      // use the nearest endpoint among ALL active lines
      let best = null;
      let bestD = Infinity;

      currentPairs.forEach(p=>{
        const dA = Math.hypot(cursor.x - p.ax, cursor.y - p.ay);
        const dB = Math.hypot(cursor.x - p.bx, cursor.y - p.by);
        if(dA < bestD){ bestD = dA; best = {x:p.ax,y:p.ay}; }
        if(dB < bestD){ bestD = dB; best = {x:p.bx,y:p.by}; }
      });

      if(best){
        // thin dashed line from nearest dot to cursor
        lctx.save();
        lctx.strokeStyle=`rgba(255,255,255,${pulse})`;
        lctx.setLineDash([6,7]);
        lctx.lineWidth = 1.2;
        lctx.beginPath();
        lctx.moveTo(best.x, best.y);
        lctx.lineTo(cursor.x, cursor.y);
        lctx.stroke();
        lctx.restore();

        // cursor tag
        drawTag(`(${cursor.x.toFixed(0)},${cursor.y.toFixed(0)})`, cursor.x + 8, cursor.y + 8);
      }
    }

    lctx.restore();
  }

  function bootAll(){
    ensureLinkCanvas();
    document.querySelectorAll(".ascii-wrap").forEach(initOne);
  }

  const bootTimer=setInterval(()=>{
    if(document.body && (document.readyState==="interactive"||document.readyState==="complete")){
      clearInterval(bootTimer);
      bootAll();

      setTimeout(()=>{
        setInterval(checkMatches, 120);
        (function linkLoop(){ drawLinks(); requestAnimationFrame(linkLoop); })();
      }, 1600);

      const obs=new MutationObserver(()=>bootAll());
      obs.observe(document.body,{childList:true,subtree:true});
    }
  },200);

})();
</script>