【個人開発】プロ野球おたくにしか通じない誰得サイト「安仁屋算速報メーカー」を制作しました

TypeScript学習中のため、オブジェクトを取り扱った簡単なアプリを制作したので、開発過程について記録します。
※本制作物では書籍教材[「TypeScriptハンズオン」](https://www.shuwasystem.co.jp/book/9784798065335.html)のサンプルコードを参考にさせていただきました。


プロ野球ファンの間では有名なネットミーム「安仁屋算」というものをご存知でしょうか。

https://dic.pixiv.net/a/%E5%AE%89%E4%BB%81%E5%B1%8B%E7%AE%97
>プロ野球のシーズン開幕前に元広島・阪神投手の安仁屋宗八氏が、広島東洋カープ投手の勝ち星勘定をした結果、合計100勝前後となる計算のこと。

1シーズンの公式戦は143試合程度でいかなる強豪球団でも勝率6割～6割5分といったところですので、合計100勝前後というのは破格の予想値となりますが、この思考は開幕への期待に満ちた楽観的なファンの心理をよく体現しており、SNS上の話題ネタに留まらず様々なメディアでも使われています。

主に開幕前の3月頃に広島のローカル番組のスポーツコーナーで安仁屋氏によるその年のカープの「安仁屋算」が発表され、SNS上で番組のキャプチャが拡散され野球ファンの間でネタにされる。というのが毎年恒例の流れとなっているようです。


[参考(Twitter)](https://twitter.com/search?q=%E5%AE%89%E4%BB%81%E5%B1%8B%E7%AE%97&src=typed_query)


この「安仁屋算」をローカルスポーツ番組の画面キャプチャっぽい形にして公開できる誰得サイトを制作しました。

# どんなサービスか
サイトはこちら

https://emuroku.github.io/aniyazan_maker/

* よくあるロゴジェネレータ的なサイトです
* 投手名と予想勝利数をフォームに入力するとリスト部分に表示されていきます
* 画像作成ボタンを押下するとリスト情報から番組キャプチャー風の画像を作成できます
* 作成したものは画像ファイルとしてダウンロードできます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1974587/0e15e0d6-21f5-0f1e-d05a-4976e42c43e2.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1974587/4ca7786b-7266-46b9-2db8-145f4a63c842.png)

# 開発リポジトリ
https://github.com/emuroku/aniyazan_maker

# 制作目的
* TypeScriptのオブジェクト管理の基礎を生かした実践開発を行うこと
* npmによるパッケージの取り扱いを練習すること
* GitHub Pagesを使った静的ホスティングの復習をすること

# 使用した技術
### フロントエンド
* Canvas
    * JavaScriptで動的に図を描画できる要素
* TypeScript(JavaScript)
    * 入出力情報のオブジェクトの管理
    * Canvasの操作
    * 入力フォームや作成ボタンなどのGUIの動作
    * 画像データ化とダウンロード
* Bootstrap4
    * html / cssのフレームワーク
* Google Fonts
    * テキスト出力用
  
### バックエンド
* とくになし

### 開発環境
* webpack
    * JavaScriptのモジュールバンドラ
    * tsファイルに処理を記述し、ビルドするとweb上で動作するjsファイルとして出力される

### ホスティング
* GitHub Pages
    * GitHubのリポジトリ上の静的ファイルを直接サイトとして公開できるホストサービス
  
https://docs.github.com/ja/pages/getting-started-with-github-pages/about-github-pages

# 開発にかかった期間
* プロトタイプに3d程度
* デバッグ、改修で1w程度
※1dあたり1～2hほど

# 実装内容
1. 入力値のデータ管理・出力をするクラスを定義
1. メインの処理と各種クリックアクションの設定
1. 保存データをCanvasで画像出力・ダウンロード

### 1. 入力値のデータ管理・出力をするクラスを定義
以下のようなPlayerDataクラスを作成
* 「投手名(string)」「勝利数(number)」をプロパティに持つPlayer型の配列
* 以下のメソッドを持つ
    * 入力値が適切かをチェックするバリデーション処理
    * 取得したPlayerデータをPlayerDataに追加
    * 追加されたデータをローカルストレージに保存
    * 保存済みのデータを読み込み
    * 保存されている勝利数の合計値を算出
    * 取得データからリスト表示用のHTMLを取得
    * 取得データから画像を出力
```index.ts
type Player = {
    name: string,
    wins: number,
}

class PlayerData {
    data: Player[] = []

    // validation
    checkInput(add_data: Player): void {
        if(add_data.name == '' || isNaN(add_data.wins)){
            console.log(add_data)
            throw new Error('正しく入力してください')
        }
    }

    // アイテムの追加
    add(add_data: Player): void {
        if (this.data.length < max_item) {
            this.data.push(add_data)
            errorMessage.textContent = ''
        } else {
            throw new Error('登録できるのは9人までです')
        }

    }

    // Jsonデータに追加アイテムを保存
    save(): void {
        localStorage.setItem('player_data', JSON.stringify(this.data))
    }

    // 保存されたアイテムを読み込み
    load(): void {
        const readed = JSON.parse(localStorage.getItem('player_data'))
        this.data = readed ? readed : []
    }

    // リスト表示用のHTMLを取得
    getHtml(): string {
        let html = '<thead><th>投手名</th><th>勝利数</th></thead><tbody>'
        for (let item of this.data) {
            html += '<tr><td>' + item.name + '</td><td>'
                + item.wins.toLocaleString() + '</td></tr>'
        }
        let total = this.getTotal()
        html += '</tbody>' + '<tr><td>' + '合計' + '</td><td>'
            + total + '</td></tr>'
        return html
    }

    // 勝利数の合計値を算出
    getTotal(): string {
        let total_num = 0
        for (let item of this.data) {
            total_num += item.wins
        }
        return total_num.toLocaleString()
    }

    // 登録したリストを画像へ出力
    createImg(): void {
        ctx.drawImage(bgImg, 0, 0)
        for (let i = 0; i < this.data.length; i++) {
            Draw(this.data[i].name, this.data[i].wins.toLocaleString(), i)
        }
        DrawTotal(this.getTotal())
    }

}
```
バリデーションの記述は試行錯誤の末やや煩雑な形になってしまいました。
* 入力値が不正な場合
    * ユーザー名(string)
    * 投手名(string)
    * 勝利数(number)
* データ数が上限に達した場合

上記の各パターンを整理してそれぞれに適切なエラーメッセージを割り当て、適切なタイミングで初期化する形式に改良できそうな気がしているので、後のリファクタリング課題として取り組みたいと思います。

### 2. メインの処理と各種クリックアクションの設定
HTMLエレメントから入力値を取り出し、各処理への引き渡しを行っています。
```index.ts
let table: HTMLTableElement // データリスト表示用テーブルを扱うエレメント
let input_player: HTMLInputElement // 投手名の入力値を扱うエレメント
let input_win_num: HTMLInputElement // 勝利数の入力値を扱うエレメント
let input_username: HTMLInputElement // ユーザー名を画像へ表示するエレメント

--- (中略) ---

const player = new PlayerData()

window.addEventListener('load', () => {
    table = document.querySelector('#table')
    input_player = document.querySelector('#player')
    input_win_num = document.querySelector('#wins')
    input_username = document.querySelector('#username')
    showTable(player.getHtml())

    document.querySelector('#btn').addEventListener('click', AddItem) // 追加ボタン
    document.querySelector('#initial').addEventListener('click', Initial) // やりなおすボタン
    document.querySelector('#create').addEventListener('click', Create) // 画像を作成するボタン
    document.getElementById('btn_dl').addEventListener('click', downloadCanvas) // 画像をダウンロードボタン
    
    player.load() // Playerデータ読み込み
    showTable(player.getHtml()) // リスト表示
})
```
各種クリックアクションの処理は以下のとおりです。
```index.ts
// リストの表示
function showTable(html: string) {
    table.innerHTML = html
}

// リストにアイテムを追加
function AddItem() {
    const player_name = input_player.value
    const wins_num = input_win_num.valueAsNumber
    try {
        player.checkInput({ name: player_name, wins: wins_num }) // validation
        player.add({ name: player_name, wins: wins_num })
        player.save()
        player.load()
        showTable(player.getHtml())
    } catch (e) {
        errorMessage.textContent = e.message
    }
}

// リスト初期化
function Initial() {
    player.data = []
    player.save()
    player.load()
    input_player.value = ''
    input_win_num.value = ''
    input_username.value = '名無しさん'
    errorMessage.textContent = ''
    showTable(player.getHtml())
    ctx.clearRect
    ctx.drawImage(bgImg, 0, 0)
}

// 画像の描画
function Create() {
    player.createImg()
}

// 画像のダウンロード
function downloadCanvas() {
    // canvasのstyleにborderを追加
    canvas.style.border = "2px solid #222222";

    // URL取得用のa要素を生成
    let link = document.createElement("a");

    link.href = canvas.toDataURL("image/png");

    link.download = "image.png";
    link.click();
}
```


### 3. 保存データをCanvasで画像出力・ダウンロード
保存データをCanvasで画像へ出力するのにCanvasを使用しました。
以前制作した応援タオルメーカーの実装をベースにしています。

https://qiita.com/emuroku/items/25587e406d11e4f7f3e8


# GitHub Pagesを利用したデプロイ
Git上に静的ファイルをアップするとそのままwebページとして公開できるGitHub Pagesを利用しました。
リポジトリ上ではプロジェクトファイル全体を管理しているものの、webpackのフォルダ構成上公開したいindexファイルはビルド出力先の `/dist/` 下にあるため、公開専用のブランチに`/dist/`以下のビルド済みのファイルを配置し、公開対象の向き先を専用ブランチに設定しました。
詳細は以下にまとめています。

https://qiita.com/emuroku/items/bb2d830b47fa271e4ba2

# おわりに
* 季節柄ふと耳にしたミームを出来心でモチーフに当てたため、成果物としてはなんの役にも立たない誰得ツールになってしまいましたが（）、TypeScriptのクラス・オブジェクトの初歩的な実践利用の練習としては自分にとっては適度なテーマでした。
* Webpackの環境周りやデプロイ作業においては流行りのChatGPT先生を大いに頼らせていただき←、パッケージ全体のディレクトリ構成やビルド設定の管理等を体系的に掴むことができました。
    * AIを利用した学習に関しては昨今様々な議論があり、当然AIの回答を何でもかんでも鵜呑みにして実装するべきではありませんが、経験の浅い個人開発者にとって「不明点を深掘りし、『何をどうやって調べるか』の取っ掛かりを掴む」には十分に活用できるものと思います。