React + TypeScript で計算ツールを速攻で作ってタダでデプロイする

TRPGをやったりしてる人は計算ツールがほしくなりますよね。

いちいち本を開いて数字を見て……ってやってるとまどろっこしいです。

というわけで、今回は例として、用紙サイズとDPIを入れるとピクセル数を計算してくれるツールをReact + TypeScriptで作ります。jQueryなんて投げ捨てよう！

# 環境構築

まずは `node` と `npm` を入れておいてください。ターミナルかコマンドプロンプト開いて `node -v` と `npm -v` って叩いてバージョンが出ればOKです。 `yarn` 使うかどうかはお好みで。

次に `create-react-app` を入れます。

```zsh
> npm i -g create-react-app
```

プロジェクトのフォルダを置きたいディレクトリの中に `cd` で移動します。
例えば、 `/home/hibikine/src/calc-tool` に作りたければ、 `cd /home/hibikine/src` と移動してください。

```zsh
/
> cd /home/hibikine/src

/home/hibikine/src
>
```

プロジェクトを作成します。今回は `calc-tool` という名前で、TypeScript使って作ります。[^1]

```zsh
> create-react-app calc-tool --scripts-version=react-scripts-ts

... いろいろ出る

Initialized a git repository.

Success! Created calc-tool at /mnt/c/Users/goods/src/calc-tool
Inside that directory, you can run several commands:

  yarn start
    Starts the development server.

  yarn build
    Bundles the app into static files for production.

  yarn test
    Starts the test runner.

  yarn eject
    Removes this tool and copies build dependencies, configuration files
    and scripts into the app directory. If you do this, you can’t go back!

We suggest that you begin by typing:

  cd calc-tool
  yarn start

Happy hacking!
```

生成されたディレクトリ内に移動し、 `npm run start` ( `yarn` の人は `yarn start` )します。

```zsh
/home/hibikine/src
> cd calc-tool

/home/hibikine/src/calc-tool
> yarn start
```

http://localhost:3000 にアクセスし、次のような画面が出ればOKです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/152713/4bfb11a7-d352-6581-2ae7-6f9e7d735ceb.png)

# 実装

生成直後のフォルダ構成を見ると次のようになっています。

```フォルダ構成.txt
node_modules/
public/
src/
  App.css
  App.test.tsx
  App.tsx
  index.css
  index.tsx
  logo.svg
  registerServiceWorker.ts
.gitignore
... etc
```

`App.tsx` を開き、中身を次のように書き換えて保存します。

```App.tsx
import * as React from 'react';
import './App.css';

interface IState {
  paperSize: number;
  dpi: number;
}

class App extends React.Component {
  public state = { paperSize: 0, dpi: 0 };

  public render() {
    const { paperSize, dpi } = this.state;
    return (
      <div className="App">
        <header className="App-header">
          <h1 className="App-title">ピクセル数計算機</h1>
        </header>
        <p className="App-main">
          <table>
            <tbody>
              <tr>
                <td>
                  <label htmlFor="paperSize">A</label>
                </td>
                <td>
                  <input id="paperSize" type="number" value={paperSize} />
                </td>
              </tr>
              <tr>
                <td>
                  <label htmlFor="dpi">DPI</label>
                </td>
                <td>
                  <input id="dpi" type="number" value={dpi} />
                </td>
              </tr>
            </tbody>
          </table>

          <table>
            <tbody>
              <tr>
                <td>
                  <label htmlFor="width-mm">幅(mm)</label>
                </td>
                <td>
                  <input id="width-mm" readOnly={true} value={5} />
                </td>
              </tr>
              <tr>
                <td>
                  <label htmlFor="height-mm">高さ(mm)</label>
                </td>
                <td>
                  <input id="height-mm" readOnly={true} value={5} />
                </td>
              </tr>
              <tr>
                <td>
                  <label htmlFor="width-px">幅(px)</label>
                </td>
                <td>
                  <input id="width-px" readOnly={true} value={5} />
                </td>
              </tr>
              <tr>
                <td>
                  <label htmlFor="height-px">高さ(px)</label>
                </td>
                <td>
                  <input id="height-px" readOnly={true} value={5} />
                </td>
              </tr>
            </tbody>
          </table>
        </p>
      </div>
    );
  }
}

export default App;
```

ついでに `App.css` も書き換えます。

```App.css
.App {
  text-align: center;
}

.App-header {
  background-color: #222;
  height: 80px;
  padding: 20px;
  color: white;
}

.App-title {
  font-size: 1.5em;
}

.App-main {
  display: flex;
  flex-direction: column;
  align-items: center;
}
```

ファイルを保存すると画面が自動で更新されて次のようになります。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/152713/948899e7-c281-65db-eddf-3f716f10c080.png)

この段階だと数字が変更できないので修正していきましょう。

## 数字を変えられるようにする

`class App` の末尾に `onChange` メソッドを追加しましょう。

```App.tsx
  private onChange = (key: keyof IState) => (
    e: React.FormEvent<HTMLInputElement>
  ) => this.setState({ [key]: parseInt(e.currentTarget.value, 10) } as any);
```

これをそれぞれの `input` に設定し、 `input` の中身が書き換えられたときに `App` 内の `state` が書き換わるようにします。
`state` の書き換えは必ず `setState` メソッドを通して行うようにします。

```App.tsx
              <tr>
                <td>
                  <label htmlFor="paperSize">A</label>
                </td>
                <td>
                  <input
                    id="paperSize"
                    type="number"
                    value={paperSize}
                    onChange={this.onChange('paperSize')}
                  />
                </td>
              </tr>
              <tr>
                <td>
                  <label htmlFor="dpi">DPI</label>
                </td>
                <td>
                  <input
                    id="dpi"
                    type="number"
                    value={dpi}
                    onChange={this.onChange('dpi')}
                  />
                </td>
              </tr>
```

これで数字が書き換えられるようになります。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/152713/c3da32c3-a124-e1c6-3c02-64f89820f554.png)

## 計算

最後に出力が書き換わるようにします。
`class App` の上に `getWidth` メソッドと `calcPixel` メソッドを追加しましょう。A系の紙のサイズは再帰で求められます。

```App.tsx
interface IState {
  paperSize: number;
  dpi: number;
}

const getWidth = (n: number): number => {
  if (n <= 0) {
    return 1189;
  } else if (n === 1) {
    return 841;
  }
  return Math.floor(getWidth(n - 2) / 2);
};
const calcPixel = (len: number, dpi: number) => Math.floor((len / 25.4) * dpi);

class App extends React.Component<{}, IState> {
```

`render` メソッドを書き換えます。

```App.tsx
  public render() {
    const { paperSize, dpi } = this.state;
    const width = getWidth(paperSize);
    const height = getWidth(paperSize + 1);
    const pixelWidth = calcPixel(width, dpi);
    const pixelHeight = calcPixel(height, dpi);

    return (
      <div className="App">
        <header className="App-header">
          <h1 className="App-title">ピクセル数計算機</h1>
        </header>
        <p className="App-main">
          <table>
            <tbody>
              <tr>
                <td>
                  <label htmlFor="paperSize">A</label>
                </td>
                <td>
                  <input
                    id="paperSize"
                    type="number"
                    value={paperSize}
                    onChange={this.onChange('paperSize')}
                  />
                </td>
              </tr>
              <tr>
                <td>
                  <label htmlFor="dpi">DPI</label>
                </td>
                <td>
                  <input
                    id="dpi"
                    type="number"
                    value={dpi}
                    onChange={this.onChange('dpi')}
                  />
                </td>
              </tr>
            </tbody>
          </table>

          <table>
            <tbody>
              <tr>
                <td>
                  <label htmlFor="width-mm">幅(mm)</label>
                </td>
                <td>
                  <input id="width-mm" readOnly={true} value={width} />
                </td>
              </tr>
              <tr>
                <td>
                  <label htmlFor="height-mm">高さ(mm)</label>
                </td>
                <td>
                  <input id="height-mm" readOnly={true} value={height} />
                </td>
              </tr>
              <tr>
                <td>
                  <label htmlFor="width-px">幅(px)</label>
                </td>
                <td>
                  <input id="width-px" readOnly={true} value={pixelWidth} />
                </td>
              </tr>
              <tr>
                <td>
                  <label htmlFor="height-px">高さ(px)</label>
                </td>
                <td>
                  <input id="height-px" readOnly={true} value={pixelHeight} />
                </td>
              </tr>
            </tbody>
          </table>
        </p>
      </div>
    );
  }
```

正しく計算が行われればOKです！

![image.png](https://qiita-image-store.s3.amazonaws.com/0/152713/f09fd1f0-4951-c9d8-ef26-60314d204d7d.png)

# デプロイする

GitHub Pagesに投げます。 [yuitnnn](https://qiita.com/yuitnnn) さんの「[GitHub PagesにReactアプリをデプロイする方法](https://qiita.com/yuitnnn/items/11375ea29ec023d19fdf)」を参考にしました！

まず `gh-pages`を入れます。npmの人は `npm i gh-pages --save-dev` で。

```zsh
> yarn add --dev gh-pages
```

次に `package.json` に `homepage` と `predeploy` と `deploy` を追記します。
homepageの先頭部分は自分のGitHubのIDにしたり、後ろはリポジトリ名にしたりしてください。

```package.json
  "homepage": "https://hibikinekage.github.io/calc-tool",
  "scripts": {
    // ...,
    "predeploy": "npm run build",
    "deploy": "gh-pages -d build"
  },
```

GitHubにリポジトリを作成し、リモートに登録してデプロイします。URLは適宜自分のものに変えてください。

```zsh
> git init
> git remote add origin https://github.com/HibikineKage/calc-tool
> yarn deploy # npm run deploy
```

自分のIDの https://hibikinekage.github.io/calc-tool で見れるようになります。これで終了です。

# まとめ

`create-react-app` 使うと簡単に色々作れて楽しいです！　ちょっとした小物をポンポン作りたい時もjQueryよりもおすすめします！

ソースコード: [GitHub](https://github.com/HibikineKage/calc-tool)

最後に全体のコードを掲載しておきます。

```App.tsx
import * as React from 'react';
import './App.css';

interface IState {
  paperSize: number;
  dpi: number;
}

const getWidth = (n: number): number => {
  if (n <= 0) {
    return 1189;
  } else if (n === 1) {
    return 841;
  }
  return Math.floor(getWidth(n - 2) / 2);
};
const calcPixel = (len: number, dpi: number) => Math.floor((len / 25.4) * dpi);

class App extends React.Component<{}, IState> {
  public state = { paperSize: 0, dpi: 0 };

  public render() {
    const { paperSize, dpi } = this.state;
    const width = getWidth(paperSize);
    const height = getWidth(paperSize + 1);
    const pixelWidth = calcPixel(width, dpi);
    const pixelHeight = calcPixel(height, dpi);

    return (
      <div className="App">
        <header className="App-header">
          <h1 className="App-title">ピクセル数計算機</h1>
        </header>
        <p className="App-main">
          <table>
            <tbody>
              <tr>
                <td>
                  <label htmlFor="paperSize">A</label>
                </td>
                <td>
                  <input
                    id="paperSize"
                    type="number"
                    value={paperSize}
                    onChange={this.onChange('paperSize')}
                  />
                </td>
              </tr>
              <tr>
                <td>
                  <label htmlFor="dpi">DPI</label>
                </td>
                <td>
                  <input
                    id="dpi"
                    type="number"
                    value={dpi}
                    onChange={this.onChange('dpi')}
                  />
                </td>
              </tr>
            </tbody>
          </table>

          <table>
            <tbody>
              <tr>
                <td>
                  <label htmlFor="width-mm">幅(mm)</label>
                </td>
                <td>
                  <input id="width-mm" readOnly={true} value={width} />
                </td>
              </tr>
              <tr>
                <td>
                  <label htmlFor="height-mm">高さ(mm)</label>
                </td>
                <td>
                  <input id="height-mm" readOnly={true} value={height} />
                </td>
              </tr>
              <tr>
                <td>
                  <label htmlFor="width-px">幅(px)</label>
                </td>
                <td>
                  <input id="width-px" readOnly={true} value={pixelWidth} />
                </td>
              </tr>
              <tr>
                <td>
                  <label htmlFor="height-px">高さ(px)</label>
                </td>
                <td>
                  <input id="height-px" readOnly={true} value={pixelHeight} />
                </td>
              </tr>
            </tbody>
          </table>
        </p>
      </div>
    );
  }

  private onChange = (key: keyof IState) => (
    e: React.FormEvent<HTMLInputElement>
  ) => this.setState({ [key]: parseInt(e.currentTarget.value, 10) } as any);
}

export default App;
```

[^1]: ちなみに今回の作り方は `create-react-app` v2.0.xまでのやり方で、v2.1からは `create-react-app --typescript` という形で公式サポートされるようになります。ただし、こちらはts-loaderではなくbabelによるトランスパイルのようなので、多少動作に違いが出るかもしれません。