野球リーグスコア管理システムの開発

2019-01-13に作成

image
野球リーグスコア管理システムキャップ野球情報局に関する進捗です。

使っている技術など

  • NodeJS
  • ReactJS
  • netlify
  • MySQL
  • materializecss
  • react-bootstrap
  • react-bootstrap-table-next

旧システムについてはこちらの記事をご覧ください。

残りタスクリスト

trello

所有者限定モードのためこのボードには投稿できません ボードとは?

react-planetがsafari12系で動かない問題

react-planet

concept.png
手軽に円形メニュー(Circular Menu)を提供できるライブラリ。

ResizeObserver is not defined

react-planetではリサイズイベントをResizeObseverによって検知しています。

ただし、ResizeObserverはsafari 13.1以降に対応しています。
手元にあるiPhone5Sはsafari12系でした。

react-planet
use-resize-observer
  ┗ resize-observer-polyfill

今回、おそらく動いていないのはresize-observer-polyfillのようです。
きちんとメンテされてそうなのはこれ。
resize-observer

ただ、use-resize-observerのソースを書き換えて、
react-planetのdependencyも変えないといけないので一旦調査してエラー回避する方向で終了。

TopMenuコンポーネントの中でreact-planetを使っています。


{typeof ResizeObserver !== 'undefined' ? <div id="topMenu"> <TopMenu/> </div> :<></> }

CIを設定していないのにNetlifyのビルドが通らなくなった!

Treating warnings as errors because of process.env.CI = true

2020/6/15にNetlifyの仕様が変わったのが原因でソースの警告があるとビルドが通らなくなる現象が発生している模様。

解決方法

(元のビルドコマンドがnpm run buildの場合)
Build Commandを「CI = npm run build」に変更する。

その他コンフィグの設定方法が変わってきているようなのでリリースなどがある場合は要注意かも。

NextJSをPWA化

NextJSをPWA化しました

「草野球放送局」という草野球のライブ配信補助ツールを作ったのですが、ブラウザにカメラ映像を映してスコアボードと合成する手法だと、どうしても動画の表示部分が狭くなってしまう問題点がありました。

草野球放送局

ReactJSをPWA化する方法

NextJSをPWA化するには

next-pwa

詳しくはREADMEを読んでいただければと思うのですが、

  1. next.config.jsを書き換える
  2. serviceWorker.jsを設置する(SW.jsにリネーム)

NextJSをcreate-next-appで作るとserviceWorker.jsがないのでCRAから拝借しました。

PWAでカメラを使うには

あれ、iOSでカメラ起動しないな...と思ってたのですが、iOS13以前ではPWAの表示モードが"browser"でしかカメラを起動できません。

PWAでカメラを使うためiOSとAndroidで異なるmanifestを読み込む

なので、react-device-detectを使ってmanifest.jsonへのリンクをユーザエージェント別に書き換えます。

import * as rdd from 'react-device-detect';
...中略

{rdd.isAndroid && rdd.isChrome 
        ? <link rel="manifest" href="/manifest.json" />
        :''
      }
 {rdd.isIOS && rdd.isSafari 
      ? <link rel="manifest" href="/manifest.ios.json" />
      :''
   }

オチ

iOSは"browser"モードだから表示領域増えないじゃん!!!
androidは(ほぼ)全画面使えますが。

蓋速計測器をリリースしました

蓋速計測器をリリースしました

EgULFyPVoAEFJi6.jpeg

基本的に動画から蓋速を算出する場合はコマ送りできるソフト(aviutlなど)を使ってフレーム数から到達時間を計算しているのですが、aviutlはwindows専用なので、誰でも同じように簡単に速度を算出できる仕組みがあれば便利だと思い、reactから動画を扱えるコンポーネントを使って、コマ送り機能と、リリースの瞬間とキャッチの瞬間の時間を記録すれば誰でも速度を計算できる機能を実装しました。

実装

video-reactはtypescriptの型がないようだったので少し面倒でした。

9.22*3.6/(cTime-rTime)
9.22mを(「キャッチした時間」-「リリースした時間」)の差で割って3.6(秒速から時速への変換)をかけます。

import React, { createRef, useEffect, useState, useMemo } from "react";
import { Player, ControlBar, ForwardControl } from "video-react";

const calcSpeed = () => {
  const playerRef = createRef();
  const [fileURL, setFileURL] = useState(null);
  const [rTime, setRTime] = useState(null);
  const [cTime, setCTime] = useState(null);

  return (
    <>
      <Player
        fluid={false}
        height={400}
        width={'95vw'}
        ref={playerRef}
        autoPlay
        src={fileURL}
      ></Player>
      <input
        onChange={(e) => {
          if (e.target.files.length > 0) {
            const url = URL.createObjectURL(e.target.files[0]);
            setFileURL(url);
          }
        }}
        type="file"
        multiple={false}
        accept=".mp4"
      />
      <button
        onClick={() => {
          const player = playerRef.current.getState();
          playerRef.current.seek(player.player.currentTime + 0.03);
          console.log(player.player.currentTime);
        }}
      >
        +0.03s
      </button>
      <button
        onClick={() => {
          const player = playerRef.current.getState();
          playerRef.current.seek(player.player.currentTime - 0.03);
          console.log(player.player.currentTime);
        }}
      >
        -0.03s
      </button>
      <button
        onClick={()=>{
          const player = playerRef.current.getState();
          setRTime(player.player.currentTime);
        }}
      >リリース</button>
      <button
        onClick={()=>{
          const player = playerRef.current.getState();
          setCTime(player.player.currentTime);
        }}
      >キャッチ</button>
      <br/>リリース:{rTime}
      <br/>キャッチ:{cTime}
      <br/>蓋速:{cTime && rTime && (cTime - rTime > 0) ? (9.22*3.6/(cTime-rTime)).toFixed(2)+'km/h':''}
    </>
  );
};

export default calcSpeed;

情報局のチームページレイアウトの刷新

CSS

チームページのレイアウトを新しくしました

各チームのトップページに新たに大きな写真を採用し、チーム名の縦置きレイアウトを廃止しました。テーマカラーの表示は維持しつつ、横置きに変更しました。
レスポンシブで最大横幅を1000pxにし、基本2カラム、スマホは1カラムにしています。
チーム紹介とメンバー紹介はだいぶteamsの影響を受けました。

チーム紹介

メンバー紹介

自分で入力した自己紹介の一部を表示するようにしました。

戦績

試合結果一覧のデザインを取り込みました。
若干デザイン崩れなので再考の余地ありというところです。

選手成績

元々打撃・投手成績タブに分けていたものを統合しました。

OGPサーバをvercelに移設した

ソース

前回、puppeteer-clusterで同時起動の制御をしたはずだったのだが、
書き方が悪いのか同時アクセスすると暴走してVPSの応答がなくなってしまうので、vercelに分離した。

vercelとは

vercel

herokuと同じようにサーバサイドのプログラムをデプロイできるサービス。
netlifyはフロントエンド特化。

OGPサーバの用途

ルーティングに苦戦

vercelはnow.jsonでルーティングを記述している。
正規表現で各メソッドにreq.query.idを渡す場合の記述の仕方はこんな感じ。

 "routes": [
        { "src": "/cap-baseball/(?<id>[^/]*)", "dest": "/screenshotCap.js?id=$1" },
        { "src": "/(?<id>[^/]*)", "dest": "/screenshot.js?id=$1" }
    ]

puppeteerでスクリーンショット

このあたりは前回の記事で説明しているので不要かなと思う。
cluster使わない書き方に戻してます。

const browser = await puppeteer.launch({
    args: chrome.args,
    executablePath: await chrome.executablePath,
    headless: chrome.headless,
  });
  const page = await browser.newPage();
  await page.goto("https://pawapro-gen.netlify.app/view/" + id);
  await page.setCacheEnabled(false);
  await page.waitForSelector("#main");
  const fullPage = await page.$("#main");
  const fullPageSize = await fullPage.boundingBox();
  const VIEWPORT = { width: 600, height: 450, deviceScaleFactor: 1 };
  await page.setViewport(
    Object.assign({}, VIEWPORT, { height: fullPageSize.height })
  );
  await page.waitFor(5000);

  const elements = await page.$$("#main");
  let img;
  for (const [index, element] of elements.entries()) {
    img = await element.screenshot();
  }
  await page.close();
  await console.log("screenshot done.");
  return img;

puppeteer-clusterでpuppeteerの同時起動数制御

経緯

OGP生成用のpuppeteerアプリケーションに複数アクセスがあった場合、同時にpuppeteerが複数起動してしまってサーバのリソースを食い尽くす憂き目に遭ったのでpuppeteer-clusterを使って同時起動数を制御することにした。

puppeteer-cluster

github

maxConcurrency

同時に起動する最大プロセス数です。

puppeteerOptions

puppeteerに渡していたオプションがclusterでも使えます。

        headless: true,
        executablePath: "/usr/bin/chromium-browser",
        args: ["--no-sandbox"],

cluster.execute()

結果を取得するためにqueueではなくexecuteを使います。

実装

try {
    const cluster = await Cluster.launch({
      concurrency: Cluster.CONCURRENCY_CONTEXT,
      //同時最大起動数1
      maxConcurrency: 1,
      //puppeteerの起動オプション
      puppeteerOptions: {
        headless: true,
        executablePath: "/usr/bin/chromium-browser",
        args: ["--no-sandbox"],
      },
    });
    await cluster.task(async ({ page, data: id }) => {
      await page.goto("https://cap-baseball.com/player/" + id);
      await page.setCacheEnabled(false);
      await page.waitForSelector("#main");
      const fullPage = await page.$("#main");
      const fullPageSize = await fullPage.boundingBox();
      const VIEWPORT = { width: 920, height: 700, deviceScaleFactor: 1 };
      await page.setViewport(
        Object.assign({}, VIEWPORT, { height: fullPageSize.height })
      );
      await page.waitFor(3000);

      const elements = await page.$$("#main");
      let img;
      for (const [index, element] of elements.entries()) {
        img = await element.screenshot();
      }
      await page.close();
      await console.log("screenshot done.");
      //撮ったスクリーンショットを返却する
      return img;
    });
    //結果を取得するためにqueueではなくexecuteを使う
    const result = await cluster.execute(capId);
    await cluster.idle();
    await cluster.close();
    return result;
  } catch (err) {
    console.error(err);
    throw new Error(err);
  }

バえない機能のつくりかた

割と思い付きで機能を作ってしまう方なのですが、
(旧バージョンの情報局でムダとなった機能をリニューアル時に捨てた)
今回はブラウザで作れる野球選手カードについて。

CSSで画像を装飾する

フレームを作る


画像を見てやり方に気づいた方はご名答。
角のフレームは四角形を傾けて重ねてるだけなのです。
transform 15degで15度傾けて表示しています。
親要素をrelativeにし、この要素自体をabsoluteにすると
位置調整ができます。

<div
            style={{
              position: "absolute",
              transform: "rotate(15deg)",
              bottom: -40,
              right: 5,
              width: 350,
              height: 100,
              backgroundColor: "色を指定",
            }}
          />

はみ出した部分をカットする

親要素の大きさを指定して、overflow:hidden
とすると、はみ出した要素を非表示にできます。

<div
        style={{
          width: 340,
          height:450,
          backgroundColor:'#e6e6e6',
          overflow: "hidden",
          position: "relative",
        }}
      />

写真に丸アイコンを載せる

div要素のbackgroundImageに画像を指定して、
border-radius 50%で丸にします。
div要素の高さはwidth,heightで指定し、
画像の大きさはbackground-sizeで調整します。

親要素をrelativeにし、この要素自体をabsoluteにすると
位置調整ができます。

<div
            style={{
              width: 80,
              height: 80,
              border:'2px solid white',
              backgroundSize: "80px 80px",
              backgroundImage: `url('画像のURL')`,
              backgroundRepeat: "no-repeat",
              backgroundPosition: "center center",
              position: "absolute",
              display: "inline-block",
              borderRadius: "50%",
              top: 3,
              left: 3,
            }}
          />

CSSで遊ぶって楽しい!

これまでも色々CSSで遊んできたので比較的サクッと実装できました。(ただし不評)

キャップ野球情報局v2.0リリースしました。

キャップ野球情報局」というサイトを作っています。

アップデート履歴

ちまちまとマイナーアップデートを重ねていましたが、
5/17からNextJSへ移植するのと同時に、次のメジャーアップデート(v2.0)を7月リリースを目標に作っています。

次バージョン新機能

twitterアカウントを持っていれば誰でもログインできます。

マイページ

ユーザページ編集

別サービス「みんなのSCORE」のデータに対応する形で選手ページを持っています。

選手へのコメント機能

選手間で「この選手はどういう選手です」という他己紹介をする機能です。

チームページ編集

別サービス「みんなのSCORE」のデータに対応する形でチームページを持っています。
チームの紹介のほか、チームのテーマカラーが設定できます。

イベント登録・編集

キャップ野球には、主なイベントとして

  • 大会
  • リーグ
  • 練習会

がありますが、それらの情報を登録・編集することができます。

次バージョンに採用している技術

  • フロント
    • NextJS
    • ReactJS
    • Netlify
    • TypeScript
  • サーバサイド
    • NodeJS
    • MySQL
    • Docker(-compose)
    • TypeScript
  • ミドルウェアなど
    • slack
    • firebase
    • cloudinary

imgurではなくcloudinaryを採用した理由

運営者ギルドでは画像ストレージとしてimgurを薦められていたのですが、imgurとcloudinary両方を実装して、使いやすさの観点からcloudinaryを採用することにしました。

ログイン

imgurはOAuth認証すればログイン状態でアップロードできるのですが、ブラウザでPINが必要など使い勝手と実装に難がある印象です。

匿名アップロード

比較的実装が簡単ですが、imgurでは匿名アップロードした画像をGUIでは管理できません。

工数的にはcloudinaryのログイン状態アップロード≒imgurの匿名アップロードという印象だったので、GUIで全体管理ができるcloudinaryを選びました。用途としては無料枠で足りると思います。

2つのアプリケーションに共通の項目はどこに保持するのがよいか

考え方の変遷

以前、こういう記事を書いたのですが、

設定を変えるたびにnpm installしないと反映されないのが不便だったのもあり、同じDB、同じアプリケーションサーバを使っているならば、即座に反映されるDBに保持するのがよいという考えに変わりました。

2つのアプリケーションで共通していてテキストファイルで保持していたデータは以下の2つです。

  • チームの公式twitter
    • チームテーブルにカラム追加
  • 試合ごとのyoutube 動画URL
    • 試合テーブルにカラム追加

試合ごとのyoutube URLは自分でDB設計を変えたときに用意していたみたいですが、完全に存在を失念していました。

特段新しい技術の導入はしてないです。

みんなのSCOREがver4αになりました。

NodeJS側

続・Twitterログイン

以下のパッケージを使う。
- passport-twitter
- twitterログインを行うライブラリ
- express-session
- ログイン情報をセッション(メモリ)に格納するライブラリ。
- express-mysql-session
- 揮発するメモリのセッション情報をmysqlに格納するライブラリ。

reactJSからAPI(NodeJS/Express)へはcredentialsを要求するようにする。

データ保全の強化

ユーザが書き換えるテーブルに履歴テーブルを用意した。
これでこのプロジェクトの合計テーブル数は21となった。

ユーザの権限周りの実装

これまで権限レベルが単一だったが、今回権限を制限したユーザを想定しているので権限周りの実装を行った。

ReactJS側

自動入力コンポーネントの移行

スコア入力で大人数の中から選手を選ぶのに自動入力コンポーネントを使用している。

react-autocompleteはどうもモバイルフレンドリーではなかったようで、モバイルでサジェストが見えなくなってしまう欠点があったので使用するコンポーネントを移行した。

SCSS化

他プロジェクトではすでに導入しているSCSSをこのプロジェクトでも導入。最も古参プロジェクトだったのでCRA(react-scripts)のバージョンを上げるなど。

その他

みんなのSCORE、version4開発開始

長らくボードを放置していましたが、
みんなのSCOREのversion4に向けて開発を開始しました。

4/15修正内容

三塁打対応

  • OPS
  • 長打率

の計算を三塁打を加味したものに修正。

セイバーメトリクス

メンテナンスの都合上、セイバーメトリクスの計算をSQLからTypescript側で行うように修正。

修正前(SQL)

        CASE
            WHEN ((sum(hit)+sum(twobase)*1+sum(homerun)*2)/sum(at_bats)) is null THEN 0
            WHEN ((sum(hit)+sum(twobase)*1+sum(homerun)*2)/sum(at_bats)) is not null THEN ((sum(hit)+sum(twobase)*1+sum(homerun)*2)/sum(at_bats))
        END as slg,
        CASE
            WHEN ((sum(hit)+sum(four_ball))/sum(tpa))+((sum(hit)+sum(twobase)*1+sum(homerun)*2)/sum(at_bats)) is null THEN 0
            WHEN ((sum(hit)+sum(four_ball))/sum(tpa))+((sum(hit)+sum(twobase)*1+sum(homerun)*2)/sum(at_bats)) is not null THEN ((sum(hit)+sum(four_ball))/sum(tpa))+((sum(hit)+sum(twobase)*1+sum(homerun)*2)/sum(at_bats))
        END as ops,
            (sum(hit)+sum(four_ball))/sum(tpa) as obp,
            ((((sum(hit)+sum(twobase)*1+sum(homerun)*2)+0.26*sum(four_ball)-0.03*sum(strike_out)+3*sum(tpa))*(sum(hit)+sum(four_ball)+2.4*sum(tpa)))/(9*sum(tpa))-0.9*sum(tpa))*27/(sum(at_bats)-sum(hit)) as RC27,
            CASE
            WHEN sum(at_bats)/sum(strike_out) is null THEN 0
            WHEN sum(at_bats)/sum(strike_out) is not null THEN sum(at_bats)/sum(strike_out)
        END as not_strike_out,
        CASE
            WHEN sum(at_bats)/sum(homerun) is null THEN 0
            WHEN sum(at_bats)/sum(homerun) is not null THEN sum(at_bats)/sum(homerun)
        END as avg_homerun,
        CASE
            WHEN sum(rbi)/sum(at_bats) is null THEN 0
            WHEN sum(rbi)/sum(at_bats) is not null THEN sum(rbi)/sum(at_bats)
        END as avgRbi,

修正後

public calcSABR = (players) => {
    for(let i = 0; i < players.length; i++){
      players[i].average = players[i].hit/players[i].at_bats;
      players[i].slg = (players[i].hit + players[i].twobase*1 + players[i].three_base*2 + players[i].homerun*3 )/players[i].at_bats;
      players[i].obp = (players[i].hit + players[i].four_ball)/players[i].tpa;
      players[i].ops = players[i].slg + players[i].obp;
      const rc27b = players[i].slg * players[i].at_bats + 0.26 * players[i].four_ball - 0.03 * players[i].strike_out;
      const rc27a = players[i].slg * players[i].at_bats + players[i].four_ball;
      const rc27c = players[i].at_bats + players[i].four_ball;
      players[i].rc27 = ((rc27a + 2.4*rc27c)*(rc27b + 3*rc27c)/(9*rc27c))-(0.9 + rc27c);
      players[i].not_strike_out = players[i].at_bats/players[i].strike_out;
      players[i].avg_homerun = players[i].at_bats/players[i].homerun;
      players[i].avgRbi = players[i].rbi/players[i].at_bats;
    }
    return players;
  }

残りのversion4 実装予定機能

スコア入力一般開放

  • twitterログイン
    • 先日の蓋々交換で技術調査・実装済み
  • スコア入力画面レスポンシブ対応

要件

チーム管理者は

  • 自分のチームの成績のみ入力できる
  • チームメンバーを追加できる
  • メンバーのサジェストを自チームに限定する

その他バグ修正

リファクタリング

テストコード実装

意見募集

みんなのSCORE、キャップ野球情報局はユーザの皆さんのご意見を募集しています。

1/9進捗

サービスURL

イベント登録フォーム設置

DBに下書き状態でイベント情報を登録するフォームです。
bot対策にrecaptchaを採用しました。

reactJSでgoogle recaptchaを使う

site keyを取得する

コンポーネントを設置する

<ReCAPTCHA
    sitekey="Your client site key"
    onChange={onChange}
  />

レスポンシブ対応

PC画面での表示を2カラムにしました。

1/2 キャップ野球情報局リニューアル

2020.jpg
新年2本目のボード投稿になります。
(新年の実績としては1本目)

キャップ野球情報局

https://cap-baseball.com/

成績管理システムとの連携

今回の改修の目玉は、成績管理システム「みんなのSCORE」との連携です。
成績管理システムのデータベースを利用しているのでいわば兄弟サイトです。

試合結果の表示

  • リーグ戦
  • 練習試合
  • 大会(大福大会、佐倉大会など)

の結果をトップページで閲覧できるようになりました。
右下のページング部分から10件ずつ遡ることが可能です。

試合結果詳細

試合結果とともに関連するツイートの表示と
(あれば)試合動画の表示を行います。

チームページ


例:一橋大のページ
通算成績と直近の試合結果が表示されます。

使っている技術

  • ReactJS
  • Netlify
  • NodeJS
  • MySQL
  • Docker
  • nginx

今後の課題

  • 一回見ると飽きてしまう(リピーターがいない)
  • ブログ記事などの集約・紹介
  • コンテンツなどの充実
  • 個別記事とチームの紐づけ

さよなら自宅サーバー

経緯

これまで、フロントはnetlify、バックエンドは自宅サーバーという構成でサービス提供していたのですが、某アップデートの際にOSの再起動でdockerが止まって度々サービス断が発生してしまうのが段々いやになってきたのと、インフラの基礎ができてきたのでconoha VPSでインスタンス作ってサーバ移行しました。

サーバ構築

conoha VPSでubuntuインスタンスを作る

初期設定をする

DNSの設定をする

SSL証明書を発行する

またしてもLet's encryptに苦しめられました。今回はdocker-composeの中のnginxコンテナに適用するつもりでした。調べたところ、証明書生成用のコンテナは別途あったのですが、わざわざdocker-composeの構成書き直すのも...と思ったので、
nginxだけdockerコンテナを使わない構成に変更しました。