デイリースクラムの発表順をランダムで決める bot を作る [discord.js + TypeScript]

新型コロナの影響で、ご多分に漏れず弊社の開発メンバーもフルリモートでお仕事をしています。

コアタイム開始の11:00になると Discord に集まりデイリースクラムを行っているのですが、毎回発表の順番を決めるのが面倒なので、参加メンバーをシャッフルしてリストで返す bot を作ってみました。

パッケージ類

Discord コミュニティ謹製のパッケージ discord.js を使いました。

外部パッケージ使う際は型情報が分かっておくと便利ですね。discord.js はそれ自体で型情報を持っているので @types/◯◯ みたいなパッケージを入れる必要はないです。

{
  "name": "discord_sort_gacha",
  "version": "1.0.0",
  "description": "Sort random and list up the online member.",
  "main": "index.js",
  "scripts": {
    "start": "node ./build/index.js",
    "debug": "ts-node ./src/index.ts",
    "compile": "tsc -p .",
    "compile:test": "tsc -p . --noEmit"
  },
  "author": "Shohei-Japan",
  "license": "MIT",
  "dependencies": {
    "discord.js": "^12.5.3",
    "dotenv": "^8.2.0"
  },
  "devDependencies": {
    "@types/node": "^14.14.41",
    "ts-node": "^9.1.1",
    "typescript": "^4.2.4"
  }
}

Bot のアカウント作成

こちらのページに全部書いてあります。

Botアカウント作成- discord.py

このページの通りに行って、Bot アカウント作成、トークン取得、Discord サーバーに招待、までできたら OK です。

テスト送信

さっそくテスト送信してみます。

Discord テキストチャンネルで「おはよう」と送信すると、「やあやあ」と返してくれる処理を書きます。

import { Client, Message } from "discord.js"
const client = new Client()

client.on('message', (message: Message) => {
  if (message.content === 'おはよう') {
    message.channel.send('やあやあ')
  }
})

const token = xxxxxx // 取得した bot のトークン
client.login(token)

結果

f:id:show-hei:20210423045835p:plain うまく通ったので、続きをやっていきましょう。

Discord 用語のおさらい

Discord ではメンバーが集まるためのサーバーを作りますが、discord.js ではこれをギルド (guild) と呼んでます。

ギルドにはチャンネルがあり、テキストチャンネル、ボイスチャンネル、DM チャンネルなどが存在します。

今回の目的としては、デイリースクラムのために「ボイスチャンネルに集まったメンバー」を、「bot にコマンドを送信したテキストチャンネル」にリスト化して返すことですので、テキストチャンネルとボイスチャンネルを活用します。

チャンネルに参加しているメンバー情報は各チャンネル内に含まれているのでそれも使っていきましょう。

実装

これからの処理はこの client.on() の中に記述していきます。 Message 型を参照しているので、message.xxx のデータは補完が効くので楽です。

client.on('message', (message: Message) => {
// bot コメントを受け取ったあとの処理
})

bot コメントしたメンバーのいるボイスチャンネルとメンバー一覧を取得

変数 message 内には、送信されたギルド情報が入っており、ギルド情報にはそのギルド内のチャンネル一覧情報が含まれています。 名前からは連想しづらいのですが、チャンネル情報はチャンネルの cache 内に含まれているようです。ここから bot コメントを送信したメンバーの ID が含まれているボイスチャンネルと、取得したボイスチャンネルに存在するメンバー一覧を取得していきます。

bot コメントは !gacha というテキストとしました。

// コメントしたメンバーを取得
const author = message.author

// コメントを取得
if (message.content === '!gacha') {
  // ギルドに含まれるチャンネルを取得
  const channels = message.guild.channels
  // channels 内の各チャンネルは type を持ち、'text', 'voice' などの値を持つ
  const voiceCH = channels.cache.find((ch: GuildChannel) => {
    if (ch.type !== 'voice') {
      return false
    }
    // ボイスチャンネルのうち、コメントしたメンバーがいるチャンネルを取得
    return !!ch.members.filter((member: GuildMember) => member.user.id === author.id)
  }) as VoiceChannel
    
  if (!voiceCH) {
    console.error('チャンネルがみっかんないよ')
    return
  }
    
  // voice チャンネルに参加しているメンバー一覧を取得
  const voiceCHMemberNames: string[] = voiceCH.members.map((member: GuildMember) => member.user.username)

メンバーをシャッフルする関数

メンバーをシャッフルするための関数を別途用意しました。これを通してあげるといいですね。

const shuffleArray = (array: string[]) => {
  for(let i = (array.length - 1); 0 < i; i--){
    var r = Math.floor(Math.random() * (i + 1))

    var tmp = array[i]
    array[i] = array[r]
    array[r] = tmp
  }
  return array
}

シャッフルしたメンバーをリストにしてチャンネルに返す

あとはシャッフル関数を通して、体裁を整えてチャンネルに返しましょう。

const shuffledMembers = shuffleArray(voiceCHMemberNames)

// リストにしてテキストチャンネルに送信する
const joinedMembers = shuffledMembers
  .map((member: string, index: number) => `${index + 1}. ${member}`)
  .join('\n')

message.channel.send(joinedMembers)

完成

完成したので試してみましょう。

f:id:show-hei:20210423063842p:plain

f:id:show-hei:20210423063904p:plain

ランダムでリスト化することができました!

f:id:show-hei:20210423063944p:plain

channels.cache を参照しているので、その名称から古いキャッシュを参照しちゃう?と不安でしたが、ちゃんとリアルタイムでのメンバーを取ってきてくれました。

index.ts全コード

クリックすると展開されます

// デプロイを想定してるので dotenv 使ってます。
require('dotenv').config()
import {
  Client,
  GuildChannel,
  GuildMember,
  Message,
  User,
  VoiceChannel
} from "discord.js"
const client = new Client()

// アプリケーション名を確認のために表示
client.on('ready', () => {
  if (!client.user) {
    console.error('認証できないよ')
    return
  }
  console.log(`Logged in as ${client.user.tag}.`)
})


// チャット欄のコメントを受け取る処理
client.on('message', (message: Message) => {
  if (!message.guild) {
    console.error('ギルドがないよ')
    return
  }

  // コメントしたメンバーを取得
  const author = message.author

  if (message.content === '!gacha') {
    const channels = message.guild.channels
    const voiceCH = channels.cache.find((ch: GuildChannel) => {
      if (ch.type !== 'voice') {
        return false
      }
      // ボイスチャンネルのうち、コメントしたメンバーがいるチャンネルを取得
      return !!ch.members.filter((member: GuildMember) => member.user.id === author.id)
    }) as VoiceChannel
    
    if (!voiceCH) {
      console.error('チャンネルがみっかんないよ')
      return
    }

    // voice チャンネルに参加しているメンバー一覧を取得
    const voiceCHMemberNames: string[] = voiceCH.members
      .map(member => member.user.username)
    const shuffledMembers = shuffleArray(voiceCHMemberNames)

    // リストにしてテキストチャンネルに送信する
    const joinedMembers = shuffledMembers
      .map((member: string, index: number) => `${index + 1}. ${member}`)
      .join('\n')

    const sendMessage = joinedMembers || 'メンバー取得できなかったよ'

    message.channel.send(sendMessage)
  }
})

/**
 * 与えられた配列をシャッフルして返す
 * @param array 
 * @returns 
 */
const shuffleArray = (array: string[]) => {
  for(let i = (array.length - 1); 0 < i; i--){
    var r = Math.floor(Math.random() * (i + 1))

    var tmp = array[i]
    array[i] = array[r]
    array[r] = tmp
  }
  return array
}

const token = process.env.DISCORD_TOKEN
client.login(token)

adobeのスーパー解像度で刑事ドラマのやつの検証

転載元zenn.dev


 
刑事ドラマでよくある、なんとか犯人らしき人物を監視カメラで捉えたんだけど、どうしようもないボケボケの解像度で、サイバー対策室的なのに所属しているハッカーポジションの若い人材が「お待ちください。ッターーン」のワンクリで解像度上げて犯人の顔割り出すやつ。

現実的に詳細な画像=存在しない情報なので無理だろっていつも思ってたんですけど、adobeが現実のものにしたようです。

その名も"スーパー解像度"
わかりやすくていいですね。

blog.adobe.com

実際に使ってみて、ほんとに刑事ドラマみたいなのできるのか検証してみました。

やっていくぞ!

まずはraw画像を用意します。
(この時点で違和感覚えた方は目を瞑って未来に期待してください)

f:id:ezm_u:20210422122218j:plain
コロナ禍における閑散とした雑多な駅前商店街。趣があります

ベランダから撮りました。
住んでるとこバレそうですけど男の一人暮らしなので問題ありません。

写真が準備できたら最新版のPhotoshopで開きます。
raw画像は自動でCamera Rawが立ち上がるので、画像を右クリックして"強化"を選択します。
ダイアログが開くので、完了時間など確認しつつ"強化"しましょう。

f:id:ezm_u:20210422122340p:plain

f:id:ezm_u:20210422122357p:plain
のアクションボタンは普通のデザイナーなら"実行"とかにしそうですが、"強化"ってすごい。ゲームとかじゃなくて、現実世界でボタンひとつで何かを強化したことある人いますか?

強化完了!スーパー解像度!

強化した画像がこちらです。
このサイズだと全然わからないですね。
f:id:ezm_u:20210422122514j:plain
車のナンバープレートにフォーカスしてみましょう。
刑事ドラマ感がでてきました。

まずは元の解像度の画像から。
左上の漢字っぽいの不明瞭で読めないですね。

f:id:ezm_u:20210422122546j:plain
強化前の画像です。全然知らない人の車なのでナンバーの一部を隠しています

これをスーパー解像度で強化すると...

.

.

.

.

.

.

.

.

.

.

.

.

板橋!

なんとか読める状態に。
普通にすごい。どういう仕組みか全然わかりません。

f:id:ezm_u:20210422122651j:plain
強化後の画像です。全然知らない人の車なのでナンバーの一部を隠しています

Gifアニも用意したのでスーパー解像度による強化をお楽しみください。
f:id:ezm_u:20210422122722g:plain
以上です。

Supervisor を実際に操作してデーモン化に触れてみる

この記事は個人ブログと同じ内容です

www.ritolab.com


プロセス制御システム である Supervisor を実際に動かしてみて、彼のことを理解しようとしてみました。

Supervisor(スーパーバイザー)

Supervisor

supervisord.org

スーパーバイザーは、ユーザーがUNIXライクなオペレーティングシステム上の多数のプロセスを監視および制御できるようにするクライアント/サーバーシステムです。

プロセスを監視して、落ちたら起動し直してくれたりするツール。

Supervisor を起動すると、設定している管理対象も一緒に起動してくれる。

実行環境

linux 環境があれば良いので Docker で適当に作成

Dockerfile

FROM amazonlinux:latest

# EPEL インストール
RUN amazon-linux-extras install -y epel

# supervisord インストール
RUN yum install -y supervisor

# 設定ファイル設置
COPY nginx.ini /etc/supervisord.d/nginx.ini

イメージをビルドして

# イメージをビルド
docker build -t amazon_linux_20210417 .

コンテナを起動する

# コンテナ起動
docker run -d --privileged amazon_linux_20210417 /sbin/init

# コンテナ ID 出力
-> e17xxxxxxxxxxxxxxxxxxxxxxxx

出力されたコンテナ ID を指定してコンテナの中に入る

# コンテナの中に入る
docker exec -it e17 /bin/bash

Supervisor の起動完了

# バージョン確認
$ supervisorctl version
3.4.0

管理対象を設定する

nginx を管理対象にして操作していく。

nginx.ini

[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true

.ini じゃなくて .conf が良ければ、supervisord.conf の最下部に include があるのでそこを変更する。

/etc/supervisord.conf

[include]
files = supervisord.d/*.ini

supervisorctl コマンド

スーパーバイザーを操作するには supervisorctl コマンドを使っていく。

Running supervisorctl - Supervisor 公式

http://supervisord.org/running.html#supervisorctl-command-line-options

管理対象のプロセスの状態を確認する

supervisorctl status ... で管理対象のプロセスの状態を確認できる

status <name>      単一プロセスのステータスを取得する
status <gname>:*   グループ内のすべてのプロセスのステータスを取得する
status <name> <name>   複数の名前付きプロセスのステータスを取得する
status       すべてのプロセスステータス情報を取得する

nginx の状態を確認してみます。

$ supervisorctl status
nginx                            RUNNING   pid 136, uptime 0:34:04

RUNNING しっかり動いてる。

管理対象を再起動する

supervisorctl restart ... でプロセスの再起動ができる

restart <name>     プロセスを再起動します
restart <gname>:*  グループ内のすべてのプロセスを再起動します
restart <name> <name>  複数のプロセスまたはグループを再起動します

nginx を再起動してみます。

# 再起動を実行
$ supervisorctl restart nginx
nginx: stopped
nginx: started

# ログ
2021-04-17 07:12:27,752 INFO waiting for nginx to stop
2021-04-17 07:12:27,767 INFO stopped: nginx (exit status 0)
2021-04-17 07:12:27,771 INFO spawned: 'nginx' with pid 152
2021-04-17 07:12:37,814 INFO success: nginx entered RUNNING state, process has stayed up for > than 10 seconds (startsecs)

再起動されました。

プロセスを落としてみる

プロセスを何度か落としてみましたが、何度でも這い上がってくる感じがさすがデーモン化といったところ。

f:id:ro9rito:20210419201222p:plain

これがスーパーバイザーの力か。恐るべしデーモン閣下

設定を変更して反映する

nginx の管理設定項目を変更してそれを適用させてみます。

設定ファイルを変更しただけでは適応されないので反映してあげる必要がある。

/etc/supervisord.d/nginx.ini

[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true

# 起動後設定値(秒)より早くプロセスが終了した場合は起動失敗とする
startsecs=10

# 起動失敗時のリトライ回数。この回数を超えると FATAL 状態となり起動施行をストップする
startretries=2

下の 2 を新たに設定値に加えました。

startsecs 起動後設定値(秒)より早くプロセスが終了した場合は起動失敗とする startretries 起動失敗時のリトライ回数。この回数を超えるとステータスが FATAL になって起動をストップする Section Settings http://supervisord.org/configuration.html#program-x-section-settings

変更を反映するには update を使う。プロセスが再起動されて新しい設定値も読み込まれる。

$ supervisorctl update nginx
nginx: stopped
nginx: updated process group

一度 remove してから再度 add する

nginx を Supervisor の管理対象から外したのち、再度管理対象にしてみます。

stop でプロセスを止めます。

$ supervisorctl stop nginx
nginx: stopped

remove で管理対象から外します。

$ supervisorctl remove nginx
nginx: removed process group

この状態から start でプロセスを開始させようとすると、管理対象から外れているのでエラーになります。

$ supervisorctl start nginx
nginx: ERROR (no such process)

再度管理対象とするには add を使います。

$ supervisorctl add nginx
nginx: added process group

再度 nginx が起動します。

ちなみに、remove していようが Supervisor 自体を再起動したら起動します。

ステータスが FATAL になったものを再度起動させる

起動に失敗して FATAL になった管理対象ってどうやって復帰させるんだろう。という事で確認。

現在、FATAL の状態

$ supervisorctl status
nginx                            FATAL     Exited too quickly (process log may have details)

手動で起動させるには start で行う

start <name>       プロセスを開始します
start <gname>:*       グループ内のすべてのプロセスを開始します
start <name> <name>    複数のプロセスまたはグループを開始する
start all         すべてのプロセスを開始します

nginx を起動してみます。

$ supervisorctl start nginx
nginx: started

FATAL ステータスの nginx を起動できました。

まとめ

Supervisor 実際に触ってみたら操作はとてもシンプルでした。

supervisorctl コマンドは add/start とか、reload/reread/update みたいな、シンプルゆえに雰囲気で触ってると意外とどれがどういう動きをするのかわからないなと思ったので、実際に触ってみて確認して理解が深まったことは収穫でした。

みんながきっと1万回は聞いている、VS Code Remoteでコンテナ開発をやる方法

この記事はzenn.devで書いたやつなんで、そっちの方も見てやってくださいな。

zenn.dev


こんにちは皆さん。

いやね、皆さんこう言いますよ。 「それもう何番煎じやねん!前も聞いたわ!」 ってね。

でも、こうも思うじゃないですか。 「たくさん記事があるってことは、ひょっとしてめっちゃくちゃ大事なことなのでは?」 とね。

とういうわけで、毎回まるで前提のごとく使っていたVS Code Remote Containerによるコンテナ開発について、自分が使っているやり方について簡潔に書いてみます。 マニュアルなぞっても面白くないですしね。

VS Code Remote

VSCではリモート環境に入ってエディタを起動することができます。 イメージ的にはこんな感じで、リモート環境上で立ち上げたエディタを、外部から遠隔操作するようなイメージですね。 VSC Remote VS Code Remote自体は随分前からあったのですが、WindowsのDocker開発の環境が整うまでに結構かかったのですよね。 私のプライベートマシンがWindowsであることもあって、完全にRemote Containersができるようになったときは、無駄に感動したものです。

このエクステンションは、このリモート環境をどこに用意するかで、3つに分かれています。 普通の外部サーバで実行しSSHでアクセスするもの、WindowsのWSL上で実行するもの、そして、立ち上げたコンテナ上で実行するものです。 今回解説するのは、コンテナにアクセスするものです。

VS Code Remote Containers

VSCをコンテナ上で実行できるパッケージですが、その実行方法がいくつかあります。各々について解説していきます。

パッケージインストール

何をやるにもVSCの Remote Containerが必要です。さっさと入れちゃいましょう。 https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers 入れるのは一瞬ですが、前提として、docker for mac もしくは docker for windowsが必要です。 私の環境ではdocker for windows の WSL2ホスティングを使っています。

attatch container

まずは手っ取り早くコンテナ上でVSCを実行してみましょう。 例として私の環境でPHP8の開発をしたいと考えたとき、まぁ、PHP8をそもそも入れていないわけですよ。Windowsですし。

> php
php : 用語 'php' は、コマンドレット、関数、スクリプト ファイル、または操作可能なプログラムの名前として認識されません。名前
が正しく記述されていることを確認し、パスが含まれている場合はそのパスが正しいことを確認してから、再試行してください。
発生場所 行:1 文字:1
+ php
+ ~~~
    + CategoryInfo          : ObjectNotFound: (php:String) [], CommandNotFoundException
    + FullyQualifiedErrorId : CommandNotFoundException

ただ、dockerはあるので、そいつでphpを立ち上げてみます。

docker run -it --rm --name php-testing php:8 bash

終わったら、後でexitして落とします。

さて、コンテナが立ち上がってい状況で、VSCで以下のような手順を踏みます。 1. 左下のアイコンを押す 2. Remote-containers: Attatch to runnning container を選択する 3. 立ち上がっているコンテナを選択する

これだけで、コンテナ上で実行するVSCが立ち上がります。 実際に見てみたのはこんな感じです。 (PHP古いな。。。更新しよう) これでPHP8の入った環境中でのVSCを使った開発が始められるようになります。 もちろん、PHP intelephense などのエクステンションをインストールすることもできます。 しかし、注意すべきは、エクステンションのインストールは、環境ごとになるという点です。普段ホストで開発していると気にしないですが、コンテナ開発をする場合、基本的にコンテナは実行終了とともに破棄することが多いので、その場合、いちいちエクステンションをインストールしなおさなきゃいけない点に留意しましょう。 面倒な場合は、コンテナを破棄せず、使いまわすようにしましょう。

devcontainer

複数人での開発を考えると、構築する環境定義をプロダクトコードの中に入れておくのが楽です。 VSCのRemote Containerでは、動かしたいコンテナ環境を、docker-composeと少々に設定で定義することができます。 その方法を解説していきましょう。

.devcontainer

まず、プロダクトリポジトリのルート配下に以下のようにディレクトリとファイルを作ります。

.devcontainer
  ┗ devcontainer.json
  ┗ docker-compose.yml

超単純には、docker-compose.ymlがVSCで開発するうえで必要な環境を用意する部分で、devcontainer.jsonが実際にVSCをどこでどのように起動するかを定義する部分です。 docker-composeを使わないで、dockerのイメージを直接指定するやり方もありますが、今回はdocker-composeを使った方法の解説だけにします。

コンテナの定義

私がよくLaravelの記事を書くときに使っているテンプレを例にしてみましょう。 大体の場合、PHPを動かす環境とデータベースがあれば事足りますので、docker-compose.ymlは以下のようになる場合が多いです。

version: "3"

services:
    workspace:
        build: workspace
        command: sleep infinity
        volumes:
            - ../:/var/www/
        ports: 
            - 8000:8000

    db:
        image: mysql
        environment:
            - MYSQL_ROOT_PASSWORD=secret
            - MYSQL_USER=niisan
            - MYSQL_DATABASE=niisan
            - MYSQL_PASSWORD=secret

ここで、workspaceという未定義のコンテナが出てくるので、いつものdocker-composeで環境を立ち上げるのと同じやり方で、workspaceのコンテナを定義しておきます。

.devcontainer
  ┗ devcontainer.json
  ┗ docker-compose.yml
  ┗ workspace
    ┗ Dockerfile

仕事じゃないので、たいして派手な内容ではないですが、以下のようなDockerfileを使っています。

FROM php:8

RUN apt-get update && apt-get install -y git unzip libonig-dev libzip-dev && \
docker-php-ext-install mbstring pdo pdo_mysql zip && \
pecl install xdebug && docker-php-ext-enable xdebug

COPY --from=composer /usr/bin/composer /usr/bin/composer

とりあえず、composerとテストできる環境があればいいや的なやつですね。

devcontainer.json

最後に、devcontainer.jsonを定義しましょう。

{
    "name": "oauth-laravel",
    "dockerComposeFile": "docker-compose.yml",
    "service": "workspace",
    "workspaceFolder": "/var/www",
    "settings": {
        "editor.tabSize": 4
    },
    "shutdownAction": "stopCompose"
}

このなかで、dockerComposeFileserviceは必須で、どのcomposeファイルを使って、どのサービス上でVSCを動作させればいいかを判断します。 ここまでできれば、あとはVSCを再起動させるだけです。 左下のアイコンを押して、Remote Containers: Reopen in Container を選択します。これで、開発環境に入った状態で、VSCが再起動します。 ちょっとわかりにくいかもですが、コンテナ環境上でエディタが起動しています。

おまけ

エクステンションの指定

devcontainerで起動させる場合、エクステンションについては少しありがたい機能があります。上に挙げたdevcontainer.jsonでextensionsという項目があるので、ここにあらかじめ入れておきたいエクステンションを記録しておけばよいです。jsonに簡単に追加する機能もあります。 そうすると、こんな感じになります。

{
    "name": "oauth-laravel",
    "dockerComposeFile": "docker-compose.yml",
    "service": "workspace",
    "workspaceFolder": "/var/www",
    "settings": {
        "editor.tabSize": 4
    },
    "shutdownAction": "stopCompose",
    "extensions": [
        "felixfbecker.php-intellisense"
    ]
}

とりあえず、PHPで必要そうなエクステンションはあらかじめ入れるようにしておきましょう。

デバッガ

デバッガもリモート環境中で動かせます。 ...動かせているはず。 試しにPHPxdebug動かしてみましょう。 とりあえず、以下のiniファイルを/usr/local/etc/php/conf.d/docker-php-ext-xdebug.iniに突っ込みます。

zend_extension=xdebug
xdebug.mode = debug
xdebug.start_with_request = yes
xdebug.client_port = 9000

自動でやるんならDockerfileで入れてあげればいいかなって思いますが、今回は普通に編集したのを突っ込みました。 普通に動きますね。

まとめ

というわけで、VS Code Remote Containersの自分流のやり方を書いてみました。 もし、もっと深く知りたいとかであれば、マニュアルサイトを見に行ってくれればと思います。

今回は今すぐに始めるとしたら、どういう感じで始められるかな?という観点でやってみました。 開発環境をリモートの隔離された環境に作り、ホストをきれいに保つというのは、私にとっては割と重要事項ですので、同じ考えを持つ方々にとって良い記事となっていれば幸いです。

今回はこんなところです。

参考

マニュアル
devcontainer.jsonの仕様

Chart.js , vue-chart-jsで、データのフィルターを外部のinputから行う

この記事は個人ブログと同じ内容です

zenn.dev

概要

chart.jsのデータセットの絞り込みをLegend(凡例)のクリックではなくて、chart.jsのコンポーネント外のインプットから行うサンプルを作ってみた。

まとめ

chart.js の表現を超えた凝った表現をする場合は、なかなか面倒そうだということがわかった。
(今回のサンプル以外に、やりたいことあったけど、調べてるうちに作るより、デザインや受け入れ要件と調整するほうが楽やろ!もしくはスクラッチで作ったほうが早いやんか!となったので)
本来の目的は楽することなので、それを忘れないようにしたい。

本題

chartjsのデフォルト

chartjsのデフォルトだと、上記のような凡例をクリックする事により表示項目を絞ることができるが、
下記のように絞り込みのUIを chartjs のコンポーネントから引き剥がしたかったため、試しに作ってみた。
おまけで、カテゴリごとに表示項目絞るみたいなこともできるようになった。
(野菜だけ表示するみたいなことができる)

今回作ったもの

コードはこちらにおいてます
https://codesandbox.io/s/chartjs-toggle-datasets-outer-input-mh0r6

外部から、Legendのフィルタ情報を渡してdatasetをフィルタすることはできなかった。

chartjs にデフォルトで用意されてるだろうと調べてみたけど、入ってなかった。

凡例からのフィルタらしきものをoptions.legend.labels.filter見つけたが、これは、凡例のラベルをフィルタするものであって、凡例のラベルをクリックしたときのdatasetのフィルタには全く関係がなかった。

https://www.chartjs.org/docs/latest/configuration/legend.html#legend-label-configuration

propsで datasets を渡す際に、 外でフィルタした状態のdatasetsを渡す方針に変更

chartjsに用意されてない昨日だとわかった & chartjsを拡張するほど力をかけたくないので、簡単そうな方法「そもそもフィルタしたdatasets渡したらええやん」にきりかえた。
(「力かけたくないなら デフォルトの凡例クリックでのフィルタでいいじゃん」には耳をふさぎます。)

できたもの

やってることは単純で、

  1. filter用の配列(filterItem)をdataに持つ
  2. filterItemはinputや、methodで変え放題
  3. 表示したいdataをchartのコンポーネントに渡す際に、filterItemでフィルタして渡す

https://codesandbox.io/s/chartjs-toggle-datasets-outer-input-mh0r6

<template>
  <div>
    <button @click="fillData()">Randomize</button>

    <hr />
    <button @click="filterByCategory('vegetable')">野菜だけ</button>
    <button @click="filterByCategory('fruit')">果物だけ</button>
    <hr />

    <template v-for="item in dataSetsKey">
      <label
        :for="`check_${item.index}`"
        :key="item.label"
        :style="`color: ${item.color}; margin: 8px;`"
        ><input
          :id="`check_${item.index}`"
          type="checkbox"
          v-model="filterItem"
          :value="item.index"
        />{{ item.label }}</label
      >
    </template>
    <BarChart
      :chart-data="filteredDataCollection"
      :options="{
        responsive: true,
        maintainAspectRatio: false,
        labeling: { display: false },
        legend: {
          display: false,
        },
      }"
    />
  </div>
</template>

<script>
import BarChart from "./components/BarChart.vue";

export default {
  name: "SandBox",
  components: {
    BarChart,
  },
  data() {
    return {
      datacollection: {},
      filterItem: [],
    };
  },
  created() {
    this.fillData();
    this.filterItem = this.datacollection.datasets.map((_s, i) => i);
  },
  computed: {
    filteredDataCollection() {
      const collection = {
        ...this.datacollection,
        datasets: this.datacollection.datasets.filter((_s, i) => {
          return this.filterItem.includes(i);
        }),
      };
      return collection;
    },
    dataSetsKey() {
      return this.datacollection.datasets.map((s, i) => ({
        label: s.label,
        index: i,
        color: s.backgroundColor,
      }));
    },
  },
  methods: {
    filterByCategory(category) {
      this.filterItem = this.datacollection.datasets
        .map((s, i) => {
          if (s.dataCategory === category) return i;
          return -1;
        })
        .filter((s) => s >= 0);
    },
    fillData() {
      this.datacollection = {
        labels: ["1月", "2月", "3月", "4月", "5月", "6月"],
        datasets: [
          {
            label: "ほうれん草",
            backgroundColor: "#4cc36b",
            dataCategory: "vegetable",
            data: [
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
            ],
          },
          {
            label: "なす",
            backgroundColor: "#456dfe",
            dataCategory: "vegetable",
            data: [
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
            ],
          },
          {
            label: "りんご",
            backgroundColor: "#f44b81",
            dataCategory: "fruit",
            data: [
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
            ],
          },
          {
            label: "みかん",
            backgroundColor: "#f48817",
            dataCategory: "fruit",
            data: [
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
              this.getRandomInt(),
            ],
          },
        ],
      };
    },
    getRandomInt() {
      return Math.floor(Math.random() * (50 - 5 + 1)) + 5;
    },
  },
};
</script>

最後に

chartjsで調べたり書いてみたりしていたけど、ライブラリの表現を超える表現をする場合にはやっぱり、なかなかパワーいるなと思った。
楽したくて使ってるのに、カスタマイズ重ねて結局スクラッチのほうが楽だったねってならないように気をつけようとおもた。

PHPerKaigi 2021 ガヤ担当の感想

こちらの記事は個人ブログの転載です

akki-megane.hatenablog.com

まずは 今年も無事にPHPerKaigi 2021 開催できたこと、本当にありがとうございます!

f:id:akki_megane:20210329185249j:plain

スタッフとして

PHPerKaigi 2020 に引き続き、コアスタッフとして参加させていただきました。 当日はTrack-A の担当として、配信の設定やAsk The Speaker のがや(ファシリテーション)をしてました。

Ask The Speaker ・・・登壇後にスピーカー方へ質問や感想を視聴者が言う場です。 今回はDiscord で実施しました。

Ask The Speaker 楽しかった

3日間 Ask The Speaker をしましたが、配信会場的に1部屋しかなく、
他の音が入らないように部屋の隅っこのほうでブツブツPCに喋っている状況でした。

Ask The Speakerは基本的は視聴者方がスピーカーへ質問や感想を述べる場ですが、慣れない状況やオンラインということもあり、
最初に質問等をするにはややハードルが上がっている状況だったので、
取っ掛かりになるように、自分が積極的に質問やディスカッションをするように心がけました。

話が盛り上がると質問が活発になったり、別の参加者から補足事項が出てき新しい知見があったり、
本来カンファレンスの懇親会でするようなざっくばらんな話をこの場でできたのはとても楽しい体験でした。

スピーカー方からも、「Ask The Speaker 素敵な体験でした」、「Ask The Speaker で新しい発見がありました」等好意的な意見を頂いたので、部屋の隅っこで3日間喋り倒した甲斐がありました。

アンカンファレンス 今年も無限LTをした

実は2019年から、毎年恒例で無限LTという狂気のLT回を実施しているんですが、 今年もおかしょい (@okashoi) | Twitter が発起人となり実施しました。

fortee.jp

突発でしたが、20人ぐらい参加してくれて、7人LTが実施できました。
参加者のみなさんありがとうございました!

自分は、技術的負債を返し続ける取り組み というタイトルで発表しました。
当日におかしょいさんから依頼があったのでスタッフ業の合間を縫ってこそこそ作ってました。

18:20~ 開催だったためスタッフは解散し部屋を追い出され、
途方にくれた結果、近くの喫茶店に入り隅っこでコソコソ話してました。
f:id:akki_megane:20210329195716p:plain

無限LTの説明
speakerdeck.com

感想

今年も最高な PHPerKaigi でした!

素晴らしいセッションもたくさん聞けて、明日からのモチベーションをかなりもらいました!

オンラインということもあり、多くの人とはコミュニケーションが取れず、ちょっとさみしい気持ちもありますが、とても楽しかったです。

来年は懇親会でみんなで酒を飲みたいな!!!!

Vue3のSuspenseを使ってみた

Vue3のSuspenseについて興味があったので、試しに触ってみた内容をまとめます

Suspenseって?

非同期処理が解決されるまで、コンポーネントの代わりにフォールバックコンテンツをレンダリングする特別なコンポーネントです。 今まで、computedで変数を定義して、v-ifで表示制御していたのを簡単に書けるようにしたもののようです。

実際に書いてみた

親コンポネ

親コンポネでは以下のように書きます。

<template>
  <Suspense>
    <template #default>
      <ArticleList/>
    </template>
    <template #fallback>
      Loading...
    </template>
  </Suspense>
</template>

<script lang="ts">
import {defineComponent} from 'vue'
import ArticleList from "./components/ArticleList.vue"

export default defineComponent({
  components: {
    ArticleList
  },
})
</script>

Suspenseは、2つのスロットを持っています。

default

最終的にレンダリングするコンテンツ

fallback

defaultに定義したコンテンツの非同期処理が完了するまでのコンテンツ


今回作ったサンプルだと、子コンポネの非同期処理が終わるまではLoadingと表示されます。

子コンポネ

非同期処理を行う子コンポネでは、以下のようにかきます。

<template>
  <div class="card-wrapper">
    <div class="card" v-for="(article, key) in articles" :key="key">
      <h1>{{ article.title }}</h1>
      <div>{{ article.content }}</div>
    </div>
  </div>
</template>

<script lang="ts">
import {defineComponent} from "vue";

export default defineComponent({
  async setup() {
    const sampleArticles = [
      {title: '記事A', content: '記事Aの内容'},
      {title: '記事B', content: '記事Bの内容'},
      {title: '記事C', content: '記事Cの内容'},
    ]

    const fetchArticles = () => {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve(sampleArticles)
        }, 3000)
      })
    };
    const articles = await fetchArticles();

    return {
      articles
    }
  }
});
</script>

通常ではAPIから何らかのデータを取得して、取得したデータを表示すると思いますが 今回は、簡潔にするためにsetTimeoutで擬似的に非同期処理にしています。 ブログの記事一覧を引っ張ってくるAPIを叩いているイメージで書いています。


このように書くことで非同期処理が終わるまではLoadingと表示され、終わったら記事が表示されるようになります。 f:id:ryonnsui1201:20210329004426g:plain

エラーが発生した際のハンドリング

非同期処理が失敗することもあると思います。
その場合は、onErrorCapturedでエラーを補足し、エラーを表示します。
onErrorCapturedは子孫コンポーネントからエラーが捕捉されるときに呼び出されるライフサイクルフックです。

<template>
  <div class="card-list">
    <div v-if="error">
      {{ error }}
    </div>
    <Suspense v-else>
      <template #default>
        <ArticleList/>
      </template>
      <template #fallback>
        Loading...
      </template>
    </Suspense>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, onErrorCaptured } from 'vue'
import ArticleList from "./components/ArticleList.vue"

export default defineComponent({
  components: {
    ArticleList
  },
  setup(){
    const error = ref(null);

    onErrorCaptured((e) => {
      error.value = e
      return true;
    });

    return {
      error
    }
  }
})
</script>


f:id:ryonnsui1201:20210329011320g:plain

まとめ

以上、Suspenseの使い方でした。 とてもシンプルに非同期処理の際の表示処理を書けるので便利ですね。

参考

Suspense - new feature in Vue 3 - Vue.js Tutorials