Canvas要素でGridを作ってみる#1


# はじめに

PixiJSとCanvas要素を使用してGridを作りたくなったので作ってみる。(全2記事を予定)

（Canvas内で行わなくても普通にDOMでも作ることは可能ですが、PixiJSの勉強もかねています🧑‍🎨）

PixiJSとは、(下記URLより)

```
PixiJS は本質的に、WebGL (またはオプションで Canvas) を使用して画像やその他の 2D ビジュアル コンテンツを表示するレンダリング システムです。

PixiJS は、完全なシーン グラフ (レンダリングするオブジェクトの階層) を提供し、

クリック イベントやタッチ イベントを処理できるようにするインタラクション サポートを提供します。
```

https://pixijs.com/

# 今回作るGrid

こんな感じのアプリを作成する。

主な機能は

* Grid表示

![Canvas要素でGridを作ってみる1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777149/2acb1600-0d85-f920-3463-9aef92a6628c.jpeg)

* Clickによる移動(Focus)

![Canvas要素でGridを作ってみる2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777149/2faf5672-dae4-6aa3-e85c-1ecb178b21ea.gif)

* Tabによる移動(Focus)

![Canvas要素でGridを作ってみる3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777149/5fc96d41-3071-3a6c-74ed-2140d3e147c0.gif)

* 矢印キーによる移動(Focus)

![Canvas要素でGridを作ってみる4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777149/55b71e9a-352a-5939-8dd4-0cc22ef7c05c.gif)

* テキスト入力

![Canvas要素でGridを作ってみる5.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777149/8171c3d9-94e7-bf75-f34d-04622d63c999.gif)

* Hoverテキスト(Tooltip)

![Canvas要素でGridを作ってみる6.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777149/eea884f1-6134-846f-64e4-6efe52315b22.gif)

# 成果物DEMO_URL

https://shisojuice.github.io/canvas_grid/

# 今回使用するライブラリなど

* Pixijs(描画ライブラリとして使用)

https://pixijs.com/

* Vite(Buildツール)

https://ja.vite.dev/

# 環境作成

ViteのVanillaのTemplateをベースに作成する。

今回は、プロジェクト名は my-grid で作成する。

```
npm create vite@latest my-grid -- --template vanilla
```

# プロジェクトの作成

htmlとcssを用いて画面を作成する。

```html:index.html
<!doctype html>
<html lang="ja">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <link rel="stylesheet" href="style.css" />
  <title>Pixi_Grid</title>
</head>

<body id="main">
  <div id="app_pixigrid"></div>
  <script type="module" src="/main.js"></script>
</body>

</html>

```

Canvas内のGrid上の罫線はPixiJSで引いても良いが、

より簡単にCSSのbackground-imageやbackground-sizeを使用して罫線を引いておく。

```css:style.css
body {
    background-color: black;
    margin: 0;
    padding: 0;
    font-family: monospace;
    display: grid;
    align-items: start;
    justify-content: start;
}

canvas {
    display: block;
    border: 1px solid lightgray;
    background-image:
        linear-gradient(to right, lightgray 1px, transparent 1px),
        linear-gradient(to bottom, lightgray 1px, transparent 1px);    
}

#app_pixigrid {
    width: 100%;
    height: 100vh;
    overflow: scroll;
}

#app_pixigrid_focus {
    font-family: monospace;
    position: fixed;
    z-index: 2;
    font-size: 16px;
    color: white;
    background-color: black;
}

#app_pixigrid_focus:focus {
    background-color: gray;
}
```

windowのLoadイベントで

Canvas内はPixiJSのapp.initで初期設定しておく。

Canvas内に列、行の数だけcreateDetailCellContainerの関数で

Cellを作成する。

```javascript:main.js
import * as PIXI from 'pixi.js';
const app = new PIXI.Application();

const colCount = Number(100);
const rowCount = Number(100);
const cellWidth = Number(60);
const cellHeight = Number(20);
const gridWidth = cellWidth * colCount;
const gridHeight = cellHeight * rowCount;

const canvas_id = 'app_pixigrid';
const _Graphics = '_Graphics';
const _TextGraphics = '_TextGraphics';

// GridのFocus
let last_focus_lbl = null;
const focus_input = document.createElement('input');
focus_input.id = canvas_id + '_focus';
focus_input.autocomplete = 'off';
focus_input.spellcheck = false;

window.onload = async () => {
  await app.init({
    width: gridWidth,
    height: gridHeight,
    backgroundAlpha: 0
  });
  app.canvas.id = 'maincanvas';
  app.stage.interactive = true;
  app.stage.hitArea = app.renderer.screen;
  document.getElementById(canvas_id).appendChild(app.canvas);
  document.getElementById('maincanvas').style.backgroundSize = `${cellWidth}px ${cellHeight}px`;
  for (let col = 0; col < colCount; col++) {
    for (let row = 0; row < rowCount; row++) {
      const txt = col + '-' + row;
      const container = createDetailCellContainer(col, row, txt);
      app.stage.addChild(container);
    }
  }
  detailCellFocus(0, 0); // InitFocus
}
```

createDetailCellContainer関数で

column、rowのindexと表示するテキストを引数に受け取り、Cellを返す。

テキストはCellの幅内に収まるように調整する。

pointeroverにHover時の機能、pointerdownにFocus時の機能を付与する。

```javascript:main.js
/**
 * Detail部のCellをCreateする関数です。
 * @param {number} index_column CellColumnIndex
 * @param {number} index_row CellRowIndex
 * @param {string} txt CellText
 * @returns {PIXI.Container} PIXI.Container(Cell)を返す
 */
const createDetailCellContainer = (index_column, index_row, txt) => {
  // Object群作成
  let obj = new PIXI.Graphics()
    .rect(index_column * cellWidth, index_row * cellHeight, cellWidth, cellHeight)
    .fill(0xffffff);
  obj.alpha = 0.0;
  obj.label = _Graphics;
  obj.interactive = true;
  obj.buttonMode = true;
  let text = new PIXI.Text({
    text: txt,
    style: new PIXI.TextStyle({
      fontFamily: 'monospace',
      fontSize: cellHeight - 4,
      fill: 0xffffff,
    })
  })
  text.label = _TextGraphics;
  text.x = index_column * cellWidth + 4;
  text.y = index_row * cellHeight;
  text.interactive = true;
  text.buttonMode = true;
  const bounds = text.getBounds();
  if (cellWidth < bounds.width) {
    let temptext = '';
    let maxHalfWords = Math.floor(cellWidth / Math.floor(cellHeight / 2));
    for (let i = 0; i < txt.length; i++) {
      if (/^[a-zA-Z0-9!-/:-@\[-`{-~ｧ-ﾝﾞﾟ]+$/.test(txt[i])) {
        maxHalfWords = maxHalfWords - 1;
      }
      else {
        maxHalfWords = maxHalfWords - 2;
      }
      if (maxHalfWords < 0) {
        break;
      }
      temptext += txt[i];
    }
    text.text = temptext
  }
  obj.on('pointerover', (e) => { txthover(txt); });
  text.on('pointerover', (e) => { txthover(txt); });
  obj.on('pointerdown', (e) => {
    detailCellFocus(index_column, index_row);
  });
  text.on('pointerdown', (e) => {
    detailCellFocus(index_column, index_row);
  });
  // Container作成
  const lbl = `col${index_column}row${index_row}`;
  const container = new PIXI.Container();
  container.zIndex = 1;
  container.label = lbl;
  container.my_col = index_column;
  container.my_row = index_row;
  container.my_txt = txt;
  container.addChild(obj);
  container.addChild(text);
  return container;
}

/**
 * CellのHover時にTextのTitleを表示する関数です。
 * @param {string} txt CellText
 */
const txthover = (txt) => {
  document.getElementById(canvas_id).title = txt;
}

```

HTMLInputを使い、実際にそのCanvas内のCellに対して

Focusしているように見せる。

```javascript:main.js
/**
 * Detail部のCellをFocusする関数です。
 * @param {number} index_column CellColumnIndex
 * @param {number} index_row CellRowIndex
 */
const detailCellFocus = (index_column, index_row) => {
  detailCellBlur();
  let targetcontainer = app.stage.getChildByLabel(`col${index_column}row${index_row}`);
  if (targetcontainer) {
    // HTMLInputFocus
    focus_input.value = targetcontainer.my_txt; focus_input.title = targetcontainer.my_txt;
    focus_input.dataset.my_col = targetcontainer.my_col; focus_input.dataset.my_row = targetcontainer.my_row;
    focus_input.style.width = (cellWidth - 8) + 'px'; focus_input.style.height = (cellHeight - 6) + 'px';
    const rect = document.getElementById(canvas_id).getBoundingClientRect();
    const scr_left = document.getElementById(canvas_id).scrollLeft;
    const scr_top = document.getElementById(canvas_id).scrollTop;
    focus_input.style.top = ((targetcontainer.my_row * cellHeight) + rect.top - scr_top) + 'px'; focus_input.style.left = ((targetcontainer.my_col * cellWidth) + rect.left - scr_left) + 'px';
    document.getElementById(canvas_id).appendChild(focus_input);
    requestAnimationFrame(() => {
      focus_input.focus();
      focus_input.select();
    });
  }
}


```


# まとめ

今回は、構成の説明と画面の初期設定まで行った。

次回はGrid内のイベント操作のロジックを記事にしていく。

次回につづく。

https://qiita.com/shisojuice/items/e771c5bb253dc2a2df77