JEPA4Japan · チュートリアル

第1章:まだ描かない——Canvasはプロダクト設計ではない

4,222文字 12分で読めます #Canvas#Frontend Engineering#Infinite Canvas#ELI5

同じ業務図をDOM、SVG、Canvas、GPUで実装して比較し、最初のRenderer選定ADRを残します。

コース進捗 コース目次 18レッスン中 18件を公開中

第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標

  1. 01 第1章:まだ描かない——Canvasはプロダクト設計ではない 現在のレッスン
  2. 02 第2章:すぐに記憶を失うピクセルの紙 公開中
  3. 03 第3章:お絵描きを再現可能なレシピにする 公開中
  4. 04 第4章:4枚の地図と1台のカメラ 公開中

第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール

  1. 05 第5章:ピクセル世界に台帳を作る 公開中
  2. 06 第6章:ランプが点いたときだけ描き直す——Render SchedulerとReactの境界 公開中
  3. 07 第7章:マウス、指、ペンに同じ言葉を話してもらう 公開中
  4. 08 第8章:細い縁を調べる前に大きな箱を探す 公開中
  5. 09 第9章:ツールは信号機であり、Booleanの袋ではない 公開中

第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧

  1. 10 第10章:触って気持ちよいエディターにする 公開中
  2. 11 第11章:描かれた文字は編集できる文字ではない 公開中
  3. 12 第12章:借りた画像を勝手に箱へ詰めてはいけない 公開中
  4. 13 第13章:タイムマシンと古い箱 公開中
  5. 14 第14章:正しく見えることと、本当に正しいことは違う 公開中

第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI

  1. 15 第15章:1万人を一人ずつ探さない 公開中
  2. 16 第16章:受付を厨房へ入れない——WorkerとGPUへの更新 公開中
  3. 17 第17章:車を自作するか、実績あるシャーシを買うか 公開中
  4. 18 第18章:人とAIが同じ台帳を編集する 公開中

5歳児にもわかるゲームから始めよう

白い紙を1枚、透明なプラスチック板を1枚、積み木を1箱、そして消して書き直せる小さな黒板を用意します。白い紙には「注文A」と書き、透明板には矢印を描き、積み木は2つの四角形になるように並べ、最後に黒板の一部を青く塗ります。

先へ進む前に、4つのことを予想してください。文字を最も直しやすいのはどれでしょう。下にある文字を透かして見られるのはどれでしょう。手で直接動かせるのはどれでしょう。消した後、もともと何が描かれていたのか最もわかりにくいのはどれでしょう。実際に一度試し、「注文A」の A を B に変えてみます。「どれも同じ絵を表せる」ことは、「同じ操作のコストも同じ」ことを意味しないとわかります。

  1. 紙に文字を書く読めて、選択できる
  2. 透明板に線を描く拡大しても鮮明
  3. 積み木を並べる一つずつ識別できる
  4. 黒板を塗る残るのはPixelだけ
「何で描くか」を選ぶ前に、「どう直すか」を予想します。素材そのものがプロダクトなのではありません。

これが本章で伝える唯一の真実です。Infinite Canvasとは、Pan、Zoom、Object配置ができるプロダクトの操作モデルであり、描画技術ではありません。 DOM、SVG、Canvas 2D、GPU、あるいはそれらを組み合わせて実現できます。

おもちゃをCanvasに翻訳する

おもちゃの素材エンジニアリング上の対応物本当に問うべきこと
白い紙に書いた文字DOM入力、選択、読み上げ、自動Layoutが必要か
透明板に描いた線SVGVector Geometryとアドレス可能なNodeが必要か
消せる黒板に塗った色Canvas 2D Bitmap大量の独自2D Pixel描画が必要か
大勢の料理人が同時に盛り付けるWebGL / WebGPUBatchingや並列計算でボトルネックを解消できるという証拠があるか
黒板に紙を貼るHybrid DOM/CanvasText編集はDOMに残し、Graphic描画はCanvasに任せる
GPU画面に紙を貼るHybrid DOM/GPUSemantic層はDOMに残し、高Throughput描画はGPUに任せる
「どこまでも動かせる机」Infinite Canvas基盤となる素材を指定しないプロダクト挙動

この比喩が通用するのはここまでです。現実の素材は一度に1か所にしか置けませんが、BrowserはCanvasの上にDOMを重ねられます。SVGも本物の透明板ではなく、やはりBrowserによってRasterizeされます。GPUも常に高速な魔法の料理人ではありません。Data Upload、Shader、Device Loss、Accessibilityにはそれぞれコストがあります。比喩は最初に正しい問いを立てる助けにはなりますが、Measurement、仕様、互換性検証の代わりにはなりません。

まず誤った直感を捨てる

  1. 「Infinite Canvasなら<canvas>を使うべきだ」。誤りです。Infinite Canvasが約束するのはCameraとObjectの操作だけです。Textが多く、Object数が中程度で、Semanticsが重要なプロダクトでは、DOM/SVGのほうが適していることがよくあります。
  2. 「Objectが多ければ必ずCanvasを使う」。誤りです。総Object数が10万でも表示中が10個の場面と、影や画像を持つ2,000個が表示中の場面ではコストが異なります。まず表示数、変更頻度、Pixel面積を測ります。
  3. 「Three.jsはCanvas 2Dの上位版だ」。誤りです。Three.jsはScene、Camera、Mesh、Material、Depthなどの3D課題を扱います。普通の2D Whiteboardにそれらが不要なら、導入されるのは別種の、より複雑な制約です。
  4. 「tldrawはすべてのShapeをCanvas 2Dで描いている」。誤りです。tldrawの公式Shape契約ではcomponent()がReact Componentを返し、Export経路ではSVGを生成します。Library名やプロダクトの見た目だけではRendererを証明できません。ここで述べているのは主要なShape描画Architectureであり、個々のComponent内部で<canvas>を使えることを否定しているわけではありません。

本番用バックパック

前提となる契約

本章ではMatrixやHit Testingの知識は求めません。Browser、TypeScript、Vitestを実行できれば十分です。まずプロダクト要件を固定します。Canvas Labは業務Process Diagram Editorです。CardのTitleをNativeに編集でき、ConnectorはZoomしても鮮明で、Gridは高頻度に移動します。ExportにはPNGと構造化JSONが必要で、KeyboardとScreen Readerも欠かせません。

正式な知識:信仰ではなく評価軸で選ぶ

Renderer強み主なコストCanvas Labでの役割
DOMNative Text、CSS Layout、Semantics、Focus、Automated Testing多数のNodeを高頻度に変形するとStyle/Layoutコストが増えるToolbar、Form、Text編集、Inspector
SVGVector、アドレス可能なGeometry Node、CSS、鮮明なExport多数のNodeが継続的に変化する場合はDOMコストの計測が必要小規模なConnectorまたはExportの候補
Canvas 2D2D描画を直接制御でき、APIが成熟し、頻繁な再描画に向くRetained Object、Native Semantics、Text編集がない学習用KernelのGrid、Shape、Overlay
WebGL成熟したGPU Pipeline、Batching、ShaderBuffer、Texture、Picking、Context Lossを自分で扱う必要があるProfileで根拠を得た後の置換候補
WebGPUより現代的なGPU Compute/Rendering Interface対応範囲、Device/Pipeline管理、FallbackのコストProgressive Enhancementの実験であり、Baselineにはしない
DOM/CanvasSemantic UIと高頻度Pixel層がそれぞれの役割を担う2層の座標、Focus、Stackingを同期する必要がある現在の選択
DOM/GPUNative UIと高Throughput描画2つのLifecycleと、より高い実装難度規模が大きく、GPUの効果が証明された場合

Reviewでは、Native Text編集、DOM Semantics、Accessibility、表示Object数、総Object数、秒間変更頻度、Pixel Effect、視覚的・SemanticなExport要件、Renderer置換コスト、多人共同編集Model、対象Browser、そしてTeamがGeometry/GPUを保守できるかを一項ずつ記録しなければなりません。共同編集そのものはRendererを決めません。同期するのはDocument Operationであり、Pixelではないからです。

根拠と互換性

Browserは<canvas>を、2d、webgl、webgl2、webgpuなどのContextをBindingできるElementとして定義しています。この事実自体が、Elementと特定のプロダクトが同じものではないと示しています。Release前には対象Browserの互換表を確認し、実機Benchmarkを行います。「Baseline」を性能保証として扱ってはいけません。

上記Sourceの確認日は2026-08-29です。WebGPUの可用性とCapabilityはRuntimeで検出しなければなりません。本Tutorialでは、特定のBrowser Versionを永続的な事実として固定しません。

この章のエンジニアリング増分

開始点: 空のPageと、同じ一組のBusiness Data。到達点: DOM、SVG、Canvas 2Dの3実装が同じ「注文→発送」Business Diagramを表示し、Project最初のADRを作成します。File構成は次のとおりです。

canvas-lab/
  src/lab/ch01/render-three-ways.ts
  src/lab/ch01/index.html
  src/lab/ch01/renderer-choice.ts
  src/lab/ch01/renderer-choice.test.ts
  docs/adr/0001-canvas-2d-teaching-kernel.md

完全なHTML Containerです。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>Canvas Lab / Renderer</title>
  </head>
  <body>
    <main>
      <section>
        <h1>DOM</h1>
        <div id="dom-demo"></div>
      </section>
      <section>
        <h1>SVG</h1>
        <svg id="svg-demo" viewBox="0 0 360 140" aria-label="注文プロセス図"></svg>
      </section>
      <section>
        <h1>Canvas</h1>
        <canvas id="canvas-demo" width="360" height="140">注文から発送まで</canvas>
      </section>
    </main>
    <script type="module" src="./render-three-ways.ts"></script>
  </body>
</html>

完全なTypeScriptです。3つの経路が同じDataを消費するため、Business ObjectがRendererに固定されません。

type Card = { id: string; label: string; x: number; y: number };
type Diagram = { cards: readonly Card[]; edge: readonly [string, string] };

export const diagram: Diagram = {
  cards: [
    { id: 'order', label: '注文', x: 20, y: 40 },
    { id: 'ship', label: '発送', x: 240, y: 40 },
  ],
  edge: ['order', 'ship'],
};

function boxStyle(card: Card): string {
  return `position:absolute;left:${card.x}px;top:${card.y}px;width:100px;height:56px;border:2px solid #2563eb;border-radius:10px;display:grid;place-items:center`;
}

export function renderDOM(root: HTMLElement, model: Diagram): void {
  root.replaceChildren();
  root.style.cssText = 'position:relative;width:360px;height:140px';
  const line = document.createElement('div');
  line.style.cssText =
    'position:absolute;left:120px;top:68px;width:120px;border-top:2px solid #64748b';
  root.append(line);
  for (const card of model.cards) {
    const article = document.createElement('article');
    article.dataset.shapeId = card.id;
    article.style.cssText = boxStyle(card);
    article.textContent = card.label;
    root.append(article);
  }
}

export function renderSVG(root: SVGSVGElement, model: Diagram): void {
  const ns = 'http://www.w3.org/2000/svg';
  root.replaceChildren();
  const line = document.createElementNS(ns, 'line');
  Object.entries({
    x1: '120',
    y1: '68',
    x2: '240',
    y2: '68',
    stroke: '#64748b',
    'stroke-width': '2',
  }).forEach(([key, value]) => line.setAttribute(key, value));
  root.append(line);
  for (const card of model.cards) {
    const group = document.createElementNS(ns, 'g');
    group.dataset.shapeId = card.id;
    const rect = document.createElementNS(ns, 'rect');
    Object.entries({
      x: String(card.x),
      y: String(card.y),
      width: '100',
      height: '56',
      rx: '10',
      fill: 'white',
      stroke: '#2563eb',
      'stroke-width': '2',
    }).forEach(([key, value]) => rect.setAttribute(key, value));
    const text = document.createElementNS(ns, 'text');
    text.setAttribute('x', String(card.x + 50));
    text.setAttribute('y', String(card.y + 34));
    text.setAttribute('text-anchor', 'middle');
    text.textContent = card.label;
    group.append(rect, text);
    root.append(group);
  }
}

export function renderCanvas(canvas: HTMLCanvasElement, model: Diagram): void {
  const ctx = canvas.getContext('2d');
  if (!ctx) throw new Error('Canvas 2Dを利用できません');
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.strokeStyle = '#64748b';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(120, 68);
  ctx.lineTo(240, 68);
  ctx.stroke();
  for (const card of model.cards) {
    ctx.fillStyle = 'white';
    ctx.strokeStyle = '#2563eb';
    ctx.beginPath();
    ctx.roundRect(card.x, card.y, 100, 56, 10);
    ctx.fill();
    ctx.stroke();
    ctx.fillStyle = '#0f172a';
    ctx.textAlign = 'center';
    ctx.font = '16px system-ui';
    ctx.fillText(card.label, card.x + 50, card.y + 34);
  }
}

renderDOM(document.querySelector('#dom-demo')!, diagram);
renderSVG(document.querySelector('#svg-demo')!, diagram);
renderCanvas(document.querySelector('#canvas-demo')!, diagram);

Decision InterfaceとTestにより、ADRへ検証可能なInputを与えます。

export type ProductNeeds = Readonly<{
  nativeSceneText: boolean;
  semanticScene: boolean;
  visibleDynamicShapes: number;
  changesPerSecond: number;
  benchmark?: Readonly<{ domP95Ms: number; canvasP95Ms: number; frameBudgetMs: number }>;
}>;
type SceneChoice = 'dom' | 'canvas2d' | 'prototype-required';

export function chooseLayers(n: ProductNeeds): { ui: 'dom'; scene: SceneChoice } {
  if (n.nativeSceneText || n.semanticScene) return { ui: 'dom', scene: 'dom' };
  if (!n.benchmark) return { ui: 'dom', scene: 'prototype-required' };
  const canvasWins =
    n.benchmark.canvasP95Ms <= n.benchmark.frameBudgetMs &&
    n.benchmark.canvasP95Ms < n.benchmark.domP95Ms;
  return { ui: 'dom', scene: canvasWins ? 'canvas2d' : 'dom' };
}
import { describe, expect, it } from 'vitest';
import { chooseLayers } from './renderer-choice';
describe('renderer ADR rule', () => {
  it('never moves semantic product UI into the bitmap', () => {
    expect(
      chooseLayers({
        nativeSceneText: true,
        semanticScene: true,
        visibleDynamicShapes: 2000,
        changesPerSecond: 120,
      }),
    ).toEqual({ ui: 'dom', scene: 'dom' });
  });
  it('refuses to turn an object count into an unmeasured renderer verdict', () => {
    expect(
      chooseLayers({
        nativeSceneText: false,
        semanticScene: false,
        visibleDynamicShapes: 2000,
        changesPerSecond: 120,
      }).scene,
    ).toBe('prototype-required');
  });
  it('permits Canvas only when the measured scene meets its frame budget', () => {
    expect(
      chooseLayers({
        nativeSceneText: false,
        semanticScene: false,
        visibleDynamicShapes: 2000,
        changesPerSecond: 120,
        benchmark: { domP95Ms: 24, canvasP95Ms: 8, frameBudgetMs: 12 },
      }).scene,
    ).toBe('canvas2d');
  });
});

ADRには、学習用KernelでCanvas 2Dを選んだ理由が、Bitmap Lifecycle、Renderer、Geometry、Schedulingを自分で実装するためだと明記します。Toolbar、Form、Text編集は引き続きDOMに置きます。置換Triggerには、「Native TextとSemanticsが支配的になればDOM/SVGへ戻す」と、「BenchmarkでCanvas 2Dがp95 Frame Budgetを満たせず、GPU Prototypeによる改善が確認できた場合にGPUへ移行する」を含めます。互換性、Team Capability、Export、共同編集、Exit Costも記録します。

visibleDynamicShapesとchangesPerSecondは実験ScenarioとADRのInputになりますが、製品横断の普遍的なThresholdではありません。Benchmarkがなければ「Prototypeが必要」と返すことで、例の数値が法則のように見えるのを防ぎます。npm run devを実行し、Browserに同じ内容の図が3つ表示され、DOM Textを選択できることを確認します。npx vitest run src/lab/ch01/renderer-choice.test.tsを実行し、3 passedを期待します。

ゲームに戻りましょう。「紙と黒板のどちらが先進的か」を議論したのではありません。編集できる文字は紙に置き、繰り返し塗り直す場面は黒板に置きました。

わざと壊す

Titleと通常のFormもCanvas内に描き、Pageから本物の<h1>、<label>、<input>を削除します。

注入する故障症状根拠修正Regression Test復旧
Pixelの見出し見出しを選択できず、Document Outlineにも現れないDevTools Accessibility Treeにheadingがない本物の<h1>を使い、CanvasにはSceneだけを描くgetByRole('heading')が成功するDOMの見出しを戻す
PixelのInputTabで入れず、Screen Readerがlabel/valueを認識できないKeyboard操作の録画とAccessibility Tree<label><input>のDOM Overlayを使うKeyboardだけで入力して送信する偽Inputを削除する
CanvasでLayoutしたFormCSS Grid、折り返し、Validationをすべて手作業で再実装する必要がある実装Code量とBrowser Test数を比較するProduct UIをDOMに保つ200% Zoomでも重ならない元のLayoutを戻す
座標AssertによるTestFont/DPRが変わると壊れやすい別OSでTestが失敗するDOMはRoleで検索し、CanvasはModelとScreenshotをTestするNode Unit TestとBrowser Visual TestHard-coded座標を削除する

Screen Reader、Keyboard、Text Selection、CSS、Layout、Testingは「後から足す飾り」ではなく、Renderer選定のコストです。故障実験を終えたら本物のDOM Controlを戻し、KeyboardとScreen Readerの確認をもう一度実行します。

根拠を示して合格する

証明する結論受け入れられる根拠受け入れられない回答
どの層にCanvasが必要か動的な表示ObjectとFrame Trace、ADR、Prototype「Canvasのほうが速い」
どの層をDOMに残すかKeyboard、Semantic Tree、Text編集の要件「みんなそうしている」
Rendererを置換できるか3つの実装が同じDiagram Typeを消費するBusiness ObjectをDOM Nodeとして保存する
互換性を制御できるか対象Browserでの実測とFeature Detection世界全体のSupport Tableだけを見る
  • Infinite Canvasと<canvas> Elementの違いを説明できる。
  • DOM、SVG、Canvas 2D、WebGL、WebGPU、2種類のHybridを比較した。
  • Object数だけでなく、12の選定軸をすべて記入した。
  • 3つの実装が同じBusiness Diagramを表示し、同じDataを消費する。
  • Canvasの偽Form故障について、録画、Semantic Tree、Testの根拠があり、復旧も済んでいる。
  • ADRに現在の選択、置換条件、共同編集・Export・License・Teamのコストを記した。

5歳児に説明する

「Renderer」「DOM」「GPU」という言葉を使わずに答えてください。読み上げられる物語の本を、なぜ1枚の写真として全部描いてはいけないのでしょう。では、高速で動き続ける色鮮やかな背景を、なぜ1万枚の編集可能な紙片で作る必要はないのでしょう。

専門用語を使わない合格回答を開く

写真はたくさんの色を一度に見せるのは得意ですが、どこが題名で、どこに文字を入力できるかを知りません。本を読む機械にも、どこから読めばよいかわかりません。だから文字やButtonは、見つけて、選んで、操作できるものとして残します。反対に、ずっと動く大きな色の面を細かな紙片に分けすぎると、動くたびに全部を並べ直すことになり、とても忙しくなります。人が理解して操作するものには名前を残し、素早く描き直す部分はお絵描き板に任せるのが最善です。ゲームで黒板に紙を貼ったのと同じです。