Week 14: Application and Interface Programming

Planted May 6, 2026

Week 14: Application and Interface Programming

Overview

This week in Fab Academy was about connecting software to a real device in a practical way. For my assignment, I decided to build something directly useful for my final project: a small drawing app that creates custom sleep screens for my e-ink display.

Drawing app UI

Group Assignment

The group task was to compare as many interface and application tool options as possible. We focused on what is realistic for quick prototyping versus what scales better for robust systems.

We compared:

  • Programming language choices
  • Device communication options (serial, I2C, sockets, MQTT)
  • Data formats (files, JSON, spreadsheet, database)
  • UI frameworks (desktop and web)
  • Graphics options (Canvas, SVG, WebGL)
  • AI coding tools and practical limitations

This comparison helped me choose a browser + MicroPython architecture because it gave me the fastest build-test loop.

Group app link: fakre group application

Individual Assignment

For the individual task, I wrote an application that interfaces the user with an output device I made (my e-ink module on Pico W). My goal was not only to make it work, but to make it easy to reuse later in final-project development.

Main deliverables:

  • Web drawing interface (250x122 monochrome)
  • Backend running on Pico W using MicroPython
  • HTTP endpoints for status, draw, and clear
  • Partial/full e-ink refresh workflow

Development

System flow

  1. User draws on browser canvas
  2. Canvas is converted to 1-bit packed bytes
  3. Browser sends bytes to /draw
  4. Pico W writes bytes into e-ink framebuffer
  5. Display refreshes in partial or full mode

In practice, this flow felt very close to the Fab Academy spirit: make a tool, test it physically, fail quickly, and improve immediately.

Send controls

Build and test photos

Browser UI during drawing and send workflow:

maze on black UI workflow photo 1

mandelbrot UI workflow photo 2

Hardware-side testing and live e-ink update checks:

complex flower Hardware test photo 1

honeycomb Hardware test photo 2

ed.) Hardware test photo 3

App interface (HTML/CSS/JS)

The drawing UI served to the browser lives in canvas_server.py as the HTML_PAGE embedded string. Extracted copy for highlight/read:

Download: canvas_app_interface.html

Full browser app interface source (canvas_app_interface.html)
<!-- Snapshot of HTML_PAGE from canvas_server.py — regenerate if the embedded UI changes. -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>PICO E-INK CANVAS</title>
<style>
  :root{--ink:#111;--paper:#f4f1ea;--rule:#111;--accent:#ff3b30;--mute:#7a7268;}
  *{box-sizing:border-box}
  html,body{margin:0;background:var(--paper);color:var(--ink);
    font-family:"JetBrains Mono","IBM Plex Mono",ui-monospace,Menlo,monospace;}
  body{min-height:100vh;padding:18px clamp(12px,3vw,32px);
    background-image:
      repeating-linear-gradient(0deg,rgba(0,0,0,.04) 0 1px,transparent 1px 24px),
      repeating-linear-gradient(90deg,rgba(0,0,0,.04) 0 1px,transparent 1px 24px);}
  header{display:flex;align-items:flex-end;justify-content:space-between;
    border-bottom:2px solid var(--rule);padding-bottom:8px;margin-bottom:14px;flex-wrap:wrap;gap:8px;}
  header h1{margin:0;font-size:clamp(20px,3.4vw,30px);letter-spacing:.04em;
    text-transform:uppercase;font-weight:800;}
  header h1 .dot{color:var(--accent)}
  header .meta{font-size:12px;color:var(--mute);text-transform:uppercase;letter-spacing:.12em}
  #status{font-weight:700;color:var(--ink)}
  #status.bad{color:var(--accent)}
  main{display:grid;grid-template-columns:minmax(0,1fr) 260px;gap:18px;align-items:start;}
  @media (max-width:780px){main{grid-template-columns:1fr}}
  .stage{background:#fff;border:2px solid var(--rule);box-shadow:6px 6px 0 var(--rule);
    padding:14px;position:relative;overflow:hidden;}
  .stage::before{content:"250 \\00D7 122 \\00B7 MONO";position:absolute;top:6px;right:10px;
    font-size:10px;letter-spacing:.18em;color:var(--mute);}
  .canvas-wrap{display:flex;justify-content:center;align-items:center;padding:8px 0 4px;}
  #view{image-rendering:pixelated;image-rendering:crisp-edges;
    width:min(100%, 750px);aspect-ratio:250 / 122;background:#fff;
    border:1px solid var(--rule);cursor:crosshair;touch-action:none;display:block;}
  .ruler{margin-top:8px;display:flex;justify-content:space-between;
    font-size:10px;color:var(--mute);letter-spacing:.18em;}
  aside{border:2px solid var(--rule);background:#fff;box-shadow:6px 6px 0 var(--rule);
    padding:12px;display:flex;flex-direction:column;gap:14px;}
  .group{display:flex;flex-direction:column;gap:6px}
  .label{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);
    border-bottom:1px dashed var(--rule);padding-bottom:3px;margin-bottom:2px;}
  .row{display:flex;flex-wrap:wrap;gap:6px}
  button{appearance:none;border:1.5px solid var(--rule);background:var(--paper);
    color:var(--ink);font-family:inherit;font-size:12px;padding:7px 9px;cursor:pointer;
    letter-spacing:.04em;text-transform:uppercase;font-weight:700;
    transition:transform .04s ease, background .1s ease;}
  button:hover{background:#fff}
  button:active{transform:translate(1px,1px)}
  button.on{background:var(--ink);color:var(--paper)}
  button.send{background:var(--accent);color:#fff;border-color:var(--accent);
    box-shadow:3px 3px 0 var(--rule);}
  button.send:hover{background:#e62e23}
  button.danger{border-color:var(--accent);color:var(--accent)}
  input[type=range]{width:100%}
  .size-readout{font-size:11px;color:var(--mute);text-align:right}
  .swatches{display:flex;gap:6px}
  .sw{width:32px;height:24px;border:1.5px solid var(--rule);cursor:pointer;
    display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;}
  .sw.black{background:#000;color:#fff}
  .sw.white{background:#fff;color:#000}
  .sw.on{outline:3px solid var(--accent);outline-offset:1px}
  footer{margin-top:14px;border-top:2px solid var(--rule);padding-top:6px;
    display:flex;justify-content:space-between;font-size:10px;color:var(--mute);
    letter-spacing:.16em;text-transform:uppercase;flex-wrap:wrap;gap:6px;}
  kbd{font-family:inherit;border:1px solid var(--rule);padding:1px 4px;
    background:var(--paper);font-size:10px;}
</style>
</head>
<body>
<header>
  <h1>PICO E-INK CANVAS<span class="dot">.</span></h1>
  <div class="meta">STATUS: <span id="status">ready</span></div>
</header>
<main>
  <section class="stage">
    <div id="dbg" style="background:#fff7d6;border:1px solid #c8b663;padding:4px 8px;font-size:11px;margin-bottom:8px;font-family:ui-monospace,monospace;color:#000">init...</div>
    <div class="canvas-wrap">
      <canvas id="view" width="250" height="122"></canvas>
    </div>
    <div class="ruler"><span>0</span><span>125</span><span>250 PX</span></div>
  </section>
  <aside>
    <div class="group">
      <div class="label">Tool</div>
      <div class="row" id="tools">
        <button data-tool="brush" class="on">Brush</button>
        <button data-tool="eraser">Eraser</button>
        <button data-tool="line">Line</button>
        <button data-tool="rect">Rect</button>
        <button data-tool="circle">Circle</button>
        <button data-tool="fill">Fill</button>
      </div>
    </div>
    <div class="group">
      <div class="label">Ink</div>
      <div class="swatches" id="swatches">
        <div class="sw black on" data-ink="0">B</div>
        <div class="sw white" data-ink="1">W</div>
      </div>
    </div>
    <div class="group">
      <div class="label">Brush size</div>
      <input id="size" type="range" min="1" max="16" value="2">
      <div class="size-readout"><span id="sizeOut">2</span> px</div>
    </div>
    <div class="group">
      <div class="label">Waves &amp; flow</div>
      <div class="row">
        <button data-gen="scribble">Scribble</button>
        <button data-gen="sinewave">Sine</button>
        <button data-gen="interference">Ripples</button>
        <button data-gen="rose">Rose</button>
        <button data-gen="lissajous">Lissajous</button>
        <button data-gen="flow">Flow field</button>
      </div>
    </div>
    <div class="group">
      <div class="label">Symmetry &amp; tiling</div>
      <div class="row">
        <button data-gen="truchet">Truchet</button>
        <button data-gen="kaleido">Kaleido</button>
        <button data-gen="hex">Hex grid</button>
        <button data-gen="herringbone">Herring</button>
        <button data-gen="weave">Weave</button>
        <button data-gen="moire">Moir\u00e9</button>
      </div>
    </div>
    <div class="group">
      <div class="label">Fractals</div>
      <div class="row">
        <button data-gen="sierpinski">Sierpinski</button>
        <button data-gen="carpet">Carpet</button>
        <button data-gen="dragon">Dragon</button>
        <button data-gen="koch">Koch</button>
        <button data-gen="hilbert">Hilbert</button>
        <button data-gen="mandelbrot">Mandel</button>
      </div>
    </div>
    <div class="group">
      <div class="label">Geometric</div>
      <div class="row">
        <button data-gen="grad">Gradient</button>
        <button data-gen="circles">Circles</button>
        <button data-gen="sunburst">Sunburst</button>
        <button data-gen="parabolic">Stitching</button>
        <button data-gen="mystic">Mystic rose</button>
        <button data-gen="spirograph">Spiro</button>
      </div>
    </div>
    <div class="group">
      <div class="label">Cellular &amp; random</div>
      <div class="row">
        <button data-gen="stars">Stars</button>
        <button data-gen="noise">Noise</button>
        <button data-gen="maze">Maze</button>
        <button data-gen="life">Life</button>
        <button data-gen="voronoi">Voronoi</button>
        <button data-gen="dla">DLA tree</button>
      </div>
    </div>
    <div class="group">
      <div class="label">Canvas</div>
      <div class="row">
        <button id="btn-undo">Undo</button>
        <button id="btn-clear" class="danger">Clear</button>
        <button id="btn-invert">Invert</button>
        <button id="btn-png">Save PNG</button>
      </div>
    </div>
    <div class="group">
      <div class="label">Send to e-ink</div>
      <div class="row">
        <button id="btn-send" class="send">Send (partial)</button>
        <button id="btn-sendfull">Send (full)</button>
      </div>
      <div class="row">
        <button id="btn-wipe" class="danger">Blank screen</button>
      </div>
    </div>
  </aside>
</main>
<footer>
  <span>Pico W \\u00b7 250\\u00d7122 \\u00b7 1bpp</span>
  <span><kbd>B</kbd> brush \\u00b7 <kbd>E</kbd> eraser \\u00b7 <kbd>X</kbd> swap \\u00b7 <kbd>Ctrl+Z</kbd> undo \\u00b7 <kbd>Enter</kbd> send</span>
</footer>
<script>
window.addEventListener('error', e => {
  const d = document.getElementById('dbg');
  if (d) d.textContent = 'JS ERROR: ' + e.message + ' @ ' + e.lineno;
});

(function(){
  const W = 250, H = 122;
  const view = document.getElementById('view');
  const dbg = document.getElementById('dbg');
  const setDbg = m => { if(dbg) dbg.textContent = m; };

  if(!view){ setDbg('FATAL: canvas element missing'); return; }
  const ctx = view.getContext('2d');
  if(!ctx){ setDbg('FATAL: 2D context unavailable'); return; }

  // pixel buffer: 1 byte per pixel, 0=black 1=white
  let pixels = new Uint8Array(W*H);
  pixels.fill(1);

  // --- repaint via ImageData ---
  const imgd = ctx.createImageData(W, H);
  function repaint(){
    const d = imgd.data;
    for(let i=0;i<pixels.length;i++){
      const v = pixels[i] ? 255 : 0;
      const j = i*4;
      d[j]=v; d[j+1]=v; d[j+2]=v; d[j+3]=255;
    }
    ctx.putImageData(imgd, 0, 0);
  }
  repaint();
  setDbg('ready - tap or drag the canvas');

  // --- drawing ops in pixel space ---
  function setPx(x,y,ink){
    if(x<0||y<0||x>=W||y>=H) return;
    pixels[y*W + x] = ink;
  }
  function stamp(cx,cy,r,ink){
    cx|=0; cy|=0;
    if(r<=1){ setPx(cx,cy,ink); return; }
    const h = Math.max(1, r/2)|0;
    const r2 = h*h;
    for(let y=Math.max(0,cy-h); y<=Math.min(H-1,cy+h); y++){
      for(let x=Math.max(0,cx-h); x<=Math.min(W-1,cx+h); x++){
        const dx=x-cx, dy=y-cy;
        if(dx*dx+dy*dy <= r2) pixels[y*W+x]=ink;
      }
    }
  }
  function drawLine(x0,y0,x1,y1,r,ink){
    x0|=0; y0|=0; x1|=0; y1|=0;
    const dx=Math.abs(x1-x0), sx=x0<x1?1:-1;
    const dy=-Math.abs(y1-y0), sy=y0<y1?1:-1;
    let err=dx+dy, guard=0;
    while(guard++<10000){
      stamp(x0,y0,r,ink);
      if(x0===x1 && y0===y1) break;
      const e2=2*err;
      if(e2>=dy){ err+=dy; x0+=sx; }
      if(e2<=dx){ err+=dx; y0+=sy; }
    }
  }
  function drawRect(x0,y0,x1,y1,r,ink){
    drawLine(x0,y0,x1,y0,r,ink); drawLine(x1,y0,x1,y1,r,ink);
    drawLine(x1,y1,x0,y1,r,ink); drawLine(x0,y1,x0,y0,r,ink);
  }
  function drawCircle(cx,cy,rad,r,ink){
    if(rad<1){ stamp(cx,cy,r,ink); return; }
    let x=rad,y=0,err=0,guard=0;
    while(x>=y && guard++<5000){
      stamp(cx+x,cy+y,r,ink); stamp(cx+y,cy+x,r,ink);
      stamp(cx-y,cy+x,r,ink); stamp(cx-x,cy+y,r,ink);
      stamp(cx-x,cy-y,r,ink); stamp(cx-y,cy-x,r,ink);
      stamp(cx+y,cy-x,r,ink); stamp(cx+x,cy-y,r,ink);
      y++; err+=1+2*y;
      if(2*(err-x)+1>0){ x--; err+=1-2*x; }
    }
  }
  function floodFill(sx,sy,ink){
    const target=pixels[sy*W+sx];
    if(target===undefined || target===ink) return;
    const stack=[sx,sy];
    while(stack.length){
      const y=stack.pop(), x=stack.pop();
      if(x<0||y<0||x>=W||y>=H) continue;
      if(pixels[y*W+x]!==target) continue;
      pixels[y*W+x]=ink;
      stack.push(x+1,y, x-1,y, x,y+1, x,y-1);
    }
  }

  // --- state ---
  let tool='brush', ink=0, size=3;
  let drawing=false, last=null, startPt=null, snap=null;
  const undoStack=[];
  function pushUndo(){
    undoStack.push(new Uint8Array(pixels));
    if(undoStack.length>20) undoStack.shift();
  }

  // --- pointer math: get pixel coords from any event ---
  function getXY(clientX, clientY){
    const r = view.getBoundingClientRect();
    let x = ((clientX - r.left) / r.width) * W;
    let y = ((clientY - r.top)  / r.height) * H;
    x = Math.max(0, Math.min(W-1, x|0));
    y = Math.max(0, Math.min(H-1, y|0));
    return [x,y];
  }

  function startStroke(clientX, clientY){
    pushUndo();
    drawing = true;
    const [x,y] = getXY(clientX, clientY);
    startPt = [x,y]; last = [x,y];
    setDbg('down @ '+x+','+y+' tool='+tool);
    if(tool==='brush' || tool==='eraser'){
      stamp(x, y, size, tool==='eraser'?1:ink);
      repaint();
    } else if(tool==='fill'){
      floodFill(x, y, ink);
      repaint();
      drawing = false;
    } else {
      snap = new Uint8Array(pixels);
    }
  }
  function moveStroke(clientX, clientY){
    if(!drawing) return;
    const [x,y] = getXY(clientX, clientY);
    if(tool==='brush' || tool==='eraser'){
      drawLine(last[0], last[1], x, y, size, tool==='eraser'?1:ink);
      last = [x,y];
      repaint();
      setDbg('draw @ '+x+','+y);
    } else if(tool==='line' || tool==='rect' || tool==='circle'){
      pixels.set(snap);
      if(tool==='line') drawLine(startPt[0],startPt[1],x,y,size,ink);
      else if(tool==='rect') drawRect(startPt[0],startPt[1],x,y,size,ink);
      else {
        const dx=x-startPt[0], dy=y-startPt[1];
        drawCircle(startPt[0], startPt[1], Math.round(Math.sqrt(dx*dx+dy*dy)), size, ink);
      }
      repaint();
    }
  }
  function endStroke(){
    if(!drawing) return;
    drawing=false; last=null; startPt=null; snap=null;
    setDbg('stroke ended');
  }

  // --- attach pointer events with both pointer + mouse + touch fallbacks ---
  if('PointerEvent' in window){
    view.addEventListener('pointerdown', e => {
      e.preventDefault();
      try{ view.setPointerCapture(e.pointerId); }catch(_){}
      startStroke(e.clientX, e.clientY);
    });
    view.addEventListener('pointermove', e => {
      if(drawing){ e.preventDefault(); moveStroke(e.clientX, e.clientY); }
    });
    window.addEventListener('pointerup', endStroke);
    window.addEventListener('pointercancel', endStroke);
    setDbg('using PointerEvent');
  } else {
    view.addEventListener('mousedown', e => { e.preventDefault(); startStroke(e.clientX, e.clientY); });
    window.addEventListener('mousemove', e => moveStroke(e.clientX, e.clientY));
    window.addEventListener('mouseup', endStroke);
    view.addEventListener('touchstart', e => {
      if(!e.touches[0]) return;
      e.preventDefault();
      startStroke(e.touches[0].clientX, e.touches[0].clientY);
    }, {passive:false});
    view.addEventListener('touchmove', e => {
      if(!e.touches[0]) return;
      e.preventDefault();
      moveStroke(e.touches[0].clientX, e.touches[0].clientY);
    }, {passive:false});
    view.addEventListener('touchend', endStroke);
    view.addEventListener('touchcancel', endStroke);
    setDbg('using mouse+touch fallback');
  }

  // --- toolbar ---
  document.getElementById('tools').addEventListener('click', e => {
    const b = e.target.closest('button'); if(!b) return;
    tool = b.dataset.tool;
    document.querySelectorAll('#tools button').forEach(x => x.classList.toggle('on', x===b));
    setDbg('tool: '+tool);
  });
  document.getElementById('swatches').addEventListener('click', e => {
    const s = e.target.closest('.sw'); if(!s) return;
    ink = parseInt(s.dataset.ink, 10);
    document.querySelectorAll('#swatches .sw').forEach(x => x.classList.toggle('on', x===s));
    setDbg('ink: '+(ink?'white':'black'));
  });
  const sizeIn = document.getElementById('size');
  const sizeOut = document.getElementById('sizeOut');
  size = +sizeIn.value || 3;
  sizeOut.textContent = size;
  sizeIn.addEventListener('input', () => { size = +sizeIn.value; sizeOut.textContent = size; });

  document.getElementById('btn-clear').addEventListener('click', () => { pushUndo(); pixels.fill(1); repaint(); });
  document.getElementById('btn-invert').addEventListener('click', () => {
    pushUndo(); for(let i=0;i<pixels.length;i++) pixels[i]^=1; repaint();
  });
  document.getElementById('btn-undo').addEventListener('click', () => {
    if(!undoStack.length) return; pixels = undoStack.pop(); repaint();
  });
  document.getElementById('btn-png').addEventListener('click', () => {
    const off = document.createElement('canvas'); off.width=W*4; off.height=H*4;
    const oc = off.getContext('2d'); oc.imageSmoothingEnabled=false;
    oc.drawImage(view, 0, 0, off.width, off.height);
    off.toBlob(b => {
      const a = document.createElement('a');
      a.href = URL.createObjectURL(b);
      a.download = 'pico-canvas.png'; a.click();
    });
  });

  // --- generators ---
  // --- generators (math / art patterns) ---
  // Helpers used by several gens
  function plotBresenham(x0,y0,x1,y1,ink){
    drawLine(x0|0,y0|0,x1|0,y1|0,1,ink);
  }
  function fillRectPx(x0,y0,x1,y1,ink){
    if(x0>x1){ const t=x0;x0=x1;x1=t; }
    if(y0>y1){ const t=y0;y0=y1;y1=t; }
    x0=Math.max(0,x0|0); y0=Math.max(0,y0|0);
    x1=Math.min(W-1,x1|0); y1=Math.min(H-1,y1|0);
    for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++) pixels[y*W+x]=ink;
  }

  const gens = {
    scribble(){
      pixels.fill(1); let x=W/2, y=H/2;
      for(let i=0;i<600;i++){
        const nx=x+(Math.random()-.5)*14, ny=y+(Math.random()-.5)*14;
        drawLine(x|0, y|0, nx|0, ny|0, 1, 0);
        x=Math.max(2,Math.min(W-3,nx)); y=Math.max(2,Math.min(H-3,ny));
      }
    },
    sinewave(){
      pixels.fill(1);
      const layers = 5;
      for(let L=0; L<layers; L++){
        const amp = 8 + L*4;
        const freq = 0.05 + L*0.015;
        const phase = L*0.7;
        const yc = H/2 + (L-layers/2)*4;
        let py = (yc + Math.sin(phase)*amp)|0;
        for(let x=0; x<W; x++){
          const y = (yc + Math.sin(x*freq + phase)*amp)|0;
          drawLine(x-1, py, x, y, 1, 0);
          py = y;
        }
      }
    },
    interference(){
      // Two wave sources; threshold the sum to make moire/ripple bands
      pixels.fill(1);
      const s1x=W*0.30, s1y=H*0.5;
      const s2x=W*0.70, s2y=H*0.5;
      const k = 0.55; // wave number
      for(let y=0;y<H;y++){
        for(let x=0;x<W;x++){
          const d1 = Math.sqrt((x-s1x)**2+(y-s1y)**2);
          const d2 = Math.sqrt((x-s2x)**2+(y-s2y)**2);
          const v = Math.cos(d1*k) + Math.cos(d2*k);
          pixels[y*W+x] = v>0 ? 0 : 1;
        }
      }
    },
    rose(){
      // Rose curve r = a*cos(k*theta), k = n/d
      pixels.fill(1);
      const cx=W/2, cy=H/2;
      const a = Math.min(W,H)*0.45;
      const n = 5, d = 1;          // 5-petal rose; tweak for variety
      const steps = 4000;
      let px=null, py=null;
      for(let i=0; i<=steps; i++){
        const th = (i/steps) * Math.PI * 2 * d;
        const r = a * Math.cos(n/d * th);
        const x = (cx + r*Math.cos(th))|0;
        const y = (cy + r*Math.sin(th))|0;
        if(px!==null) drawLine(px,py,x,y,1,0);
        px=x; py=y;
      }
    },
    lissajous(){
      pixels.fill(1);
      const cx=W/2, cy=H/2;
      const A=W*0.46, B=H*0.46;
      const a=3, b=2, delta=Math.PI/2;
      const steps=3000;
      let px=null, py=null;
      for(let i=0;i<=steps;i++){
        const t = (i/steps)*Math.PI*2;
        const x = (cx + A*Math.sin(a*t + delta))|0;
        const y = (cy + B*Math.sin(b*t))|0;
        if(px!==null) drawLine(px,py,x,y,1,0);
        px=x; py=y;
      }
    },
    flow(){
      // Pseudo-Perlin flow field via cheap value-noise hash
      pixels.fill(1);
      function hash(x,y){
        let h = (x*374761393 + y*668265263) | 0;
        h = (h ^ (h>>>13)) * 1274126177 | 0;
        return ((h ^ (h>>>16)) >>> 0) / 4294967295;
      }
      function noise(x,y){
        const xi=Math.floor(x), yi=Math.floor(y);
        const xf=x-xi, yf=y-yi;
        const u=xf*xf*(3-2*xf), v=yf*yf*(3-2*yf);
        const n00=hash(xi,yi), n10=hash(xi+1,yi);
        const n01=hash(xi,yi+1), n11=hash(xi+1,yi+1);
        return (n00*(1-u)+n10*u)*(1-v) + (n01*(1-u)+n11*u)*v;
      }
      const seeds = 80, steps = 60;
      for(let s=0; s<seeds; s++){
        let x = Math.random()*W, y = Math.random()*H;
        for(let i=0;i<steps;i++){
          const ang = noise(x*0.04, y*0.04) * Math.PI * 4;
          const nx = x + Math.cos(ang)*1.4;
          const ny = y + Math.sin(ang)*1.4;
          drawLine(x|0,y|0,nx|0,ny|0,1,0);
          x=nx; y=ny;
          if(x<0||x>=W||y<0||y>=H) break;
        }
      }
    },

    truchet(){
      pixels.fill(1); const t=12;
      for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
        if(Math.random()<.5) drawLine(x,y,x+t,y+t,1,0);
        else drawLine(x+t,y,x,y+t,1,0);
      }
    },
    kaleido(){
      // Draw random strokes in one wedge, then mirror across N-fold symmetry
      pixels.fill(1);
      const cx=W/2, cy=H/2;
      const N = 6;
      const segs = 30;
      const pts = [];
      for(let i=0;i<segs;i++){
        const r1 = Math.random()*Math.min(W,H)*0.45;
        const r2 = Math.random()*Math.min(W,H)*0.45;
        const a1 = Math.random()*Math.PI*2/N;
        const a2 = a1 + (Math.random()-.5)*0.6;
        pts.push([r1,a1,r2,a2]);
      }
      for(let k=0;k<N;k++){
        const base = k*Math.PI*2/N;
        for(const [r1,a1,r2,a2] of pts){
          const x1=cx+Math.cos(base+a1)*r1, y1=cy+Math.sin(base+a1)*r1;
          const x2=cx+Math.cos(base+a2)*r2, y2=cy+Math.sin(base+a2)*r2;
          drawLine(x1|0,y1|0,x2|0,y2|0,1,0);
          // mirror within wedge
          const x1m=cx+Math.cos(base-a1)*r1, y1m=cy+Math.sin(base-a1)*r1;
          const x2m=cx+Math.cos(base-a2)*r2, y2m=cy+Math.sin(base-a2)*r2;
          drawLine(x1m|0,y1m|0,x2m|0,y2m|0,1,0);
        }
      }
    },
    hex(){
      pixels.fill(1);
      const r = 8;                    // hex circumradius
      const dx = r*Math.sqrt(3);
      const dy = r*1.5;
      for(let row=-1; row*dy<H+r; row++){
        for(let col=-1; col*dx<W+r; col++){
          const cx = col*dx + (row&1?dx/2:0);
          const cy = row*dy;
          // hex outline
          let px=null, py=null;
          for(let k=0;k<=6;k++){
            const a = Math.PI/3*k - Math.PI/2;
            const x = (cx+Math.cos(a)*r)|0;
            const y = (cy+Math.sin(a)*r)|0;
            if(px!==null) drawLine(px,py,x,y,1,0);
            px=x; py=y;
          }
        }
      }
    },
    herringbone(){
      pixels.fill(1);
      const bw=18, bh=6;
      for(let y=0;y<H+bh;y+=bh){
        for(let x=-bw;x<W;x+=bw){
          const ox = ((y/bh)|0) % 2 === 0 ? 0 : bw/2;
          // diagonal brick
          drawLine(x+ox, y, x+ox+bw, y+bh, 1, 0);
        }
      }
    },
    weave(){
      pixels.fill(1);
      const t=6;
      // horizontal stripes
      for(let y=0;y<H;y+=t*2){
        for(let x=0;x<W;x+=t*2){
          fillRectPx(x, y, x+t-1, y+t-1, 0);
          fillRectPx(x+t, y+t, x+2*t-1, y+2*t-1, 0);
        }
      }
    },
    moire(){
      // Two rotated line gratings
      pixels.fill(1);
      const a1 = 0.1, a2 = -0.13;
      const sp = 4;
      for(let y=0;y<H;y++){
        for(let x=0;x<W;x++){
          const u = x*Math.cos(a1)+y*Math.sin(a1);
          const v = x*Math.cos(a2)+y*Math.sin(a2);
          const on = (Math.floor(u/sp)&1) ^ (Math.floor(v/sp)&1);
          if(on) pixels[y*W+x]=0;
        }
      }
    },

    sierpinski(){
      pixels.fill(1);
      const ax=W/2, ay=4;
      const bx=4, by=H-4;
      const cx=W-4, cy=H-4;
      let px=W/2, py=H/2;
      const verts=[[ax,ay],[bx,by],[cx,cy]];
      // Chaos game
      for(let i=0;i<8000;i++){
        const v = verts[(Math.random()*3)|0];
        px = (px+v[0])/2;
        py = (py+v[1])/2;
        if(i>20) setPx(px|0, py|0, 0);
      }
    },
    carpet(){
      // Sierpinski carpet by recursive subdivision
      pixels.fill(0); // start black, carve white holes
      function carve(x,y,w,h,depth){
        if(depth===0 || w<3 || h<3) return;
        const w3=w/3, h3=h/3;
        fillRectPx(x+w3, y+h3, x+2*w3-1, y+2*h3-1, 1);
        for(let iy=0;iy<3;iy++) for(let ix=0;ix<3;ix++){
          if(ix===1 && iy===1) continue;
          carve(x+ix*w3, y+iy*h3, w3, h3, depth-1);
        }
      }
      carve(0,0,W,H,4);
    },
    dragon(){
      pixels.fill(1);
      let path = [1];
      for(let i=0;i<11;i++){
        const rev = [];
        for(let j=path.length-1;j>=0;j--) rev.push(path[j]^1);
        path = path.concat([1]).concat(rev);
      }
      // walk
      let x=W*0.35, y=H*0.55, dir=0;
      const step=2;
      for(const turn of path){
        const nx = x + Math.cos(dir)*step;
        const ny = y + Math.sin(dir)*step;
        drawLine(x|0,y|0,nx|0,ny|0,1,0);
        x=nx; y=ny;
        dir += turn ? Math.PI/2 : -Math.PI/2;
      }
    },
    koch(){
      pixels.fill(1);
      // Koch snowflake from equilateral triangle, 4 iterations
      function koch(p1,p2,depth){
        if(depth===0){
          drawLine(p1[0]|0,p1[1]|0,p2[0]|0,p2[1]|0,1,0);
          return;
        }
        const dx=(p2[0]-p1[0])/3, dy=(p2[1]-p1[1])/3;
        const a=[p1[0]+dx, p1[1]+dy];
        const b=[p1[0]+2*dx, p1[1]+2*dy];
        const ang = Math.atan2(dy,dx) - Math.PI/3;
        const len = Math.sqrt(dx*dx+dy*dy);
        const peak=[a[0]+Math.cos(ang)*len, a[1]+Math.sin(ang)*len];
        koch(p1,a,depth-1); koch(a,peak,depth-1);
        koch(peak,b,depth-1); koch(b,p2,depth-1);
      }
      const cx=W/2, cy=H/2;
      const r=Math.min(W,H)*0.42;
      const v=[];
      for(let i=0;i<3;i++){
        const a = -Math.PI/2 + i*Math.PI*2/3;
        v.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
      }
      koch(v[0],v[1],4); koch(v[1],v[2],4); koch(v[2],v[0],4);
    },
    hilbert(){
      pixels.fill(1);
      // Iterative Hilbert curve fitting in min(W,H) square
      const order = 5; // 2^5 = 32 cells per side
      const N = 1<<order;
      const cell = Math.min(W,H)/N;
      const ox = (W-N*cell)/2, oy = (H-N*cell)/2;
      function d2xy(d){
        let x=0,y=0,t=d;
        for(let s=1;s<N;s<<=1){
          const rx = 1 & (t>>1);
          const ry = 1 & (t ^ rx);
          if(ry===0){
            if(rx===1){ x=s-1-x; y=s-1-y; }
            const tmp=x; x=y; y=tmp;
          }
          x += s*rx; y += s*ry;
          t >>= 2;
        }
        return [x,y];
      }
      let prev=null;
      for(let i=0;i<N*N;i++){
        const [gx,gy]=d2xy(i);
        const x = (ox + gx*cell + cell/2)|0;
        const y = (oy + gy*cell + cell/2)|0;
        if(prev) drawLine(prev[0],prev[1],x,y,1,0);
        prev=[x,y];
      }
    },
    mandelbrot(){
      pixels.fill(1);
      const xmin=-2.1, xmax=0.7, ymin=-1.0, ymax=1.0;
      const maxIter=24;
      for(let py=0;py<H;py++){
        const cy = ymin + (ymax-ymin)*py/H;
        for(let px=0;px<W;px++){
          const cx = xmin + (xmax-xmin)*px/W;
          let zx=0, zy=0, i=0;
          while(i<maxIter && zx*zx+zy*zy<4){
            const t = zx*zx-zy*zy+cx;
            zy = 2*zx*zy+cy;
            zx = t;
            i++;
          }
          // Threshold dither: high-iter -> dark, low-iter -> light
          const v = i/maxIter;
          const bayer = ((px+py*7)*0.137)%1;
          pixels[py*W+px] = v < 0.95 && v*1.1 > bayer ? 0 : 1;
          if(i===maxIter) pixels[py*W+px]=0;
        }
      }
    },

    grad(){
      const bayer=[
        [0,32,8,40,2,34,10,42],[48,16,56,24,50,18,58,26],
        [12,44,4,36,14,46,6,38],[60,28,52,20,62,30,54,22],
        [3,35,11,43,1,33,9,41],[51,19,59,27,49,17,57,25],
        [15,47,7,39,13,45,5,37],[63,31,55,23,61,29,53,21]];
      for(let y=0;y<H;y++) for(let x=0;x<W;x++){
        pixels[y*W+x] = ((x/(W-1))*64 > bayer[y&7][x&7]) ? 1 : 0;
      }
    },
    circles(){
      pixels.fill(1);
      const cx=W/2, cy=H/2;
      for(let r=4; r<Math.max(W,H); r+=6){
        drawCircle(cx,cy,r,1,0);
      }
    },
    sunburst(){
      pixels.fill(1);
      const cx=W/2, cy=H/2;
      const rays = 64;
      const R = Math.max(W,H);
      for(let i=0;i<rays;i++){
        const a = i*Math.PI*2/rays;
        const x = cx+Math.cos(a)*R;
        const y = cy+Math.sin(a)*R;
        drawLine(cx|0,cy|0,x|0,y|0,1,0);
      }
    },
    parabolic(){
      // Curve stitching: connect points across two perpendicular axes
      pixels.fill(1);
      const N = 24;
      const x0=4, y0=4, x1=W-4, y1=H-4;
      // top-left corner
      for(let i=0;i<=N;i++){
        const t = i/N;
        drawLine(x0+(x1-x0)*t|0, y0|0, x0|0, y0+(y1-y0)*t|0, 1, 0);
      }
      // bottom-right corner
      for(let i=0;i<=N;i++){
        const t = i/N;
        drawLine(x1-(x1-x0)*t|0, y1|0, x1|0, y1-(y1-y0)*t|0, 1, 0);
      }
    },
    mystic(){
      // Mystic rose: N points on a circle, every chord drawn
      pixels.fill(1);
      const cx=W/2, cy=H/2;
      const r = Math.min(W,H)*0.45;
      const N = 18;
      const pts = [];
      for(let i=0;i<N;i++){
        const a = i*Math.PI*2/N - Math.PI/2;
        pts.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
      }
      for(let i=0;i<N;i++) for(let j=i+1;j<N;j++){
        drawLine(pts[i][0]|0, pts[i][1]|0, pts[j][0]|0, pts[j][1]|0, 1, 0);
      }
    },
    spirograph(){
      // Hypotrochoid: outer R, inner r, pen offset d
      pixels.fill(1);
      const cx=W/2, cy=H/2;
      const R = Math.min(W,H)*0.42;
      const r = R*0.31;
      const d = R*0.55;
      const steps = 4000;
      let px=null, py=null;
      for(let i=0;i<=steps;i++){
        const t = (i/steps)*Math.PI*2*7;
        const x = ((R-r)*Math.cos(t) + d*Math.cos((R-r)/r*t)) + cx;
        const y = ((R-r)*Math.sin(t) - d*Math.sin((R-r)/r*t)) + cy;
        if(px!==null) drawLine(px|0,py|0,x|0,y|0,1,0);
        px=x; py=y;
      }
    },

    stars(){
      pixels.fill(0);
      for(let i=0;i<180;i++){
        stamp((Math.random()*W)|0, (Math.random()*H)|0,
              Math.random()<.15?2:1, 1);
      }
    },
    noise(){
      for(let i=0;i<pixels.length;i++) pixels[i] = Math.random()<.5 ? 0 : 1;
    },
    maze(){
      pixels.fill(1); const t=8;
      for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
        if(Math.random()<.5) drawLine(x,y,x+t,y,1,0);
        else drawLine(x,y,x,y+t,1,0);
      }
      drawRect(0,0,W-1,H-1,1,0);
    },
    life(){
      // Run Conway's Game of Life from random soup, snapshot at step N
      let cur = new Uint8Array(W*H);
      for(let i=0;i<cur.length;i++) cur[i] = Math.random()<.35 ? 1 : 0;
      let nxt = new Uint8Array(W*H);
      for(let gen=0; gen<30; gen++){
        for(let y=0;y<H;y++){
          for(let x=0;x<W;x++){
            let n=0;
            for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
              if(dx===0&&dy===0) continue;
              const xx=(x+dx+W)%W, yy=(y+dy+H)%H;
              n += cur[yy*W+xx];
            }
            const c = cur[y*W+x];
            nxt[y*W+x] = (c && (n===2||n===3)) || (!c && n===3) ? 1 : 0;
          }
        }
        const t=cur; cur=nxt; nxt=t;
      }
      // alive=black on white paper
      for(let i=0;i<pixels.length;i++) pixels[i] = cur[i] ? 0 : 1;
    },
    voronoi(){
      // Voronoi cell edges: pixel is "edge" if its nearest seed differs from a neighbor's
      const N = 22;
      const sx=[], sy=[];
      for(let i=0;i<N;i++){ sx.push(Math.random()*W); sy.push(Math.random()*H); }
      const owner = new Int16Array(W*H);
      for(let y=0;y<H;y++){
        for(let x=0;x<W;x++){
          let best=0, bd=1e9;
          for(let i=0;i<N;i++){
            const dx=x-sx[i], dy=y-sy[i];
            const d=dx*dx+dy*dy;
            if(d<bd){ bd=d; best=i; }
          }
          owner[y*W+x] = best;
        }
      }
      pixels.fill(1);
      for(let y=0;y<H;y++){
        for(let x=0;x<W;x++){
          const o = owner[y*W+x];
          if(x+1<W && owner[y*W+x+1]!==o){ pixels[y*W+x]=0; }
          else if(y+1<H && owner[(y+1)*W+x]!==o){ pixels[y*W+x]=0; }
        }
      }
    },
    dla(){
      // Diffusion-limited aggregation: random walkers stick to growing tree
      pixels.fill(1);
      const grid = new Uint8Array(W*H);
      const cx=W/2|0, cy=H/2|0;
      grid[cy*W+cx]=1; pixels[cy*W+cx]=0;
      const maxParticles = 800;
      for(let p=0;p<maxParticles;p++){
        // spawn on a circle of radius rmax
        let ang = Math.random()*Math.PI*2;
        let rad = Math.min(W,H)*0.45;
        let x = cx + (Math.cos(ang)*rad)|0;
        let y = cy + (Math.sin(ang)*rad)|0;
        for(let step=0; step<2000; step++){
          x += (Math.random()<.5?-1:1);
          y += (Math.random()<.5?-1:1);
          if(x<1||x>=W-1||y<1||y>=H-1){ break; }
          // touch?
          if(grid[(y-1)*W+x]||grid[(y+1)*W+x]||
             grid[y*W+x-1]||grid[y*W+x+1]){
            grid[y*W+x]=1; pixels[y*W+x]=0; break;
          }
        }
      }
    },
  };

  document.querySelectorAll('[data-gen]').forEach(btn => {
    btn.addEventListener('click', () => {
      const k = btn.dataset.gen;
      const fn = gens[k];
      if(!fn){ setDbg('unknown gen: '+k); return; }
      pushUndo();
      try { fn(); } catch(e){ setDbg('gen '+k+' err: '+e.message); }
      repaint();
      setDbg('generated: '+k);
    });
  });

  // --- network ---
  const statusEl = document.getElementById('status');
  function setStatus(msg, bad){ statusEl.textContent = msg; statusEl.classList.toggle('bad', !!bad); }

  function packBitmap(){
    const stride = (W+7)>>3;
    const out = new Uint8Array(stride * H);
    for(let y=0;y<H;y++){
      for(let xb=0;xb<stride;xb++){
        let b = 0;
        for(let bit=0;bit<8;bit++){
          const x = xb*8 + bit;
          const v = (x<W) ? pixels[y*W+x] : 1;
          if(v) b |= (1 << (7-bit));
        }
        out[y*stride + xb] = b;
      }
    }
    return out;
  }
  async function sendDraw(mode){
    setStatus('sending...');
    try{
      const r = await fetch('/draw?mode='+mode, {
        method: 'POST',
        headers: {'Content-Type':'application/octet-stream'},
        body: packBitmap()
      });
      if(!r.ok) throw new Error('HTTP '+r.status);
      const j = await r.json();
      setStatus('sent - '+j.mode+' - '+j.ms+'ms');
    } catch(err){ setStatus('send failed: '+err.message, true); }
  }
  async function sendClear(){
    setStatus('clearing...');
    try{
      const r = await fetch('/clear', {method:'POST'});
      if(!r.ok) throw new Error('HTTP '+r.status);
      setStatus('blanked');
    } catch(err){ setStatus('clear failed: '+err.message, true); }
  }
  document.getElementById('btn-send').addEventListener('click', () => sendDraw('partial'));
  document.getElementById('btn-sendfull').addEventListener('click', () => sendDraw('full'));
  document.getElementById('btn-wipe').addEventListener('click', sendClear);

  fetch('/status').then(r => r.json()).then(j => {
    setStatus('connected '+j.w+'x'+j.h);
  }).catch(() => setStatus('offline', true));
})();
</script>
</body>
</html>

Code In Chunks (Human Explanation)

Chunk 1: UI skeleton

This is the minimal interface surface. I intentionally kept it simple so I could spend more time on the hardware communication part.

<canvas id="view" width="250" height="122"></canvas>
<button id="btn-send" class="send">Send (partial)</button>
<button id="btn-sendfull">Send (full)</button>

Chunk 2: Font and look choice

I used a mono-style technical font stack because it fits the tool-like style and stays readable while debugging.

font-family: "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace;

Chunk 3: Pixel packing for e-ink

The browser canvas is pixel-based, but the display expects 1-bit packed bytes. This conversion step was one of the key technical points of the week.

function packBitmap(){
  const stride = (W + 7) >> 3;
  const out = new Uint8Array(stride * H);
  for (let y = 0; y < H; y++) {
    for (let xb = 0; xb < stride; xb++) {
      let b = 0;
      for (let bit = 0; bit < 8; bit++) {
        const x = xb * 8 + bit;
        const v = (x < W) ? pixels[y * W + x] : 1;
        if (v) b |= (1 << (7 - bit));
      }
      out[y * stride + xb] = b;
    }
  }
  return out;
}

Chunk 4: Sending bitmap data

This sends binary data to Pico W and lets me choose partial or full refresh from the UI.

async function sendDraw(mode){
  const r = await fetch('/draw?mode=' + mode, {
    method: 'POST',
    headers: {'Content-Type':'application/octet-stream'},
    body: packBitmap()
  });
  const j = await r.json();
  console.log('sent', j.mode, j.ms + 'ms');
}

Chunk 5: Wi-Fi bring-up on Pico W

The board connects to local Wi-Fi and returns an IP address so I can open the drawing interface from any device on the same network.

def _wifi_connect(ssid, password, timeout=60):
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        wlan.connect(ssid, password)
    return wlan.ifconfig()[0]

Chunk 6: Server routing

Core endpoints:

  • /status for device info
  • /draw for bitmap payload
  • /clear for wiping display
if method == "GET" and path == "/status":
    _send(conn, "200 OK", '{"ok":true}', content_type="application/json")
elif method == "POST" and path == "/clear":
    epd.clear()
elif method == "POST" and path == "/draw":
    body = _read_exact(conn, n, leftover)
    _blit_bitmap_to_fb(epd, body)

Chunk 7: Partial vs full refresh policy

Partial refresh is faster for interaction. Periodic full refresh is necessary to reduce ghosting on e-ink.

mode = "partial"
if "mode=full" in query:
    mode = "full"
full_refresh_counter += 1
if full_refresh_counter >= 12:
    mode = "full"
    full_refresh_counter = 0

All-in-one Pico server (canvas_server.py)

The assignment stack above is also bundled as a single MicroPython file you can drop on the Pico beside epd2in13.py: it embeds the full browser UI (drawing tools, procedural generators, packBitmap(), fetch to /draw / /clear / /status) and implements the socket HTTP server, Wi-Fi bring-up, chunked send for large responses, bitmap unpack into the EPD buffer, and partial vs full refresh (including automatic full every 12 partial updates). wifi_secrets.py on the device must define SSID and PASSWORD; the published download does not ship credentials.

Run from Thonny (Run) or execute as __main__; on stacks that import the module without __main__, the file still invokes run() so the server starts the same way as your other fab sketches.

Download: canvas_server.py

Full canvas_server.py source
"""
canvas_server.py  --  All-in-one Pico W e-ink canvas server.

Files needed on the Pico:
    epd2in13.py        - your EPD driver
    canvas_server.py   - this file

Just run this file (Thonny green play, or `import canvas_server` in REPL).
The bottom of the file calls run() automatically.
"""

import gc
import socket
import time
import network

from epd2in13 import EPD, EPD_WIDTH, EPD_HEIGHT

try:
    from wifi_secrets import SSID, PASSWORD
except ImportError:
    SSID = ""
    PASSWORD = ""

HTTP_PORT = 80

EXPECTED_BYTES = ((EPD_WIDTH + 7) // 8) * EPD_HEIGHT  # 32 * 122 = 3904

# =============================================================================
# Embedded web app
# =============================================================================
HTML_PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>PICO E-INK CANVAS</title>
<style>
  :root{--ink:#111;--paper:#f4f1ea;--rule:#111;--accent:#ff3b30;--mute:#7a7268;}
  *{box-sizing:border-box}
  html,body{margin:0;background:var(--paper);color:var(--ink);
    font-family:"JetBrains Mono","IBM Plex Mono",ui-monospace,Menlo,monospace;}
  body{min-height:100vh;padding:18px clamp(12px,3vw,32px);
    background-image:
      repeating-linear-gradient(0deg,rgba(0,0,0,.04) 0 1px,transparent 1px 24px),
      repeating-linear-gradient(90deg,rgba(0,0,0,.04) 0 1px,transparent 1px 24px);}
  header{display:flex;align-items:flex-end;justify-content:space-between;
    border-bottom:2px solid var(--rule);padding-bottom:8px;margin-bottom:14px;flex-wrap:wrap;gap:8px;}
  header h1{margin:0;font-size:clamp(20px,3.4vw,30px);letter-spacing:.04em;
    text-transform:uppercase;font-weight:800;}
  header h1 .dot{color:var(--accent)}
  header .meta{font-size:12px;color:var(--mute);text-transform:uppercase;letter-spacing:.12em}
  #status{font-weight:700;color:var(--ink)}
  #status.bad{color:var(--accent)}
  main{display:grid;grid-template-columns:minmax(0,1fr) 260px;gap:18px;align-items:start;}
  @media (max-width:780px){main{grid-template-columns:1fr}}
  .stage{background:#fff;border:2px solid var(--rule);box-shadow:6px 6px 0 var(--rule);
    padding:14px;position:relative;overflow:hidden;}
  .stage::before{content:"250 \\00D7 122 \\00B7 MONO";position:absolute;top:6px;right:10px;
    font-size:10px;letter-spacing:.18em;color:var(--mute);}
  .canvas-wrap{display:flex;justify-content:center;align-items:center;padding:8px 0 4px;}
  #view{image-rendering:pixelated;image-rendering:crisp-edges;
    width:min(100%, 750px);aspect-ratio:250 / 122;background:#fff;
    border:1px solid var(--rule);cursor:crosshair;touch-action:none;display:block;}
  .ruler{margin-top:8px;display:flex;justify-content:space-between;
    font-size:10px;color:var(--mute);letter-spacing:.18em;}
  aside{border:2px solid var(--rule);background:#fff;box-shadow:6px 6px 0 var(--rule);
    padding:12px;display:flex;flex-direction:column;gap:14px;}
  .group{display:flex;flex-direction:column;gap:6px}
  .label{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);
    border-bottom:1px dashed var(--rule);padding-bottom:3px;margin-bottom:2px;}
  .row{display:flex;flex-wrap:wrap;gap:6px}
  button{appearance:none;border:1.5px solid var(--rule);background:var(--paper);
    color:var(--ink);font-family:inherit;font-size:12px;padding:7px 9px;cursor:pointer;
    letter-spacing:.04em;text-transform:uppercase;font-weight:700;
    transition:transform .04s ease, background .1s ease;}
  button:hover{background:#fff}
  button:active{transform:translate(1px,1px)}
  button.on{background:var(--ink);color:var(--paper)}
  button.send{background:var(--accent);color:#fff;border-color:var(--accent);
    box-shadow:3px 3px 0 var(--rule);}
  button.send:hover{background:#e62e23}
  button.danger{border-color:var(--accent);color:var(--accent)}
  input[type=range]{width:100%}
  .size-readout{font-size:11px;color:var(--mute);text-align:right}
  .swatches{display:flex;gap:6px}
  .sw{width:32px;height:24px;border:1.5px solid var(--rule);cursor:pointer;
    display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;}
  .sw.black{background:#000;color:#fff}
  .sw.white{background:#fff;color:#000}
  .sw.on{outline:3px solid var(--accent);outline-offset:1px}
  footer{margin-top:14px;border-top:2px solid var(--rule);padding-top:6px;
    display:flex;justify-content:space-between;font-size:10px;color:var(--mute);
    letter-spacing:.16em;text-transform:uppercase;flex-wrap:wrap;gap:6px;}
  kbd{font-family:inherit;border:1px solid var(--rule);padding:1px 4px;
    background:var(--paper);font-size:10px;}
</style>
</head>
<body>
<header>
  <h1>PICO E-INK CANVAS<span class="dot">.</span></h1>
  <div class="meta">STATUS: <span id="status">ready</span></div>
</header>
<main>
  <section class="stage">
    <div id="dbg" style="background:#fff7d6;border:1px solid #c8b663;padding:4px 8px;font-size:11px;margin-bottom:8px;font-family:ui-monospace,monospace;color:#000">init...</div>
    <div class="canvas-wrap">
      <canvas id="view" width="250" height="122"></canvas>
    </div>
    <div class="ruler"><span>0</span><span>125</span><span>250 PX</span></div>
  </section>
  <aside>
    <div class="group">
      <div class="label">Tool</div>
      <div class="row" id="tools">
        <button data-tool="brush" class="on">Brush</button>
        <button data-tool="eraser">Eraser</button>
        <button data-tool="line">Line</button>
        <button data-tool="rect">Rect</button>
        <button data-tool="circle">Circle</button>
        <button data-tool="fill">Fill</button>
      </div>
    </div>
    <div class="group">
      <div class="label">Ink</div>
      <div class="swatches" id="swatches">
        <div class="sw black on" data-ink="0">B</div>
        <div class="sw white" data-ink="1">W</div>
      </div>
    </div>
    <div class="group">
      <div class="label">Brush size</div>
      <input id="size" type="range" min="1" max="16" value="2">
      <div class="size-readout"><span id="sizeOut">2</span> px</div>
    </div>
    <div class="group">
      <div class="label">Waves &amp; flow</div>
      <div class="row">
        <button data-gen="scribble">Scribble</button>
        <button data-gen="sinewave">Sine</button>
        <button data-gen="interference">Ripples</button>
        <button data-gen="rose">Rose</button>
        <button data-gen="lissajous">Lissajous</button>
        <button data-gen="flow">Flow field</button>
      </div>
    </div>
    <div class="group">
      <div class="label">Symmetry &amp; tiling</div>
      <div class="row">
        <button data-gen="truchet">Truchet</button>
        <button data-gen="kaleido">Kaleido</button>
        <button data-gen="hex">Hex grid</button>
        <button data-gen="herringbone">Herring</button>
        <button data-gen="weave">Weave</button>
        <button data-gen="moire">Moir\u00e9</button>
      </div>
    </div>
    <div class="group">
      <div class="label">Fractals</div>
      <div class="row">
        <button data-gen="sierpinski">Sierpinski</button>
        <button data-gen="carpet">Carpet</button>
        <button data-gen="dragon">Dragon</button>
        <button data-gen="koch">Koch</button>
        <button data-gen="hilbert">Hilbert</button>
        <button data-gen="mandelbrot">Mandel</button>
      </div>
    </div>
    <div class="group">
      <div class="label">Geometric</div>
      <div class="row">
        <button data-gen="grad">Gradient</button>
        <button data-gen="circles">Circles</button>
        <button data-gen="sunburst">Sunburst</button>
        <button data-gen="parabolic">Stitching</button>
        <button data-gen="mystic">Mystic rose</button>
        <button data-gen="spirograph">Spiro</button>
      </div>
    </div>
    <div class="group">
      <div class="label">Cellular &amp; random</div>
      <div class="row">
        <button data-gen="stars">Stars</button>
        <button data-gen="noise">Noise</button>
        <button data-gen="maze">Maze</button>
        <button data-gen="life">Life</button>
        <button data-gen="voronoi">Voronoi</button>
        <button data-gen="dla">DLA tree</button>
      </div>
    </div>
    <div class="group">
      <div class="label">Canvas</div>
      <div class="row">
        <button id="btn-undo">Undo</button>
        <button id="btn-clear" class="danger">Clear</button>
        <button id="btn-invert">Invert</button>
        <button id="btn-png">Save PNG</button>
      </div>
    </div>
    <div class="group">
      <div class="label">Send to e-ink</div>
      <div class="row">
        <button id="btn-send" class="send">Send (partial)</button>
        <button id="btn-sendfull">Send (full)</button>
      </div>
      <div class="row">
        <button id="btn-wipe" class="danger">Blank screen</button>
      </div>
    </div>
  </aside>
</main>
<footer>
  <span>Pico W \\u00b7 250\\u00d7122 \\u00b7 1bpp</span>
  <span><kbd>B</kbd> brush \\u00b7 <kbd>E</kbd> eraser \\u00b7 <kbd>X</kbd> swap \\u00b7 <kbd>Ctrl+Z</kbd> undo \\u00b7 <kbd>Enter</kbd> send</span>
</footer>
<script>
window.addEventListener('error', e => {
  const d = document.getElementById('dbg');
  if (d) d.textContent = 'JS ERROR: ' + e.message + ' @ ' + e.lineno;
});

(function(){
  const W = 250, H = 122;
  const view = document.getElementById('view');
  const dbg = document.getElementById('dbg');
  const setDbg = m => { if(dbg) dbg.textContent = m; };

  if(!view){ setDbg('FATAL: canvas element missing'); return; }
  const ctx = view.getContext('2d');
  if(!ctx){ setDbg('FATAL: 2D context unavailable'); return; }

  // pixel buffer: 1 byte per pixel, 0=black 1=white
  let pixels = new Uint8Array(W*H);
  pixels.fill(1);

  // --- repaint via ImageData ---
  const imgd = ctx.createImageData(W, H);
  function repaint(){
    const d = imgd.data;
    for(let i=0;i<pixels.length;i++){
      const v = pixels[i] ? 255 : 0;
      const j = i*4;
      d[j]=v; d[j+1]=v; d[j+2]=v; d[j+3]=255;
    }
    ctx.putImageData(imgd, 0, 0);
  }
  repaint();
  setDbg('ready - tap or drag the canvas');

  // --- drawing ops in pixel space ---
  function setPx(x,y,ink){
    if(x<0||y<0||x>=W||y>=H) return;
    pixels[y*W + x] = ink;
  }
  function stamp(cx,cy,r,ink){
    cx|=0; cy|=0;
    if(r<=1){ setPx(cx,cy,ink); return; }
    const h = Math.max(1, r/2)|0;
    const r2 = h*h;
    for(let y=Math.max(0,cy-h); y<=Math.min(H-1,cy+h); y++){
      for(let x=Math.max(0,cx-h); x<=Math.min(W-1,cx+h); x++){
        const dx=x-cx, dy=y-cy;
        if(dx*dx+dy*dy <= r2) pixels[y*W+x]=ink;
      }
    }
  }
  function drawLine(x0,y0,x1,y1,r,ink){
    x0|=0; y0|=0; x1|=0; y1|=0;
    const dx=Math.abs(x1-x0), sx=x0<x1?1:-1;
    const dy=-Math.abs(y1-y0), sy=y0<y1?1:-1;
    let err=dx+dy, guard=0;
    while(guard++<10000){
      stamp(x0,y0,r,ink);
      if(x0===x1 && y0===y1) break;
      const e2=2*err;
      if(e2>=dy){ err+=dy; x0+=sx; }
      if(e2<=dx){ err+=dx; y0+=sy; }
    }
  }
  function drawRect(x0,y0,x1,y1,r,ink){
    drawLine(x0,y0,x1,y0,r,ink); drawLine(x1,y0,x1,y1,r,ink);
    drawLine(x1,y1,x0,y1,r,ink); drawLine(x0,y1,x0,y0,r,ink);
  }
  function drawCircle(cx,cy,rad,r,ink){
    if(rad<1){ stamp(cx,cy,r,ink); return; }
    let x=rad,y=0,err=0,guard=0;
    while(x>=y && guard++<5000){
      stamp(cx+x,cy+y,r,ink); stamp(cx+y,cy+x,r,ink);
      stamp(cx-y,cy+x,r,ink); stamp(cx-x,cy+y,r,ink);
      stamp(cx-x,cy-y,r,ink); stamp(cx-y,cy-x,r,ink);
      stamp(cx+y,cy-x,r,ink); stamp(cx+x,cy-y,r,ink);
      y++; err+=1+2*y;
      if(2*(err-x)+1>0){ x--; err+=1-2*x; }
    }
  }
  function floodFill(sx,sy,ink){
    const target=pixels[sy*W+sx];
    if(target===undefined || target===ink) return;
    const stack=[sx,sy];
    while(stack.length){
      const y=stack.pop(), x=stack.pop();
      if(x<0||y<0||x>=W||y>=H) continue;
      if(pixels[y*W+x]!==target) continue;
      pixels[y*W+x]=ink;
      stack.push(x+1,y, x-1,y, x,y+1, x,y-1);
    }
  }

  // --- state ---
  let tool='brush', ink=0, size=3;
  let drawing=false, last=null, startPt=null, snap=null;
  const undoStack=[];
  function pushUndo(){
    undoStack.push(new Uint8Array(pixels));
    if(undoStack.length>20) undoStack.shift();
  }

  // --- pointer math: get pixel coords from any event ---
  function getXY(clientX, clientY){
    const r = view.getBoundingClientRect();
    let x = ((clientX - r.left) / r.width) * W;
    let y = ((clientY - r.top)  / r.height) * H;
    x = Math.max(0, Math.min(W-1, x|0));
    y = Math.max(0, Math.min(H-1, y|0));
    return [x,y];
  }

  function startStroke(clientX, clientY){
    pushUndo();
    drawing = true;
    const [x,y] = getXY(clientX, clientY);
    startPt = [x,y]; last = [x,y];
    setDbg('down @ '+x+','+y+' tool='+tool);
    if(tool==='brush' || tool==='eraser'){
      stamp(x, y, size, tool==='eraser'?1:ink);
      repaint();
    } else if(tool==='fill'){
      floodFill(x, y, ink);
      repaint();
      drawing = false;
    } else {
      snap = new Uint8Array(pixels);
    }
  }
  function moveStroke(clientX, clientY){
    if(!drawing) return;
    const [x,y] = getXY(clientX, clientY);
    if(tool==='brush' || tool==='eraser'){
      drawLine(last[0], last[1], x, y, size, tool==='eraser'?1:ink);
      last = [x,y];
      repaint();
      setDbg('draw @ '+x+','+y);
    } else if(tool==='line' || tool==='rect' || tool==='circle'){
      pixels.set(snap);
      if(tool==='line') drawLine(startPt[0],startPt[1],x,y,size,ink);
      else if(tool==='rect') drawRect(startPt[0],startPt[1],x,y,size,ink);
      else {
        const dx=x-startPt[0], dy=y-startPt[1];
        drawCircle(startPt[0], startPt[1], Math.round(Math.sqrt(dx*dx+dy*dy)), size, ink);
      }
      repaint();
    }
  }
  function endStroke(){
    if(!drawing) return;
    drawing=false; last=null; startPt=null; snap=null;
    setDbg('stroke ended');
  }

  // --- attach pointer events with both pointer + mouse + touch fallbacks ---
  if('PointerEvent' in window){
    view.addEventListener('pointerdown', e => {
      e.preventDefault();
      try{ view.setPointerCapture(e.pointerId); }catch(_){}
      startStroke(e.clientX, e.clientY);
    });
    view.addEventListener('pointermove', e => {
      if(drawing){ e.preventDefault(); moveStroke(e.clientX, e.clientY); }
    });
    window.addEventListener('pointerup', endStroke);
    window.addEventListener('pointercancel', endStroke);
    setDbg('using PointerEvent');
  } else {
    view.addEventListener('mousedown', e => { e.preventDefault(); startStroke(e.clientX, e.clientY); });
    window.addEventListener('mousemove', e => moveStroke(e.clientX, e.clientY));
    window.addEventListener('mouseup', endStroke);
    view.addEventListener('touchstart', e => {
      if(!e.touches[0]) return;
      e.preventDefault();
      startStroke(e.touches[0].clientX, e.touches[0].clientY);
    }, {passive:false});
    view.addEventListener('touchmove', e => {
      if(!e.touches[0]) return;
      e.preventDefault();
      moveStroke(e.touches[0].clientX, e.touches[0].clientY);
    }, {passive:false});
    view.addEventListener('touchend', endStroke);
    view.addEventListener('touchcancel', endStroke);
    setDbg('using mouse+touch fallback');
  }

  // --- toolbar ---
  document.getElementById('tools').addEventListener('click', e => {
    const b = e.target.closest('button'); if(!b) return;
    tool = b.dataset.tool;
    document.querySelectorAll('#tools button').forEach(x => x.classList.toggle('on', x===b));
    setDbg('tool: '+tool);
  });
  document.getElementById('swatches').addEventListener('click', e => {
    const s = e.target.closest('.sw'); if(!s) return;
    ink = parseInt(s.dataset.ink, 10);
    document.querySelectorAll('#swatches .sw').forEach(x => x.classList.toggle('on', x===s));
    setDbg('ink: '+(ink?'white':'black'));
  });
  const sizeIn = document.getElementById('size');
  const sizeOut = document.getElementById('sizeOut');
  size = +sizeIn.value || 3;
  sizeOut.textContent = size;
  sizeIn.addEventListener('input', () => { size = +sizeIn.value; sizeOut.textContent = size; });

  document.getElementById('btn-clear').addEventListener('click', () => { pushUndo(); pixels.fill(1); repaint(); });
  document.getElementById('btn-invert').addEventListener('click', () => {
    pushUndo(); for(let i=0;i<pixels.length;i++) pixels[i]^=1; repaint();
  });
  document.getElementById('btn-undo').addEventListener('click', () => {
    if(!undoStack.length) return; pixels = undoStack.pop(); repaint();
  });
  document.getElementById('btn-png').addEventListener('click', () => {
    const off = document.createElement('canvas'); off.width=W*4; off.height=H*4;
    const oc = off.getContext('2d'); oc.imageSmoothingEnabled=false;
    oc.drawImage(view, 0, 0, off.width, off.height);
    off.toBlob(b => {
      const a = document.createElement('a');
      a.href = URL.createObjectURL(b);
      a.download = 'pico-canvas.png'; a.click();
    });
  });

  // --- generators ---
  // --- generators (math / art patterns) ---
  // Helpers used by several gens
  function plotBresenham(x0,y0,x1,y1,ink){
    drawLine(x0|0,y0|0,x1|0,y1|0,1,ink);
  }
  function fillRectPx(x0,y0,x1,y1,ink){
    if(x0>x1){ const t=x0;x0=x1;x1=t; }
    if(y0>y1){ const t=y0;y0=y1;y1=t; }
    x0=Math.max(0,x0|0); y0=Math.max(0,y0|0);
    x1=Math.min(W-1,x1|0); y1=Math.min(H-1,y1|0);
    for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++) pixels[y*W+x]=ink;
  }

  const gens = {
    scribble(){
      pixels.fill(1); let x=W/2, y=H/2;
      for(let i=0;i<600;i++){
        const nx=x+(Math.random()-.5)*14, ny=y+(Math.random()-.5)*14;
        drawLine(x|0, y|0, nx|0, ny|0, 1, 0);
        x=Math.max(2,Math.min(W-3,nx)); y=Math.max(2,Math.min(H-3,ny));
      }
    },
    sinewave(){
      pixels.fill(1);
      const layers = 5;
      for(let L=0; L<layers; L++){
        const amp = 8 + L*4;
        const freq = 0.05 + L*0.015;
        const phase = L*0.7;
        const yc = H/2 + (L-layers/2)*4;
        let py = (yc + Math.sin(phase)*amp)|0;
        for(let x=0; x<W; x++){
          const y = (yc + Math.sin(x*freq + phase)*amp)|0;
          drawLine(x-1, py, x, y, 1, 0);
          py = y;
        }
      }
    },
    interference(){
      // Two wave sources; threshold the sum to make moire/ripple bands
      pixels.fill(1);
      const s1x=W*0.30, s1y=H*0.5;
      const s2x=W*0.70, s2y=H*0.5;
      const k = 0.55; // wave number
      for(let y=0;y<H;y++){
        for(let x=0;x<W;x++){
          const d1 = Math.sqrt((x-s1x)**2+(y-s1y)**2);
          const d2 = Math.sqrt((x-s2x)**2+(y-s2y)**2);
          const v = Math.cos(d1*k) + Math.cos(d2*k);
          pixels[y*W+x] = v>0 ? 0 : 1;
        }
      }
    },
    rose(){
      // Rose curve r = a*cos(k*theta), k = n/d
      pixels.fill(1);
      const cx=W/2, cy=H/2;
      const a = Math.min(W,H)*0.45;
      const n = 5, d = 1;          // 5-petal rose; tweak for variety
      const steps = 4000;
      let px=null, py=null;
      for(let i=0; i<=steps; i++){
        const th = (i/steps) * Math.PI * 2 * d;
        const r = a * Math.cos(n/d * th);
        const x = (cx + r*Math.cos(th))|0;
        const y = (cy + r*Math.sin(th))|0;
        if(px!==null) drawLine(px,py,x,y,1,0);
        px=x; py=y;
      }
    },
    lissajous(){
      pixels.fill(1);
      const cx=W/2, cy=H/2;
      const A=W*0.46, B=H*0.46;
      const a=3, b=2, delta=Math.PI/2;
      const steps=3000;
      let px=null, py=null;
      for(let i=0;i<=steps;i++){
        const t = (i/steps)*Math.PI*2;
        const x = (cx + A*Math.sin(a*t + delta))|0;
        const y = (cy + B*Math.sin(b*t))|0;
        if(px!==null) drawLine(px,py,x,y,1,0);
        px=x; py=y;
      }
    },
    flow(){
      // Pseudo-Perlin flow field via cheap value-noise hash
      pixels.fill(1);
      function hash(x,y){
        let h = (x*374761393 + y*668265263) | 0;
        h = (h ^ (h>>>13)) * 1274126177 | 0;
        return ((h ^ (h>>>16)) >>> 0) / 4294967295;
      }
      function noise(x,y){
        const xi=Math.floor(x), yi=Math.floor(y);
        const xf=x-xi, yf=y-yi;
        const u=xf*xf*(3-2*xf), v=yf*yf*(3-2*yf);
        const n00=hash(xi,yi), n10=hash(xi+1,yi);
        const n01=hash(xi,yi+1), n11=hash(xi+1,yi+1);
        return (n00*(1-u)+n10*u)*(1-v) + (n01*(1-u)+n11*u)*v;
      }
      const seeds = 80, steps = 60;
      for(let s=0; s<seeds; s++){
        let x = Math.random()*W, y = Math.random()*H;
        for(let i=0;i<steps;i++){
          const ang = noise(x*0.04, y*0.04) * Math.PI * 4;
          const nx = x + Math.cos(ang)*1.4;
          const ny = y + Math.sin(ang)*1.4;
          drawLine(x|0,y|0,nx|0,ny|0,1,0);
          x=nx; y=ny;
          if(x<0||x>=W||y<0||y>=H) break;
        }
      }
    },

    truchet(){
      pixels.fill(1); const t=12;
      for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
        if(Math.random()<.5) drawLine(x,y,x+t,y+t,1,0);
        else drawLine(x+t,y,x,y+t,1,0);
      }
    },
    kaleido(){
      // Draw random strokes in one wedge, then mirror across N-fold symmetry
      pixels.fill(1);
      const cx=W/2, cy=H/2;
      const N = 6;
      const segs = 30;
      const pts = [];
      for(let i=0;i<segs;i++){
        const r1 = Math.random()*Math.min(W,H)*0.45;
        const r2 = Math.random()*Math.min(W,H)*0.45;
        const a1 = Math.random()*Math.PI*2/N;
        const a2 = a1 + (Math.random()-.5)*0.6;
        pts.push([r1,a1,r2,a2]);
      }
      for(let k=0;k<N;k++){
        const base = k*Math.PI*2/N;
        for(const [r1,a1,r2,a2] of pts){
          const x1=cx+Math.cos(base+a1)*r1, y1=cy+Math.sin(base+a1)*r1;
          const x2=cx+Math.cos(base+a2)*r2, y2=cy+Math.sin(base+a2)*r2;
          drawLine(x1|0,y1|0,x2|0,y2|0,1,0);
          // mirror within wedge
          const x1m=cx+Math.cos(base-a1)*r1, y1m=cy+Math.sin(base-a1)*r1;
          const x2m=cx+Math.cos(base-a2)*r2, y2m=cy+Math.sin(base-a2)*r2;
          drawLine(x1m|0,y1m|0,x2m|0,y2m|0,1,0);
        }
      }
    },
    hex(){
      pixels.fill(1);
      const r = 8;                    // hex circumradius
      const dx = r*Math.sqrt(3);
      const dy = r*1.5;
      for(let row=-1; row*dy<H+r; row++){
        for(let col=-1; col*dx<W+r; col++){
          const cx = col*dx + (row&1?dx/2:0);
          const cy = row*dy;
          // hex outline
          let px=null, py=null;
          for(let k=0;k<=6;k++){
            const a = Math.PI/3*k - Math.PI/2;
            const x = (cx+Math.cos(a)*r)|0;
            const y = (cy+Math.sin(a)*r)|0;
            if(px!==null) drawLine(px,py,x,y,1,0);
            px=x; py=y;
          }
        }
      }
    },
    herringbone(){
      pixels.fill(1);
      const bw=18, bh=6;
      for(let y=0;y<H+bh;y+=bh){
        for(let x=-bw;x<W;x+=bw){
          const ox = ((y/bh)|0) % 2 === 0 ? 0 : bw/2;
          // diagonal brick
          drawLine(x+ox, y, x+ox+bw, y+bh, 1, 0);
        }
      }
    },
    weave(){
      pixels.fill(1);
      const t=6;
      // horizontal stripes
      for(let y=0;y<H;y+=t*2){
        for(let x=0;x<W;x+=t*2){
          fillRectPx(x, y, x+t-1, y+t-1, 0);
          fillRectPx(x+t, y+t, x+2*t-1, y+2*t-1, 0);
        }
      }
    },
    moire(){
      // Two rotated line gratings
      pixels.fill(1);
      const a1 = 0.1, a2 = -0.13;
      const sp = 4;
      for(let y=0;y<H;y++){
        for(let x=0;x<W;x++){
          const u = x*Math.cos(a1)+y*Math.sin(a1);
          const v = x*Math.cos(a2)+y*Math.sin(a2);
          const on = (Math.floor(u/sp)&1) ^ (Math.floor(v/sp)&1);
          if(on) pixels[y*W+x]=0;
        }
      }
    },

    sierpinski(){
      pixels.fill(1);
      const ax=W/2, ay=4;
      const bx=4, by=H-4;
      const cx=W-4, cy=H-4;
      let px=W/2, py=H/2;
      const verts=[[ax,ay],[bx,by],[cx,cy]];
      // Chaos game
      for(let i=0;i<8000;i++){
        const v = verts[(Math.random()*3)|0];
        px = (px+v[0])/2;
        py = (py+v[1])/2;
        if(i>20) setPx(px|0, py|0, 0);
      }
    },
    carpet(){
      // Sierpinski carpet by recursive subdivision
      pixels.fill(0); // start black, carve white holes
      function carve(x,y,w,h,depth){
        if(depth===0 || w<3 || h<3) return;
        const w3=w/3, h3=h/3;
        fillRectPx(x+w3, y+h3, x+2*w3-1, y+2*h3-1, 1);
        for(let iy=0;iy<3;iy++) for(let ix=0;ix<3;ix++){
          if(ix===1 && iy===1) continue;
          carve(x+ix*w3, y+iy*h3, w3, h3, depth-1);
        }
      }
      carve(0,0,W,H,4);
    },
    dragon(){
      pixels.fill(1);
      let path = [1];
      for(let i=0;i<11;i++){
        const rev = [];
        for(let j=path.length-1;j>=0;j--) rev.push(path[j]^1);
        path = path.concat([1]).concat(rev);
      }
      // walk
      let x=W*0.35, y=H*0.55, dir=0;
      const step=2;
      for(const turn of path){
        const nx = x + Math.cos(dir)*step;
        const ny = y + Math.sin(dir)*step;
        drawLine(x|0,y|0,nx|0,ny|0,1,0);
        x=nx; y=ny;
        dir += turn ? Math.PI/2 : -Math.PI/2;
      }
    },
    koch(){
      pixels.fill(1);
      // Koch snowflake from equilateral triangle, 4 iterations
      function koch(p1,p2,depth){
        if(depth===0){
          drawLine(p1[0]|0,p1[1]|0,p2[0]|0,p2[1]|0,1,0);
          return;
        }
        const dx=(p2[0]-p1[0])/3, dy=(p2[1]-p1[1])/3;
        const a=[p1[0]+dx, p1[1]+dy];
        const b=[p1[0]+2*dx, p1[1]+2*dy];
        const ang = Math.atan2(dy,dx) - Math.PI/3;
        const len = Math.sqrt(dx*dx+dy*dy);
        const peak=[a[0]+Math.cos(ang)*len, a[1]+Math.sin(ang)*len];
        koch(p1,a,depth-1); koch(a,peak,depth-1);
        koch(peak,b,depth-1); koch(b,p2,depth-1);
      }
      const cx=W/2, cy=H/2;
      const r=Math.min(W,H)*0.42;
      const v=[];
      for(let i=0;i<3;i++){
        const a = -Math.PI/2 + i*Math.PI*2/3;
        v.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
      }
      koch(v[0],v[1],4); koch(v[1],v[2],4); koch(v[2],v[0],4);
    },
    hilbert(){
      pixels.fill(1);
      // Iterative Hilbert curve fitting in min(W,H) square
      const order = 5; // 2^5 = 32 cells per side
      const N = 1<<order;
      const cell = Math.min(W,H)/N;
      const ox = (W-N*cell)/2, oy = (H-N*cell)/2;
      function d2xy(d){
        let x=0,y=0,t=d;
        for(let s=1;s<N;s<<=1){
          const rx = 1 & (t>>1);
          const ry = 1 & (t ^ rx);
          if(ry===0){
            if(rx===1){ x=s-1-x; y=s-1-y; }
            const tmp=x; x=y; y=tmp;
          }
          x += s*rx; y += s*ry;
          t >>= 2;
        }
        return [x,y];
      }
      let prev=null;
      for(let i=0;i<N*N;i++){
        const [gx,gy]=d2xy(i);
        const x = (ox + gx*cell + cell/2)|0;
        const y = (oy + gy*cell + cell/2)|0;
        if(prev) drawLine(prev[0],prev[1],x,y,1,0);
        prev=[x,y];
      }
    },
    mandelbrot(){
      pixels.fill(1);
      const xmin=-2.1, xmax=0.7, ymin=-1.0, ymax=1.0;
      const maxIter=24;
      for(let py=0;py<H;py++){
        const cy = ymin + (ymax-ymin)*py/H;
        for(let px=0;px<W;px++){
          const cx = xmin + (xmax-xmin)*px/W;
          let zx=0, zy=0, i=0;
          while(i<maxIter && zx*zx+zy*zy<4){
            const t = zx*zx-zy*zy+cx;
            zy = 2*zx*zy+cy;
            zx = t;
            i++;
          }
          // Threshold dither: high-iter -> dark, low-iter -> light
          const v = i/maxIter;
          const bayer = ((px+py*7)*0.137)%1;
          pixels[py*W+px] = v < 0.95 && v*1.1 > bayer ? 0 : 1;
          if(i===maxIter) pixels[py*W+px]=0;
        }
      }
    },

    grad(){
      const bayer=[
        [0,32,8,40,2,34,10,42],[48,16,56,24,50,18,58,26],
        [12,44,4,36,14,46,6,38],[60,28,52,20,62,30,54,22],
        [3,35,11,43,1,33,9,41],[51,19,59,27,49,17,57,25],
        [15,47,7,39,13,45,5,37],[63,31,55,23,61,29,53,21]];
      for(let y=0;y<H;y++) for(let x=0;x<W;x++){
        pixels[y*W+x] = ((x/(W-1))*64 > bayer[y&7][x&7]) ? 1 : 0;
      }
    },
    circles(){
      pixels.fill(1);
      const cx=W/2, cy=H/2;
      for(let r=4; r<Math.max(W,H); r+=6){
        drawCircle(cx,cy,r,1,0);
      }
    },
    sunburst(){
      pixels.fill(1);
      const cx=W/2, cy=H/2;
      const rays = 64;
      const R = Math.max(W,H);
      for(let i=0;i<rays;i++){
        const a = i*Math.PI*2/rays;
        const x = cx+Math.cos(a)*R;
        const y = cy+Math.sin(a)*R;
        drawLine(cx|0,cy|0,x|0,y|0,1,0);
      }
    },
    parabolic(){
      // Curve stitching: connect points across two perpendicular axes
      pixels.fill(1);
      const N = 24;
      const x0=4, y0=4, x1=W-4, y1=H-4;
      // top-left corner
      for(let i=0;i<=N;i++){
        const t = i/N;
        drawLine(x0+(x1-x0)*t|0, y0|0, x0|0, y0+(y1-y0)*t|0, 1, 0);
      }
      // bottom-right corner
      for(let i=0;i<=N;i++){
        const t = i/N;
        drawLine(x1-(x1-x0)*t|0, y1|0, x1|0, y1-(y1-y0)*t|0, 1, 0);
      }
    },
    mystic(){
      // Mystic rose: N points on a circle, every chord drawn
      pixels.fill(1);
      const cx=W/2, cy=H/2;
      const r = Math.min(W,H)*0.45;
      const N = 18;
      const pts = [];
      for(let i=0;i<N;i++){
        const a = i*Math.PI*2/N - Math.PI/2;
        pts.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
      }
      for(let i=0;i<N;i++) for(let j=i+1;j<N;j++){
        drawLine(pts[i][0]|0, pts[i][1]|0, pts[j][0]|0, pts[j][1]|0, 1, 0);
      }
    },
    spirograph(){
      // Hypotrochoid: outer R, inner r, pen offset d
      pixels.fill(1);
      const cx=W/2, cy=H/2;
      const R = Math.min(W,H)*0.42;
      const r = R*0.31;
      const d = R*0.55;
      const steps = 4000;
      let px=null, py=null;
      for(let i=0;i<=steps;i++){
        const t = (i/steps)*Math.PI*2*7;
        const x = ((R-r)*Math.cos(t) + d*Math.cos((R-r)/r*t)) + cx;
        const y = ((R-r)*Math.sin(t) - d*Math.sin((R-r)/r*t)) + cy;
        if(px!==null) drawLine(px|0,py|0,x|0,y|0,1,0);
        px=x; py=y;
      }
    },

    stars(){
      pixels.fill(0);
      for(let i=0;i<180;i++){
        stamp((Math.random()*W)|0, (Math.random()*H)|0,
              Math.random()<.15?2:1, 1);
      }
    },
    noise(){
      for(let i=0;i<pixels.length;i++) pixels[i] = Math.random()<.5 ? 0 : 1;
    },
    maze(){
      pixels.fill(1); const t=8;
      for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
        if(Math.random()<.5) drawLine(x,y,x+t,y,1,0);
        else drawLine(x,y,x,y+t,1,0);
      }
      drawRect(0,0,W-1,H-1,1,0);
    },
    life(){
      // Run Conway's Game of Life from random soup, snapshot at step N
      let cur = new Uint8Array(W*H);
      for(let i=0;i<cur.length;i++) cur[i] = Math.random()<.35 ? 1 : 0;
      let nxt = new Uint8Array(W*H);
      for(let gen=0; gen<30; gen++){
        for(let y=0;y<H;y++){
          for(let x=0;x<W;x++){
            let n=0;
            for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
              if(dx===0&&dy===0) continue;
              const xx=(x+dx+W)%W, yy=(y+dy+H)%H;
              n += cur[yy*W+xx];
            }
            const c = cur[y*W+x];
            nxt[y*W+x] = (c && (n===2||n===3)) || (!c && n===3) ? 1 : 0;
          }
        }
        const t=cur; cur=nxt; nxt=t;
      }
      // alive=black on white paper
      for(let i=0;i<pixels.length;i++) pixels[i] = cur[i] ? 0 : 1;
    },
    voronoi(){
      // Voronoi cell edges: pixel is "edge" if its nearest seed differs from a neighbor's
      const N = 22;
      const sx=[], sy=[];
      for(let i=0;i<N;i++){ sx.push(Math.random()*W); sy.push(Math.random()*H); }
      const owner = new Int16Array(W*H);
      for(let y=0;y<H;y++){
        for(let x=0;x<W;x++){
          let best=0, bd=1e9;
          for(let i=0;i<N;i++){
            const dx=x-sx[i], dy=y-sy[i];
            const d=dx*dx+dy*dy;
            if(d<bd){ bd=d; best=i; }
          }
          owner[y*W+x] = best;
        }
      }
      pixels.fill(1);
      for(let y=0;y<H;y++){
        for(let x=0;x<W;x++){
          const o = owner[y*W+x];
          if(x+1<W && owner[y*W+x+1]!==o){ pixels[y*W+x]=0; }
          else if(y+1<H && owner[(y+1)*W+x]!==o){ pixels[y*W+x]=0; }
        }
      }
    },
    dla(){
      // Diffusion-limited aggregation: random walkers stick to growing tree
      pixels.fill(1);
      const grid = new Uint8Array(W*H);
      const cx=W/2|0, cy=H/2|0;
      grid[cy*W+cx]=1; pixels[cy*W+cx]=0;
      const maxParticles = 800;
      for(let p=0;p<maxParticles;p++){
        // spawn on a circle of radius rmax
        let ang = Math.random()*Math.PI*2;
        let rad = Math.min(W,H)*0.45;
        let x = cx + (Math.cos(ang)*rad)|0;
        let y = cy + (Math.sin(ang)*rad)|0;
        for(let step=0; step<2000; step++){
          x += (Math.random()<.5?-1:1);
          y += (Math.random()<.5?-1:1);
          if(x<1||x>=W-1||y<1||y>=H-1){ break; }
          // touch?
          if(grid[(y-1)*W+x]||grid[(y+1)*W+x]||
             grid[y*W+x-1]||grid[y*W+x+1]){
            grid[y*W+x]=1; pixels[y*W+x]=0; break;
          }
        }
      }
    },
  };

  document.querySelectorAll('[data-gen]').forEach(btn => {
    btn.addEventListener('click', () => {
      const k = btn.dataset.gen;
      const fn = gens[k];
      if(!fn){ setDbg('unknown gen: '+k); return; }
      pushUndo();
      try { fn(); } catch(e){ setDbg('gen '+k+' err: '+e.message); }
      repaint();
      setDbg('generated: '+k);
    });
  });

  // --- network ---
  const statusEl = document.getElementById('status');
  function setStatus(msg, bad){ statusEl.textContent = msg; statusEl.classList.toggle('bad', !!bad); }

  function packBitmap(){
    const stride = (W+7)>>3;
    const out = new Uint8Array(stride * H);
    for(let y=0;y<H;y++){
      for(let xb=0;xb<stride;xb++){
        let b = 0;
        for(let bit=0;bit<8;bit++){
          const x = xb*8 + bit;
          const v = (x<W) ? pixels[y*W+x] : 1;
          if(v) b |= (1 << (7-bit));
        }
        out[y*stride + xb] = b;
      }
    }
    return out;
  }
  async function sendDraw(mode){
    setStatus('sending...');
    try{
      const r = await fetch('/draw?mode='+mode, {
        method: 'POST',
        headers: {'Content-Type':'application/octet-stream'},
        body: packBitmap()
      });
      if(!r.ok) throw new Error('HTTP '+r.status);
      const j = await r.json();
      setStatus('sent - '+j.mode+' - '+j.ms+'ms');
    } catch(err){ setStatus('send failed: '+err.message, true); }
  }
  async function sendClear(){
    setStatus('clearing...');
    try{
      const r = await fetch('/clear', {method:'POST'});
      if(!r.ok) throw new Error('HTTP '+r.status);
      setStatus('blanked');
    } catch(err){ setStatus('clear failed: '+err.message, true); }
  }
  document.getElementById('btn-send').addEventListener('click', () => sendDraw('partial'));
  document.getElementById('btn-sendfull').addEventListener('click', () => sendDraw('full'));
  document.getElementById('btn-wipe').addEventListener('click', sendClear);

  fetch('/status').then(r => r.json()).then(j => {
    setStatus('connected '+j.w+'x'+j.h);
  }).catch(() => setStatus('offline', true));
})();
</script>
</body>
</html>
"""

# =============================================================================
# WiFi
# =============================================================================
def _wifi_connect(ssid, password, timeout=60):
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    try:
        wlan.config(pm=0xa11140)  # disable power save BEFORE connecting
    except Exception:
        pass

    if not wlan.isconnected():
        print("Connecting to", repr(ssid))
        if password:
            wlan.connect(ssid, password)
        else:
            wlan.connect(ssid)

        t0 = time.ticks_ms()
        last_status = None
        while not wlan.isconnected():
            try:
                st = wlan.status()
            except Exception:
                st = None
            if st != last_status:
                print("  wlan status:", st)
                last_status = st
            if time.ticks_diff(time.ticks_ms(), t0) > timeout * 1000:
                raise RuntimeError(
                    "WiFi connect timeout (last status=%s). "
                    "Check: hotspot ON, 2.4 GHz / Maximize Compatibility ON, "
                    "WPA2 (not WPA3), correct password." % st
                )
            time.sleep(0.5)
    ip = wlan.ifconfig()[0]
    print("WiFi OK, IP =", ip)
    return ip

# =============================================================================
# Bitmap unpack
# =============================================================================
def _blit_bitmap_to_fb(epd, payload):
    src_stride = (EPD_WIDTH + 7) // 8
    dst_stride = epd._fb_stride
    h = EPD_HEIGHT

    fb = epd._fb_buf
    for i in range(len(fb)):
        fb[i] = 0xFF

    last_real_bits = EPD_WIDTH & 7
    if last_real_bits:
        keep = (0xFF << (8 - last_real_bits)) & 0xFF
        pad  = 0xFF ^ keep
    else:
        keep, pad = 0xFF, 0x00

    n = len(payload)
    for y in range(h):
        so = y * src_stride
        do = y * dst_stride
        if so + src_stride > n:
            break
        for x in range(src_stride - 1):
            fb[do + x] = payload[so + x]
        last = payload[so + src_stride - 1]
        if last_real_bits:
            last = (last & keep) | pad
        fb[do + src_stride - 1] = last

# =============================================================================
# HTTP helpers
# =============================================================================
def _sendall(conn, data):
    """Robust send -- MicroPython's socket.send() may not send everything,
    and sendall() can fail on large buffers; chunk it explicitly."""
    if isinstance(data, str):
        data = data.encode("utf-8")
    mv = memoryview(data)
    total = len(mv)
    sent = 0
    chunk = 1024  # safe size for Pico W lwIP buffers
    while sent < total:
        end = sent + chunk
        if end > total:
            end = total
        # Try send(); may return short
        try:
            n = conn.send(mv[sent:end])
        except OSError as e:
            # EAGAIN -> wait a bit and retry; otherwise give up
            err = e.args[0] if e.args else 0
            if err in (11, 35):  # EAGAIN / EWOULDBLOCK
                time.sleep_ms(5)
                continue
            raise
        if n is None:
            n = end - sent  # some MP builds return None on success
        if n <= 0:
            time.sleep_ms(5)
            continue
        sent += n

def _send(conn, status, body, content_type="text/plain", extra=""):
    if isinstance(body, str):
        body = body.encode("utf-8")
    head = (
        "HTTP/1.1 {s}\r\n"
        "Content-Type: {ct}\r\n"
        "Content-Length: {n}\r\n"
        "Access-Control-Allow-Origin: *\r\n"
        "Connection: close\r\n"
        "{x}"
        "\r\n"
    ).format(s=status, ct=content_type, n=len(body), x=extra)
    _sendall(conn, head)
    _sendall(conn, body)

def _read_request(conn):
    conn.settimeout(5.0)
    buf = b""
    while b"\r\n\r\n" not in buf:
        chunk = conn.recv(1024)
        if not chunk:
            break
        buf += chunk
        if len(buf) > 8192:
            break
    head, _, rest = buf.partition(b"\r\n\r\n")
    lines = head.split(b"\r\n")
    if not lines:
        return None, None, {}, b""
    try:
        method, path, _ = lines[0].decode("utf-8").split(" ", 2)
    except ValueError:
        return None, None, {}, b""
    headers = {}
    for line in lines[1:]:
        if b":" in line:
            k, _, v = line.partition(b":")
            headers[k.decode("utf-8").strip().lower()] = v.decode("utf-8").strip()
    return method, path, headers, rest

def _read_exact(conn, n, already=b""):
    out = bytearray(already)
    while len(out) < n:
        chunk = conn.recv(min(1024, n - len(out)))
        if not chunk:
            break
        out += chunk
    return bytes(out)

def _show_boot_screen(epd, ip):
    fb = epd.fb
    fb.fill(0xFF)
    fb.rect(0, 0, EPD_WIDTH, EPD_HEIGHT, 0x00)
    fb.text("PICO E-INK CANVAS", 8, 10, 0x00)
    fb.hline(8, 24, EPD_WIDTH - 16, 0x00)
    fb.text("WiFi: connected", 8, 38, 0x00)
    fb.text("IP:", 8, 56, 0x00)
    fb.text(ip, 40, 56, 0x00)
    fb.text("Open in browser:", 8, 80, 0x00)
    fb.text("http://" + ip + "/", 8, 96, 0x00)
    epd.display_full()

# =============================================================================
# Main entry
# =============================================================================
def run(ssid=None, password=None, port=HTTP_PORT):
    if ssid is None:
        ssid = SSID
    if password is None:
        password = PASSWORD
    if not ssid:
        print("ERROR: create wifi_secrets.py with SSID and PASSWORD (copy from weather app pattern)")
        raise SystemExit

    epd = EPD()
    epd.clear()

    ip = _wifi_connect(ssid, password)
    _show_boot_screen(epd, ip)

    addr = socket.getaddrinfo("0.0.0.0", port)[0][-1]
    s = socket.socket()
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(addr)
    s.listen(2)
    print("HTTP server listening on", addr)
    print("Open http://%s/ in your browser." % ip)

    full_refresh_counter = 0

    try:
        while True:
            try:
                conn, client = s.accept()
            except OSError:
                continue
            try:
                method, path, headers, leftover = _read_request(conn)
                if method is None:
                    _send(conn, "400 Bad Request", "bad request")
                    continue

                qpos = path.find("?")
                query = ""
                if qpos >= 0:
                    query = path[qpos + 1:]
                    path = path[:qpos]

                print(method, path, "from", client)

                if method == "GET" and path in ("/", "/index.html"):
                    _send(conn, "200 OK", HTML_PAGE,
                          content_type="text/html; charset=utf-8")

                elif method == "GET" and path == "/status":
                    _send(conn, "200 OK",
                          '{"ok":true,"w":%d,"h":%d,"bytes":%d}' % (
                              EPD_WIDTH, EPD_HEIGHT, EXPECTED_BYTES),
                          content_type="application/json")

                elif method == "OPTIONS":
                    _send(conn, "204 No Content", "",
                          extra=("Access-Control-Allow-Methods: GET,POST,OPTIONS\r\n"
                                 "Access-Control-Allow-Headers: Content-Type\r\n"))

                elif method == "POST" and path == "/clear":
                    epd.clear()
                    full_refresh_counter = 0
                    _send(conn, "200 OK", '{"ok":true}', content_type="application/json")

                elif method == "POST" and path == "/draw":
                    try:
                        n = int(headers.get("content-length", "0"))
                    except ValueError:
                        n = 0
                    if n <= 0 or n > 16384:
                        _send(conn, "400 Bad Request", "bad length")
                        continue
                    body = _read_exact(conn, n, leftover)
                    if len(body) < n:
                        _send(conn, "400 Bad Request", "short body")
                        continue

                    _blit_bitmap_to_fb(epd, body)

                    mode = "partial"
                    if "mode=full" in query:
                        mode = "full"
                    full_refresh_counter += 1
                    if full_refresh_counter >= 12:
                        mode = "full"
                        full_refresh_counter = 0

                    t0 = time.ticks_ms()
                    if mode == "full":
                        epd.display_full()
                    else:
                        epd.display_partial()
                    dt = time.ticks_diff(time.ticks_ms(), t0)
                    print("  drew %d bytes, mode=%s, %dms" % (len(body), mode, dt))

                    _send(conn, "200 OK",
                          '{"ok":true,"mode":"%s","ms":%d}' % (mode, dt),
                          content_type="application/json")

                else:
                    _send(conn, "404 Not Found", "no")

            except Exception as e:
                print("req error:", e)
                try:
                    _send(conn, "500 Internal Server Error", str(e))
                except Exception:
                    pass
            finally:
                try:
                    conn.close()
                except Exception:
                    pass
                gc.collect()
    except KeyboardInterrupt:
        print("stopped")
    finally:
        try:
            s.close()
        except Exception:
            pass

if __name__ == '__main__':
    run()
else:
    run()

Testing

I tested:

  • Pixel alignment from browser to e-ink
  • Tool behavior (brush, eraser, fill, basic shapes)
  • Endpoint behavior (/status, /draw, /clear)
  • Partial refresh speed and full refresh recovery
  • Repeated updates to observe e-ink ghosting behavior over time

Photo notes:

  • assets/week14-01.jpg and assets/week14-02.jpg: interface interaction and send controls
  • assets/week14-03.jpg to assets/week14-05.jpg: hardware-side validation and e-ink output checks

The full flow worked on local Wi-Fi and produced usable custom sleep screens. Most importantly, it was stable enough to use repeatedly without restarting the board.

Results

The app now lets me draw and push custom monochrome graphics to the e-ink screen for my final project. This is now a real tool I can keep using, not just a one-week demo.

Working features:

  • Drawing and editing on browser canvas
  • Bitmap packing and transfer to Pico
  • E-ink update via partial/full modes
  • Fast repeat workflow for sleep-screen design

Reflection

The key lesson for me was to design for 1-bit output from the beginning, because e-ink constraints shape both UI behavior and data format. Another lesson was that a clean software architecture saves a lot of time once physical testing starts.

Next improvements:

  • Add text tool and layout templates
  • Save/load preset screens
  • Add profile presets for daily final-project use