asoview! TECH BLOG

アソビュー株式会社のテックブログ

React Context APIを使ってメディアクエリを共通化してみた。

こんにちはフロントエンドエンジニアの指田です。
コロナの影響でフルリモートになって1か月ほど経ちました。
運動不足気味です。

さて、今回はレスポンシブ対応で利用するメディアクエリをReact Context APIを使って共有・取得がシンプルに実装できたので紹介します。

Context APIについて

簡単に言うとpropsで渡さなくても、コンポーネント間で共有する方法を提供しているAPIになります。
詳しくはReactの公式を確認いただければと思います! reactjs.org

メディアクエリについて

今回、ブレークポイントの設定・取得は「react-responsive」を使います。
Hookになっているので使いやすいです。 github.com

そして、ブレークポイントはこちらになります。

  • 560px未満をスマホと設定
  • 960px未満をタブレットと設定

hashimotosan.hatenablog.jp

さっそく実装を見てみましょう

// MediaQuery.tsx
import React, { useContext } from 'react'
import { useMediaQuery } from 'react-responsive'

const MediaQueryContext = React.createContext({
  isSmartPhone: false,
  isTablet: false,
  isMobile: false,
  isPc: false
})

export const MediaQueryProvider: React.FC = ({ children }) => {
  const isSmartPhone = useMediaQuery({ maxWidth: 559 })
  const isTablet = useMediaQuery({
    minWidth: 560,
    maxWidth: 959
  })
  const isMobile = isSmartPhone || isTablet
  const isPc = !isMobile

  return (
    <MediaQueryContext.Provider
      value={{ isSmartPhone, isTablet, isMobile, isPc }}
    >
      {children}
    </MediaQueryContext.Provider>
  )
}

export const useDeviceType = () => useContext(MediaQueryContext)

まずはContextProvideruseContextでメディアクエリを取得するための関数を作成します。
Contextexportせずに隠蔽します。
また、MediaQueryProviderはFunctionComponentで定義してuseMediaQueryにてブレークポイントを設定します。

これでほぼ実装はお終いです。

// App.tsx
import { MediaQueryProvider } from './MediaQuery'

render(
  <MediaQueryProvider>
    <App />
  </MediaQueryProvider>,
  document.querySelector('#app')
)

さきほどのMediaQueryProviderでアプリケーションを囲います。
これでどのコンポーネントでも利用できるようになります。

// HogePage.tsx
import { useDeviceType } from './MediaQuery'

const HogePage: React.FC = () => {
  const { isSmartPhone, isTablet, isMobile, isPc } = useDeviceType()

  return ()
}

export default HogePage

コンポーネントでuseDeviceTypeを利用してメディアクエリを取得して完了です!

まとめ

最初の実装はCustom Hookで作成していましたが、コンポーネント間でもっと簡単に共有したいと思いContext APIを利用しました。
他にも色々な用途(認証、翻訳等)でContext APIは利用できそうです。
※社内に展開したら認証処理はこの形に変更されました。

Storybook v5.3のざっくり解説

こんにちはアソビューでテックリードやってます井上です。
コロナの影響でテレワークが続いており、運動不足です。(元々してない)
リングフィットアドベンチャーが買えないので筋トレ&30分散歩&Beat Saberを始めました。

さて、弊社ではフロントエンドは基本的にReactで開発しており、コンポーネントカタログとしてstorybookを活用していますが、バージョン5.3のアップデートに伴って設定周りが使いやすく一新されていましたので今回はそちらを簡単に紹介したいと思います。
古い設定からのマイグレーションの参考にしていただければと思います。

ちなみにすでに6.0もα版が進んでいてリリースを控えているのでそちらはまた追って。。

Storybook 6.0 Release 🏆 · Issue #9311 · storybookjs/storybook · GitHub

Storybookについて

f:id:masino83:20200316103959p:plain
storybook github
storybook はReact,Vueを始めとした様々なJSのUIライブラリやフレームワークのコンポーネントカタログやUT結果の表示をしてくれるツールです。
弊社でもプロダクトごとにstorybookを導入してコンポーネントカタログを生成して、開発時にすでにあるコンポーネントを把握して活用しやすいようにしたり、storyファイルを作ることで共有するコンポーネントの独立性を保つように心がけやすくしています。

storybook 5.3の設定方法

5.2 -> 5.3でシンプルになった設定周りについてかいつまんで説明します。
より詳しくはこちらを確認いただければと思います!

storybook/MIGRATION.md at next · storybookjs/storybook · GitHub

設定ファイルの構成

まず、各設定ファイルの名前、構成が変わっています。わかりやすくなりましたね。
大体こういう対応みたいです。(中身は結構違う)

preset.js -> main.js

config.js -> preview.js

addon.js -> manager.js

main.js

Reactの場合の説明はこちら https://storybook.js.org/docs/guides/guide-react/

storybookのメインの設定を書くファイルです。
具体的には下記のような記述になります。

module.exports = {
  stories: ['../**/*.stories.js'],
  addons: ['@storybook/addon-knobs', '@storybook/addon-actions'],
};

story fileのエントリーポイントの指定やaddonの設定を書く感じですね。
基本の設定はこれだけでOKです。シンプル!

これまでaddon.jsに下記のような形で書いてましたがそれは不要でmain.jsに書きます。

// 5.2までの書き方 addon.js
import '@storybook/addon-knobs/register'
import '@storybook/addon-actions/register'

storyファイルの書き方

export const storyName = () => (
  <SomeComponent />
)

こういう風にコンポーネントを直接exportする感じです。 結果、コンポーネント名がstory名に変換されます。 (camel case, snake caseが対応)
storyName or story_name--> Story Name 直感的でわかりやすいですね。

import React from 'react'

import base from 'paths.macro'
import { withKnobs, text } from '@storybook/addon-knobs'
import {action} from '@storybook/addon-actions'
import Button from '../'

export default {
  title: base.replace('/src/components/', '').replace('/__stories__/', ''),
  decorators: [withKnobs]
}

export const normal = () => (
  <Button
    label={text('label', 'ボタン')}
    onClick={action('clicked')}
  />
)

export const primary = () => (
  <Button
    label={text('label', 'ボタン')}
    type='primary'
    onClick={action('clicked')}
  />
)

export const primaryDisabled = () => (
  <Button
    label={text('label', 'ボタン')}
    type='primary'
    disabled
    onClick={action('clicked')}
  />
)

export const secondary = () => (
  <Button
    label={text('label', 'ボタン')}
    type='secondary'
    onClick={action('clicked')}
  />
)

export const secondaryDisabled = () => (
  <Button
    label={text('label', 'ボタン')}
    type='secondary'
    disabled
    onClick={action('clicked')}
  />
)

出力結果はこちら

f:id:masino83:20200402095450p:plain
storybookの表示結果

5.2以前はこういった書き方でした。

// 5.2以前のstory fileの書き方。
const stories = storiesOf('sp/atoms/Button', module)
stories.addDecorator(withKnobs)

stories.add('other', () => (
  <Button
    label={text('label', 'ボタン')}
    onClick={(e) => console.log(`click`)}
  />))

storybookのtitleについて

story fileのtitleの設定でstorybookのディレクトリ階層が指定できますが、ここを手で指定するのが地味に面倒でした。

// これまではパスの指定を手で書いていた
const stories = storiesOf('sp/atoms/Button', module)

babelプラグインのpath.macroを活用するとstorybook階層化が楽になります。

https://storybook.js.org/docs/basics/writing-stories/

import base from 'paths.macro'
 // console.log(base)  -> /src/components/atoms/Button/__stories__/
// このreplaceも書かないようにしたい..
export default {
  title: base.replace('/src/components/', '').replace('/__stories__/', ''),
  decorators: [withKnobs]
}

カスタムwebpackの読み込み

storybook用のwebpack設定を作らず、実際のアプリケーションビルドで使ってるconfigをそのまま読み込めるようになりました。

const custom = require('../webpack/config.base.js')

module.exports = {
  stories: ['../src/**/__stories__/index.js'],
  webpackFinal: config => {
    return {
      ...config,
      module: { ...config.module, rules: custom.module.rules }
    }
  },
  addons: ['@storybook/addon-knobs', '@storybook/addon-a11y']
}

まとめ

簡単ですが、今回は以上になります。
弊社ではデザインシステム運用のためにstorybookを活用してエンジニアやデザイナー、その他プロダクトメンバー間でコンポーネントの共有をできる形を構想しています。
figma連携のaddonなんかもあるので今度検証してみたいと思います。

AWS Lambda(Python 3.8) + S3でファイルをアップロードする

今回は、サーバレス(AWS Lambda(Python 3.8) + S3)でファイルをアップロードする仕組みが必要となったため、その時に調べた方法の備忘録です。

Spring boot(EC2+RDS)でWebアプリケーションとして作成する予定だったのですが、コスト面で見直した結果、今回のような構成で行くことにしました。 LambdaやAPI Gatewayは初めて使っみてたのですが、当初思っていたよりは簡単に利用できて驚きました。 今回アクセスコントロール周りには触れていないのであしからず...

Lambda関数の作成

まずはLambda関数を作成します。 コンソールでLambdaを選択し、関数の作成から新しいLambda関数を作成します。 冒頭の通り、今回はPython 3.8をランタイムに選択します。 f:id:uentseit:20200328114414p:plain 関数の内容は以下の通りです。

import json
import boto3
import base64
import io
from datetime import datetime
import cgi

BUCKET_NAME='xxxx-test-backet-1'
DIRECTORY='uploaded_files/'

s3 = boto3.resource('s3')

def lambda_handler(event, context):
    bucket = s3.Bucket(BUCKET_NAME)
    
    body = base64.b64decode(event['body-json'])
    fp = io.BytesIO(body)
    
    environ = {'REQUEST_METHOD': 'POST'}
    headers = {
        'content-type': event['params']['header']['content-type'], 
        'content-length': len(body)
    }

    fs = cgi.FieldStorage(fp=fp, environ=environ, headers=headers)
    for f in fs.list:
        print("filename=" + f.filename)
        bucket.put_object(Body=f.value, Key=DIRECTORY+f.filename)

    return {
        'statusCode': 200,
        'body': json.dumps('アップロード完了')
    }

multipart/form-dataで複数ファイルをアップロードできるようにしておきます。
ただし、Lambdaのペイロードサイズ上限は6MBとなっているため、事前に要件の確認が必要です。 アップロードされたファイルの読み込みには、cgiモジュールのFieldStorageを利用することにします。
保存先はS3で、Bucket内に'uploaded_files'というフォルダを作ってそこに保存します。
multipart/form-dataで複数アップロードしたいので、保存もファイルごとに行います。
S3への保存は、AWS SDK for Python (Boto3)をimportして利用します。

API Gatewayでインタフェースを作成

トリガーの追加から、API Gatewayを選択します。
f:id:uentseit:20200328114300p:plain API Gatewayのコンソール画面でリソースを設定します。今回はmultipart/form-dataでファイルを送信する為に、アクション→メソッドの作成→POSTを選択してPOSTメソッドを受け付けるようにします。
f:id:uentseit:20200328114532p:plain 統合リクエストにはLambdaを指定しておきます。
f:id:uentseit:20200328114545p:plain

ファイルをアップロードする

Chrome拡張のTalend API Testerを使用してファイルをアップロードします。
今回は試しにエクセルファイルを2つアップロードしてみます。
f:id:uentseit:20200328114634p:plain

すでにアップロード済みのファイルと同じファイル名でアップロードすると上書き保存となります。
以下のようなHTMLをS3上に用意してしまえば、一つのアプリケーションの出来上がりです。

<!doctype html>
<html>
<head>
        <meta charset="utf-8"/>
    </head>
<body>
    <form method="post" action="https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/default/fileUploader" enctype="multipart/form-data">        
        <p>ファイル:<br>
        <input type="file" name="file1" size="30"><br>
        <input type="file" name="file2" size="30"></p>
      
        <p><input type="submit" value="送信する"></p>        
    </form>
</body>
</html>

今回は以上です。

参考リンク

[Python] POSTされたmultipart/form-dataをFieldStorageでパースする - Qiita

CommandLineRunnerでバッチ処理を実装する

Spring BootでWebアプリケーションを作っている際、次のような時にバッチ処理を検討することがあると思います。
・cronなどで定期的に処理を実行したい
・シェルで実行したい
・サーバ側で時間のかかる処理を非同期で行いたい

CommandLineRunnerを使うことで、Spring Bootでバッチ処理のようなものを実装することができるという事で、調べてみました。
ということで今回は、CommandLineRunnerの実装備忘録です。

続きを読む

グローバル決済Night!~越境ビジネスやインバウンド対策を知る~ に登壇した話


こんにちは。アソビューの並木です。
マイクラと猫に癒される日々です。

先日、stripe 様からの招待で以下のイベントに登壇させていただきました
内容について少し触れたいと思います。 connpass.com

東京海上日動様とストライプジャパン様の合同イベントでして

グローバル決済Night!~越境ビジネスやインバウンド対策を知る~

と銘打って

まず

楠谷 勝氏(東京海上日動火災保険株式会社 デジタルイノベーション共創部長)

ダニエル へフェルナン氏 (ストライプジャパン株式会社 代表取締役)

のお二人による

・決済処理のグローバルの動き(中国の顔認証決済のお話など)

・現在の市場について

・これからの動き

・不正決済

についてを座談会形式で実施しました。

その後、私並木が

ユーザー事例の紹介

として、誠に恐縮ながらユーザー代表として登壇させていただきました。

※※※内容がセンシティブなこともあり※※※
※※※一部情報は伏せます。ご了承ください。※※※

当日の様子を一枚だけ。。
手前味噌ですが、弊社の事例について、紹介をさせていただきました。
f:id:yusuke-namiki:20200128203654j:plain

会場は wework 様 の アイスバーグ
www.wework.com

机を並べた前に立つわけではなく、みなさん自由なスタイルで聴いていただき
初めて経験する、良い意味でのラフなスタイルでの登壇でした。
途中まで、イベント参加者ではない一般の利用者の方もいらっしゃいました

当日のお品書き(アジェンダ)

f:id:yusuke-namiki:20200128204351p:plain

どうでもいいんですが、アジェンダという言葉が使い慣れなくて
「お題」とか「お品書き」っていう日本語を使いたくなります。

最初はアクセス制御の話から

f:id:yusuke-namiki:20200128204556p:plain
ここはAWSのサービスを紹介しているだけなのでそのまま載せる&説明は省きます。

次に、stripe様のサービスを使った不正ガードについて事例を交えて

設定できる項目は実際にはもっと多く、細かな設定ができます。

f:id:yusuke-namiki:20200128205240p:plain

次に、取引データから、チェックのためのリストを作成する仕組みについて紹介しました。

f:id:yusuke-namiki:20200128205551p:plain

簡単に説明すると

Jenkins

  • 特定のパスに格納されている、処理を実行する

  • 取得結果を特定のフォルダに格納

処理内容

  • 特定の期間を指定して、メタデータを取得して表示します。
    stripe の API リファレンス

stripe.com を参考に作成し、取得。

textファイル

  • 処理の実行結果がtext ファイルとして出力される

google drive

  • text ファイルを特定のフォルダにアップロード(これはどこでも大丈夫です)

まとめ

不正対策に終わりはないと思いますが、有効な対策は必ずその時その時で存在します。

一番の有効対策は諦めないこと

だと思っています。

イタチごっこだから。。といって対策をしないのは本当に勿体無いので
終わりのない戦いだとしても、皆さんで立ち向かいましょう。

最後におきまりのやつ。

アソビューでは、一緒に働いてもらえる仲間を募集中です! ゲストににワクワクを届けるため、最高のプロダクトを作っていこう!というマインドをもったメンバーがたくさん働いてます。 話を聞いてみたい!という方ははお気軽にご連絡ください。 最初は、会社の雰囲気知りたいーと言った気軽な感じでも大丈夫です。

www.wantedly.com

www.wantedly.com

CEC TOKYO イベントレポート

こんにちは。アソビュー!プロダクト開発チームでプロダクトマネジャーを担当している矢野です。

1月22日に開催されたCEC TOKYO(カスタマーエンゲージメントカンファレンス)に参加してきましたので、 社内共有も含めてテックブログにイベントレポートを書きたいと思います。

この記事は主に、マーケティングやカスタマーサクセスを業務にしている人向けの内容です。 多くのセッションに参加しましたが、個人的に学びの多かったセッションに絞ってレポートを書いています。

世界の“Customer Engagement”の潮流

Repro 平田氏 Amplitude 米田氏、Mr Jeferry Wang氏 Burger King Mr Preston氏

Amplitudeとは?

ネクストユニコーン。 エンジニア30人体制で、アプリ内のデータ、行動分析のしやすさを追求したプロダクト。 今回のバーガーキングのアプリに導入済みで、今回のセッションでAmplitudeの活用事例をお話いただきました

amplitude.com

バーガーキングのエンゲージメントの考え方

▼ ターゲットはどう定義してるのか?

バーガーキングのロイヤルカスタマーはいない。マックでもレストランでも行く人がターゲット。 オンラインで注文できることで、デジタルユーザー化していくことを目指してる。

▼ バーガーキングのマーケティング活動について

アプリで取得している情報は

  1. 行動ログ
  2. 位置情報
  3. 広告計測データ

⇒精度の高いターゲティングが可能になるため

▼ カンヌライオンズ2019を受賞したプロモーションの裏側

以前のアプリにはクーポン配布しかなかったが、新アプリからオンライン注文、決済ができるようになった。 オンライン注文、決済が新アプリでできることを認知してもらうためにプロモーションをおこなった。

▼プロモーションの内容は?

マクドナルドの店舗の半径600フィート内にはいると、ワッパーのクーポンがダウンロードできるという内容。 プロモーション詳しくは記事参照 bizseeds.net

▼ バーガーキングのマーケティング施策について

エンゲージメント評価(NSM ノーススター・メトリックス)を設定する

ユーザー毎のデジタルトランザクション数(つまりエンゲージメントも高いという裏付け)と設定

KPIの設定方法

ノーススター・メトリックスを最大化するためのKPIの落とし方はAmplitudeが定義したフレームワークを使って以下のように定義

  • 広がり(ユーザー数)
  • 深さ(エンゲージメントレベル )
  • 頻度(再訪頻度)
  • 効率(タスク完了までの速度)

それぞれのKPIに応じた施策

f:id:javeshi100:20200207172803j:plain

  • 広がり(ユーザー数)→ファネルコンバージョンの向上
  • 深さ(エンゲージメントレベル )→PUSH通知
  • 頻度(再訪頻度)→モバイルオーダーに特化したクーポン配布
  • 効率(タスク完了までの速度)→フリクションレスペイメントサービス導入

ノーススター・メトリックス 最大化のためのグロース運用

  • ノーススター・メトリックスを向上させているユーザー行動の特徴を分析する
  • ノーススター・メトリックスを向上させるマジックナンバーを求める
  • カスタマージャーニーから施策実行箇所を求める

いかに施策を早く回せるか?が重要。 バーガーキングでは学習回数を増やすことを意識して運用したそうです。

▼ マジックナンバーの見つけ方

マジックナンバーとは?

アプリ内で特定の行動をするとリテンションが上がる数値等

マジックナンバーの見つけ方?

性別、年齢などのデモグラデータだけではターゲティングが不完全だから、 アプリ内行動でセグメント化すると、良いインサイトが取れるとのこと。

たとえば、アプリ内の行動で成果のでている機能を特定する。 マジックナンバーが上がる機能を特定し、利用頻度が高くなるようにアプリを改善している

▼ 良いプロモーションは?

クラスターごとにプロモーションを分けて行っている。 たとえば、3ドルで反応するクラスター、2ドルで反応するクラスターなど、クラスターにあったキャンペーンを実施するなど パーソナリゼーションを活用し様々なタッチポイントをつくること。 コンテンツベースのパーソナリゼーションが有効

このセッションで学びまとめ

———

  • 囲い込みではなく、マインドシェア
  • マインドシェアを勝ち取るマーケティング施策の実行
  • 獲得からリテンション
  • 獲得はコストが高い
  • パーソナリゼーションによる、エンゲージメントの高いプロダクトを提供する

———

2020リテールビジネス最新予測!

株式会社ビジョナリーホールディングス 川添氏 コメ兵 藤原氏 FABRIC TOKYO 森氏 アスクル輿水氏

▼ リテールビジネスの課題

  • 商業集積地の家賃が上がる
  • 人件費の高等

→かわらない売上、コストの増加

いままで →いい場所にお店を出店する

これから →デジタル空間の中での集客と投資 ものを販売するお店はつくらない

▼ ケーススタディ

■コメ兵

店舗規模を縮小し、買取機能と、購入前の試着機能をリアル店舗で提供 オムニチャネルは物流の話 余剰在庫から物流への投資にシフト お客様がほしいときにお店に商品がある状態を作れれば良い 予約時点が一番欲しい状態、1週間後だといらないと感じちゃう。物流はUXで非常に大事

■ Fabric tokyo

リアル店舗のあり方がかわってきた。売り場ではなく、体験の場、サービスの価値提供の場。

Fabric tokyoでは全組織をデジタルネイティブにしていっている。 小売はサービス化していく(RaaS retail as a service) 弊社では、体型変動の保証を月額390円で何度でも対応するサブスクを開始した。

昨年やった施策としてリピート施策に振り切った カスタマーエンゲージメントを取り組んだ結果、新規会員も自然に増える。 なぜなら、リファラル、口コミから新規会員につながるから 今年もカスタマーエンゲージメント高める施策に振り切る。

今までの小売 →売ることがゴールだった

これからの小売 →関係性の構築

このセッションでの学び

購入から配送までもUXの一部 UXとして大事なことはお客様の時間を奪わないこと

まとめ

  • 新規獲得からリテンションに施策の優先度がシフトしてる
  • リテンションを上げるためにはエンゲージメント施策が大事
  • エンゲージメントを高める手段として、パーソナリゼーションを活用したターゲティングが必要
  • ターゲットのセグメントはデモグラ属性では不十分。アプリ内の行動をもとにセグメント化すると効果が高い
  • ユーザーから時間を奪わない。最短で目標を達成できることをUXとして盛り込む
最後に採用の宣伝をさせてください

アソビューでは、一緒に働いてもらえる仲間を募集中です! ゲストにワクワクを届けるため、最高のプロダクトを作っていこう!というマインドをもったメンバーがたくさん働いてます。 話を聞いてみたい!という方ははお気軽にご連絡ください。

www.wantedly.com

www.wantedly.com

canvasを使って動体感知。"あの"犯人(犬)を捕まえた!

犬アレルギーだけど犬が大好きな相原(@raihara3)です。

実家で定期的に起こる、ワンコのとある事件の犯人を捕まえるべく
canvasを活用して簡単につくった監視ツールの紹介です。

その名も「トイレ警察24時
名前の通り、トイレの話です。
汚い表現は極力控えていますが、苦手な方はご注意ください。


実家には現在3匹のワンコがいます。
そこで起こるワンコの事件というのは、
犬あるある「他の子の排泄物を口にしてしまう子」がうちにもいるんです...
母性本能で良い事なんですが、みんな成犬なのでやめて欲しい...

犯人はもう分かっています。(なぜ分かったかは省略)
でも、いつも人間の目を盗んで犯行を行う為証拠もなく、注意できずにいます。

そこで思いついたのが「トイレ警察24時」

概要

ざっくり。

f:id:raihara3a:20200113193334p:plain

(3)で差分がなければ(1)(2)を繰り返し、
差分がある場合は、被疑者が近づいては危ないので(4)(5)のような流れになります。
(差分 = 排泄物あり)

仕組み

※この先折り紙を丸めたものが排泄物として登場します。本物ではありません。

通常時の情報を取得

f:id:raihara3a:20200113193611p:plain
監視スタートして、15秒後に現在の状態を基準として取得します

setTimeout(() => {
  standardStatus = document.getElementById('toiret-canvas').getContext('2d').getImageData(0, 0, canvasSize.width, canvasSize.height).data;
}, 15000)

モーション検出

Diffy.jsというライブラリを使用しました。
フレームを少しずらしたWebカメラのスナップショットを取得し、
ハイコントラストの差分があれば"モーション検出"される仕組みです。

f:id:raihara3a:20200113193714p:plain
トイレに末っ子がやってきました。

import { create } from 'diffy.min.js';

const resolution = {
  x: 20,
  y: 15
}

let diffy = create({
  resolution: { x: resolution.x, y: resolution.y },
  sensitivity: 0.2,
  threshold: 21,
  debug: true,
  containerClassName: 'diffy-container',
  sourceDimensions: { w: 250, h: 200},
  onFrame:(matrix) => {
    matrix.forEach((row) => {
      const notWhiteCount = row.filter((color) => {return color !== 255}).length;
      if(notWhiteCount > resolution.x / 2.5) {
        // モーション検出
      }
    });
  }
});

window.diffy = diffy;

今回だとresolutionで指定している20 x 15に映像を分割し、
matrixに白〜黒のカラーコードが配列で渡されます。差分がなければ白が入ります。

モーション検出の条件に設定している割合は、
カメラからの距離や映り込む被写体の大きさによって前後すると思うので要調整です。

差分検出

動きがなくなった5秒後に最初の状態と変わりないか確認します。

const currentStatus = document.getElementById('toiret-canvas').getContext('2d').getImageData(0, 0, canvasSize.width, canvasSize.height).data;

for(let i = 0; i < currentStatus.length; i++) {
  if(standardStatus[i] - currentStatus[i] < -30 || 30 < standardStatus[i] - currentStatus[i]) {
    new Audio('chime.mp3').play();
    hasAbnormal = true;
    return;
  }
}

dataプロパティで取得したRGBAから+-30を許容値としました。
これは白のトイレシートと排泄物の明暗差からざっくり決めました。

f:id:raihara3a:20200113193824p:plain

差分が検出されると人間に向けた通知音を流します。

この段階で近くに人間がいて片付けができればいいのですが、必ずいるとも限らないので...

再度モーション検出

今が取り締まる瞬間です!
hasAbnormal = trueの状態でモーション検出すると、音と共にcanvasから写真のダウンロードリンクを生成します。
音といっても、あまりビックリさせても良くないので知らない犬の鳴き声にしてみました。
(これでもかなり反応する)

new Audio('dog.mp3').play();

const canvas = document.getElementById('toiret-canvas');
const aTag = document.createElement('a');
aTag.innerText = new Date();
aTag.download = new Date();
aTag.href = canvas.toDataURL("image/jpeg");
document.getElementById('evidence-box').appendChild(aTag);

この音に気を引かれてやめてくれるか、人間が駆けつけて止めるか、です。
被疑者の長女がやってきました。

f:id:raihara3a:20200113194043p:plain

しっかり音のする方を気にしてくれてます。

もし止めることができなくても、リンクから画像をダウンロードすればしっかり証拠が手に入ります。

f:id:raihara3a:20200113194216p:plain

排泄物がある状態ではモーションを検出している間、5秒おきに写真を撮り続けます。
その為ただトイレをしにきただけだった、という場合もちゃんと確認できます。冤罪防止。

しかし、いくら折り紙を丸めたと言ってもちょっとリアルだったかな...

さいごに

ワンコに後から注意することはできませんが、
これで「人間は見てないはずなのにバレる違和感」を感じてもらえればと思います...笑

毎回人間の目を盗んで犯行を行うなんて賢いけど、しっかり撮ってるからね...

f:id:raihara3a:20200113194311p:plain

これから取り締まっていこうと思います。