オンライン上でオープンキャンパスを実現するシステム

https://github.com/kk3desuyo/StreetRyu
# 実際の動作
<img src="https://storage.googleapis.com/zenn-user-upload/da99aae0eceb-20241209.gif" width="600" height="200">

**左メニューから指定の建物に移動**
<img src="https://storage.googleapis.com/zenn-user-upload/12722e618f23-20241209.gif" width="600" height="200">

# 目的
遠方の方や身体的な理由などでキャンパスに来ることが困難な方に向けてオンライン上でオープンキャンパスを実現できるようなシステムを開発しもっと大学の事を知っていただきたいと思い開発を始めました。

# 技術スタック
**JavaScript**
**Pannellum**

https://pannellum.org/documentation/overview/


## Pannullumでできること

・360度パノラマ写真をブラウザで表示
・地点を移動する(画像1の真ん中にある移動ボタンをクリック)
・建物の部分に情報を埋め込む
![](https://storage.googleapis.com/zenn-user-upload/ad3f8b05fe67-20241209.png)
※画像1
![](https://storage.googleapis.com/zenn-user-upload/d50055bdd7c6-20241209.png)
※画像2
# 実装方法
## まず、キャンパス内の道をルートに分割
分岐があるごとに、ルートを分割していきました。撮影時に、矢印の方向が360度写真の前方となるようにしました。また、画像の名前を**ルートの名前 +数字**(ex.A0,A1)にして、地点を一意に識別でき量にしました。
![](https://storage.googleapis.com/zenn-user-upload/4d038602f1bc-20241209.png =500x)
※数字はそのルートで取る枚数

>90度、-90度など数値が出てきますが、全て360度写真の前方の真ん中を0として、左側が負、右側が正となります。


## 次に、移動処理の実装
①画面上のどこをクリックしても移動可能にする
②360度写真の後方に向かっている時に自然になるようにする

### ①画面上のどこをクリックしても移動可能にする
Pannellumでは、移動ボタンを押すことで特定の位置に移動できますが、Google Street Viewのように画面上の任意の場所をクリックして、そのクリックした場所に応じた地点に移動することはできません。そこで、画面上のどこをクリックしても擬似的に移動ボタンを押すようにして移動機能を実装しました。

### Q.では、分岐部分でどのようにして、どの移動ボタンを押したか判定を行っているのか？
![](https://storage.googleapis.com/zenn-user-upload/37780a01abb2-20241209.png )



それぞれの地点ごとに、ある範囲をクリックした時にどの地点に飛ぶのかをJsonに記述しました。この情報を元にして、クリック時に移動先の地点を計算してその移動先に対応する移動ボタンをHTMLから探索してクリックするようにしました。
>this.ranges : 現在地におけるどこをクリックしたらどこに移動するかの情報
clickYaw　: クリック地点の座標情報
``` js:移動先を計算する関数
  //クリックした位置の移動先計算
  static getMovePlace(clickYaw) {
    for (var i = 0; i < this.ranges.length; i++) {
      //範囲内にあるかの計算
      if (
        this.ranges[i].yawSmall <= clickYaw &&
        this.ranges[i].yawLarge >= clickYaw
      ) {
        return this.locations[i];
      }
    }
    throw new Error("クリックした位置に対応する移動先が見つかりません。");
  }
```
``` json:posiInfos.json
    {
      "positionId": "A0",
      "moveSets": {
        "straight": false,
        "rangesByLocation": [{"location":"A1","pitch":-90,"location":"M10","yaw":90},{"location":"N8","pitch":90, "yaw":180}, "pitch":180, "yaw":270],
        "map": [69.2, 88.5],
        "buildings":[{"name":"一号館","img":"1.jpg","description":"一号館の説明","pitch":0,"yaw":0}]
      }
```
::: message
分岐がない地点では、毎回-90度〜90度の範囲が前に進む、90度〜270度までは後ろに進むと固定なので、データ入力負担軽減のために、 "straight"項目がtrueの場合にはrangesの項目部分を"positionId"から次の地点、一個前の地点を計算し自動で生成するように工夫しました。

``` js:分岐がない場合の自動生成処理
          //直進のyaw pitch処理
          if (position.moveSets.straight == true) {
            // `positionId` 末尾の数字に対して1増やした地点と減らした地点を計算
            const basePositionNumber = parseInt(sceneId.slice(1)); // `A1` のように最初の文字を除外して数字を取得
            const incrementedPosition =
              sceneId.charAt(0) + (basePositionNumber + 1); // 数字を1増やした地点
            const decrementedPosition =
              sceneId.charAt(0) + (basePositionNumber - 1); // 数字を1減らした地点
            // `ranges` の値に応じてyawを決定
            yaw = i == 0 ? 0 : 180;
    
            // 自動的にlocationsを更新
            position.moveSets.locations = [incrementedPosition, decrementedPosition];
          } 
```


### ②戻る時に自然になるようにする!

``` js:後方にすすんでいるかどうか判定する関数
//写真の後方に向かっている場合にtrueを返す
function isReturn(currentLocation) {
  const prevChar = prevLocation_g.charAt(0);
  const prevNumber = parseInt(prevLocation_g.match(/\d+/)[0]);
  const currChar = currentLocation.charAt(0);
  const currNumber = parseInt(currentLocation.match(/\d+/)[0]);

  //同じルートの場合の判定
  if(prevChar === currChar){
    return prevNumber >= currNumber;
  }

  //ルートが異なる場合の判定
  const key1 = prevChar + currChar; // ABパターン
  const key2 = currChar + prevChar; // BAパターン
  // ルートマップ内にkey1またはkey2が存在するか、または同じ文字かを確認
  const keyExists = routeRelationMap[key1] !== undefined || routeRelationMap[key2] !== undefined || prevChar === currChar;

  if (keyExists)  throw new Error("ルート情報が見つかりませんでした。");
  
  const routeValue = routeRelationMap[key1] || routeRelationMap[key2];    
  //矢印が逆行している場合の処理
  if(routeValue.reverse){
    return true;
  }
  else{
    //逆行していない場合の処理
    return currNumber == 0?false:true;
  }
}
```

Pannellumは360度写真を表示する時に、毎回360度写真の前方を向くような仕組みになっています。そのため、写真の前方にすすんでいる時には、何も起こりませんが、後方にすすんできる時には移動するごとに毎回前方を向いてしまいます。

**写真に対して前方に進んでいる時**
<img src="https://storage.googleapis.com/zenn-user-upload/e95600a0d3f2-20241209.gif" width="600" height="200">

**写真に対して後方に進んでいる時**
<img src="https://storage.googleapis.com/zenn-user-upload/09214d423374-20241209.gif" width="600" height="200">

後方にすすんでいるので、移動するごとに前方を向いてしまっていてとても使いにくい。

#### 解決策
**移動するごとに後方にすすんでいるのかを判定する**。pannellumには視点を指定の角度分移動する関数があるため必要に応じて、地点を移動する時に180度分だけ視点を移動するようにすれば良い。

### 判定するパターンとして大まかに以下が挙げられる。
①同じルート上を移動する場合
②二つのルート間を跨ぐ場合
※ルートとは下記画像のA,B,C,Dなど道を分岐があるごとに分割したもの。

<table>
  <tr>
    <!-- 1枚目のタイトル -->
    <td style="text-align: center;">
      <p>ルートの例</p>
      <img src="https://storage.googleapis.com/zenn-user-upload/4d038602f1bc-20241209.png" alt="ルートの例" style="width: 250px; height: auto;">
    </td>
    <!-- 2枚目のタイトル -->
    <td style="text-align: center;">
      <p>地点ごとに分割した図</p>
      <img src="https://storage.googleapis.com/zenn-user-upload/2feb82bb5abe-20241210.png" alt="地点ごとに分割した図" style="width: 200px; height: auto;">
    </td>
  </tr>
</table>


### ①同じルート上を移動する場合
同じルート上の場合には2パターンに分けられる。
#### A.今いる地点と次の地点で、添え字が増えている場合
写真はルートの矢印方向を前方に撮影していて、矢印の方向に向かって、添え字が増えていきます。そのため、今:A0->次の地点:A1, B3->4のように添え字が増えている場合には写真の前方に向かってすすんでいるとわかります。
#### B.今いる地点と次の地点で、添え字が減っている場合
逆に減っている時には、ルートの矢印とは逆行するため、ex.A2->A1の場合に後方にすすんでいることがわかります。この場合には、移動時に視点を180度視点移動させてあげることで自然に後方にも移動できるようになります。

### ②二つのルート間を跨ぐ場合
二つのルート間同士で、移動する場合には主に以下のパターンが考えられます。
**A.ルートの方向が同じ向き ex.ルートAとルートD,ルートAとルートC**
**B.ルートの方向が逆向き ex.ルートAとルートB**

#### A.ルートの方向が同じ向き ex.ルートAとルートD,ルートAとルートC
ルートの例を見てもらうとわかる通り、ルートAとルートD,ルートAとルートCは矢印の先端通しがぶつかっていません。このような、ルート間の移動の場合にはさらに2パターンに分けられます。
**移動先の地点の添字が0かどうかです**。
(i).移動先の添え字が0の場合
視点移動が不要です。実際に、A4->D0の地点を見てもらうとわかる通り視点移動が不要であることがわかります。
(2).移動先の添え字が0以外の場合
視点移動が必要です。実際に逆の移動である、D0->A4の移動を考えた時に、A4に移動した時に後方を向いて欲しいためです。
#### B.ルートの方向が逆向き ex.ルートAとルートB
ルートAとルートBは矢印の先端がぶつかっています。このような場合には、視点移動が必要になります。実際に、A5からB5に移動しと場合を考えてもらうと、B5に移動した時には自然な移動を実現する場合には視点移動が必要になります。


**全てのルートの組み合わせに、それぞれルートの方向が同じならfalse、逆向きならtrueの情報を全て入力したrouteRelationMapを作成**
```js
 routeRelationMap = {
  AB: { reverse: true },
  AC: { reverse: false },
  AM: { reverse: true },
  BC: { reverse: false },
  ...
}
```

## カーソルに表示される矢印の実装
![](https://storage.googleapis.com/zenn-user-upload/05b9338160db-20241209.png)
カーソルに矢印を表示したいのですが、ただ矢印表示するだけではクリックした時にどの地点に移動するのかわかりません。

### 矢印がどのくらい回転をするべきかを計算する
カーソル上に表示される矢印は、画面上のカーソルと移動ボタン
の位置関係に応じて回転させる必要があります。これにより、ユーザーがクリックした際にどの方向に移動するかを直感的に示すことができます。

![](https://storage.googleapis.com/zenn-user-upload/fdaf4b93f041-20241209.png)

回転角度の計算には、カーソルと移動先ボタンの位置差を利用します。以下の関数でその計算を行っています。
``` js
function rotateArrow(pitchMoveBtn, yawMoveBtn) {
  // カーソルと移動ボタンの位置差を計算
  var deltaX = moveButtonY_g - cursorY_g;
  var deltaY = moveButtonX_g - cursorX_g;

  // 回転角度を計算（ラジアンから度に変換）
  var angle = Math.atan2(deltaY, deltaX) * (180 / Math.PI);

  // 特定の範囲で角度を補正
  if ((yaw_g < 0 && yaw_g > -90) || (yaw_g > 90 && yaw_g < 180)) {
    angle = 180 - angle;
  }

  // 矢印画像の回転を適用
  var arrowButtonImg = document.querySelector("#arrowButtonImg");
  if (arrowButtonImg) {
    arrowButtonImg.style.transform = `rotate(${angle}deg)`;
  } else {
    console.error("arrowButtonImg要素が見つかりません。");
  }
}
```
この関数では、Math.atan2を使用して角度を求めています。deltaXとdeltaYはそれぞれカーソルと移動ボタンのX軸およびY軸の差分です。これにより、矢印がカーソルから移動先ボタンの方向を指すように回転させています。

https://daeudaeu.com/c-atan/

### 矢印の大きさを調整する
矢印が画面上で適切な視覚的フィードバックを提供するためには、カーソルと撮影地点までの距離に基づいてスケール（大きさ）を動的に調整すること必要があります。
![](https://storage.googleapis.com/zenn-user-upload/c03cea29a89c-20241209.png)

calculateDistanceFromCenter関数を用いて、カーソルと撮影地点までの3D空間上の距離を計算しています。その距離を基に、scaleImageBasedOnDistance関数で適切なスケール値を算出しています。

具体的には、距離がmaxDistanceに近づくほどスケールが0.1に近づき、距離が0に近づくほどスケールが1に近づくようになっています。

``` js
function scaleImageBasedOnDistance(distance, maxDistance) {
  // 最大距離を超えないようにスケールを計算
  const scale = Math.max(0.1, 1 - distance / maxDistance);
  return scale;
}

function rotateArrow(pitchMoveBtn, yawMoveBtn) {
  // ...前述の回転角度計算

  // 距離の計算
  const distance = calculateDistanceFromCenter(
    pitch_g,
    yaw_g,
    centerPitch,
    centerYaw
  );

  // スケールの計算
  const maxDistance = 2; // スケールが0になる最大距離を設定
  const scale = scaleImageBasedOnDistance(distance, maxDistance);

  // 矢印画像の回転とスケールを適用
  var arrowButtonImg = document.querySelector("#arrowButtonImg");
  if (arrowButtonImg) {
    arrowButtonImg.style.transform = `rotate(${angle}deg) scale(${scale})`;
  } else {
    console.error("arrowButtonImg要素が見つかりません。");
  }
}
```

# 最後に
今回のシステムは、ただ実装する能力だけでなく、座標変換などの数学的知識が求められる場面もあり、とても難しく感じました。しかし、実際にシステムを作り上げることができ、大きな達成感を味わうことができました。また、今回の実装では建物情報の表示機能を実現しましたが、今後、音声付きのツアー機能を追加することで、オープンキャンパスの体験をさらに豊かにすることができるのではないかと考えています。