第3版が公開されました。こちらで読んでください!

第19章
プロジェクト: ペイントプログラム

目の前の様々な色を見つめる。真っ白なキャンバスを見つめる。そして、詩を形作る言葉のように、音楽を形作る音符のように、色を塗り重ねていく。

ジョアン・ミロ

前の章までの内容で、シンプルなウェブアプリケーションを構築するために必要な要素は全て揃っています。この章では、まさにそれを実行します。

私たちのアプリケーションは、Microsoft Paintのようなウェブベースの描画プログラムになります。これを使って画像ファイルを開き、マウスで落書きし、保存することができます。これがその外観です。

A simple paint program

コンピュータで絵を描くのは素晴らしい。材料、スキル、才能を心配する必要はありません。ただ塗り始めるだけです。

実装

ペイントプログラムのインターフェースは、上に大きな<canvas>要素、その下にいくつかのフォームフィールドを表示します。ユーザーは、<select>フィールドからツールを選択し、キャンバスをクリックまたはドラッグすることで絵を描きます。線を描く、絵の一部を消去する、テキストを追加するなどのツールがあります。

キャンバスをクリックすると、選択されているツールに"mousedown"イベントが渡され、ツールは好きな方法で処理できます。例えば、線描画ツールは、マウスボタンが離されるまで"mousemove"イベントを待ち受け、現在の色とブラシサイズを使用してマウスの軌跡に沿って線を描画します。

色とブラシサイズは、追加のフォームフィールドで選択します。これらは、変更されるたびにキャンバス描画コンテキストのfillStylestrokeStylelineWidthを更新するように接続されています。

プログラムに画像を読み込む方法は2つあります。1つ目はファイルフィールドを使用する方法で、ユーザーは自分のファイルシステムからファイルを選択できます。2つ目はURLを要求する方法で、Webから画像を取得します。

画像は、やや特殊な方法で保存されます。右側の保存リンクは現在の画像を指しています。これは、フォロー、共有、または保存できます。これはどのように実現されるのか、すぐに説明します。

DOMの構築

私たちのプログラムのインターフェースは、30以上のDOM要素で構築されています。これらを何らかの方法で構築する必要があります。

HTMLは、複雑なDOM構造を定義するための最も明白な形式です。しかし、プログラムをHTMLの一部とスクリプトに分離することは、多くのDOM要素がイベントハンドラーを必要とするか、スクリプトによって他の方法で触れられる必要があるという事実によって困難になります。そのため、スクリプトは、作用する必要があるDOM要素を見つけるために、多くのquerySelector(または同様の)呼び出しを行う必要があります。

インターフェースの各部分のDOM構造が、それを駆動するJavaScriptコードの近くに定義されていれば良いでしょう。そのため、私はDOMノードのすべての作成をJavaScriptで行うことを選択しました。第13章で見たように、DOM構造を構築するための組み込みインターフェースは非常に冗長です。多くのDOM構築を行う場合、ヘルパー関数が必要です。

このヘルパー関数は、第13章elt関数の拡張版です。指定された名前と属性を持つ要素を作成し、さらに受け取る引数をすべて子ノードとして追加し、文字列をテキストノードに自動的に変換します。

function elt(name, attributes) {
  var node = document.createElement(name);
  if (attributes) {
    for (var attr in attributes)
      if (attributes.hasOwnProperty(attr))
        node.setAttribute(attr, attributes[attr]);
  }
  for (var i = 2; i < arguments.length; i++) {
    var child = arguments[i];
    if (typeof child == "string")
      child = document.createTextNode(child);
    node.appendChild(child);
  }
  return node;
}

これにより、企業のエンドユーザー契約のようにソースコードを長く退屈にすることなく、簡単に要素を作成できます。

基礎

プログラムの中核はcreatePaint関数であり、これは引数として与えられたDOM要素にペイントインターフェースを追加します。プログラムを少しずつ構築したいので、画像の下の様々なコントロールを初期化する関数を保持するcontrolsというオブジェクトを定義します。

var controls = Object.create(null);

function createPaint(parent) {
  var canvas = elt("canvas", {width: 500, height: 300});
  var cx = canvas.getContext("2d");
  var toolbar = elt("div", {class: "toolbar"});
  for (var name in controls)
    toolbar.appendChild(controls[name](cx));

  var panel = elt("div", {class: "picturepanel"}, canvas);
  parent.appendChild(elt("div", null, panel, toolbar));
}

各コントロールは、キャンバス描画コンテキストにアクセスし、そのコンテキストのcanvasプロパティを通じて<canvas>要素にアクセスできます。プログラムの状態のほとんどはこのキャンバスに存在します。これは、現在の画像だけでなく、選択された色(fillStyleプロパティ内)とブラシサイズ(lineWidthプロパティ内)も保持しています。

キャンバスとコントロールをクラス付きの<div>要素でラップして、画像の周りに灰色の境界線を追加するなど、スタイルを追加できるようにします。

ツールの選択

最初に追加するコントロールは、ユーザーが描画ツールを選択できる<select>要素です。controlsと同様に、オブジェクトを使用して様々なツールを収集し、それらをすべて1か所にハードコードする必要がなく、後でさらにツールを追加できるようにします。このオブジェクトは、ツールの名前とそのツールが選択され、キャンバスがクリックされたときに呼び出される関数とを関連付けます。

var tools = Object.create(null);

controls.tool = function(cx) {
  var select = elt("select");
  for (var name in tools)
    select.appendChild(elt("option", null, name));

  cx.canvas.addEventListener("mousedown", function(event) {
    if (event.which == 1) {
      tools[select.value](event, cx);
      event.preventDefault();
    }
  });

  return elt("span", null, "Tool: ", select);
};

ツールフィールドは、定義されているすべてのツールに対応する<option>要素で設定され、キャンバス要素の"mousedown"ハンドラーは、現在のツールの関数を呼び出し、イベントオブジェクトと描画コンテキストの両方を引数として渡します。また、preventDefaultを呼び出すため、マウスボタンを押したままドラッグしても、ブラウザがページの一部を選択することはありません。

最も基本的なツールは線ツールで、これによりユーザーはマウスで線を描くことができます。線端を正しい位置に配置するには、特定のマウスイベントに対応するキャンバス相対座標を見つける必要があります。第13章で簡単に触れたgetBoundingClientRectメソッドがここで役立ちます。これは、要素が画面の左上隅を基準としてどこで表示されているかを示します。マウスイベントのclientXclientYプロパティもこの隅を基準としているため、キャンバスの左上隅を差し引くことで、その隅を基準とした位置を取得できます。

function relativePos(event, element) {
  var rect = element.getBoundingClientRect();
  return {x: Math.floor(event.clientX - rect.left),
          y: Math.floor(event.clientY - rect.top)};
}

いくつかの描画ツールは、マウスボタンが押されている限り"mousemove"イベントをリッスンする必要があります。trackDrag関数は、そのような状況でのイベントの登録と登録解除を処理します。

function trackDrag(onMove, onEnd) {
  function end(event) {
    removeEventListener("mousemove", onMove);
    removeEventListener("mouseup", end);
    if (onEnd)
      onEnd(event);
  }
  addEventListener("mousemove", onMove);
  addEventListener("mouseup", end);
}

この関数は2つの引数を取ります。1つは各"mousemove"イベントに対して呼び出す関数で、もう1つはマウスボタンが離されたときに呼び出す関数です。必要ない場合は、どちらの引数も省略できます。

線ツールは、これらの2つのヘルパーを使用して実際の描画を行います。

tools.Line = function(event, cx, onEnd) {
  cx.lineCap = "round";

  var pos = relativePos(event, cx.canvas);
  trackDrag(function(event) {
    cx.beginPath();
    cx.moveTo(pos.x, pos.y);
    pos = relativePos(event, cx.canvas);
    cx.lineTo(pos.x, pos.y);
    cx.stroke();
  }, onEnd);
};

この関数は、最初に描画コンテキストのlineCapプロパティを"round"に設定します。これにより、ストロークされたパスの両端がデフォルトの正方形ではなく丸になります。これは、別々のイベントに応答して描画された複数の別々の線が、単一の、一貫性のある線のように見えるようにするためのトリックです。線幅が大きい場合、デフォルトのフラットなラインキャップを使用すると、コーナーに隙間が見えることがあります。

その後、マウスボタンが押されている限り発生するすべての"mousemove"イベントに対して、現在のstrokeStylelineWidthを使用して、マウスの古い位置と新しい位置の間に単純な線分が描画されます。

tools.LineへのonEnd引数は、単にtrackDragに渡されます。ツールを通常実行する方法は、第3引数を渡さないため、線ツールを使用する場合、その引数はundefinedを保持し、マウスドラッグの終了時には何も起こりません。この引数は、非常に少ない追加コードで消去ツールを線ツールの上に実装できるようにするために存在します。

tools.Erase = function(event, cx) {
  cx.globalCompositeOperation = "destination-out";
  tools.Line(event, cx, function() {
    cx.globalCompositeOperation = "source-over";
  });
};

globalCompositeOperationプロパティは、キャンバス上の描画操作が接触するピクセルの色をどのように変更するかを制御します。デフォルトでは、プロパティの値は"source-over"で、これは描画された色がその位置にある既存の色に重ね合わされることを意味します。色が不透明な場合、古い色は単純に置き換えられますが、部分的に透明な場合は、2つが混合されます。

消去ツールはglobalCompositeOperation"destination-out"に設定します。これにより、接触したピクセルが消去され、再び透明になります。

これで、ペイントプログラムに2つのツールができました。1ピクセル幅の黒い線を描画し(キャンバスのデフォルトのstrokeStylelineWidth)、再び消去できます。動作する、ただしかなり限定的なペイントプログラムです。

色とブラシサイズ

ユーザーは黒以外の色で描画したり、さまざまなブラシサイズを使用したいと思うと仮定して、これらの2つの設定用のコントロールを追加しましょう。

第18章では、様々なフォームフィールドについて説明しました。色のフィールドについては触れませんでした。従来、ブラウザはカラーピッカーを組み込みでサポートしていませんでしたが、ここ数年で多くの新しいフォームフィールドタイプが標準化されました。その1つが`<input type="color">`です。その他には`「date」`、`「email」`、`「url」`、`「number」`などがあります。すべてのブラウザでサポートされているわけではなく、執筆時点ではどのバージョンのInternet Explorerも色のフィールドをサポートしていません。`<input>`タグのデフォルトのタイプは`「text」`であり、サポートされていないタイプが使用された場合、ブラウザはテキストフィールドとして扱います。つまり、ペイントプログラムを実行しているInternet Explorerユーザーは、便利なウィジェットから選択するのではなく、希望の色名を自分で入力する必要があります。

controls.color = function(cx) {
  var input = elt("input", {type: "color"});
  input.addEventListener("change", function() {
    cx.fillStyle = input.value;
    cx.strokeStyle = input.value;
  });
  return elt("span", null, "Color: ", input);
};

色のフィールドの値が変更されるたびに、描画コンテキストの`fillStyle`と`strokeStyle`が新しい値に更新されます。

ブラシサイズの構成フィールドも同様に動作します。

controls.brushSize = function(cx) {
  var select = elt("select");
  var sizes = [1, 2, 3, 5, 8, 12, 25, 35, 50, 75, 100];
  sizes.forEach(function(size) {
    select.appendChild(elt("option", {value: size},
                           size + " pixels"));
  });
  select.addEventListener("change", function() {
    cx.lineWidth = select.value;
  });
  return elt("span", null, "Brush size: ", select);
};

コードはブラシサイズの配列からオプションを生成し、ブラシサイズが選択されたときにキャンバスの`lineWidth`が更新されるようにします。

保存

保存リンクの実装を説明するために、まずデータURLについて説明する必要があります。データURLは、プロトコルとしてdata:を持つURLです。通常のhttp:およびhttps: URLとは異なり、データURLはリソースを指すのではなく、リソース全体をその中に含んでいます。これは、単純なHTMLドキュメントを含むデータURLです。

data:text/html,<h1 style="color:red">Hello!</h1>

データURLは、スタイルシートファイルに小さな画像を直接含めるなど、様々なタスクに役立ちます。また、サーバーに移動する前に、ブラウザ内でクライアント側で作成したファイルにリンクすることもできます。

キャンバス要素には`toDataURL`という便利なメソッドがあり、キャンバス上の画像を画像ファイルとして含むデータURLを返します。ただし、画像が変更されるたびに保存リンクを更新するわけにはいきません。大きな画像の場合、大量のデータをリンクに移動することになり、著しく遅くなります。代わりに、キーボードでフォーカスされたり、マウスがその上を移動したりしたときに、その`href`属性が更新されるようにリンクを調整します。

controls.save = function(cx) {
  var link = elt("a", {href: "/"}, "Save");
  function update() {
    try {
      link.href = cx.canvas.toDataURL();
    } catch (e) {
      if (e instanceof SecurityError)
        link.href = "javascript:alert(" +
          JSON.stringify("Can't save: " + e.toString()) + ")";
      else
        throw e;
    }
  }
  link.addEventListener("mouseover", update);
  link.addEventListener("focus", update);
  return link;
};

したがって、リンクは静かにそこに座って間違ったものを指していますが、ユーザーが近づくと、現在の画像を指すように魔法のように自身を更新します。

大きな画像を読み込むと、一部のブラウザはこの方法で生成される巨大なデータURLで処理が詰まる可能性があります。小さな画像の場合、この方法は問題なく動作します。

しかし、ここで再びブラウザのサンドボックスの微妙な点に遭遇します。別のドメインのURLから画像を読み込む場合、サーバーのレスポンスに、ブラウザにリソースを他のドメインから使用しても良いことを伝えるヘッダーが含まれていない場合(第17章を参照)、キャンバスにはユーザーが見ることができる情報が含まれますが、スクリプトが見ることができない情報も含まれます。

ユーザーのセッションを使用して、個人情報(たとえば、ユーザーの銀行口座残高を示すグラフ)を含む画像を要求した可能性があります。スクリプトがその画像から情報を取得できれば、望ましくない方法でユーザーを盗聴する可能性があります。

このような情報漏洩を防ぐために、スクリプトが見ることができない画像が描画されると、ブラウザはキャンバスを汚染されたものとしてマークします。データURLを含むピクセルデータは、汚染されたキャンバスから抽出できません。書き込むことはできますが、読み込むことはできなくなります。

これが、保存リンクの`update`関数で`try/catch`ステートメントが必要な理由です。キャンバスが汚染されると、`toDataURL`を呼び出すと、`SecurityError`のインスタンスである例外が発生します。その場合、`javascript:`プロトコルを使用して、リンクを別の種類のURLを指すように設定します。そのようなリンクは、コロンの後に指定されたスクリプトをたどると実行されるため、クリックされたときに問題についてユーザーに通知する`alert`ウィンドウが表示されます。

画像ファイルの読み込み

最後の2つのコントロールは、ローカルファイルとURLから画像を読み込むために使用されます。URLから画像ファイルを読み込み、キャンバスの内容を置き換える次のヘルパー関数が必要です。

function loadImageURL(cx, url) {
  var image = document.createElement("img");
  image.addEventListener("load", function() {
    var color = cx.fillStyle, size = cx.lineWidth;
    cx.canvas.width = image.width;
    cx.canvas.height = image.height;
    cx.drawImage(image, 0, 0);
    cx.fillStyle = color;
    cx.strokeStyle = color;
    cx.lineWidth = size;
  });
  image.src = url;
}

画像にぴったり合うようにキャンバスのサイズを変更したいと考えています。何らかの理由で、キャンバスのサイズを変更すると、`fillStyle`や`lineWidth`などの描画コンテキストの構成プロパティが忘れられるため、関数はそれらを保存し、キャンバスのサイズを更新した後に復元します。

ローカルファイルを読み込むためのコントロールは、第18章の`FileReader`テクニックを使用します。そこで使用した`readAsText`メソッドとは別に、このようなリーダーオブジェクトには`readAsDataURL`というメソッドもあり、まさにここで必要となるものです。ユーザーが選択したファイルをデータURLとして読み込み、`loadImageURL`に渡してキャンバスに入れます。

controls.openFile = function(cx) {
  var input = elt("input", {type: "file"});
  input.addEventListener("change", function() {
    if (input.files.length == 0) return;
    var reader = new FileReader();
    reader.addEventListener("load", function() {
      loadImageURL(cx, reader.result);
    });
    reader.readAsDataURL(input.files[0]);
  });
  return elt("div", null, "Open file: ", input);
};

URLからのファイルの読み込みはさらに簡単です。しかし、テキストフィールドでは、ユーザーがURLの入力を終えた時点が不明瞭なため、`「change」`イベントを単純にリッスンすることはできません。代わりに、フィールドをフォームでラップし、ユーザーがEnterキーを押した場合、またはロードボタンをクリックした場合に、フォームが送信されたときに応答します。

controls.openURL = function(cx) {
  var input = elt("input", {type: "text"});
  var form = elt("form", null,
                 "Open URL: ", input,
                 elt("button", {type: "submit"}, "load"));
  form.addEventListener("submit", function(event) {
    event.preventDefault();
    loadImageURL(cx, input.value);
  });
  return form;
};

これで、シンプルなペイントプログラムに必要なすべてのコントロールを定義しましたが、さらにいくつかのツールを追加できます。

仕上げ

`prompt`を使用してユーザーに描画する文字列を問い合わせるテキストツールを簡単に追加できます。

tools.Text = function(event, cx) {
  var text = prompt("Text:", "");
  if (text) {
    var pos = relativePos(event, cx.canvas);
    cx.font = Math.max(7, cx.lineWidth) + "px sans-serif";
    cx.fillText(text, pos.x, pos.y);
  }
};

フォントサイズとフォントの追加フィールドを追加できますが、簡潔にするために、常にサンセリフフォントを使用し、フォントサイズを現在のブラシサイズに基づいて設定します。7ピクセル未満のテキストは読み取れないため、最小サイズは7ピクセルです。

アマチュアらしいコンピュータグラフィックを描くためのもう1つの不可欠なツールは、スプレーペイントツールです。これは、マウスが押されている間、ブラシの下のランダムな位置にドットを描画し、マウスの移動速度に応じて、濃淡のある斑点を作成します。

tools.Spray = function(event, cx) {
  var radius = cx.lineWidth / 2;
  var area = radius * radius * Math.PI;
  var dotsPerTick = Math.ceil(area / 30);

  var currentPos = relativePos(event, cx.canvas);
  var spray = setInterval(function() {
    for (var i = 0; i < dotsPerTick; i++) {
      var offset = randomPointInRadius(radius);
      cx.fillRect(currentPos.x + offset.x,
                  currentPos.y + offset.y, 1, 1);
    }
  }, 25);
  trackDrag(function(event) {
    currentPos = relativePos(event, cx.canvas);
  }, function() {
    clearInterval(spray);
  });
};

スプレーツールは、マウスボタンが押されている間、25ミリ秒ごとに色の付いたドットを出力するために`setInterval`を使用します。`trackDrag`関数は、`currentPos`を現在のマウス位置に合わせ続け、マウスボタンが離されたときに間隔をオフにするために使用されます。

間隔が発火するたびに描画するドットの数を決定するために、関数は現在のブラシの面積を計算し、それを30で割ります。ブラシの下のランダムな位置を見つけるために、`randomPointInRadius`関数が使用されます。

function randomPointInRadius(radius) {
  for (;;) {
    var x = Math.random() * 2 - 1;
    var y = Math.random() * 2 - 1;
    if (x * x + y * y <= 1)
      return {x: x * radius, y: y * radius};
  }
}

この関数は、(-1,-1)と(1,1)の間の正方形に点を生成します。ピタゴラスの定理を使用して、生成された点が半径1の円内にあるかどうかをテストします。関数がそのような点を見つけるとすぐに、`radius`引数で乗算された点を返します。

ループは、ドットの一様な分布のために必要です。円内のランダムな点を生成する簡単な方法は、ランダムな角度と距離を使用し、`Math.sin`と`Math.cos`を呼び出して対応する点を生成することです。しかし、この方法では、ドットは円の中心に近づくほど出現する可能性が高くなります。それを回避する他の方法もありますが、前のループよりも複雑です。

これで、機能するペイントプログラムができました。以下のコードを実行して試してみてください。

<link rel="stylesheet" href="css/paint.css">

<body>
  <script>createPaint(document.body);</script>
</body>

演習

このプログラムには、まだ改善の余地がたくさんあります。演習として、いくつかの機能を追加してみましょう。

長方形

第16章の`fillRect`メソッドを使用して、長方形を現在の色で塗りつぶす`Rectangle`というツールを定義します。長方形は、ユーザーがマウスボタンを押した点から離した点まで広がる必要があります。後者は前者の上または左にある可能性があることに注意してください。

動作したら、マウスをドラッグしてサイズを選択している間、長方形が表示されないのは少しぎこちないことに気付くでしょう。マウスボタンが離されるまでキャンバスに実際に描画せずに、ドラッグ中に何らかの長方形を表示する方法を考案できますか?

思い浮かばない場合は、第13章で説明されている`position: absolute`スタイルを思い出してください。これは、ドキュメントの残りの部分にノードを重ねて表示するために使用できます。マウスイベントの`pageX`プロパティと`pageY`プロパティを使用して、`left`、`top`、`width`、`height`スタイルを正しいピクセル値に設定することで、マウスの真下に要素を正確に配置できます。

<script>
  tools.Rectangle = function(event, cx) {
    // Your code here.
  };
</script>

<link rel="stylesheet" href="css/paint.css">
<body>
  <script>createPaint(document.body);</script>
</body>

マウスドラッグの開始に対応する角を見つけるには、relativePosを使用できます。ドラッグの終了位置の特定は、trackDragを使用するか、独自のイベントハンドラーを登録することで行うことができます。

長方形の2つの角が分かっている場合、それらをfillRectが期待する引数(左上の角、幅、高さ)に変換する必要があります。左端のx座標と上端のy座標を見つけるには、Math.minを使用できます。幅または高さを取得するには、2辺の差に対してMath.abs(絶対値)を呼び出すことができます。

マウスドラッグ中に長方形を表示するには、キャンバスを基準としたのではなく、ページ全体を基準とした同様の数値のセットが必要です。同じロジックを2回記述する必要がないように、topleftwidthheightプロパティを持つオブジェクトに2点を変換する関数findRectを作成することを検討してください。

その後、<div>ノードを作成し、そのstyle.positionabsoluteに設定できます。位置付けスタイルを設定する際は、数値に"px"を付加することを忘れないでください。ノードはドキュメントに追加する必要があり(document.bodyに追加できます)、ドラッグが終了し、実際の長方形がキャンバスに描画されるときに削除する必要があります。

カラーピッカー

グラフィックプログラムによくあるもう1つのツールはカラーピッカーです。これにより、ユーザーは画像をクリックして、マウスポインターの下の色を選択できます。これを構築してください。

このツールでは、キャンバスの内容にアクセスする方法が必要です。toDataURLメソッドはほぼそれを実行しましたが、そのようなデータURLからピクセル情報を取得するのは困難です。代わりに、描画コンテキストのgetImageDataメソッドを使用します。これは、画像の長方形の部分をwidthheightdataプロパティを持つオブジェクトとして返します。dataプロパティには、0〜255の数値の配列が格納されており、4つの数値を使用して各ピクセルの赤、緑、青、アルファ(不透明度)コンポーネントを表します。

この例では、キャンバスが空白の場合(すべてのピクセルが透明な黒)とピクセルが赤で着色されている場合の1つのピクセルの数値を取得します。

function pixelAt(cx, x, y) {
  var data = cx.getImageData(x, y, 1, 1);
  console.log(data.data);
}

var canvas = document.createElement("canvas");
var cx = canvas.getContext("2d");
pixelAt(cx, 10, 10);
// → [0, 0, 0, 0]

cx.fillStyle = "red";
cx.fillRect(10, 10, 1, 1);
pixelAt(cx, 10, 10);
// → [255, 0, 0, 255]

getImageDataへの引数は、取得する長方形の開始x座標とy座標を示し、その後に幅と高さが続きます。

この演習では透明性を無視し、特定のピクセルの最初の3つの値のみを調べます。また、ユーザーが色を選択したときにカラーフィールドを更新することについては心配しないでください。描画コンテキストのfillStylestrokeStyleが、マウスカーソルの下の色に設定されていることを確認してください。

これらのプロパティは、CSSで認識される任意の色を受け入れます。これには、第15章で見たrgb(R, G, B)スタイルが含まれます。

getImageDataメソッドはtoDataURLと同じ制限を受けます。つまり、キャンバスに別のドメインに由来するピクセルが含まれている場合、エラーが発生します。try/catchステートメントを使用して、alertダイアログでそのようなエラーを報告してください。

<script>
  tools["Pick color"] = function(event, cx) {
    // Your code here.
  };
</script>

<link rel="stylesheet" href="css/paint.css">
<body>
  <script>createPaint(document.body);</script>
</body>

クリックされたピクセルを特定するには、再度relativePosを使用する必要があります。例のpixelAt関数は、特定のピクセルの値を取得する方法を示しています。それらをrgb文字列に入れるには、文字列連結を行うだけです。

キャッチする例外がSecurityErrorのインスタンスであることを確認して、誤って間違った種類の例外を処理しないようにしてください。

塗りつぶし

これは、前の2つの演習よりも高度な演習であり、難しい問題に対する重要な解決策を設計する必要があります。この演習に取り組み始める前に十分な時間と忍耐力があることを確認し、最初の失敗に落胆しないでください。

塗りつぶしツールは、マウスの下のピクセルと、同じ色の周囲のピクセルを塗りつぶします。この演習では、このようなグループには、開始ピクセルから1ピクセルの水平および垂直ステップ(対角線ではない)で移動することで到達できるすべてのピクセルが含まれ、開始ピクセルとは異なる色のピクセルには決して触れないものとします。

次の画像は、塗りつぶしツールがマークされたピクセルで使用された場合に着色されるピクセルのセットを示しています。

Flood fill example

塗りつぶしは、対角線の隙間をすり抜けることはなく、ターゲットピクセルと同じ色であっても、到達できないピクセルには触れません。

各ピクセルの色を見つけるには、再度getImageDataが必要になります。一度に画像全体を取得し、結果の配列からピクセルデータを選択するのが良いでしょう。ピクセルはこの配列に、第7章のグリッド要素と同様の方法で、行ごとに1つずつ配置されています。ただし、各ピクセルは4つの値で表されます。(x,y)のピクセルの最初の値は、(x + y × width) × 4の位置にあります。

空のピクセルと黒いピクセルの違いを識別できるように、今回は4番目の(アルファ)値を含めてください。

同じ色の隣接するすべてのピクセルを見つけるには、新しい同じ色のピクセルが見つかる限り、ピクセル表面を1ピクセルずつ上、下、左、または右に「歩く」必要があります。しかし、最初の歩行ですべてのピクセルが見つかるわけではありません。第9章で説明されている正規表現マッチャーによって行われるバックトラッキングと同様のことを行う必要があります。複数の進行方向がある場合は、すぐに取らない方向をすべて保存し、現在の歩行が終了したら後で検討する必要があります。

通常サイズの画像には、非常に多くのピクセルがあります。したがって、必要な最小限の作業を行うように注意しないと、プログラムの実行に非常に時間がかかります。たとえば、すべての歩行は、以前の歩行で見られたピクセルを無視して、すでに完了している作業をやり直さないようにする必要があります。

着色する必要があるピクセルが見つかったときに個々のピクセルに対してfillRectを呼び出し、すでに調べたすべてのピクセルに関する情報を伝えるデータ構造を保持することをお勧めします。

<script>
  tools["Flood fill"] = function(event, cx) {
    // Your code here.
  };
</script>

<link rel="stylesheet" href="css/paint.css">
<body>
  <script>createPaint(document.body);</script>
</body>

開始座標のペアとキャンバス全体の画像データが与えられると、このアプローチで機能するはずです。

  1. すでに着色された座標に関する情報を保持する配列を作成します。

  2. 検討する必要がある座標を保持する作業リスト配列を作成します。開始位置をそこに配置します。

  3. 作業リストが空の場合、作業は完了です。

  4. 作業リストから座標のペアを1つ削除します。

  5. これらの座標がすでに着色済みピクセルの配列にある場合、ステップ3に戻ります。

  6. 現在の座標のピクセルを着色し、座標を着色済みピクセルの配列に追加します。

  7. 開始ピクセルの元のカラーと同じカラーの隣接する各ピクセルの座標を作業リストに追加します。

  8. ステップ3に戻ります。

作業リストは、ベクターオブジェクトの配列にすることができます。着色済みピクセルを追跡するデータ構造は、非常に頻繁に参照されます。新しいピクセルが訪問されるたびに全体を検索すると、時間がかかります。代わりに、すべてのピクセルに値を持つ配列を作成し、位置とピクセルを関連付けるために、再度x + y × widthスキームを使用できます。ピクセルがすでに着色されているかどうかを確認する際に、現在のピクセルに対応するフィールドに直接アクセスできます。

データ配列の関連部分を実行して、フィールドを1つずつ比較することで色を比較できます。または、色を単一の数値または文字列に「圧縮」して、それらを比較することもできます。この場合、すべての色が一意の値を生成することを確認してください。たとえば、色のコンポーネントを単純に加算することは安全ではありません。複数の色が同じ合計を持つためです。

特定のポイントの隣接要素を列挙する場合は、キャンバス内にない隣接要素を除外しないと、プログラムが一方の方向に永遠に実行される可能性があります。