0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-10-04

こんなものを作りました。
カメラに映した手で、画面上のものをツマむことができます。
KTCFを使うと簡単にできました。
2022-09-20_15h47_15_Trim.gif

KTCFとは

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

https://youtu.be/bFUt_vbzlWk

Hello World

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

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

2022-09-13_09h11_28.gif

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

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

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

完成イメージ
2022-09-13_10h03_36.png

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

外観を作る

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

  <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

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

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

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

  <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、3種類のイベントを使います。
HelloWorldのかいばくんを動かす方法をほとんどそのまま使いました。

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

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

駒のドラッグ&ドロップ

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

  • 指先毎に固有のidpidが振られる
    • ずっとカメラに写っているなら同じpid
    • 別の手には別のpid
    • 一度ひっこめてまた出てきた手は別のpid
掴んだ時
  • 要素にクラスgrabNowを与え、IDや必要な値をカスタムデータ属性に保持させる
  • 以下を考慮して、上限値とオフセットを保持
    • frameは、駒が動ける範囲
    • css width/height は左上の相対座標
    • event.detail.x/y はポインタの中央の絶対座標
    • 最初に掴んだ位置からの移動分だけ要素を動かしたい
  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が一致した駒を指先の動きに従って動かす
    • ただし上限/下限がある
  $(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を破棄するだけ
  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にまとめる
  • このキット的には「ブラウザを閉じる==アプリの終了」
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になったときと同じ動きをする
  $("#forceEnd").click(() => {
    sendToSlack(orderList());
    window.close();
  });

slackへの書き込み

書き込むだけなら、APIやトークンを使わず、Incoming Webhookだけでできるのです。

curlコマンドなら

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

jsに埋め込むなら

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
を参考に。
箇条書きにしたかったので、こんな感じに作ります。

{
  "type":"section",
  "blocks":[
    {
      "type":"rich_text",
      "elements":[
        {
          "type":"rich_text_list",
          "style":"bullet",
          "elements":[
            {
              "type":"rich_text_section",
              "elements":[
                {
                  "type":"text",
                  "text":"箇条書き1"
                }
              ]
            },
            {
              "type":"rich_text_section",
              "elements":[
                {
                  "type":"text",
                  "text":"箇条書き2"
                }
              ]
            },
            ~~~~~~~
          ]
        }
      ]
    }
  ]
}

朝の起動

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

コード

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?