D3.jsで、ドラッグで縦横無限スクロール

# はじめに
Google Mapのように、マウスドラッグとスクロールで、絵をぐりぐりする試作品を作ってみました。
今回は、D3.jsを使っています。D3.jsで絵を描くといえばSVG。絵を描く方法は、SVGとcanvas、どっちがいいの議論(?)があります。

ちなみに私、ドラッグでスクロールに関しては、過去に似たような投稿をしています。

まずこちらは、SVGです。Reactの中で、react-zoom-pan-pinchというモジュールを使って、SVGをドラッグする方法。プロパティ操作で簡単にできるように作られているというメリットはある一方、技術はコードの中なので、使う人の技術力があんまり上がらないのと、細かいことが気になったときに手が出せないデメリットもありました。

https://qiita.com/yo16/items/77fad21dfe9c9f61d245

次にこちらは、canvasです。またReactですが、今度はcanvasで手作り。座標操作に関するハードルがちょっと高いと思います。

https://qiita.com/yo16/items/0362f263fee618ad2cee

今回は最初に書いた通り、SVG＋D3.jsです。言ってしまうと、これが一番楽かも。

## 完成版見た目
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/722610/00930430-b5be-aed0-012d-18978c8514c4.png)

## 動作デモ
https://small-pieces-sandbox.onrender.com/contents/infinity_scroll

## ソース
https://github.com/yo16/d3js_prac/tree/main/prac_20241112

※ 動作デモは、HP用にちょっと加工していますが、この記事はこちらのソースを基準に説明します。ソースの方がもとです。
※ ソースが置いてあるリポジトリは、私のD3.jsの練習場なので、どうでもいいものとか初心者っぽいものとか途中とか、変なのもありますが、お気になさらず。


# 技術説明
D3.jsの細かい話は割愛します。

## データの持ち方
縦横のマスごとにデータを持ち、それをD3.jsの`data()`へ食わせたいのですが、単純に考えて２つ問題があります。

### 問題点１：配列の負のインデックス
１つ目の問題点は、ドラッグしてインデックスが増えていく方向であれば、`push()`していけばいいんですが、減っていく方の場合、`unshift()`すると、indexが変わっちゃいます。

わかりやすく説明するために１次元にしますが、
```javascript
ary[0] = "b";
ary[1] = "c";
ary[2] = "d";
```
初期でこの状態の場合で、この配列の前に"a"を、後に"e"を追加するとき、
```javascript
ary[0] = "a";
ary[1] = "b";
ary[2] = "c";
ary[3] = "d";
ary[4] = "e";
```
は困ります。なぜなら、"b"の位置がわからなくなるから。"b"は原点位置(index=0)だと思ってたのに、そこに"a"がいると、ドラッグが破綻します。

感覚的には
```javascript
ary[-1] = "a";
ary[3] = "e";
```
であってほしいんです。

JavaScriptでは、インデックスがマイナス値だと、後ろから数えた値になるか、エラーか、普通に成り立つか、 ~~ちょっと調べたり動かしたりした感じでわからなかったけど~~ とにかくかなり嫌な感じがします。

### 問題点２：２次元配列
JavaScriptで普通に２次元配列は持てるのですが、ドラッグで操作して広げた領域は、四角形になるわけはなく、マウス操作次第で変な形になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/722610/db2ebf97-77a0-b2f3-1323-28ecb7b93367.png)
雑ですがこんなイメージ。

初期は２次元配列らしいきれいな長方形ですが、黄色の線のようにドラッグするとしたら、表示するために水色の領域のデータを作っていくことになります。その外の白い部分は表示されないので、不要です。

２次元配列で素直に作ると、長方形を広げていくことになって、白い部分も作ってしまいそうという問題です。

個人的には、２次元配列のどっちがx座標でどっちがy座標か、わからなくなることも多々ありますｗ

### 問題点１、２の解決策
２つの問題を一気に解決した方法が、`Map()`を使って、キー値を文字列として持って、キーと値のセットにすることです。JSON的な感じで、キーが"0"に値を"b"、キーが"-1"に値を"a"、みたいな感じです。

```javascript
// ２次元データ
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)`と呼べば、その値が取り出せるようになりました。

デメリットは、配列に比べれば遅いだろうなぁとは思います。比較していませんが。

## もう１つの変数
データは`Map()`で持っていると書きましたが、Mapの１要素をオブジェクトにして、オブジェクトの配列にしたデータも持ちました。これはD3.js用です。
Mapの中では、
```javascript
Map({"0,0": "val1"}, {"0,1": "val2"}, ...)
```
のようなイメージで、それを配列に読み替えて
```javascript
[{x:0, y:0, value:"val1"}, {x:0, y:0, value:"val2"}, ...]
```
のようなイメージの変数です。

ここは正直、D3.jsのdataに食わせる直前で加工した方がよかったかもですが、わかりやすさ重視で同じ値を２つの形で持つように。


## データ読み込み
上のデータの定義を使って、「矩形の(x1, y1)から(x2, y2)の範囲のデータを頂戴！」という関数を作りました。

```javascript
// ２次元の指定範囲のデータを読み込み、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座標値に変換してから呼び出すようにしました。試行錯誤して最終的にこの形になりましたが、画面の座標値でも、まぁどっちでもよかったと思います。
```javascript
// スケールを定義
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の設定
ドラッグしたときの処理を、下記のように定義します。
```javascript
// 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を上書きして絵を動かします。

# おわりに
コードが、説明順になっていないので、あっちこっちつまみ食いしながら説明して、自分でもわかりづらさを自覚しています:sweat_smile: でも縦横に無限スクロールの話は調べてもあまり出てこないので、誰かの助けになればと思います！
コードの全体感は、ソースを見ていただければ。

ではよいD3.js生活を！