CoolFace
Apppublic

lerobot/robot-learning-tutorial

sourceHugging Faceupdated 1y agoView on Hugging Face
508likes
d3-neural-network.html759 linesDownload Raw Back to embeds
1<div class="d3-neural"></div>2<style>3  .d3-neural { position: relative; width:100%;margin:0;}4  .d3-neural .controls { margin-top: 12px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }5  .d3-neural .controls label { font-size: 12px; color: var(--muted-color); display: flex; align-items: center; gap: 8px; white-space: nowrap; padding: 6px 10px; }6  .d3-neural .controls input[type="range"]{ width: 160px; }7  .d3-neural .panel { display:flex; gap:8px; align-items:stretch; flex-wrap: nowrap; }8  .d3-neural .left { flex: 0 0 33.333%; max-width: 33.333%; min-width: 160px; display:flex; flex-direction:column; gap:8px; }9  .d3-neural .right { flex: 1 1 66.666%; max-width: 66.666%; min-width: 280px; display:flex; }10  .d3-neural .right > svg { flex: 1 1 auto; height: 100%; }11  .d3-neural .arrow-sep { flex: 0 0 18px; max-width: 18px; display:flex; align-items:center; justify-content:center; color: var(--muted-color); }12  .d3-neural .arrow-sep svg { display:block; width: 16px; height: 16px; }13  @media (max-width: 800px) {14    .d3-neural .panel { flex-direction: column; }15    .d3-neural .left,16    .d3-neural .right { flex: 0 0 100%; max-width: 100%; min-width: 0; }17    .d3-neural .arrow-sep { display: none; }18  }19  .d3-neural canvas { width: 100%; height: auto; border-radius: 8px; border: 1px solid var(--border-color); background: var(--surface-bg); display:block; }20  .d3-neural .preview28 { display:grid; grid-template-columns: repeat(28, 1fr); gap: 1px; width: 100%; }21  .d3-neural .preview28 span { display:block; aspect-ratio:1/1; border-radius:2px; }22  .d3-neural .legend { font-size: 12px; color: var(--text-color); line-height:1.35; }23  .d3-neural .probs { display:flex; gap:6px; align-items:flex-end; height: 64px; }24  .d3-neural .probs .bar { width: 10px; border-radius:2px 2px 0 0; background: var(--border-color); transition: height .15s ease, background-color .15s ease; }25  .d3-neural .probs .bar.active { background: var(--primary-color); }26  .d3-neural .probs .tick { font-size: 10px; color: var(--muted-color); text-align:center; margin-top: 2px; }27  .d3-neural .canvas-wrap { position: relative; }28  .d3-neural .erase-btn { position: absolute; top: 8px; right: 8px; width: 32px; height: 32px; display:flex; align-items:center; justify-content:center; border: 1px solid var(--border-color);  }29  .d3-neural .canvas-hint { position: absolute; top: 8px; left: 12px; font-size: 12px; font-weight: 700; color: rgba(0,0,0,.9); pointer-events: none; transition: opacity .12s ease; }30  31</style>32<script>33  (() => {34    const ensureD3 = (cb) => {35      if (window.d3 && typeof window.d3.select === 'function') return cb();36      let s = document.getElementById('d3-cdn-script');37      if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }38      const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };39      s.addEventListener('load', onReady, { once: true });40      if (window.d3) onReady();41    };42 43    const ensureTF = (cb) => {44      if (window.tf && typeof window.tf.tensor === 'function') return cb();45      let s = document.getElementById('tfjs-cdn-script');46      if (!s) { s = document.createElement('script'); s.id = 'tfjs-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js'; document.head.appendChild(s); }47      const onReady = () => { if (window.tf && typeof window.tf.tensor === 'function') cb(); };48      s.addEventListener('load', onReady, { once: true });49      if (window.tf) onReady();50    };51 52    const bootstrap = () => {53      const mount = document.currentScript ? document.currentScript.previousElementSibling : null;54      const container = (mount && mount.querySelector && mount.querySelector('.d3-neural')) || document.querySelector('.d3-neural');55      if (!container) return;56      if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }57 58      // (tooltip removed)59 60      // Layout: left (canvas + preview + controls), right (svg network)61      const panel = document.createElement('div');62      panel.className = 'panel';63      const left = document.createElement('div'); left.className = 'left';64      const arrowSep = document.createElement('div'); arrowSep.className = 'arrow-sep';65      arrowSep.innerHTML = '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><line x1="3" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><polyline points="17,7 22,12 17,17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';66      const right = document.createElement('div'); right.className = 'right';67      panel.appendChild(left); panel.appendChild(arrowSep); panel.appendChild(right);68      container.appendChild(panel);69 70      // Canvas for drawing71      const CANVAS_PX = 224; // canvas pixels (square)72      const canvas = document.createElement('canvas'); canvas.width = CANVAS_PX; canvas.height = CANVAS_PX;73      const ctx = canvas.getContext('2d');74      // init white bg75      ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,CANVAS_PX,CANVAS_PX);76      const canvasWrap = document.createElement('div'); canvasWrap.className = 'canvas-wrap';77      canvasWrap.appendChild(canvas);78      // Erase icon button (top-right)79      const eraseBtn = document.createElement('button'); eraseBtn.className='erase-btn button--ghost'; eraseBtn.type='button'; eraseBtn.setAttribute('aria-label','Clear');80      // Hidden until the user interacts with the canvas81      eraseBtn.style.display = 'none';82      eraseBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg>';83      eraseBtn.addEventListener('click', () => clearCanvas());84      canvasWrap.appendChild(eraseBtn);85      // Hint (top-left)86      const hint = document.createElement('div'); hint.className='canvas-hint'; hint.textContent='Draw a digit here';87      canvasWrap.appendChild(hint);88      left.appendChild(canvasWrap);89 90      // (preview grid removed)91 92      // (controls removed; erase button is overlayed on canvas)93 94      // (prediction panel removed; predictions rendered next to output nodes)95 96      // SVG network on right97      const svg = d3.select(right).append('svg').attr('width','100%').style('display','block');98      const defs = svg.append('defs');99      const gRoot = svg.append('g');100      const gInput = gRoot.append('g').attr('class','input');101      const gInputLinks = gRoot.append('g').attr('class','input-links');102      const gLinks = gRoot.append('g').attr('class','links');103      const gNodes = gRoot.append('g').attr('class','nodes');104      const gLabels = gRoot.append('g').attr('class','labels');105      const gOutText = gRoot.append('g').attr('class','out-probs');106 107      // Network structure (compact: 8 -> 8 -> 10)108      const layerSizes = [8, 8, 10];109      const layers = layerSizes.map((n, li)=> Array.from({length:n}, (_, i)=>({ id:`L${li}N${i}`, layer: li, index: i, a:0 })));110      // Links only between hidden->hidden and hidden->output111      const links = [];112      for (let i=0;i<layerSizes[0];i++){113        for (let j=0;j<layerSizes[1];j++) links.push({ s:{l:0,i}, t:{l:1,j}, w: (Math.sin(i*17+j*31)+1)/2 });114      }115      for (let i=0;i<layerSizes[1];i++){116        for (let j=0;j<layerSizes[2];j++) links.push({ s:{l:1,i}, t:{l:2,j}, w: (Math.cos(i*7+j*13)+1)/2 });117      }118 119      // Linear classifier: logits = W * feats + b, feats in [0,1]120      // features: [total, cx, cy, lr, tb, htrans, vtrans, loopiness]121      const W = [122        // 0  1    2    3    4    5      6      7123        [ 0.3, 0.0, 0.0, 0.0, 0.0, -0.8, -0.6,  1.2], // 0124        [-0.2, 0.9, 0.2, 0.8, 0.1, -0.2,  0.2, -1.1], // 1125        [ 0.1, 0.4, 0.2, 0.5, 0.2,  0.9,  0.1, -0.6], // 2126        [ 0.2, 0.3, 0.2, 0.2, 0.2,  0.9,  0.0, -0.2], // 3127        [ 0.0,-0.3, 0.2,-0.6, 0.4,  0.2,  0.8, -0.6], // 4128        [ 0.1,-0.4, 0.2,-0.5, 0.5,  0.9,  0.1, -0.6], // 5129        [ 0.2,-0.2, 0.6,-0.2, 0.8, -0.3,  0.2,  0.6], // 6130        [ 0.0, 0.6,-0.2, 0.6,-0.8,  0.6,  0.0, -0.8], // 7131        [ 0.4, 0.0, 0.0, 0.1, 0.1,  0.6,  0.6,  1.0], // 8132        [ 0.2, 0.2,-0.6, 0.2,-0.8,  0.2,  0.6,  0.5], // 9133      ];134      const b = [-0.2, -0.1, -0.05, -0.05, -0.05, -0.05, -0.05, -0.1, -0.15, -0.1];135 136      function computeFeatures(x28){137        // x28: Float32Array length 784, values in [0,1] (1 = black/ink)138        let sum=0, cx=0, cy=0; const w=28, h=28;139        const rowSum = new Array(h).fill(0); const colSum = new Array(w).fill(0);140        let hTransitions=0, vTransitions=0;141        for (let y=0;y<h;y++){142          for (let x=0;x<w;x++){143            const v = x28[y*w+x]; sum += v; cx += x*v; cy += y*v; rowSum[y]+=v; colSum[x]+=v;144            if (x>0){ const v0=x28[y*w+(x-1)], v1=v; if ((v0>0.25)!==(v1>0.25)) hTransitions+=1; }145            if (y>0){ const v0=x28[(y-1)*w+x], v1=v; if ((v0>0.25)!==(v1>0.25)) vTransitions+=1; }146          }147        }148        const total = sum/(w*h); // [0,1]149        const cxn = sum>1e-6 ? (cx/sum)/(w-1) : 0.5; // [0,1]150        const cyn = sum>1e-6 ? (cy/sum)/(h-1) : 0.5; // [0,1]151        let left=0,right=0,top=0,bottom=0;152        for (let y=0;y<h;y++){ for (let x=0;x<w;x++){ const v=x28[y*w+x]; if (x<w/2) left+=v; else right+=v; if (y<h/2) top+=v; else bottom+=v; }}153        const lr = (right/(right+left+1e-6));154        const tb = (bottom/(bottom+top+1e-6));155        const htn = Math.min(1, hTransitions/(w*h*0.35));156        const vtn = Math.min(1, vTransitions/(w*h*0.35));157        // Loopiness proxy: ink near perimeter low vs center high158        let perimeter=0, center=0; const m=5;159        for (let y=0;y<h;y++){160          for (let x=0;x<w;x++){161            const v=x28[y*w+x];162            const isBorder = (x<m||x>=w-m||y<m||y>=h-m);163            if (isBorder) perimeter+=v; else center+=v;164          }165        }166        const loopiness = Math.min(1, center/(perimeter+center+1e-6)*1.8);167        return [total, cxn, cyn, lr, tb, htn, vtn, loopiness];168      }169 170      function softmax(arr){ const m=Math.max(...arr); const ex=arr.map(v=>Math.exp(v-m)); const s=ex.reduce((a,b)=>a+b,0)+1e-12; return ex.map(v=>v/s); }171      function l2norm(a){ return Math.hypot(...a) || 0; }172      function normalize(a){ const n=l2norm(a); return n>0 ? a.map(v=>v/n) : a.slice(); }173      function cosine(a,b){ let s=0; for (let i=0;i<a.length;i++) s+=a[i]*b[i]; const na=l2norm(a), nb=l2norm(b)||1; return na>0 ? s/(na*nb) : 0; }174 175      // MNIST-like normalization: crop to tight bbox, scale into 20x20, center in 28x28176      function normalize28(x28){177        const w=28,h=28, thr=0.2;178        let minX=29,minY=29,maxX=-1,maxY=-1, sum=0, cx=0, cy=0;179        for (let y=0;y<h;y++){180          for (let x=0;x<w;x++){181            const v = x28[y*w+x];182            if (v>thr){ if (x<minX) minX=x; if (x>maxX) maxX=x; if (y<minY) minY=y; if (y>maxY) maxY=y; }183            sum += v; cx += x*v; cy += y*v;184          }185        }186        if (sum < 1e-3 || maxX<0){ return x28; }187        const comX = cx/sum, comY = cy/sum;188        const bw = Math.max(1, maxX-minX+1), bh = Math.max(1, maxY-minY+1);189        const scale = 20/Math.max(bw, bh);190        const out = new Float32Array(w*h);191        // center of canvas192        const cxOut = (w-1)/2, cyOut = (h-1)/2;193        for (let y=0;y<h;y++){194          for (let x=0;x<w;x++){195            // map output pixel to source space around COM196            const sx = (x - cxOut)/scale + comX;197            const sy = (y - cyOut)/scale + comY;198            out[y*w+x] = bilinearSample(x28, w, h, sx, sy);199          }200        }201        return out;202      }203      function bilinearSample(img, w, h, x, y){204        const x0 = Math.floor(x), y0 = Math.floor(y);205        const x1 = x0+1, y1 = y0+1;206        const tx = x - x0, ty = y - y0;207        function at(ix,iy){ if (ix<0||iy<0||ix>=w||iy>=h) return 0; return img[iy*w+ix]; }208        const v00 = at(x0,y0), v10 = at(x1,y0), v01 = at(x0,y1), v11 = at(x1,y1);209        const a = v00*(1-tx)+v10*tx; const b = v01*(1-tx)+v11*tx; return a*(1-ty)+b*ty;210      }211      // Simple dilation (max-pooling 3x3) to thicken strokes212      function dilate28(x){213        const w=28,h=28; const out=new Float32Array(w*h);214        for (let y=0;y<h;y++){215          for (let x0=0;x0<w;x0++){216            let m=0;217            for (let dy=-1;dy<=1;dy++){218              for (let dx=-1;dx<=1;dx++){219                const xx=x0+dx, yy=y+dy; if (xx<0||yy<0||xx>=w||yy>=h) continue;220                const v = x[yy*w+xx]; if (v>m) m=v;221              }222            }223            out[y*w+x0]=m;224          }225        }226        return out;227      }228 229      // Glyph-based 28x28 prototypes for digits 0-9 (normalized)230      const protoGlyphs28 = [];231      (function buildGlyphProtos(){232        const off = document.createElement('canvas'); off.width = CANVAS_PX; off.height = CANVAS_PX;233        const c = off.getContext('2d');234        for (let d=0; d<10; d++){235          c.fillStyle = '#ffffff'; c.fillRect(0,0,off.width,off.height);236          c.fillStyle = '#000000'; c.textAlign='center'; c.textBaseline='middle';237          c.font = 'bold 180px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';238          c.fillText(String(d), off.width/2, off.height*0.56);239          const src = c.getImageData(0,0,off.width,off.height).data; const block = off.width/28;240          const vec = new Float32Array(28*28);241          for (let gy=0; gy<28; gy++){242            for (let gx=0; gx<28; gx++){243              let acc=0, cnt=0; const x0=Math.floor(gx*block), y0=Math.floor(gy*block);244              for (let yy=y0; yy<y0+block; yy++){245                for (let xx=x0; xx<x0+block; xx++){246                  const idx=(yy*off.width+xx)*4; const r=src[idx], g=src[idx+1], b=src[idx+2];247                  const gray=(r+g+b)/3/255; acc += (1-gray); cnt++;248                }249              }250              vec[gy*28+gx] = acc/(cnt||1);251            }252          }253          const normed = normalize28(vec);254          const n = l2norm(normed)||1; protoGlyphs28.push(normed.map(v=>v/n));255        }256      })();257      function dot(a,b){ let s=0; for (let i=0;i<a.length;i++) s+=a[i]*b[i]; return s; }258 259      // Resize handling and node layout260      let width=640, height=360; const margin = { top: 16, right: 8, bottom: 24, left: 8 };261      let inputGrid = { cell: 0, x: 0, y: 0, width: 0, height: 0 };262      function layoutNodes(){263        // Right panel width, and a non-square aspect ratio for clarity264        width = Math.max(280, Math.round(right.clientWidth || 640));265        height = Math.max(260, Math.round(width * 0.56));266        svg.attr('width', width).attr('height', height);267        // Match canvas height to SVG height so both columns align vertically268        try { canvas.style.height = '100%'; canvasWrap.style.height = height + 'px'; } catch(_) {}269        const innerW = width - margin.left - margin.right; const innerH = height - margin.top - margin.bottom;270        gRoot.attr('transform', `translate(${margin.left},${margin.top})`);271        // Input grid layout (28x28) at left — cap width to a fraction of innerW272        const maxGridFrac = 0.28; // at most 28% of available width273        const cellByHeight = Math.floor(innerH / 28);274        const cellByWidth = Math.floor((innerW * maxGridFrac) / 28);275        let cell = Math.max(3, Math.min(cellByHeight, cellByWidth));276        let gridH = cell * 28; let gridY = Math.floor((innerH - gridH)/2);277        inputGrid = { cell, x: 0, y: gridY, width: cell*28, height: gridH };278        // Equal horizontal gaps: grid -> L0 -> L1 -> L2279        const nLayers = layerSizes.length; // 3280        const rightLabelPad = 36; // smaller pad; use more width for spreading layers281        const minGap = 28; const maxGap = 260;282        // Ensure enough free space; shrink grid if needed283        const desiredMinFree = rightLabelPad + nLayers * minGap; // 3 equal gaps284        if (inputGrid.width + desiredMinFree > innerW) {285          cell = Math.max(3, Math.floor((innerW - desiredMinFree) / 28));286          gridH = cell * 28; gridY = Math.floor((innerH - gridH)/2);287          inputGrid = { cell, x: 0, y: gridY, width: cell*28, height: gridH };288        }289        const gridRight = inputGrid.x + inputGrid.width;290        const freeW = Math.max(nLayers * minGap, innerW - gridRight - rightLabelPad);291        const gapX = Math.min(maxGap, Math.max(minGap, Math.floor(freeW / nLayers)));292        const xs = Array.from({ length: nLayers }, (_, li) => gridRight + gapX * (li + 1));293        // Y positions evenly spaced per layer294        layers.forEach((nodes, li)=>{295          const n = nodes.length;296          if (n <= 1) {297            nodes.forEach((nd)=>{ nd.x = xs[li]; nd.y = innerH/2; });298          } else {299            const occupancy = 0.9; // use 90% of vertical space300            const span = innerH * occupancy;301            const topPad = (innerH - span) / 2;302            const spacing = span / (n - 1);303            nodes.forEach((nd, i)=>{ nd.x = xs[li]; nd.y = topPad + i*spacing; });304          }305        });306      }307 308      let lastX28 = new Float32Array(28*28);309      function nodeRadiusForNode(n){310        const a = Math.max(0, Math.min(1, (n && typeof n.a === 'number') ? n.a : 0));311        if (n && n.layer === 2) {312          // Output nodes: variable radius based on activation313          return 8 + 10 * a; // ~8–18314        }315        // Hidden/feature nodes: variable radius based on activation316        return 5 + 5 * a; // ~5–10317      }318      function renderInputGrid(){319        if (!inputGrid || inputGrid.cell <= 0) return;320        const data = Array.from({ length: 28*28 }, (_, i) => ({ i, v: lastX28[i] || 0 }));321        const sel = gInput.selectAll('rect.input-px').data(data, d=>d.i);322        const gap = Math.max(1, Math.floor(inputGrid.cell * 0.10));323        const inner = Math.max(1, inputGrid.cell - gap);324        const offset = Math.floor(gap / 2);325        sel.enter().append('rect').attr('class','input-px')326          .attr('width', inner).attr('height', inner)327          .merge(sel)328          .attr('x', d => inputGrid.x + (d.i % 28) * inputGrid.cell + offset)329          .attr('y', d => inputGrid.y + Math.floor(d.i / 28) * inputGrid.cell + offset)330          .attr('fill', d => {331            // Increase perceived contrast of the input grid by applying a gamma curve332            const k = Math.pow(Math.max(0, Math.min(1, d.v)), 0.6); // gamma < 1 → darker darks333            const g = 255 - Math.round(k * 255);334            return `rgb(${g},${g},${g})`;335          })336          .attr('stroke', 'none');337        sel.exit().remove();338 339        // Border around the input grid area340        const borderSel = gInput.selectAll('rect.input-border').data([0]);341        borderSel.enter().append('rect').attr('class','input-border')342          .attr('fill','none')343          .attr('rx', 0).attr('ry', 0)344          .attr('stroke','var(--text-color)')345          .attr('stroke-opacity', 0.25)346          .attr('stroke-width', 1)347          .lower()348          .merge(borderSel)349          .attr('x', inputGrid.x-1)350          .attr('y', inputGrid.y-1)351          .attr('width', inputGrid.width+1)352          .attr('height', inputGrid.height+1);353 354        // Centered label above the input grid355        const labelSel = gInput.selectAll('text.input-label').data([0]);356        labelSel.enter().append('text').attr('class','input-label')357          .attr('text-anchor','middle')358          .style('font-size','12px')359          .style('font-weight','700')360          .style('fill','var(--muted-color)')361          .merge(labelSel)362          .attr('x', inputGrid.x + inputGrid.width / 2)363          .attr('y', Math.max(12, inputGrid.y - 10))364          .text('Input 28×28');365      }366 367      // Compute link path between two layered nodes using their current radii368      function computeLinkD(d){369        const s = layers[d.s.l][d.s.i];370        const t = layers[d.t.l][d.t.j];371        if (!s || !t) return '';372        const rs = nodeRadiusForNode(s);373        const rt = nodeRadiusForNode(t);374        // Use fixed anchors on circle edges for all inter-layer links (except grid->L0 handled elsewhere)375        const x1 = s.x + rs, y1 = s.y;    // right edge of source circle376        const x2 = t.x - rt, y2 = t.y;    // left edge of target circle377        const dx = (x2 - x1) * 0.45;378        return `M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}`;379      }380 381      function renderInputLinks(){382        // Draw bundle-like links from input grid right edge to first layer nodes (features)383        const firstLayer = layers[0];384        if (!firstLayer || !inputGrid || inputGrid.cell <= 0) { gInputLinks.selectAll('path').remove(); return; }385        const x0 = inputGrid.x + inputGrid.width;386        // Define a centered vertical band (half grid height) and distribute sources evenly387        const k = firstLayer.length;388        const band = inputGrid.height * 0.5;389        const centerY = inputGrid.y + inputGrid.height / 2;390        const yStart = centerY - band / 2;391        const spacing = k > 1 ? band / (k - 1) : 0;392        const paths = firstLayer.map((n, idx) => {393          // source y from centered band, equidistant394          const y0 = k > 1 ? (yStart + idx * spacing) : centerY;395          // Target anchor: center of left edge of the node circle396          const r = nodeRadiusForNode(n);397          const x1 = n.x - r;398          const y1 = n.y;399          const dx = (x1 - x0) * 0.35;400          return { x0, y0, x1, y1, c1x: x0 + dx, c1y: y0, c2x: x1 - dx, c2y: y1, idx };401        });402        const sel = gInputLinks.selectAll('path.input-link').data(paths);403        sel.enter().append('path').attr('class','input-link')404          .attr('fill','none')405          .attr('stroke','var(--text-color)')406          .attr('stroke-opacity', 0.25)407          .attr('stroke-width', 1)408          .attr('stroke-linecap','round')409          .merge(sel)410          .attr('d', d => `M${d.x0},${d.y0} C${d.c1x},${d.c1y} ${d.c2x},${d.c2y} ${d.x1},${d.y1}`)411          .attr('stroke','var(--text-color)');412        sel.exit().remove();413      }414 415      // Recompute input link path on the fly (used when node radii change)416      function computeInputLinkD(idx){417        const firstLayer = layers[0];418        const n = firstLayer[idx]; if (!n) return '';419        const x0 = inputGrid.x + inputGrid.width;420        const k = firstLayer.length;421        const band = inputGrid.height * 0.5;422        const centerY = inputGrid.y + inputGrid.height / 2;423        const yStart = centerY - band / 2;424        const spacing = k > 1 ? band / (k - 1) : 0;425        const y0 = k > 1 ? (yStart + idx * spacing) : centerY;426        const yTarget = n.y;427        const vx = n.x - x0; const vy = yTarget - y0; const L = Math.hypot(vx, vy) || 1;428        const r = nodeRadiusForNode(n);429        const x1 = n.x - (vx / L) * r;430        const y1 = yTarget - (vy / L) * r;431        const dx = (x1 - x0) * 0.35;432        const c1x = x0 + dx, c1y = y0, c2x = x1 - dx, c2y = y1;433        return `M${x0},${y0} C${c1x},${c1y} ${c2x},${c2y} ${x1},${y1}`;434      }435 436      function renderGraph(showEdges){437        layoutNodes();438        renderInputGrid();439        renderInputLinks();440        // Nodes441        const allNodes = layers.flat();442        const nodeSel = gNodes.selectAll('circle.node').data(allNodes, d=>d.id);443        nodeSel.enter().append('circle').attr('class','node')444          .attr('r', 10)445          .attr('cx', d=>d.x).attr('cy', d=>d.y)446          .attr('fill', d=> d.layer===2 ? 'var(--page-bg)' : 'var(--primary-color)')447          .attr('fill-opacity', d=> d.layer===2 ? 1 : 0.12)448          .attr('stroke', d=> d.layer===2 ? 'var(--border-color)' : 'var(--border-color)')449          .attr('stroke-width',1)450          .attr('stroke-linejoin','round')451          .merge(nodeSel)452          .attr('cx', d=>d.x).attr('cy', d=>d.y)453          .attr('opacity', 1);454        nodeSel.exit().remove();455 456        // Labels for first hidden layer only (avoid stacking with output probs)457        const labels = [];458        layers[0].forEach((n,i)=> labels.push({ x:n.x-30, y:n.y+4, txt:`f${i+1}` }));459        const labSel = gLabels.selectAll('text').data(labels);460        labSel.enter().append('text')461          .style('font-size','10px')462          .style('fill','var(--muted-color)')463          .style('paint-order','stroke')464          .style('stroke','var(--page-bg)')465          .style('stroke-width','3px')466          .attr('x', d=>d.x)467          .attr('y', d=>d.y)468          .text(d=>d.txt)469          .merge(labSel)470          .style('paint-order','stroke')471          .style('stroke','var(--page-bg)')472          .style('stroke-width','5px')473          .attr('x', d=>d.x)474          .attr('y', d=>d.y)475          .text(d=>d.txt);476        labSel.exit().remove();477 478        // Links as smooth curves479        const linkSel = gLinks.selectAll('path.link').data(links, d=> `${d.s.l}-${d.s.i}-${d.t.l}-${d.t.j}`);480        linkSel.enter().append('path').attr('class','link')481          .attr('d', computeLinkD)482          .attr('fill','none')483          .attr('stroke','var(--text-color)')484          .attr('stroke-opacity', 0.25)485          .attr('stroke-width', d=> 0.5 + d.w*1.2)486          .attr('stroke-linecap','round')487          .merge(linkSel)488          .attr('d', computeLinkD)489          .attr('stroke','var(--text-color)')490          .attr('stroke-width', d=> 0.5 + d.w*1.2);491        linkSel.exit().remove();492 493        // Ensure output labels remain aligned with the last layer on resize494        gOutText.selectAll('g.out-label')495          .attr('transform', function(d){496            if (!d || typeof d.digit !== 'number') return d3.select(this).attr('transform');497            const n = layers[2][d.digit];498            if (!n) return d3.select(this).attr('transform');499            const offset = nodeRadiusForNode(n) + 8;500            return `translate(${n.x + offset},${n.y})`;501          });502        // Ensure clip-path circles are updated on resize503        if (defs) {504          const clips = defs.selectAll('clipPath.clip-node').data(layers[2], d=>d.id);505          const ce = clips.enter().append('clipPath').attr('class','clip-node').attr('clipPathUnits','userSpaceOnUse').attr('id', d=>`clip-${d.id}`);506          ce.append('circle');507          clips.merge(ce).select('circle').attr('cx', d=>d.x).attr('cy', d=>d.y).attr('r', d=>nodeRadiusForNode(d));508          clips.exit().remove();509        }510      }511 512      function setNodeActivations(h1, h2, out){513        layers[0].forEach((n,i)=> n.a = h1[i] || 0);514        layers[1].forEach((n,i)=> n.a = h2[i] || 0);515        layers[2].forEach((n,i)=> n.a = out[i] || 0);516        // Determine top prediction (for ghosting others)517        let argmaxIdx = 0; let bestProb = -1;518        if (Array.isArray(out)) {519          for (let i=0;i<out.length;i++){ if (out[i] > bestProb){ bestProb = out[i]; argmaxIdx = i; } }520        }521        // Color/size by activation with smooth transitions522        gNodes.selectAll('circle.node')523          .transition().duration(180).ease(d3.easeCubicOut)524          .attr('fill', d=> d.layer===2 ? 'var(--page-bg)' : 'var(--primary-color)')525          .attr('fill-opacity', d=> d.layer===2 ? 1 : (0.12 + 0.58*Math.min(1, d.a||0)))526          .attr('stroke', 'var(--primary-color)')527          .attr('stroke-opacity', d=> (d.layer===2 ? 0.9 : (0.45 + 0.45*Math.min(1, d.a||0))))528          .attr('opacity', d=> 0.55 + 0.45*Math.min(1, d.a||0))529          .attr('r', d=> nodeRadiusForNode(d));530        // Link opacity by activation flow531        gLinks.selectAll('path.link')532          .transition().duration(180).ease(d3.easeCubicOut)533          .attr('d', computeLinkD)534          .attr('stroke','var(--text-color)')535          .attr('stroke-opacity', d=>{536            const aS = layers[d.s.l][d.s.i].a || 0; const aT = layers[d.t.l][d.t.j].a || 0;537            return Math.min(1, 0.15 + 0.85 * (aS * aT));538          })539          .attr('stroke-width', d=>{540            const aS = layers[d.s.l][d.s.i].a || 0; const aT = layers[d.t.l][d.t.j].a || 0;541            return 0.6 + 2.2*(aS*aT);542          });543        // Theme-aware and activation-aware input links544        gInputLinks.selectAll('path.input-link')545          .transition().duration(180).ease(d3.easeCubicOut)546          .attr('d', (d)=> computeInputLinkD(d.idx))547          .attr('stroke','var(--text-color)')548          .attr('stroke-opacity', 0.25)549          .attr('stroke-width', d=> 0.6 + 2.0*(layers[0][d.idx] ? (layers[0][d.idx].a||0) : 0));550        // Update clip-path circles to match new radii/positions of output nodes551        if (defs) {552          const clips = defs.selectAll('clipPath.clip-node').data(layers[2], d=>d.id);553          clips.select('circle')554            .transition().duration(180).ease(d3.easeCubicOut)555            .attr('cx', d=>d.x)556            .attr('cy', d=>d.y)557            .attr('r', d=> nodeRadiusForNode(d));558        }559        // Theme-aware input links on updates handled above via transition560        // Output labels: digit placed to the right of the node561        const outs = layers[2].map((n,i)=>({ x:n.x + nodeRadiusForNode(n) + 8, y:n.y, digit: i, prob: (out[i]||0), isTop: i===argmaxIdx }));562        const gSel = gOutText.selectAll('g.out-label').data(outs, d=>d.digit);563        const gEnter = gSel.enter().append('g').attr('class','out-label');564        gEnter.append('text').attr('class','out-digit')565          .style('font-size','12px').style('font-weight','800').style('fill','var(--text-color)')566          .attr('text-anchor','start').attr('dominant-baseline','middle')567          .style('paint-order','stroke').style('stroke','var(--transparent-page-contrast)').style('stroke-width','3px');568        const merged = gEnter.merge(gSel)569          .attr('transform', d=>`translate(${d.x},${d.y})`)570          .each(function(d){571            const sel = d3.select(this);572            sel.select('text.out-digit')573              .attr('x', 0).attr('y', 0)574              .text(String(d.digit));575            // Ghost non-top predictions576            sel.style('opacity', d.isTop ? 1 : 0.35);577          });578        // Remove any previous decorative rings (no highlight ring desired)579        gRoot.selectAll('circle.top-ring').remove();580        // (tooltip interactions removed)581        gSel.exit().remove();582 583        // Output liquid fill using clipPath + rect from bottom584        const rects = gNodes.selectAll('rect.out-liquid').data(layers[2], d=>d.id);585        const rectEnter = rects.enter().append('rect').attr('class','out-liquid')586          .attr('fill','var(--primary-color)')587          .attr('fill-opacity', 0.55)588          .attr('clip-path', d => `url(#clip-${d.id})`);589        rectEnter.merge(rects)590          .transition().duration(180).ease(d3.easeCubicOut)591          .attr('x', d=> d.x - nodeRadiusForNode(d))592          .attr('width', d=> 2 * nodeRadiusForNode(d))593          .attr('y', d=> {594            const r = nodeRadiusForNode(d);595            const h = 2 * r * Math.max(0, Math.min(1, d.a||0));596            return d.y + r - h;597          })598          .attr('height', d=> 2 * nodeRadiusForNode(d) * Math.max(0, Math.min(1, d.a||0)))599          .attr('fill-opacity', 0.55);600        rects.exit().remove();601      }602 603      // (no separate updateBars; bars are rendered next to nodes)604 605      function runPipeline(){606        const x28raw = downsample28();607        const x28 = dilate28(normalize28(x28raw));608        // Update input grid data609        lastX28 = x28;610        renderInputGrid();611        const feats = computeFeatures(x28); // 8D in [0,1]612        const inkMass = feats[0];613        // Hide hint when user has drawn something614        if (hint) { hint.style.opacity = inkMass < 0.01 ? 1 : 0; }615        // Hidden 1 = raw features616        const h1 = feats;617        // Hidden 2 = simple non-linear mix for visualization only618        const h2 = layers[1].map((_, j)=>{619          let s=0; for (let i=0;i<layers[0].length;i++){ const w = (Math.sin(i*17+j*31)+1)/2 * 0.8 + 0.1; s += w*h1[i]; }620          return Math.tanh(s*0.8);621        });622        let prob;623        if (inkMass < 0.03){624          // Too little ink: return near-uniform distribution625          prob = Array.from({length:10}, ()=> 1/10);626        } else {627          // Prefer TFJS model if available628          const tfProbs = predictTfjs(x28);629          if (tfProbs && tfProbs.length === 10) {630            prob = tfProbs;631          } else {632            // Fallback: rely mostly on glyph similarity633            const x28n = normalize(x28);634            const logitsGlyph = protoGlyphs28.map(p => 8.0 * cosine(x28n, p));635            const logitsLinear = W.map((row, k)=> dot(row, h1) + b[k]);636            const logits = logitsGlyph.map((v,k)=> v + 0.2*logitsLinear[k]);637            prob = softmax(logits);638          }639        }640        setNodeActivations(h1, h2.map(v => (v+1)/2), prob);641      }642 643      function downsample28(){644        // From canvas (224x224) to 28x28 by average pooling in 8x8 blocks645        const block = CANVAS_PX/28; // 8646        const src = ctx.getImageData(0,0,CANVAS_PX,CANVAS_PX).data;647        const out = new Float32Array(28*28);648        for (let gy=0; gy<28; gy++){649          for (let gx=0; gx<28; gx++){650            let acc=0; let cnt=0;651            const x0 = Math.floor(gx*block), y0 = Math.floor(gy*block);652            for (let y=y0; y<y0+block; y++){653              for (let x=x0; x<x0+block; x++){654                const idx = (y*CANVAS_PX + x)*4; // RGBA655                const r=src[idx], g=src[idx+1], b=src[idx+2];656                const gray = (r+g+b)/3/255; // 1: white, 0: black657                const ink = 1-gray; // 1: ink/black658                acc += ink; cnt++;659              }660            }661            out[gy*28+gx] = acc/(cnt||1);662          }663        }664        return out;665      }666 667      function clearCanvas(){ ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,CANVAS_PX,CANVAS_PX); runPipeline(); }668 669      // Drawing interactions670      let drawing=false; let last=null;671      let hasInteracted=false;672      const getPos = (ev) => {673        const rect = canvas.getBoundingClientRect();674        const sx = CANVAS_PX/rect.width; const sy = CANVAS_PX/rect.height;675        const x = (('touches' in ev)? ev.touches[0].clientX : ev.clientX) - rect.left;676        const y = (('touches' in ev)? ev.touches[0].clientY : ev.clientY) - rect.top;677        return { x: x*sx, y: y*sy };678      };679      function drawTo(p){680        const size = 24;681        ctx.lineCap='round'; ctx.lineJoin='round'; ctx.strokeStyle='#000000'; ctx.lineWidth=size;682        if (!last) last = p;683        ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke();684        last = p; runPipeline();685      }686      function onDown(ev){687        drawing=true; last=null;688        if (!hasInteracted){ hasInteracted=true; try { eraseBtn.style.display = 'flex'; } catch(_) {} }689        drawTo(getPos(ev)); ev.preventDefault();690      }691      function onMove(ev){ if (!drawing) return; drawTo(getPos(ev)); ev.preventDefault(); }692      function onUp(){ drawing=false; last=null; }693      canvas.addEventListener('mousedown', onDown); canvas.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp);694      canvas.addEventListener('touchstart', onDown, { passive:false }); canvas.addEventListener('touchmove', onMove, { passive:false }); window.addEventListener('touchend', onUp);695 696      // (erase button handled as overlay)697      const rerender = () => { renderGraph(true); };698      if (window.ResizeObserver) {699        const ro = new ResizeObserver(()=>rerender());700        ro.observe(right);701        ro.observe(canvas);702      } else { window.addEventListener('resize', rerender); }703 704      // TFJS model (optional)705      let tfModel = null;706      const tryLoadModel = async () => {707        await new Promise((res)=> ensureTF(res));708        const candidates = [709          // Prefer public path via symlink to assets/data710          '/data/mnist-variant-model.json',711          // Fallbacks to relative copies under content assets (shards must be colocated)712          './assets/data/mnist-variant-model.json',713          '../assets/data/mnist-variant-model.json',714          '/assets/data/mnist-variant-model.json',715          // Fallback to public TFJS MNIST716          'https://storage.googleapis.com/tfjs-models/tfjs/mnist/model.json'717        ];718        for (const u of candidates){719          try { tfModel = await tf.loadLayersModel(u); return; } catch(_) { /* try next */ }720        }721        tfModel = null;722      };723 724      function predictTfjs(x28){725        if (!tfModel || !window.tf) return null;726        const run = (arr) => {727          const t = tf.tidy(()=> tf.tensor(arr, [28,28,1]).expandDims(0));728          try { const y = tfModel.predict(t); const p = y.softmax(); const out = Array.from(p.dataSync()); tf.dispose([y,p,t]); return out; } catch(e){ tf.dispose(t); return null; }729        };730        // Try both orientations and keep the one with higher confidence731        const p1 = run(x28);732        const inv = x28.map(v=>1-v);733        const p2 = run(inv);734        let probs = p1 || p2;735        if (p1 && p2){736          const m1 = Math.max(...p1), m2 = Math.max(...p2);737          probs = m2>m1 ? p2 : p1;738        }739        if (!probs) return null;740        // Normalize output size to 10 classes (pad or slice)741        if (probs.length < 10){ probs = probs.concat(Array(10 - probs.length).fill(0)); }742        if (probs.length > 10){ probs = probs.slice(0,10); }743        return probs;744      }745 746      // Initial render747      renderGraph(true);748      clearCanvas();749      tryLoadModel();750    };751 752    if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }753  })();754</script>755 756 757 758 759