JEPA4Japan · tutorials

Chapter 2: A Sheet of Pixels That Forgets

2,622 words 12 min read #Canvas#Frontend Engineering#Infinite Canvas#ELI5

Understand bitmaps, CSS pixels, physical pixels, and DPR, then build a CanvasHost that neither duplicates subscriptions nor loses state.

Course progress Course outline 18 of 18 lessons available

Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates

  1. 01 Chapter 1: Do Not Draw Yet—Canvas Is Not a Product Architecture available now
  2. 02 Chapter 2: A Sheet of Pixels That Forgets Current lesson
  3. 03 Chapter 3: Turn Drawing into a Replayable Recipe available now
  4. 04 Chapter 4: Four Maps and a Camera available now

Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools

  1. 05 Chapter 5: Give the Pixel World a Registry available now
  2. 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
  3. 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
  4. 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
  5. 09 Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans available now

Part III: From “It Drags” to “It Is Trustworthy”—Interaction, Text, Assets, and Recovery

  1. 10 Chapter 10: Make the Editor Feel Right available now
  2. 11 Chapter 11: Drawn Text Is Not Editable Text available now
  3. 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
  4. 13 Chapter 13: Time Machines and Old Boxes available now
  5. 14 Chapter 14: Looking Correct Is Not Being Correct available now

Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI

  1. 15 Chapter 15: Do Not Search Ten Thousand Children One by One available now
  2. 16 Chapter 16: Keep the Front Desk Out of the Kitchen—Worker and GPU Upgrades available now
  3. 17 Chapter 17: Build the Car or Buy a Proven Chassis? available now
  4. 18 Chapter 18: People and AI Edit the Same Ledger available now

Start with a game a five-year-old can understand

Take a sheet of graph paper and color the fourth square in the third row blue. Put a sticky note beside it that says, “This is shape-7.” Let a friend take only the graph paper while you keep the note. Ask, “What is the blue square called? Who drew it? Can you move it two squares to the right?” Your friend can see only the color and cannot answer its name. Now replace the paper with one that has a denser grid. The old blue square disappears too, so you must redraw it from the record on the sticky note.

First predict: if you stretch a 10×10 drawing sheet to twice its size, do you gain more squares, or merely make the old squares fatter? If you replace it directly with a new 20×20 sheet, does the old drawing move over automatically? Try it yourself.

  1. Remember objectsWhere shape-7 is
  2. Prepare graph paperHow dense the screen is
  3. Color from the recordBecome a Bitmap
  4. Change paper and redrawDo not rely on old colors
The application remembers objects; Canvas retains only the color of each square.

The single truth of this chapter is: Canvas stores a Bitmap; the application stores objects. Canvas 2D is immediate-mode: a call changes pixels, and the browser does not retain “rectangle shape-7” for you.

Translate the toys into Canvas engineering

Object or action in the toy worldBrowser conceptResult
Size of the outer frameCSS SizeHow many CSS Pixels it occupies in page layout
Number of squares on the papercanvas.width/heightPixel dimensions of the Backing Bitmap
Grid densitydevicePixelRatioSeveral Physical Pixels may surround one CSS Pixel
Replacing the graph paperSet width / heightClears the Bitmap and resets Context State
Shape records on the sticky noteDocument DataRebuilds the scene deterministically
Assistant measuring the frameResizeObserverNotifies the Host when the container changes
Box of drawing toolsCanvasRenderingContext2DStateful tools such as Transform, alpha, and styles

The analogy has limits. A CSS Pixel is not a physical square of fixed size, and DPR is not determined only by hardware; browser zoom and moving between displays can change the effective ratio. A ResizeObserver is not a synchronous ruler: the browser schedules callback batches and layout cycles. Replacing real paper does not “reset the pen color,” but setting a Canvas size attribute resets Context State as well, so state must be rebuilt explicitly.

Kill the misleading intuitions first

  • “CSS width: 800px gives me 800 sharp pixels.” CSS changes only the displayed size. Enlarging the default 300×150 Bitmap still makes it blurry and stretched.
  • “Read DPR only during initial load.” DPR can change after browser zoom or a cross-display move, so the Host must recalculate it on every effective measurement.
  • “Assigning the same canvas.width has no effect.” The specification requires size assignment to reset the canvas. Even canvas.width = canvas.width clears it and restores default Context State.
  • “Restoring the camera after Resize is enough.” Transform, clip, alpha, lineDash, fonts, and other state can all disappear. The reliable approach is to run unified initialization and a complete redraw after a size change.
  • “More Observers make it more responsive.” Duplicate Observers/listeners cause duplicate measurements, loops, and leaks. One Host owns one resource set.

Production backpack

Prerequisite contract

The prerequisite is the boundary from Chapter 1: product UI stays in the DOM, while the dynamic scene uses Canvas 2D. At this point Canvas Lab has only a redraw(viewport) callback that does not preserve pixels. The camera belongs to application data and must never be hidden inside the Context’s current Transform. The container may be hidden and its width or height may be zero. After destruction, no callback may draw again.

Formal knowledge

<canvas> is a DOM element; getContext('2d', settings) binds the Rendering Context. Canvas 2D calls such as fillRect() immediately modify the output Bitmap; they do not produce a queryable rectangle node. CSS Size determines layout dimensions, while Backing Bitmap Size determines the number of available pixels. A common calculation is round(cssSize × effectiveDpr), followed by ctx.setTransform(dpr, 0, 0, dpr, 0, 0) so subsequent drawing can continue in CSS Pixels.

A Physical Pixel is a display unit on a device; a CSS Pixel is a browser logical unit. devicePixelRatio is a ratio, not a permanent device label. Pixel dimensions need an upper bound, or a high-DPR large display can allocate astonishing amounts of memory. If width or height is 0, do not create a 0-sized Bitmap and pretend the operation completed. Enter a suspended state and wait for the next nonzero measurement.

Under the specification, assigning a dimension initializes a new Bitmap and resets Context State. Even when the value matches the old value, it cannot be treated as side-effect-free. Assign only when the calculated pixel dimensions actually change. Once they do, follow a fixed order: set CSS Size → set Backing Size → initialize Context → redraw completely from the model.

Context creation options are requests, not guarantees. alpha: false says that a transparent background is unnecessary. desynchronized is a hint for lower latency. willReadFrequently suits frequent getImageData() use and may change the acceleration strategy. colorSpace can request srgb or a supported wide gamut. Support for a colorType such as unorm8/float16 requires even more careful detection. Report the actual attributes through getContextAttributes?.(); the input settings are not proof of a capability. getContext() may also return null.

Use ResizeObserver to watch the container instead of polling every frame. Some environments provide devicePixelContentBoxSize, but retain a contentRect × DPR fallback. If a callback changes the observed container merely because it changed Canvas’s own dimensions, it can create a Resize loop. Host CSS must make Canvas follow its container; Bitmap attributes must not participate in layout.

Evidence and compatibility

Sources above were checked on 2026-08-29. Feature-detect colorType, device-pixel-content-box, and context-attribute reporting. When unsupported, fall back to sRGB, CSS Size × DPR, and “unknown” rather than blocking basic drawing.

This chapter’s engineering increment

Starting point: Chapter 1 calls renderCanvas directly with fixed dimensions. Finish line: a CanvasHost that creates only one Observer, supports DPR/zero size/destruction, redraws deterministically after every reset, and emits a capability report.

canvas-lab/
  src/lab/ch02/canvas-host.ts
  src/lab/ch02/canvas-host.test.ts
  src/lab/ch02/demo.html

Interface contract: redraw receives the CSS viewport and actual DPR. The Host does not store the Document; it only notifies the Renderer. Here is the complete key implementation:

export type Viewport = { cssWidth: number; cssHeight: number; dpr: number };
export type CapabilityReport = {
  requested: CanvasRenderingContext2DSettings;
  actual: Record<string, unknown> | null;
};
export type HostPlatform = {
  dpr(): number;
  observe(element: Element, callback: () => void): () => void;
  watchDpr(callback: () => void): () => void;
};

const browserPlatform: HostPlatform = {
  dpr: () => window.devicePixelRatio || 1,
  observe(element, callback) {
    const observer = new ResizeObserver(callback);
    observer.observe(element);
    return () => observer.disconnect();
  },
  watchDpr(callback) {
    let media: MediaQueryList | null = null;
    const onChange = (): void => {
      arm();
      callback();
    };
    const arm = (): void => {
      media?.removeEventListener('change', onChange);
      media = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
      media.addEventListener('change', onChange);
    };
    arm();
    return () => media?.removeEventListener('change', onChange);
  },
};

export class CanvasHost {
  readonly context: CanvasRenderingContext2D;
  readonly capabilities: CapabilityReport;
  private stopObserving: (() => void) | null = null;
  private stopWatchingDpr: (() => void) | null = null;
  private destroyed = false;
  private last: Viewport | null = null;

  constructor(
    private readonly container: HTMLElement,
    private readonly canvas: HTMLCanvasElement,
    private readonly redraw: (ctx: CanvasRenderingContext2D, viewport: Viewport) => void,
    private readonly platform: HostPlatform = browserPlatform,
  ) {
    const requested: CanvasRenderingContext2DSettings = {
      alpha: false,
      desynchronized: false,
      willReadFrequently: false,
      colorSpace: 'srgb',
      colorType: 'unorm8',
    };
    const context = canvas.getContext('2d', requested);
    if (!context) throw new Error('Canvas 2D context is unavailable');
    this.context = context;
    const readable = context as CanvasRenderingContext2D & {
      getContextAttributes?: () => Record<string, unknown>;
    };
    this.capabilities = {
      requested,
      actual: readable.getContextAttributes?.() ?? null,
    };
    this.canvas.style.display = 'block';
    this.canvas.style.width = '100%';
    this.canvas.style.height = '100%';
    this.stopObserving = this.platform.observe(this.container, () => this.measure());
    this.stopWatchingDpr = this.platform.watchDpr(() => this.measure());
    this.measure();
  }

  measure(): void {
    if (this.destroyed) return;
    const rect = this.container.getBoundingClientRect();
    const cssWidth = Math.max(0, rect.width);
    const cssHeight = Math.max(0, rect.height);
    if (cssWidth === 0 || cssHeight === 0) {
      this.last = null;
      return;
    }
    const dpr = Math.min(3, Math.max(1, this.platform.dpr()));
    const pixelWidth = Math.max(1, Math.round(cssWidth * dpr));
    const pixelHeight = Math.max(1, Math.round(cssHeight * dpr));
    const bitmapChanged = this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight;
    const viewport: Viewport = { cssWidth, cssHeight, dpr };
    const viewportChanged =
      !this.last ||
      this.last.cssWidth !== cssWidth ||
      this.last.cssHeight !== cssHeight ||
      this.last.dpr !== dpr;
    if (!bitmapChanged && !viewportChanged) return;
    if (bitmapChanged) {
      this.canvas.width = pixelWidth;
      this.canvas.height = pixelHeight;
    }
    this.initializeContext(viewport);
    this.last = viewport;
    this.redraw(this.context, viewport);
  }

  private initializeContext(viewport: Viewport): void {
    this.context.setTransform(viewport.dpr, 0, 0, viewport.dpr, 0, 0);
    this.context.globalAlpha = 1;
    this.context.globalCompositeOperation = 'source-over';
    this.context.lineCap = 'butt';
    this.context.lineJoin = 'miter';
    this.context.setLineDash([]);
    this.context.fillStyle = '#ffffff';
    this.context.fillRect(0, 0, viewport.cssWidth, viewport.cssHeight);
  }

  destroy(): void {
    if (this.destroyed) return;
    this.destroyed = true;
    this.stopObserving?.();
    this.stopWatchingDpr?.();
    this.stopObserving = null;
    this.stopWatchingDpr = null;
    this.last = null;
  }
}

The complete usage page retains a real, accessible heading:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <style>
      #stage {
        width: 80vw;
        height: 50vh;
      }
      canvas {
        display: block;
      }
    </style>
  </head>
  <body>
    <h1>Canvas Lab</h1>
    <div id="stage"><canvas id="scene">Two business cards</canvas></div>
    <script type="module">
      import { CanvasHost } from './canvas-host.ts';
      const host = new CanvasHost(stage, scene, (ctx, view) => {
        ctx.fillStyle = '#2563eb';
        ctx.fillRect(24, 24, 120, 64);
        ctx.fillStyle = '#fff';
        ctx.fillText(`${view.dpr}x`, 34, 60);
      });
      window.addEventListener('pagehide', () => host.destroy(), { once: true });
    </script>
  </body>
</html>

The test does not guess browser timing. It injects the platform and asserts resource ownership and redraw behavior:

import { describe, expect, it, vi } from 'vitest';
import { CanvasHost, type HostPlatform } from './canvas-host';

describe('CanvasHost', () => {
  it('resizes for both element and DPR changes, then releases both subscriptions once', () => {
    const container = document.createElement('div');
    vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
      width: 200,
      height: 100,
    } as DOMRect);
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d')!;
    const setTransform = vi.spyOn(ctx, 'setTransform');
    const redraw = vi.fn();
    const disconnect = vi.fn();
    const unwatchDpr = vi.fn();
    let notifyResize = () => undefined;
    let notifyDpr = () => undefined;
    let dpr = 2;
    const platform: HostPlatform = {
      dpr: () => dpr,
      observe: (_element, callback) => {
        notifyResize = callback;
        return disconnect;
      },
      watchDpr: (callback) => {
        notifyDpr = callback;
        return unwatchDpr;
      },
    };
    const host = new CanvasHost(container, canvas, redraw, platform);
    expect([canvas.width, canvas.height]).toEqual([400, 200]);
    expect(setTransform).toHaveBeenLastCalledWith(2, 0, 0, 2, 0, 0);
    expect(redraw).toHaveBeenCalledTimes(1);
    notifyResize();
    expect(redraw).toHaveBeenCalledTimes(1);
    dpr = 1.5;
    notifyDpr();
    expect([canvas.width, canvas.height]).toEqual([300, 150]);
    expect(setTransform).toHaveBeenLastCalledWith(1.5, 0, 0, 1.5, 0, 0);
    expect(redraw).toHaveBeenCalledTimes(2);
    host.destroy();
    host.destroy();
    expect(disconnect).toHaveBeenCalledTimes(1);
    expect(unwatchDpr).toHaveBeenCalledTimes(1);
  });

  it('waits through a zero-sized hidden container', () => {
    const container = document.createElement('div');
    vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
      width: 0,
      height: 0,
    } as DOMRect);
    const redraw = vi.fn();
    const platform: HostPlatform = {
      dpr: () => 2,
      observe: () => () => undefined,
      watchDpr: () => () => undefined,
    };
    new CanvasHost(container, document.createElement('canvas'), redraw, platform);
    expect(redraw).not.toHaveBeenCalled();
  });
});

ResizeObserver tells you only whether the element box changed. DPR can change independently while the box dimensions remain the same, so re-arm a matchMedia listener for the current resolution as in the MDN example. Run npx vitest run src/lab/ch02/canvas-host.test.ts --environment jsdom; expect 2 passed. Run npm run dev, then change the window and browser Zoom. The capability report in the console should remain stable, and shape edges must not stretch. Return to the graph-paper game: the Host replaces the sheet according to frame size and density and prepares the pens again; the actual picture is still redrawn from the sticky-note record.

Break it on purpose

InjectionSymptomEvidenceFixRegression testRecovery
Browser Zoom from 100% to 200%Old implementation is blurry or wrongly sizedRecord CSS/Bitmap/DPR tuple and screenshotsRecalculate DPR on every measurementPixel dimensions match after Playwright changes DPRRestore Zoom
Drag from standard display to high-DPI displayStill uses old densitydevicePixelRatio changed but Bitmap did notResize/media change triggers measureSimulate DPR 1→2Drag back and verify again
display:none, then showFirst measurement is 0 and remains permanently blankredraw call count is 0Suspend at zero size and resume on the next callback0→300×200 calls onceRestore display
Skip initialization after a size changeCamera/alpha/lineDash disappeargetTransform() is identity and screenshot is wrongInitialize state, then redraw from modelSpy order: resize→setTransform→renderRemove failure branch
Callback always writes the same dimensionsClears, flickers, and renders repeatedlyIdentical measurement still increases redraw countCompare pixel/viewport before assignmentRepeated notify does not redrawRestore guard
Create a Host on every React renderMultiple Observers and loopsConstruction/disconnection counts are asymmetricOwn only one Host in the lifecycleTwo mount/unmount cycles are both 1:1Destroy extra instances

Also test a tiny width, a large high-DPR display, and missing capability reporting manually. Recovery does not mean “it looked fine after refresh.” It means the injection is removed, Observer counts return to zero, tests pass again, and the next Resize still draws.

Pass with evidence

PropertyAutomated/manualEvidence
Not blurryManualCSS Size × DPR and Bitmap Size logs, cross-display screenshots
Not stretchedAutomatedAssertion comparing CSS and Bitmap aspect ratios
Does not lose Transform/CameraAutomatedgetTransform/camera rendering test after Resize
Does not create multiple ObserversAutomatedConstruction and disconnect counts plus repeated-mount test
Redraw is repeatableAutomatedIdentical pixel snapshot from the same Document after clear/Resize
Can degrade gracefullyAutomated/manualTests for no attribute report, DPR=1, and zero-size paths
  • I can distinguish CSS Size, Backing Size, CSS Pixel, and Physical Pixel.
  • I know changing width/height clears the Bitmap and resets Context State, including assignment of the same value.
  • The Host handles Resize, DPR, initialization, capability reporting, and deterministic redraw together.
  • alpha/desynchronized/willReadFrequently/colorSpace/colorType are recorded requests with measured capabilities.
  • Hidden→visible, Zoom, cross-display movement, zero size, and Resize loops all have evidence.
  • destroy() is idempotent and no Observer leaks.

Explain it to a five-year-old

Without using the words “pixel,” “DPR,” “Context,” or “Observer,” answer: Why is stretching graph paper not the same as replacing it with paper that has a denser grid? Why must you redraw from a record after replacing the paper?

Expand a good jargon-free answer

Stretching old paper only makes each old square look fatter, so the edges become fuzzy. Paper with more squares lets you draw finer edges. But new paper starts empty: the blue color on the old sheet does not jump over by itself, and even the last pen color cannot be trusted. So we keep a list of what exists and where it belongs. Whenever the paper's size or density changes, we prepare the pen and draw everything again from that list. The sticky note and the graph paper each have their own job.