🍎

google mapのコンパスみたいな挙動をwebで

2025/02/24に公開

はじめに

reactでgoogle mapのコンパスみたいな、中心となる座標からドラッグで回転とデバイスの傾きで回転を実装しました。
デバイスの回転検出にはdeviceOrientationを、描画にはreact-konvaを使用しています。

コード

"use client";
import { useState, useRef, useEffect } from "react";
import { Group, Layer, Stage, RegularPolygon } from "react-konva";

export default function Home() {
  const width = window.innerWidth;
  const height = window.innerHeight;
  const [degree, setDegree] = useState(0);
  const [rotation, setRotation] = useState(0);
  const lastDrag = useRef({ x: 0, y: 0 });

  const calculateRotation = (
    x0: number,
    y0: number,
    x1: number,
    y1: number,
    x2: number,
    y2: number
  ): number => {
    // 回転の向き
    const v1x = x1 - x0;
    const v1y = y1 - y0;
    const v2x = x2 - x1;
    const v2y = y2 - y1;
    const crossProductZ = v1x * v2y - v1y * v2x;
    const direction = crossProductZ > 0 ? 1 : -1;

    // 回転速度の調整係数
    const rotationSpeedFactor = 0.15;

    // 差分に基づく速度計算
    const deltaX = x2 - x1;
    const deltaY = y2 - y1;
    const deltaMagnitude = Math.sqrt(deltaX ** 2 + deltaY ** 2);

    // 内積
    const dotProduct = (x1 - x0) * (x2 - x1) + (y1 - y0) * (y2 - y1);

    // ベクトルの長さ
    const magnitude1 = Math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2);
    const magnitude2 = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);

    // 角度 (ラジアン)
    const cosTheta = Math.min(
      Math.max(dotProduct / (magnitude1 * magnitude2), -1),
      1
    );
    const angleInRadians = Math.acos(cosTheta);

    return direction * angleInRadians * rotationSpeedFactor * deltaMagnitude;
  };

  useEffect(() => {
    document.body.style.overflow = "hidden";
    document.documentElement.style.overflow = "hidden";
    return () => {
      document.body.style.overflow = "";
      document.documentElement.style.overflow = "";
    };
  }, []);

  const askPermission = () => {
    try {
      (DeviceOrientationEvent as any)
        .requestPermission()
        .then((res: string) => {
          if (res === "granted") {
            addEventListener("deviceorientation", (event: any) => {
              setRotation(event?.webkitCompassHeading);
            });
          }
        });
    } catch {
      console.log("失敗しました。");
    }
  };

  return (
    <div>
      <div className="text-center text-3xl mt-10">
        <p>マップの回転</p>
        <input
          id="check"
          type="button"
          value="デバイスの回転許可"
          className="px-5 py-3 bg-green-500 text-white border-none rounded-lg cursor-pointer"
          onClick={askPermission}
        />
      </div>
      <Stage
        width={width}
        height={height}
        className="bg-white"
        onPointerDown={(e) => {
          const x = e.evt.x;
          const y = e.evt.y;
          lastDrag.current = { x: x, y: y };
        }}
        onPointerMove={(e) => {
          const x = e.evt.x;
          const y = e.evt.y;
          const addDegree = calculateRotation(
            width / 2,
            height / 2,
            lastDrag.current.x,
            lastDrag.current.y,
            x,
            y
          );
          setDegree(degree + addDegree);
          lastDrag.current = { x: x, y: y };
        }}
      >
        <Layer>
          <Group>
            <RegularPolygon
              x={width / 2}
              y={height / 2}
              angle={60}
              radius={50}
              fill={"white"}
              stroke={"blue"}
              strokeWidth={10}
              sides={3}
              rotation={degree + rotation}
            />
          </Group>
        </Layer>
      </Stage>
    </div>
  );
}

解説

デバイス自体の回転

デバイスの回転許可ボタンを押すとaskPermissionが呼び出されて、デバイスに角度の検知の許可を求められます。
iosのデバイスのみ使えるので、pcやandroidでクリックしても何も起きません。
あとは取得したwebkitCompassHeadingの値をそのまま変数に渡しています。
詳細は過去の記事に書いたこちらを参照してくだされば良いかと思います🙏
https://zenn.dev/ncdc/articles/b49ef7b916c3f5

ドラッグ時の回転

ドラッグしているときにonPointerMoveで、前回取得したタッチ座標(lastDrag)と今回取得したタッチ座標(e.evt)の比較を計算してdegreeに足しています。
width/2とheight/2は回転の中心座標ですね。

onPointerMove={(e) => {
          const x = e.evt.x;
          const y = e.evt.y;
          const addDegree = calculateRotation(
            width / 2,
            height / 2,
            lastDrag.current.x,
            lastDrag.current.y,
            x,
            y
          );
          setDegree(degree + addDegree);
          lastDrag.current = { x: x, y: y };
        }}

onPointerDownはドラッグし始めに発火するイベントでですが、このときにlastDragを更新することでどの位置からドラッグし始めても大丈夫なようにしています。

onPointerDown={(e) => {
          const x = e.evt.x;
          const y = e.evt.y;
          lastDrag.current = { x: x, y: y };
        }}

calculateRotationには回転の中心座標と前回のタッチ座標と今回のタッチ座標のそれぞれx, yの値を渡します。
返り値には回転の向き(正か負か)と回転の角度と回転の速さを掛けたものを返しています。回転の角度に関しては内積をベクトルで割ったものをアークコサインすると出るみたいです。
こちらの記事がわかりやすかったです🙏
社会人になってベクトルを扱う機会があるとは思わなかったです(_)
https://nekojara.city/math-2vector-angle

  const calculateRotation = (
    x0: number,
    y0: number,
    x1: number,
    y1: number,
    x2: number,
    y2: number
  ): number => {
    // 回転の向き
    const v1x = x1 - x0;
    const v1y = y1 - y0;
    const v2x = x2 - x1;
    const v2y = y2 - y1;
    const crossProductZ = v1x * v2y - v1y * v2x;
    const direction = crossProductZ > 0 ? 1 : -1;

    // 回転速度の調整係数
    const rotationSpeedFactor = 0.15;

    // 差分に基づく速度計算
    const deltaX = x2 - x1;
    const deltaY = y2 - y1;
    const deltaMagnitude = Math.sqrt(deltaX ** 2 + deltaY ** 2);

    // 内積
    const dotProduct = (x1 - x0) * (x2 - x1) + (y1 - y0) * (y2 - y1);

    // ベクトルの長さ
    const magnitude1 = Math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2);
    const magnitude2 = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);

    // 角度 (ラジアン)
    const cosTheta = Math.min(
      Math.max(dotProduct / (magnitude1 * magnitude2), -1),
      1
    );
    const angleInRadians = Math.acos(cosTheta);

    return direction * angleInRadians * rotationSpeedFactor * deltaMagnitude;
  }

一応pc画面ですがこのように回転できます。

NCDCエンジニアブログ

Discussion