JINSテックブログ
🎀

指先で触る - MediaPipeとThree.jsで作る新感覚な体験を試してみた -

に公開

はじめに

J!NSのKa2です。
突然ですが、僕らは、スマホやパソコンを通して、めちゃくちゃ多くの情報に触れています。ただ、僕らと情報との間にはブラウザやアプリなど、何かしらのインターフェースが存在し、多くの場合、マウスやポインターを介して、情報に接しています。そんな時、「もっと直感的に情報に触れられたらいいのになぁ」と考えていました。
指先でのスワイプやタップも便利ですが、もっと自分の身体の延長線上にあるような感覚で、画面の中のものを掴んだり、動かしたり、大きさを変えたりできれば、情報との関わり方が一層面白く、豊かになり、情報への親近感も深まるのではないかと考えています。これは、まるで画面の向こう側を直接『触る』ような、新しい感覚です。
そんなモチベーションのもと、指先のジェスチャーで、画面のオブジェクトを触るデモを作りました。特別な機材は不要で、普段使っているパソコンだけで手軽に実現できる点も、面白いポイントです。この記事では、この新しい「触感」について、ご紹介します。

今回のデモ

https://youtu.be/1VohMfU-KZ4

今回試したこと

  • 2次元のロゴやグラフを出現させ、拡大/縮小する:
    指先のピンチイン・アウトで、まるで手の中にあるかのように2次元のロゴやグラフを自在に出現させ、大きさを変えるような、情報のスケールを直感的に操る体験

    • ピンチアウト/ピンチインの判定
  • 手の静止で、複数のボールを出現させ、手のひらとボールが接触すると、色が変わり、消滅する:
    手をかざすと、画面内にカラフルなボールが湧き出て、手のひらで触れると、ボールが反応して色を変え、消えていくような、デジタルなオブジェクトとのインタラクティブな体験

    • 手の静止条件
    • ボールの跳ね返り、接触することで情報が変化する
  • 3次元のロゴを出現させ、移動させる:
    画面奥から現れる3Dのロゴを、実際に掴んで好きな場所へ動かすような、奥行きのある空間でのダイレクトな操作感

    • 手で物体を掴んで移動させる

利用した技術

MediaPipe Hands

・ブラウザで手軽に利用できる、手のランドマーク検出ライブラリ
・カメラ映像からリアルタイムに両手の21個のキーポイントを検出し、ジェスチャーを認識する

Three.js

・Web上でリッチな3次元表現を比較的容易に実装できる、グラフィックライブラリ
・3次元のエフェクト, 3次元のテキストロゴの生成/表示/アニメーション

HTML/CSS/JavaScript

・(HTML)ウェブページの構造定義
・(CSS)見た目のスタイリング
・(JavaScript)制御ロジック

まとめ

今回の試作を通じて、画面上にあるオブジェクトに触れることの楽しさや、新しい表現方法の可能性を感じることができました。一般的なパソコン、内蔵カメラ、そしてブラウザ上で動作するMediaPipeやThree.jsといった技術を組み合わせるだけで、これだけ直感的なインタラクションが実現できるのは面白いです。
また、指先ひとつで2次元の情報を操作したり、手のひらで3次元のオブジェクトと戯れたり、物体を掴んで動かすといった体験は、情報と僕らの間にあった「壁」のようなものを取り払い、より直接的で身体的なつながりを作り出してくれると思います。
これは、僕たちが普段何気なく行っている、スマホのタップやスワイプとはまた異なる、新しい「触覚」のような気がしました。この「画面の向こうを直接触る」という体験は、新しいユーザ体験として、今後面白くなっていきそうだと実感しました。

(おまけ) 作成したコード

(おまけ)作成したコード(試行錯誤の足跡) 今回はアイデアを素早く形にすることを優先し、途中で仕様変更も重ねながら開発を進めました。そのため、コードには試行錯誤の過程が見られる部分や、まだ整理しきれていない箇所も含まれています。学習や実験の一環として、温かい目で見ていただけると嬉しいです。Geminiによるコメントも付記していますので、処理の流れを追う際の参考にしてください。 (※コード末尾の謎の空欄、原因究明中です…!ご愛嬌ということでお許しください!)

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>サンプル</title>
  <link rel="stylesheet" href="style.css">
  <script type="importmap">
    {
      "imports": {
        "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
        "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
      }
    }
  </script>
  <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
</head>
<body>
  <div id="mainContainer">
    <h1>てすと</h1>
    <div id="videoContainer">
      <video id="videoElement" autoplay playsinline></video>
      <canvas id="canvasElement"></canvas>
      <canvas id="threeCanvas"></canvas>
      <div id="logoContainer"><img id="jinsLogo" alt="JINS Logo (2D)"></div>
      <div id="leftHandGraphContainer"><img id="leftHandGraphImage" alt="グラフ画像"></div>
    </div>
  </div>
  <script type="module" src="script.js"></script>
</body>
</html>

style.css

body {
  font-family: sans-serif;
  margin: 0;
  background-color: #f0f0f0;
  overflow: hidden; /* スクロールバーを非表示に */
  display: flex;
  flex-direction: column;
  align-items: center;
}

/* ページ全体のラッパー */
#mainContainer {
  width: 100vw;
  height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  position: relative;
}

/* カメラ映像、キャンバス、インタラクティブ要素を配置するコンテナ */
#videoContainer {
  width: 960px; /* 固定幅 */
  height: 680px; /* 固定高さ */
  position: relative; /* 子要素の絶対配置の基準 */
  border: 1px solid #ccc;
  background-color: #000;
}

/* ウェブカメラの映像を表示する要素 */
#videoElement {
  width: 100%;
  height: 100%;
  transform: scaleX(-1); /* 鏡像にする */
}

/* MediaPipeの手のランドマークを描画するキャンバス */
#canvasElement {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  transform: scaleX(-1); /* 鏡像にする (videoElementと合わせる) */
  z-index: 10; /* 重なり順: 手前に */
}

/* Three.jsの3Dシーンを描画するキャンバス */
#threeCanvas {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none; /* マウスイベントを透過させる */
  z-index: 5; /* 重なり順: canvasElementより奥 */
}

/* インタラクティブな画像要素(ロゴ、グラフ)の共通スタイル */
#logoContainer,
#leftHandGraphContainer {
  display: none; /* 初期状態では非表示 */
  position: absolute; /* videoContainerを基準に絶対配置 */
  z-index: 19; /* 重なり順: 最前面に (canvasElementより手前) */
  background-color: rgba(255, 255, 255, 0.9);
  padding: 15px;
  border-radius: 8px;
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
  pointer-events: none; /* マウスイベントを透過 */
  transform-origin: center center; /* 拡大縮小の中心 */
  transition: transform 0.2s ease-out; /* スムーズな変形アニメーション */
}

/* コンテナ内の画像のスタイル */
#jinsLogo,
#leftHandGraphImage {
  display: block;
  width: 100%;
  height: auto;
  object-fit: contain; /* アスペクト比を保ってフィット */
}

/* (未使用) 参考: 情報テキスト用スタイル */
.infoText {
  margin-top: 15px;
  font-size: 16px;
  color: #333;
  text-align: center;
}

script.js

// Three.js関連モジュールのインポート
import * as THREE from 'three';
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';

// HTML要素の取得
const videoElement = document.getElementById('videoElement');
const canvasElement = document.getElementById('canvasElement');
const canvasCtx = canvasElement.getContext('2d');
const logoContainer = document.getElementById('logoContainer');
const jinsLogo = document.getElementById('jinsLogo');
const leftHandGraphContainer = document.getElementById('leftHandGraphContainer');
const leftHandGraphImage = document.getElementById('leftHandGraphImage');

// Three.jsシーン関連変数
let scene, camera, renderer, threeCanvas;
const balls = []; // 3Dボールを格納する配列
let threeAnimationFrameId = null; // Three.jsのアニメーションフレームID
let worldBoundaries = { left: 0, right: 0, top: 0, bottom: 0, near: 0, far: 0 }; // 3D空間の境界

// --- 定数定義 ---
const INTERACTION_DISTANCE = 160; // 手と3DオブジェクトのインタラクションZ距離
const FINGERTIP_INTERACTION_RADIUS = 20; // 指先とオブジェクトの衝突判定半径
const LOGO_TOUCH_RADIUS_FACTOR = 0.8; // 3Dロゴの指先タッチ判定半径の係数
const BALL_MAX_SPEED = 3.5; // ボールの最大速度
const BALL_MIN_SPEED = 1.5; // ボールの最小速度
const HIT_ANIMATION_DURATION = 0.75; // ボールヒット時のアニメーション時間
const BALL_COLORS = { // ボールの色定義
  INITIAL: new THREE.Color().setHSL(0.6 + Math.random() * 0.1, 0.95, 0.6),
  FIRST_HIT: new THREE.Color(0x00ff00), // 1回目のヒット
  SECOND_HIT: new THREE.Color(0xff0000)  // 2回目のヒット
};
let handProcessingActive = false; // MediaPipeの手の処理がアクティブか

// --- アプリケーション設定 (CONFIG) ---
const CONFIG = {
  video: { width: 960, height: 680 }, // ビデオ解像度
  urls: { // 画像やフォントのURL
    jinsLogo: 'jins_logo.svg',
    graphImages: ['sample_1.png', 'sample_2.png'],
    threeFont: 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/fonts/helvetiker_bold.typeface.json'
  },
  gestures: { // ジェスチャー判定の閾値
    pinchStartDistanceThreshold: 30, // ピンチ開始の距離閾値
    pinchFullyClosedThreshold: 20,  // ピンチが完全に閉じたと判定する閾値
    pinchToGrabThreshold: 40,       // 3Dロゴを掴むピンチ距離閾値
    pinchToReleaseThreshold: 50,    // 3Dロゴを離すピンチ距離閾値
    pinchOutExpandRatio: 1.5,       // ピンチアウトで要素を表示する際の拡大率
    palmOpenDurationMs: 1000,       // 手のひらを開いて認識するまでの時間 (ミリ秒)
    palmStillThreshold: 0.03,       // 手のひらが静止していると判定する移動閾値
    kamehamehaCooldownMs: 7000      // ボール玉のクールダウン時間 (ミリ秒)
  },
  interactiveElement: { // 2Dインタラクティブ要素の設定
    initialWidth: 200,            // 初期幅
    graphInitialWidth: 300,       // グラフの初期幅
    offsetX: 5,                   // X方向のオフセット
    offsetY: 5,                   // Y方向のオフセット
    minScale: 0.2,                // 最小スケール
    maxScale: 2.0,                // 最大スケール
    scaleSensitivity: 0.02,       // スケール変更の感度
    smoothingFactor: 0.3          // 位置追従のスムージング係数
  },
  mediaPipe: { // MediaPipe Handsの設定
    maxNumHands: 2,
    modelComplexity: 1,
    minDetectionConfidence: 0.6,
    minTrackingConfidence: 0.5
  }
};

// --- アプリケーション全体の状態管理 (APP_STATE) ---
// createInteractiveElementState: 2Dインタラクティブ要素の状態オブジェクトを生成するヘルパー
function createInteractiveElementState(domContainer, domImage, isGraph = false) {
  return {
    domContainer, domImage, imagePath: '', preloadedImage: null, isVisible: false, isPermanentlyHidden: false,
    isControlling: false, currentScale: 1.0,
    baseWidth: isGraph ? CONFIG.interactiveElement.graphInitialWidth : CONFIG.interactiveElement.initialWidth,
    baseHeight: 0, targetX: 0, targetY: 0, smoothedX: 0, smoothedY: 0,
    pinchStartDistance: 0, initialControlFrame: true,
  };
}
const APP_STATE = {
  logo: createInteractiveElementState(logoContainer, jinsLogo), // JINSロゴ(2D)の状態
  leftHandGraph: { // 左手で操作するグラフの状態
    activeDisplayState: createInteractiveElementState(leftHandGraphContainer, leftHandGraphImage, true),
    currentGraphSequenceIndex: 0,
    graphDefinitions: CONFIG.urls.graphImages.map(path => ({ imagePath: path, preloadedImage: null, isPermanentlyHidden: false })),
    graphHasBeenInitiallyPositioned: CONFIG.urls.graphImages.map(() => false) // 各グラフが初期位置に配置されたか
  },
  hands: { // 左右の手の状態 (ランドマーク、ピンチ情報など)
    left: { landmarks: null, pinchDistance: 0, pinchMidPoint: { x: 0, y: 0 }, pinchBaseDistance: 0, indexTipCanvas: null, wasOpenLastFrame: false },
    right: { landmarks: null, pinchDistance: 0, pinchMidPoint: { x: 0, y: 0 }, pinchBaseDistance: 0, calculatedPalmCenter: null, indexTipCanvas: null }
  },
  resources: { // MediaPipeやThree.jsのリソース
    hands: null,
    camera: null,
    threeDFont: null
  },
  kamehameha: { // かめはめ波エフェクトの状態
    isReady: false, // 発動準備完了か
    palmOpenStartTime: 0, // 手のひらを開き始めた時間
    lastPalmPosition: null, // 最後に認識された手のひらの位置
    lastActivationTime: 0, // 最後にかめはめ波が発動された時間
    isActive: false, // 現在エフェクトがアクティブか
    hasBeenActivatedAndFinished: false // エフェクトが一度発動し終了したか
  },
  threeDTextLogo: { // 3Dテキストロゴの状態
    group: null, // Three.jsのグループオブジェクト
    meshes: [], // 個々の文字メッシュ
    isVisible: false, // 表示されているか
    isAnimatingScale: false, // スケールアニメーション中か
    isRotating: false, // 回転中か
    currentScale: 0.1, // 現在のスケール
    initialScale: 0.1, // 初期スケール
    targetScale: 3.0, // 目標スケール
    animationSpeed: 0.04, // アニメーション速度
    currentRotationY: 0, // 現在のY軸回転角度
    rotationSpeed: 0.01, // 回転速度
    activationRequested: false, // 表示リクエスト中か
    hasBeenShownThisCycle: false, // 現在のインタラクションサイクルで表示されたか
    canStopRotationByTouch: false, // 指で回転を止められる状態か
    isDraggable: false, // ドラッグ可能か
    isDragging: false, // ドラッグ中か
    draggingHand: null, // ドラッグしている手 (left/right)
    dragStartHandNormalized: null, // ドラッグ開始時の手の正規化座標
    dragStartLogoPosition: null, // ドラッグ開始時のロゴの3D位置
    targetLogoPositionX: 0, // ドラッグ中の目標X位置
    targetLogoPositionY: 0, // ドラッグ中の目標Y位置
    smoothedLogoPositionX: 0, // スムージングされたX位置
    smoothedLogoPositionY: 0  // スムージングされたY位置
  }
};

// --- ユーティリティ関数 ---
function calculateDistance(p1, p2) { if (!p1 || !p2) return Infinity; return Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2)); } // 2点間の距離
function calculateMidPoint(p1, p2) { if (!p1 || !p2) return null; return ({ x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }); } // 2点間の中点
function getCanvasCoordinates(lm, cw, ch) { if (!lm) return null; return { x: lm.x * cw, y: lm.y * ch }; } // 正規化座標をキャンバス座標に変換
function isFingerExtended(landmarks, fingerTipIndex, fingerPipIndex) { if (!landmarks || !landmarks[fingerTipIndex] || !landmarks[fingerPipIndex]) return false; return landmarks[fingerTipIndex].y < landmarks[fingerPipIndex].y; } // 指が伸びているか判定
function isOpenPalm(landmarks) { // 手のひらが開いているか判定 (複数の指が伸びているか)
  if (!landmarks) return false;
  const fingerIndices = [[8, 6], [12, 10], [16, 14], [20, 18]]; // 人差し指, 中指, 薬指, 小指 の先端と第二関節
  let extendedFingers = 0;
  for (const [tipIdx, pipIdx] of fingerIndices) { if (isFingerExtended(landmarks, tipIdx, pipIdx)) extendedFingers++; }
  return extendedFingers >= 3; // 3本以上の指が伸びていれば開いていると判定
}

// --- 2Dインタラクティブ要素の表示・制御 ---
function updateInteractiveElementStyle(elementState) { // DOM要素のスタイル(位置、スケール)を更新
  if (!elementState || !elementState.domContainer) return;
  const { domContainer, baseWidth, baseHeight, currentScale, smoothedX, smoothedY, isVisible } = elementState;
  domContainer.style.width = `${baseWidth}px`;
  domContainer.style.height = `${baseHeight > 0 ? baseHeight : (baseWidth * 0.75)}px`; // 高さが未設定ならアスペクト比0.75で
  domContainer.style.transform = `translate(-50%, -50%) scale(${currentScale})`; // 中央基点でスケール
  if (isVisible) { domContainer.style.left = `${smoothedX}px`; domContainer.style.top = `${smoothedY}px`; }
}

async function showInteractiveElement(elementState, imagePath, _preloadedImgObj, atX, atY, isGraphInitialFixedPosition = false) { // 要素を表示し、初期位置や状態を設定
  if (!elementState || elementState.isPermanentlyHidden || !elementState.domContainer || !elementState.domImage) return;
  elementState.imagePath = imagePath;
  const imgElement = elementState.domImage;
  const finalizeDisplay = (loadedImage) => { // 画像読み込み完了後の処理
    elementState.baseHeight = (loadedImage && loadedImage.naturalWidth > 0) ? elementState.baseWidth * (loadedImage.naturalHeight / loadedImage.naturalWidth) : elementState.baseWidth * 0.75;
    Object.assign(elementState, { isVisible: true, isControlling: true, initialControlFrame: true, currentScale: 1.0 });
    if (isGraphInitialFixedPosition) { // グラフの初回表示位置 (固定)
      elementState.targetX = atX + (elementState.baseWidth / 2) + CONFIG.interactiveElement.offsetX;
      elementState.targetY = atY + (elementState.baseHeight / 2) + CONFIG.interactiveElement.offsetY;
    } else { // 通常の表示位置 (カーソル追従)
      elementState.targetX = atX + CONFIG.interactiveElement.offsetX;
      elementState.targetY = atY - (elementState.baseHeight / 2) + CONFIG.interactiveElement.offsetY;
    }
    elementState.smoothedX = elementState.targetX; elementState.smoothedY = elementState.targetY;
    elementState.domContainer.style.display = 'block';
    updateInteractiveElementStyle(elementState);
  };
  imgElement.src = imagePath; imgElement.onload = null; imgElement.onerror = null; // src設定前にコールバックをリセット
  imgElement.onload = () => finalizeDisplay(imgElement);
  imgElement.onerror = () => {
    console.error("Error loading image for interactive element:", imagePath);
    finalizeDisplay(null); // 画像読み込み失敗時も表示処理を試みる(プレースホルダ的なサイズで)
  };
  // キャッシュされているなどで既に画像が読み込み完了している場合のフォールバック
  if (imgElement.complete && imagePath) {
    setTimeout(() => { // onloadが呼ばれない場合があるので非同期でチェック
      if (imgElement.naturalWidth > 0 && !elementState.isVisible) { // 正常に読み込めている
        finalizeDisplay(imgElement);
      } else if (imgElement.naturalWidth === 0 && !elementState.isVisible && imagePath) { // completeだがサイズ0 (エラーの可能性)
        finalizeDisplay(null);
      }
    }, 0);
  }
}

function hideInteractiveElement(elementState, permanently = false) { // 要素を非表示にする
  if (!elementState || !elementState.domContainer) return;
  Object.assign(elementState, { isVisible: false, isControlling: false });
  if (permanently) { // 完全非表示 (再度表示されない)
    elementState.isPermanentlyHidden = true; elementState.currentScale = 0.01; // 極小スケールにしてから非表示
    updateInteractiveElementStyle(elementState);
    setTimeout(() => { if (!elementState.isVisible) elementState.domContainer.style.display = 'none'; }, 200); // アニメーション後にdisplay:none
  } else { // 一時的な非表示
    elementState.domContainer.style.display = 'none'; elementState.currentScale = 1.0; // スケールリセット
  }
}

function controlInteractiveElementPositionAndScale(elementState, referencePointCanvas, pinchDistance) { // ピンチ操作で要素の位置とスケールを制御
  if (!elementState || !elementState.isControlling || !elementState.isVisible || !referencePointCanvas) return;
  const { interactiveElement: ieCfg } = CONFIG;
  // ターゲット位置を更新 (参照点に追従)
  let targetX = referencePointCanvas.x + ieCfg.offsetX;
  let targetY = referencePointCanvas.y - (elementState.baseHeight / 2) + ieCfg.offsetY; // 画像の上端を基準に
  // スムージング
  if (elementState.initialControlFrame) { // 初回は直接設定
    elementState.smoothedX = targetX; elementState.smoothedY = targetY; elementState.initialControlFrame = false;
  } else {
    elementState.smoothedX += (targetX - elementState.smoothedX) * ieCfg.smoothingFactor;
    elementState.smoothedY += (targetY - elementState.smoothedY) * ieCfg.smoothingFactor;
  }
  // スケールを更新 (ピンチ距離に応じて)
  elementState.currentScale += (pinchDistance - elementState.pinchStartDistance) * ieCfg.scaleSensitivity;
  elementState.currentScale = Math.max(ieCfg.minScale, Math.min(ieCfg.maxScale, elementState.currentScale)); // 最小・最大スケール制限
  elementState.pinchStartDistance = pinchDistance; // 次のフレームのためにピンチ距離を更新
  updateInteractiveElementStyle(elementState); // スタイルを適用
}

// --- アセットのプリロード ---
function preloadAssets() { // 画像やフォントを事前に読み込む
  if (CONFIG.urls.jinsLogo && APP_STATE.logo) {
    const logoImg = new Image();
    logoImg.onload = () => { if (APP_STATE.logo) APP_STATE.logo.preloadedImage = logoImg; };
    logoImg.onerror = () => { console.error("Failed to load 2D JINS logo:", CONFIG.urls.jinsLogo); };
    logoImg.src = CONFIG.urls.jinsLogo;
  }

  APP_STATE.leftHandGraph.graphDefinitions.forEach((graphDef) => {
    if (!graphDef.imagePath) return;
    const img = new Image();
    img.onload = () => { graphDef.preloadedImage = img; };
    img.onerror = () => { console.error("Failed to load graph image:", graphDef.imagePath); };
    img.src = graphDef.imagePath;
  });

  if (CONFIG.urls.threeFont) { // Three.js用のフォントを読み込む
    const fontLoader = new FontLoader();
    fontLoader.load(
      CONFIG.urls.threeFont,
      function (font) { // 読み込み成功
        APP_STATE.resources.threeDFont = font;
        console.log("3D Text Font loaded:", CONFIG.urls.threeFont);
        if (scene) { // シーンが初期化済みなら3Dロゴも初期化
          initThreeDTextLogo();
        }
      },
      undefined, // onProgressコールバック (今回は未使用)
      function (error) { // 読み込み失敗
        console.error("Error loading 3D Text Font:", CONFIG.urls.threeFont, error);
        alert("3Dテキスト用フォントの読み込みに失敗しました。");
      }
    );
  }
}

// --- ジェスチャーに応じた2D要素の処理 ---
// processPinchForElement: ピンチジェスチャーで2D要素 (主にJINSロゴ) を操作
function processPinchForElement(distance, _midPoint, elementState, handSpecificState, imagePathForShow) {
  if (!elementState || !handSpecificState.indexTipCanvas) return; // 必要な情報がなければ処理中断
  const referencePoint = handSpecificState.indexTipCanvas; // 操作の基準点は人差し指の先端

  if (!elementState.isVisible && !elementState.isPermanentlyHidden) { // 要素が非表示かつ永続非表示でない場合 (表示トリガーの判定)
    // ピンチアウト (指を開く) ジェスチャーで表示
    if (distance > CONFIG.gestures.pinchStartDistanceThreshold && distance > handSpecificState.pinchBaseDistance * CONFIG.gestures.pinchOutExpandRatio && handSpecificState.pinchBaseDistance > 0) {
      showInteractiveElement(elementState, imagePathForShow, elementState.preloadedImage, referencePoint.x, referencePoint.y, false);
      elementState.pinchStartDistance = distance; // 表示時のピンチ距離を記録
    }
    handSpecificState.pinchBaseDistance = distance; // 次のフレーム比較用に現在のピンチ距離を記録
  } else if (elementState.isVisible) { // 要素が表示されている場合 (非表示トリガーと操作の判定)
    if (distance < CONFIG.gestures.pinchFullyClosedThreshold && elementState.isControlling) { // ピンチイン (指を閉じる) ジェスチャーで永続非表示
      hideInteractiveElement(elementState, true);
    }
    else if (elementState.isControlling) { // 表示中で操作中の場合、位置とスケールを更新
      controlInteractiveElementPositionAndScale(elementState, referencePoint, distance);
    }
  }
}
// processLeftHandGraphSequence: 左手のピンチでグラフ画像を順番に操作
function processLeftHandGraphSequence(distance, _midPoint, leftHandSpecificState) {
  const lhGraph = APP_STATE.leftHandGraph;
  const activeGraphDisplay = lhGraph.activeDisplayState; // 現在アクティブなグラフ表示状態
  const referencePoint = leftHandSpecificState.indexTipCanvas; // 左手の人差し指先端

  if (activeGraphDisplay.isVisible) { // グラフが表示されている場合
    if (distance < CONFIG.gestures.pinchFullyClosedThreshold && activeGraphDisplay.isControlling) { // ピンチインで現在のグラフを永続非表示
      hideInteractiveElement(activeGraphDisplay, true);
      // 表示中のグラフをシーケンス内で永続非表示にマーク
      if (lhGraph.currentGraphSequenceIndex >= 0 && lhGraph.currentGraphSequenceIndex < lhGraph.graphDefinitions.length) {
        lhGraph.graphDefinitions[lhGraph.currentGraphSequenceIndex].isPermanentlyHidden = true;
      }
    } else if (activeGraphDisplay.isControlling && referencePoint) { // 操作中で参照点があれば位置とスケールを制御
      controlInteractiveElementPositionAndScale(activeGraphDisplay, referencePoint, distance);
    }
  } else { // グラフが非表示の場合 (次のグラフ表示トリガーの判定)
    let nextGraphDef = null; let nextGraphIndex = -1;
    // まだ表示されていない次のグラフを探す
    for (let i = 0; i < lhGraph.graphDefinitions.length; i++) {
      if (!lhGraph.graphDefinitions[i].isPermanentlyHidden) {
        nextGraphDef = lhGraph.graphDefinitions[i];
        nextGraphIndex = i;
        break;
      }
    }
    // 表示可能な次のグラフがあり、ピンチアウトジェスチャーが検出された場合
    if (nextGraphDef && referencePoint && distance > CONFIG.gestures.pinchStartDistanceThreshold && distance > leftHandSpecificState.pinchBaseDistance * CONFIG.gestures.pinchOutExpandRatio && leftHandSpecificState.pinchBaseDistance > 0) {
      lhGraph.currentGraphSequenceIndex = nextGraphIndex; // 表示するグラフのインデックスを更新
      activeGraphDisplay.isPermanentlyHidden = false; // アクティブ表示状態の永続非表示フラグをリセット
      const isInitialFixed = !APP_STATE.leftHandGraph.graphHasBeenInitiallyPositioned[nextGraphIndex]; // このグラフが初回表示か
      // グラフを表示 (初回は固定位置、2回目以降はカーソル追従)
      showInteractiveElement(activeGraphDisplay, nextGraphDef.imagePath, nextGraphDef.preloadedImage, isInitialFixed ? 0 : referencePoint.x, isInitialFixed ? 0 : referencePoint.y, isInitialFixed);
      if (isInitialFixed) APP_STATE.leftHandGraph.graphHasBeenInitiallyPositioned[nextGraphIndex] = true; // 初回表示フラグを立てる
      activeGraphDisplay.pinchStartDistance = distance; // 表示時のピンチ距離を記録
    }
    // ピンチベース距離を更新 (参照点がない場合は0にリセット)
    if (referencePoint) leftHandSpecificState.pinchBaseDistance = distance; else leftHandSpecificState.pinchBaseDistance = 0;
  }
}

// --- Three.js関連の初期化と処理 ---
// initThreeJS: Three.jsのシーン、カメラ、レンダラーを初期化
function initThreeJS() {
  threeCanvas = document.getElementById('
JINSテックブログ
JINSテックブログ

Discussion