何も触れずにお弁当を頼むアプリを作った

こんなものを作りました。
カメラに映した手で、画面上のものをツマむことができます。
KTCFを使うと簡単にできました。
![2022-09-20_15h47_15_Trim.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488658/a2c51eb8-1bf1-c7ea-b112-313579a993b7.gif)

## KTCFとは

deepinsightという会社が提供している、非接触GUI開発キット「KAIBER Touchless Controll Framework」です。
カメラに映した指の動きでHTMLを操作できる、タッチレスなアプリを簡単に作れますよって触れ込みです。

<https://youtu.be/bFUt_vbzlWk>


## Hello World

ついてくるマニュアルに従うと、とりあえず簡単なデモが立ち上がります。

謎のキャラクター「かいばくん」を指でつまんで動かすことができます。かわいいですね。

![2022-09-13_09h11_28.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488658/3fe90c47-14b2-7575-315c-1087358bdae3.gif)

htmlはこんな感じです。jQueryで書いていますね。
cssは割愛していますが、classのつけ外しでかいばくんの表情を変えているようです。
js部分と実際の動きを見るとおり、`click`とか`grab`とかのイベントで駆動しているのですね。
`drop`, `lost`, `grabMove`については、要素に発火される訳ではないようです。ドラッグ&ドロップの実装が強引に感じますが、仕組みは簡単なようです。


```html
<!DOCTYPE html>

<head>
  <meta charset="UTF-8">
  <link rel="icon" href="images/favicon.ico">
  <title>kaibakun</title>
</head>

<body>
  <div id="kaibakun_div">
    <img class="kaibakun" src="./images/kaibakun_size_trans.png"></img>
  </div>

  <div id="log_div" class="grabscroll">
  </div>

  <!-- ================ script ================ -->
  <script type="text/javascript" src="/eel.js"></script>
  <script type="text/javascript" src="/lib/jquery_3.6.0.js"></script>
  <script type="text/javascript" src="/lib/ktcf.js"></script>

  <script>
    let kaibakun = $(".kaibakun");
    let frame = $("#kaibakun_div");

    jQuery.prototype.setTmpClass = function (_class, _ms) {
      this.addClass(_class);
      return setTimeout(
        (() => {
          this.removeClass(_class);
        }).bind(this),
        _ms
      );
    }

    function appendLog(_text) {
      var logdiv = $("#log_div");
      logdiv.append($("<p>" + _text + "</p>"));
      logdiv[0].scrollTo(0, logdiv[0].scrollHeight);
    }

    $(function () {
      appendLog("event handlers - pinch, click, hovered, grab, grabMove, drop and lost");
    });

    // ======= kaibakun ========
    kaibakun.on("click", function () {
      appendLog("click (kaibakun)");
      kaibakun.setTmpClass("click", 500);
    });

    kaibakun.on("pinch", function () {
      appendLog("pinch (kaibakun)");
      kaibakun.setTmpClass("pinch", 500);
    });

    kaibakun.on("hovered", function () {
      var split = $("#log_div p:last").html().split(" ");
      if (split[0] === "hovered") {
        $("#log_div p:last").html(
          "hovered ... " + (Number(split[2]) + 1)
        );
      } else {
        appendLog("hovered ... 1");
      }
    });

    kaibakun.on("grab", function (event) {
      appendLog("grab pid[" + event.detail.pid + "]");
      kaibakun.setTmpClass("grab", 500);
      $(this).addClass("grabNow");
      var img_offset_x = parseInt(event.detail.x) - parseInt($(this).offset().left);
      var img_offset_y = parseInt(event.detail.y) - parseInt($(this).offset().top);
      var max_top = frame.height() - $(this).height();
      var max_left = frame.width() - $(this).width();
      $(this).data({
        "pid": event.detail.pid,
        "frame_offset_x": parseInt(frame.offset().left),
        "frame_offset_y": parseInt(frame.offset().top),
        "img_offset_x": img_offset_x,
        "img_offset_y": img_offset_y,
        "max_top": max_top,
        "max_left": max_left
      });
    });

    $("body").on("drop", function (event) {
      if (kaibakun.hasClass("grabNow")) {
        if (event.detail.pid == kaibakun.data("pid")) {
          appendLog("drop pid[" + event.detail.pid + "]");
          kaibakun.setTmpClass("drop", 500);
          kaibakun.removeClass('grabNow');
          kaibakun.removeClass("grabMove");
        }
      }
    });

    $(document).on("lost", function (event) {
      if (kaibakun.hasClass("grabNow")) {
        if (event.detail.pid == kaibakun.data("pid")) {
          appendLog("lost pid[" + event.detail.pid + "]");
          kaibakun.setTmpClass("lost", 500);
          kaibakun.removeClass('grabNow');
          kaibakun.removeClass("grabMove");
        }
      }
    });

    $(document).on("grabMove", function (event) {
      if (kaibakun.hasClass("grabNow")) {
        if (event.detail.pid == kaibakun.data("pid")) {
          var split = $("#log_div p:last").html().split(" ");
          if (split[0] === "grabMove") {
            $("#log_div p:last").html(
              "grabMove pid[" + event.detail.pid + "] ... " + (Number(split[3]) + 1)
            );
          } else {
            appendLog("grabMove pid[" + event.detail.pid + "] ... 1");
          }
          kaibakun.addClass("grabMove");
          var top = parseInt(event.detail.y) - kaibakun.data("frame_offset_y") - kaibakun.data("img_offset_y");
          var left = parseInt(event.detail.x) - kaibakun.data("frame_offset_x") - kaibakun.data("img_offset_x");
          if (kaibakun.data("max_top") < top) {
            top = kaibakun.data("max_top");
          }
          if (top < 0) {
            top = 0;
          }
          if (kaibakun.data("max_left") < left) {
            left = kaibakun.data("max_left");
          }
          if (left < 0) {
            left = 0;
          }
          kaibakun.css("top", top);
          kaibakun.css("left", left);
        }
      }
    });

  </script>

</body>

</html>
```

## 何を作るか

このキットは「非接触GUI」なので、みんなが触るものを作りたいです。
また、小さいですがカメラを置くことになるので、どこかに据え置いておく必要もあります。

そこで考えました。「お弁当リクエストアプリ」

1. アプリは朝立ち上がる
1. お弁当が欲しい人は、自分の名前の駒を、欲しいお弁当の枠に入れておく
1. 時間になると、アプリはそれぞれのお弁当が何個必要かslackに書き込んで、終了する


本当はネット注文かお弁当屋さんへのメールかまでを自動化したいけれど、ひとまずslackに結果を書くことにします。

完成イメージ
![2022-09-13_10h03_36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488658/683fe8f9-fe09-cc28-ed7c-03f2c10d13fb.png)


## お弁当リクエストアプリを作る

### 外観を作る

簡単に作ります。名前の駒は画像で作りました。
時計と、11時まで待てないときの強制終了ボタンも用意しました。

```html
  <div id="pieces">
    <img class="piece" src="images/hujiwara.png"></img>
    <img class="piece" src="images/kobayashi.png"></img>
    <img class="piece" src="images/mori.png"></img>
    <img class="piece" src="images/yanagisawa.png"></img>
  </div>

  <div id="divs">
    <div class="menu">ハンバーグ</div>
    <div class="menu">サバ味噌</div>
    <div class="menu">サバ塩</div>
    <div class="menu">シーフードフライ</div>
    <div class="menu">生姜焼き</div>
    <div class="menu">幕ノ内</div>
    <div class="menu">洋風</div>
    <div class="menu">メンチ</div>
  </div>

  <span id="clock"></span>
  <button id="forceEnd">終了</button>
```

cssをいい感じにつけて、こんな感じになりました。
名前の駒`.piece`は、後で動かすので`position: absolute`にする必要があります。

![2022-09-13_11h34_16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488658/c9a50029-721f-9b8f-3904-45f0960ca04a.png)

### 指定されたjsライブラリを読み込む

eel.js, jquery_3.6.0.js, ktcf.js

指定された位置に配置して、この３つのライブラリを読み込むことで、普通のhtmlをアプリのように動かせるようです。
加えて、自作の`index.js`を作っていきます。

```html
  <script type="text/javascript" src="/eel.js"></script>
  <script type="text/javascript" src="/lib/jquery_3.6.0.js"></script>
  <script type="text/javascript" src="/lib/ktcf.js"></script>
  <script type="text/javascript" src="index.js"></script>

```

### ハンドラを作る

このアプリでは主に、駒を動かすための、つまむ`grab`->つまんだまま動かす`grabMove`->落っことす`drop/lost`、３種類のイベントを使います。
HelloWorldのかいばくんを動かす方法をほとんどそのまま使いました。

実装するべきハンドラのすべてを列挙すると

* 駒のドラッグ＆ドロップ
    * つまむ`grab`
    * つまんだまま動かす`grabMove`
    * 落っことす`drop/lost`
* 終了ボタン
    * 終了クリック`click` (KTCFのイベントは、マウスイベントの`click`と同じ名前)
* 時計
    * 時計を動かすインターバル
    * 午前11時の終了

#### 駒のドラッグ＆ドロップ

HelloWorldのかいばくんを動かす方法をほぼそのまま使いました。
ただし駒は複数あり、複数の手でそれらを操作する可能性を考えて`each`を使って修正します。

* 指先毎に固有のid`pid`が振られる
    * ずっとカメラに写っているなら同じ`pid`
    * 別の手には別の`pid`
    * 一度ひっこめてまた出てきた手は別の`pid`

##### 掴んだ時

* 要素にクラス`grabNow`を与え、IDや必要な値をカスタムデータ属性に保持させる
* 以下を考慮して、上限値とオフセットを保持
    * `frame`は、駒が動ける範囲
    * css width/height は左上の相対座標
    * event.detail.x/y はポインタの中央の絶対座標
    * 最初に掴んだ位置からの移動分だけ要素を動かしたい

```javascript
  let frame = $("body");
  $(".piece").on("grab", function (event) {
    if (0 != event.detail.index || $(this).hasClass("grabNow")) {
      return;
    }
    $(this).addClass("grabNow");
    var img_offset_x =
      parseInt(event.detail.x) - parseInt($(this).offset().left);
    var img_offset_y =
      parseInt(event.detail.y) - parseInt($(this).offset().top);
    var max_top = frame.height() - $(this).height();
    var max_left = frame.width() - $(this).width();
    $(this).data({
      pid: event.detail.pid,
      frame_offset_x: parseInt(frame.offset().left),
      frame_offset_y: parseInt(frame.offset().top),
      img_offset_x: img_offset_x,
      img_offset_y: img_offset_y,
      max_top: max_top,
      max_left: max_left,
    });
  });

```

##### 掴んだまま動いたとき

* クラス`grabNow`を持っている駒は一つではない可能性があるのでeachする
* pidが一致した駒を指先の動きに従って動かす
    * ただし上限/下限がある

```javascript
  $(document).on("grabMove", function (event) {
    $(".piece.grabNow").each(function (idx, ele) {
      if (event.detail.pid == $(ele).data("pid")) {
        var top =
          parseInt(event.detail.y) -
          $(ele).data("frame_offset_y") -
          $(ele).data("img_offset_y");
        var left =
          parseInt(event.detail.x) -
          $(ele).data("frame_offset_x") -
          $(ele).data("img_offset_x");
        if ($(ele).data("max_top") < top) {
          top = $(ele).data("max_top");
        }
        if (top < 0) {
          top = 0;
        }
        if ($(ele).data("max_left") < left) {
          left = $(ele).data("max_left");
        }
        if (left < 0) {
          left = 0;
        }
        $(ele).css("top", top);
        $(ele).css("left", left);
      }
    });
  });
```

##### 離したとき

* 普通に離したとき: drop
* 指先を見失ったとき: lost
* どちらにしても、クラス`grabNow`を破棄するだけ

```javascript
  function removeGrabNow(pid) {
    $(".piece.grabNow").each(function (idx, ele) {
      if (pid == $(ele).data("pid")) {
        $(ele).removeClass("grabNow");
      }
    });
  }
  
  $("body").on("drop", function (event) {
    removeGrabNow(event.detail.pid);
  });

  $(document).on("lost", function (event) {
    removeGrabNow(event.detail.pid);
  });
```

#### 時計

時計を更新しつつ、時間が来たら、画面の現状をslackに書き込んで終了する。
* sendSlack(): 引数(object)をslackに書き込む
* getOrderList(): メニューに載っている駒の数をobjectにまとめる
* このキット的には「ブラウザを閉じる＝＝アプリの終了」

```javascript
function getNow() {
  var now = new Date();
  var hour = ("00" + now.getHours()).slice(-2);
  var min = ("00" + now.getMinutes()).slice(-2);
  var sec = ("00" + now.getSeconds()).slice(-2);
  return hour + ":" + min + "." + sec;
}

setInterval(() => {
  let now = getNow();
  $("#clock").html(now);
  if ("11:00.00" == now) {
    sendToSlack(orderList());
    window.close();
  }
}, 500);
```

#### 終了ボタン

* クリックイベントはマウスのものと同じ名前
* 11:00になったときと同じ動きをする

```javascript
  $("#forceEnd").click(() => {
    sendToSlack(orderList());
    window.close();
  });
```

### slackへの書き込み

書き込むだけなら、APIやトークンを使わず、[Incoming Webhook](https://api.slack.com/messaging/webhooks)だけでできるのです。

curlコマンドなら

```bash
curl -X POST -H 'Content-type: application/json' --data '{"text":"Hello, World!"}' <URL of Webhook>
```

jsに埋め込むなら

```javascript
const url = <URL of Webhook>;

function sendToSlack(data) {
  const xhr = new XMLHttpRequest();
  xhr.open("POST", slackHookURL, false);
  xhr.send(JSON.stringify(data));
}
```

※ setRequestHeader("Content-Type", "application/json") について
content-typeをjsonに指定すると、pre-flightでCORS policyにブロックされます。
jsからwebhookにPOSTする場合は、pre-flightを実行しない他のTypeを指定する必要があります。
今回の場合では、敢えて明示せずにPOSTするだけで上手くいきました。
<https://stackoverflow.com/questions/45752537/slack-incoming-webhook-request-header-field-content-type-is-not-allowed-by-acce>


強制終了ボタンを押したとき/午前11時に、これを実行するように仕込みます。

#### POSTするdataの作り方

<https://api.slack.com/messaging/composing>
を参考に。
箇条書きにしたかったので、こんな感じに作ります。

```json
{
  "type":"section",
  "blocks":[
    {
      "type":"rich_text",
      "elements":[
        {
          "type":"rich_text_list",
          "style":"bullet",
          "elements":[
            {
              "type":"rich_text_section",
              "elements":[
                {
                  "type":"text",
                  "text":"箇条書き１"
                }
              ]
            },
            {
              "type":"rich_text_section",
              "elements":[
                {
                  "type":"text",
                  "text":"箇条書き２"
                }
              ]
            },
            ～～～～～～～
          ]
        }
      ]
    }
  ]
}
```

### 朝の起動

タスクスケジューラで朝10時くらいに起動させます。


## コード

https://github.com/koyayashi1231/LunchRequestOnKTCF

キットの指定ディレクトリに置いて、起動パスの設定をすることで置き換えられます。