はじめに
Google Mapのように、マウスドラッグとスクロールで、絵をぐりぐりする試作品を作ってみました。
今回は、D3.jsを使っています。D3.jsで絵を描くといえばSVG。絵を描く方法は、SVGとcanvas、どっちがいいの議論(?)があります。
ちなみに私、ドラッグでスクロールに関しては、過去に似たような投稿をしています。
まずこちらは、SVGです。Reactの中で、react-zoom-pan-pinchというモジュールを使って、SVGをドラッグする方法。プロパティ操作で簡単にできるように作られているというメリットはある一方、技術はコードの中なので、使う人の技術力があんまり上がらないのと、細かいことが気になったときに手が出せないデメリットもありました。
次にこちらは、canvasです。またReactですが、今度はcanvasで手作り。座標操作に関するハードルがちょっと高いと思います。
今回は最初に書いた通り、SVG+D3.jsです。言ってしまうと、これが一番楽かも。
完成版見た目
動作デモ
ソース
※ 動作デモは、HP用にちょっと加工していますが、この記事はこちらのソースを基準に説明します。ソースの方がもとです。
※ ソースが置いてあるリポジトリは、私のD3.jsの練習場なので、どうでもいいものとか初心者っぽいものとか途中とか、変なのもありますが、お気になさらず。
技術説明
D3.jsの細かい話は割愛します。
データの持ち方
縦横のマスごとにデータを持ち、それをD3.jsのdata()
へ食わせたいのですが、単純に考えて2つ問題があります。
問題点1:配列の負のインデックス
1つ目の問題点は、ドラッグしてインデックスが増えていく方向であれば、push()
していけばいいんですが、減っていく方の場合、unshift()
すると、indexが変わっちゃいます。
わかりやすく説明するために1次元にしますが、
ary[0] = "b";
ary[1] = "c";
ary[2] = "d";
初期でこの状態の場合で、この配列の前に"a"を、後に"e"を追加するとき、
ary[0] = "a";
ary[1] = "b";
ary[2] = "c";
ary[3] = "d";
ary[4] = "e";
は困ります。なぜなら、"b"の位置がわからなくなるから。"b"は原点位置(index=0)だと思ってたのに、そこに"a"がいると、ドラッグが破綻します。
感覚的には
ary[-1] = "a";
ary[3] = "e";
であってほしいんです。
JavaScriptでは、インデックスがマイナス値だと、後ろから数えた値になるか、エラーか、普通に成り立つか、 ちょっと調べたり動かしたりした感じでわからなかったけど とにかくかなり嫌な感じがします。
問題点2:2次元配列
JavaScriptで普通に2次元配列は持てるのですが、ドラッグで操作して広げた領域は、四角形になるわけはなく、マウス操作次第で変な形になります。
初期は2次元配列らしいきれいな長方形ですが、黄色の線のようにドラッグするとしたら、表示するために水色の領域のデータを作っていくことになります。その外の白い部分は表示されないので、不要です。
2次元配列で素直に作ると、長方形を広げていくことになって、白い部分も作ってしまいそうという問題です。
個人的には、2次元配列のどっちがx座標でどっちがy座標か、わからなくなることも多々ありますw
問題点1、2の解決策
2つの問題を一気に解決した方法が、Map()
を使って、キー値を文字列として持って、キーと値のセットにすることです。JSON的な感じで、キーが"0"に値を"b"、キーが"-1"に値を"a"、みたいな感じです。
// 2次元データ
const mapData = new Map(); // <string, string>()
// Mapのデータの出し入れ
function setValue(x, y, value) { // x: number, y: number, value: string
const key = getKey(x, y);
mapData.set(key, value);
}
function getValue(x, y) { // x: number, y: number, return: string
const key = getKey(x, y);
let val = mapData.get(key);
if (!val) {
// まだキーがない場合は、計算して、設定する
val = calcValue(x, y);
setValue(x, y, val);
}
return val;
}
function calcValue(x, y) { // x: number, y: number, return: string
return `(${x},${y})`;
}
function getKey(x, y) { // x: number, y: number, return: string
return `${x},${y}`;
}
あまり説明するまでもないと思いますが、mapData
に、キーとして${x},${y}
という形で文字列を作って使い、値をsetしたりgetしたりしています。
calcValue()
は、今回はただの文字列ですが、場合によっていろいろだと思うので、関数として切り出しました。
これによって、getValue(x,y)
と呼べば、その値が取り出せるようになりました。
デメリットは、配列に比べれば遅いだろうなぁとは思います。比較していませんが。
もう1つの変数
データはMap()
で持っていると書きましたが、Mapの1要素をオブジェクトにして、オブジェクトの配列にしたデータも持ちました。これはD3.js用です。
Mapの中では、
Map({"0,0": "val1"}, {"0,1": "val2"}, ...)
のようなイメージで、それを配列に読み替えて
[{x:0, y:0, value:"val1"}, {x:0, y:0, value:"val2"}, ...]
のようなイメージの変数です。
ここは正直、D3.jsのdataに食わせる直前で加工した方がよかったかもですが、わかりやすさ重視で同じ値を2つの形で持つように。
データ読み込み
上のデータの定義を使って、「矩形の(x1, y1)から(x2, y2)の範囲のデータを頂戴!」という関数を作りました。
// 2次元の指定範囲のデータを読み込み、dataを更新する
// ここで指定するxStartTile、・・・yEndTileは、スケール後のタイル座標値
function loadData(xStartTileNum, xEndTileNum, yStartTileNum, yEndTileNum) { // *: number, return: void
//console.log({xStartTileNum});
//console.log({xEndTileNum});
//console.log({yStartTileNum});
//console.log({yEndTileNum});
// タイル座標値を整数化
const xStartTile = Math.floor(xStartTileNum);
const xEndTile = Math.floor(xEndTileNum) + 1;
const yStartTile = Math.floor(yStartTileNum);
const yEndTile = Math.floor(yEndTileNum) + 1;
//console.log({xStartTile});
//console.log({xEndTile});
//console.log({yStartTile});
//console.log({yEndTile});
const addedData = [];
for(let x=xStartTile; x<=xEndTile; x++){
for (let y=yStartTile; y<=yEndTile; y++) {
const key = getKey(x, y);
// mapDataに存在しない場合、aryDataにも存在しない
if (! mapData.has(key)) {
// aryDataとmapDataに追加
addedData.push({
x,
y,
value: getValue(x, y), // このタイミングでmapDataにも追加される
});
}
}
}
if (addedData.length > 0) {
console.log(`追加したデータ数:${addedData.length}`);
//console.log(addedData);
// 追加するデータをまとめて追加
aryData = aryData.concat(addedData);
// 追加したデータを表示
drawGrid(aryData);
}
}
使用方法は下記のように、画面の座標値からdomain座標値に変換してから呼び出すようにしました。試行錯誤して最終的にこの形になりましたが、画面の座標値でも、まぁどっちでもよかったと思います。
// スケールを定義
let scaleX = d3.scaleLinear()
.domain([0, width / cellSize])
.range([0, width])
;
let scaleY = d3.scaleLinear()
.domain([0, height / cellSize])
.range([0, height])
;
~略~
// 初期データのロード
loadData(
scaleX.invert(0), scaleX.invert(width),
scaleY.invert(0), scaleY.invert(height)
);
zoomの設定
ドラッグしたときの処理を、下記のように定義します。
// Zoomの設定
const zoom = d3.zoom()
.scaleExtent([0.1, 10])
.on("zoom", zoomed) // zoomしたら、zoomed()を呼ぶ、パンでも呼ばれる
;
function zoomed(event) {
//console.log("zoomed!"); // パンでも呼ばれる
// 変更情報を適用した新しいスケール
const updatedScaleX = event.transform.rescaleX(scaleX);
const updatedScaleY = event.transform.rescaleY(scaleY);
// 最新の範囲を取得
const currentDomainX = updatedScaleX.domain();
const currentDomainY = updatedScaleY.domain();
//console.log("zoomしたあたらしいdomain");
//console.log({currentDomainX});
//console.log({currentDomainY});
// 今の範囲のデータをロード
loadData(
currentDomainX[0], currentDomainX[1],
currentDomainY[0], currentDomainY[1]
);
// 変更後のtransformを反映
g.attr("transform", event.transform);
}
// svgにZoom設定を追加
svg
.call(zoom)
.on("wheel", (event) => event.preventDefault()) // 限界まで拡大縮小してもブラウザがスクロールしない設定
;
zoom
でパン・拡大縮小の動作の定義をして、それを使ってsvg.call(zoom)
でsvg
に設定します。
zoomed()
では、event.transform
にパン・拡大縮小後のtransformが入っているので、それを使って、新しいscaleをゲットしてデータをロードすることと、transformを上書きして絵を動かします。
おわりに
コードが、説明順になっていないので、あっちこっちつまみ食いしながら説明して、自分でもわかりづらさを自覚しています でも縦横に無限スクロールの話は調べてもあまり出てこないので、誰かの助けになればと思います!
コードの全体感は、ソースを見ていただければ。
ではよいD3.js生活を!