Fork: 0
HiroseLabo. / 2025-Tsuji
Transfer to URL with SHA Find file
Newer

Older
2025-Tsuji / system / index.html
tsuji keita 2 minutes ago 12 KB Add files via upload
Raw Blame History

    <!DOCTYPE html>
    <html lang="ja">
    <head>
      <meta charset="UTF-8" />
      <title>非接触UI（MediaPipe Hands）</title>
      <style>
        body {
          margin: 0;
          font-family: "Arial", sans-serif;
          background: linear-gradient(135deg, #ffecd2, #fcb69f);
          min-height: 100vh;
          display: flex;
          justify-content: center;
          align-items: center;
        }

        .video-wrap {
          position: relative;
          width: 800px;
          height: 600px;
          border-radius: 16px;
          overflow: hidden;
          box-shadow: 0 8px 24px rgba(0,0,0,0.3);
          background: #000;
        }

        video {
          width: 100%;
          height: 100%;
          display: block;
          transform: scaleX(-1);
          transform-origin: center;
        }

        canvas {
          width: 100%;
          height: 100%;
          display: block;
          transform: none;
          position: absolute;
          left: 0; top: 0;
          pointer-events: none;
        }

        .panel {
          position: absolute;
          top: 10px; left: 0;
          width: 100%;
          display: flex;
          justify-content: space-around;
          z-index: 10;
        }

        .btn {
          flex: 1;
          margin: 0 10px;
          padding: 16px;
          font-size: 1.4rem;
          font-weight: bold;
          border: none;
          border-radius: 12px;
          cursor: pointer;
          transition: transform 0.2s, background 0.3s;
          color: #fff;
          user-select: none;
        }
        #btn-greet { background: #ff6f61; }
        #btn-med   { background: #6fcf97; }
        #btn-help  { background: #56ccf2; }

    #btn-mowing { background: #9b59b6; }
    #btn-shopping { background: #f39c12; }
    #btn-snow { background: #3498db; }

        .btn.active { transform: scale(1.05); filter: brightness(1.15); }

        .status {
          position: absolute;
          bottom: 10px; left: 50%;
          transform: translateX(-50%);
          font-size: 1.2rem;
          font-weight: bold;
          color: #fff;
          background: rgba(0,0,0,0.6);
          padding: 8px 16px;
          border-radius: 8px;
          z-index: 10;
        }

        #login-panel {
          position: absolute;
          top: 20px; left: 20px;
          background: rgba(255,255,255,0.9);
          padding: 10px;
          border-radius: 8px;
          z-index: 20;
        }

    #input-panel {
      position: absolute;
      top: 120px;
      left: 20px;
      width: 250px;
      background: #fff;
      padding: 16px;
      border-radius: 12px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.2);
      z-index: 20;
      font-size: 0.9rem;
    }

    #input-panel h3 {
      margin-top: 0;
      font-size: 1rem;
      color: #333;
      text-align: center;
    }

    #input-panel label {
      display: block;
      margin-top: 10px;
      font-weight: bold;
      color: #555;
    }

    #input-panel input,
    #input-panel select {
      width: 100%;
      padding: 6px 8px;
      margin-top: 4px;
      border: 1px solid #ccc;
      border-radius: 6px;
      font-size: 0.9rem;
      box-sizing: border-box;
    }

    #input-panel input:focus,
    #input-panel select:focus {
      border-color: #56ccf2;
      outline: none;
      box-shadow: 0 0 4px rgba(86,204,242,0.6);
    }

    #map {
      height: 300px;
      width: 100%;
      margin: 10px 0;
      border-radius: 8px;
    }

      </style>

      <!-- MediaPipe Hands CDN -->
      <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js"></script>
      <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js"></script>
      <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js"></script>

    <link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
    <script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>

    </head>
    <body>
      <!-- ログインフォーム -->
      <div id="login-panel">
        <input type="text" id="username" placeholder="ユーザー名">
        <input type="password" id="password" placeholder="パスワード">
        <button id="btn-register">新規登録</button>
        <button id="btn-login">ログイン</button>
        <button id="btn-logout">ログアウト</button>
      </div>

    <!-- 依頼内容・座標入力フォーム -->
    <div id="input-panel">
      <h3>依頼情報入力</h3>
      <label for="desc">依頼内容</label>
      <input type="text" id="desc" placeholder="依頼の説明を入力">

      <label for="lat">緯度</label>
      <input type="number" step="0.0001" id="lat" placeholder="例: 38.9">

      <label for="lng">経度</label>
      <input type="number" step="0.0001" id="lng" placeholder="例: 139.8">

    <div id="map" style="height:300px; margin-top:10px; border-radius:8px;"></div>

      <label for="info">開示意欲度</label>
      <select id="info">
        <option value="0.1">0.1（ほぼ非公開）</option>
        <option value="0.3">0.3</option>
        <option value="0.5" selected>0.5（標準）</option>
        <option value="0.7">0.7</option>
        <option value="1.0">1.0（完全公開）</option>
      </select>
    </div>

      <div class="video-wrap">
        <video id="video" playsinline></video>
        <canvas id="canvas"></canvas>

        <div class="panel">
          <button id="btn-greet" class="btn">🌅 朝の挨拶</button>
          <button id="btn-med" class="btn">💊 服薬確認</button>
          <button id="btn-help" class="btn">📩 依頼を送信</button>
        </div>

       <!-- 選択肢パネル（最初は非表示） -->
    <div id="task-panel" style="display:none; position:absolute; bottom:80px; left:50%; transform:translateX(-50%); background:#fff; padding:12px; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,0.2); z-index:30;  gap:10px;">
      <button id="btn-mowing" class="btn">🌿 草刈り</button>
      <button id="btn-shopping" class="btn">🛒 買い物</button>
      <button id="btn-snow" class="btn">❄️ 除雪</button>
    </div>

        <div class="status" id="status"></div>
      </div>

    <script>
      const API_BASE = "http://172.20.16.218:4567";
      const statusEl = document.getElementById("status");
      const videoEl = document.getElementById("video");
      const canvasEl = document.getElementById("canvas");
      const ctx = canvasEl.getContext("2d");

      let loggedIn = false;

      // Leaflet地図の初期化
      const map = L.map('map').setView([38.9, 139.8], 13);
      L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '© OpenStreetMap contributors'
      }).addTo(map);

      let marker;
      map.on('click', function(e) {
        if (marker) marker.remove();
        marker = L.marker(e.latlng).addTo(map);
        document.getElementById("lat").value = e.latlng.lat.toFixed(6);
        document.getElementById("lng").value = e.latlng.lng.toFixed(6);
      });

      // 新規登録
      document.getElementById("btn-register").addEventListener("click", async () => {
        const username = document.getElementById("username").value;
        const password = document.getElementById("password").value;
        const res = await fetch(`${API_BASE}/register`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          credentials: "include",
          body: JSON.stringify({ username, password })
        });
        const data = await res.json();
        statusEl.textContent = res.ok ? `登録成功: ${data.user}` : "登録失敗: " + data.error;
      });

      // ログイン
      document.getElementById("btn-login").addEventListener("click", async () => {
        const username = document.getElementById("username").value;
        const password = document.getElementById("password").value;
        const res = await fetch(`${API_BASE}/login`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          credentials: "include",
          body: JSON.stringify({ username, password })
        });
        const data = await res.json();
        if (res.ok) {
          statusEl.textContent = `ログイン成功: ${data.user}`;
          loggedIn = true;
        } else {
          statusEl.textContent = "ログイン失敗: " + data.error;
          loggedIn = false;
        }
      });

      // ログアウト
      document.getElementById("btn-logout").addEventListener("click", async () => {
        await fetch(`${API_BASE}/logout`, {
          method: "POST",
          credentials: "include"
        });
        statusEl.textContent = "ログアウトしました";
        loggedIn = false;
      });

      // --- MediaPipe Hands 処理 ---
      function resizeCanvasToDisplaySize() {
        const rect = canvasEl.getBoundingClientRect();
        canvasEl.width = Math.round(rect.width);
        canvasEl.height = Math.round(rect.height);
      }
      resizeCanvasToDisplaySize();
      window.addEventListener('resize', resizeCanvasToDisplaySize);

      const buttons = [
        { id: "btn-greet",    type: "greet",    el: document.getElementById("btn-greet") },
        { id: "btn-med",      type: "med",      el: document.getElementById("btn-med") },
        { id: "btn-help",     type: "help",     el: document.getElementById("btn-help") },
        { id: "btn-mowing",   type: "mowing",   el: document.getElementById("btn-mowing") },
        { id: "btn-shopping", type: "shopping", el: document.getElementById("btn-shopping") },
        { id: "btn-snow",     type: "snow",     el: document.getElementById("btn-snow") }
      ];

      let currentBtn = null;
      let enterTime = 0;
      const dwellMs = 2000;

      const hands = new Hands({ locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}` });
      hands.setOptions({
        maxNumHands: 1,
        modelComplexity: 1,
        minDetectionConfidence: 0.6,
        minTrackingConfidence: 0.6
      });
      hands.onResults(onResults);

      const camera = new Camera(videoEl, {
        onFrame: async () => { await hands.send({ image: videoEl }); },
        width: 800,
        height: 600
      });
      camera.start();

      async function onResults(results) {
        resizeCanvasToDisplaySize();
        ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);

        if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
          const lm = results.multiHandLandmarks[0];
          const tip = lm[8];
          const canvasX = (1 - tip.x) * canvasEl.width;
          const canvasY = tip.y * canvasEl.height;

          ctx.fillStyle = "red";
          ctx.beginPath();
          ctx.arc(canvasX, canvasY, 10, 0, Math.PI * 2);
          ctx.fill();

          const hitBtn = buttons.find(b => pointInElementCanvasCoords({x: canvasX, y: canvasY}, b.el));
          if (hitBtn) {
            hitBtn.el.classList.add("active");
            if (currentBtn !== hitBtn.id) {
              currentBtn = hitBtn.id;
              enterTime = performance.now();
            } else {
              if (performance.now() - enterTime > dwellMs) {
                if (hitBtn.type === "help") {
                  document.getElementById("task-panel").style.display = "flex";
                } else {
                  sendRequest(hitBtn.type);
                  document.getElementById("task-panel").style.display = "none";
                }
                currentBtn = null;
                enterTime = 0;
                buttons.forEach(b => b.el.classList.remove("active"));
              }
            }
          } else {
            currentBtn = null;
            buttons.forEach(b => b.el.classList.remove("active"));
          }
        } else {
          currentBtn = null;
          buttons.forEach(b => b.el.classList.remove("active"));
        }
      }

      function pointInElementCanvasCoords(ptCanvas, el) {
        const v = videoEl.getBoundingClientRect();
        const relX = ptCanvas.x / canvasEl.width;
        const relY = ptCanvas.y / canvasEl.height;
        const domX = v.left + relX * v.width;
        const domY = v.top  + relY * v.height;
        const r = el.getBoundingClientRect();
        return domX >= r.left && domX <= r.right && domY >= r.top && domY <= r.bottom;
      }

      async function sendRequest(type) {
        if (!loggedIn) {
          statusEl.textContent = "ログインしてください";
          return;
        }

        const now = new Date();
        const timeStr =
          `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,"0")}-${String(now.getDate()).padStart(2,"0")} ` +
          `${String(now.getHours()).padStart(2,"0")}:${String(now.getMinutes()).padStart(2,"0")}`;

        const description = document.getElementById("desc").value || "依頼内容";
        const lat = parseFloat(document.getElementById("lat").value) || 38.9;
        const lng = parseFloat(document.getElementById("lng").value) || 139.8;
        const info_level = parseFloat(document.getElementById("info").value) || 0.5;

        const payload = {
          category: type,
          description,
          latitude: lat,
          longitude: lng,
          start_time: timeStr,
          end_time: null,
          info_level
        };

        try {
          const res = await fetch(`${API_BASE}/api/requests`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            credentials: "include",
            body: JSON.stringify(payload)
          });
          const data = await res.json();
          if (res.ok) {
            statusEl.textContent = `送信成功: ${data.category} / ${data.start_time} → ID:${data.id}`;
          } else {
            statusEl.textContent = "送信失敗: " + (data.error || "不明なエラー");
          }
        } catch (e) {
          statusEl.textContent = "送信エラー: " + e.message;
        }
      }
    </script>

    </body>
    </html>