HERE Maps API for JavaScript に3Dマーカーを追加してみた

## はじめに

前回は**HERE Maps API for JavaScript**にコンパスアイコンを追加してみました。

https://qiita.com/kai1103/items/eda640125423e444282a

やってみて分かったことは、```mapviewchange```イベントの拡張性と応答性が結構良かったので、これで地図と連動した独自の3D表現も行けるんじゃないかと思いました。

というわけで今回は**HERE Maps API for JavaScript**に3Dマーカーを追加してみました。

## 動作環境

- OS: macOS Monterey (Version 12.6.2)
- Browser: Google Chrome (Version 108.0.5359.124)
- HERE Maps API for JavaScript (Version 3.1.37.0)
- Three.js (Version r147)

## 事前準備

まずは前回最後のテンプレートから始まります。

<p class="codepen" data-height="400" data-default-tab="result,js" data-slug-hash="xxJxdoQ" data-editable="true" data-user="kairyu1103" style="height: 400px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/kairyu1103/pen/xxJxdoQ">
  HERE Maps API for JavaScript - Japan</a> by kairyu (<a href="https://codepen.io/kairyu1103">@kairyu1103</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

## Three.jsの追加

JavaScriptで3Dの表現といえば、真っ先に思いつくのが**Three.js**というライブラリではないでしょうか。

https://threejs.org/

しかし残念ながら私は**Three.js**に関しては全くの初心者なので、ここでは先人の知恵を拝借させて頂きます。

https://www.freecodecamp.org/news/three-js-tutorial/

下記コードを追加します。

```diff_javascript
...
  <meta name="viewport" content="initial-scale=1.0, width=device-width" />
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/0.147.0/three.min.js"></script>
  <script src="https://js.api.here.com/v3/3.1/mapsjs-core.js"></script>
...
```

```html
  <script>
    function createScene() {
      const scene = new THREE.Scene();

      const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
      scene.add(ambientLight);

      const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
      directionalLight.position.set(200, 500, 300);
      scene.add(directionalLight);
      return scene;
    }

    function createCamera(container) {
      // Setting up camera
      const aspectRatio = container.offsetWidth / container.offsetHeight;
      const cameraWidth = 150;
      const cameraHeight = cameraWidth / aspectRatio;
      const camera = new THREE.OrthographicCamera(
        cameraWidth / -2, // left
        cameraWidth / 2, // right
        cameraHeight / 2, // top
        cameraHeight / -2, // bottom
        0, // near plane
        1000 // far plane
      );
      camera.position.set(200, 200, 200);
      camera.lookAt(0, 10, 0);
      return camera;
    }

    function createRenderer(container, pixelRatio) {
      // Set up renderer
      const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
      renderer.setSize(container.offsetWidth, container.offsetHeight);
      renderer.setPixelRatio(pixelRatio);
      renderer.setClearColor(0x000000, 0);
      container.appendChild(renderer.domElement);
      return renderer;
    }

    function getCarFrontTexture() {
      const canvas = document.createElement("canvas");
      canvas.width = 64;
      canvas.height = 32;
      const context = canvas.getContext("2d");

      context.fillStyle = "#ffffff";
      context.fillRect(0, 0, 64, 32);

      context.fillStyle = "#666666";
      context.fillRect(8, 8, 48, 24);

      return new THREE.CanvasTexture(canvas);
    }

    function getCarSideTexture() {
      const canvas = document.createElement("canvas");
      canvas.width = 128;
      canvas.height = 32;
      const context = canvas.getContext("2d");

      context.fillStyle = "#ffffff";
      context.fillRect(0, 0, 128, 32);

      context.fillStyle = "#666666";
      context.fillRect(10, 8, 38, 24);
      context.fillRect(58, 8, 60, 24);

      return new THREE.CanvasTexture(canvas);
    }

    function createWheels() {
      const geometry = new THREE.BoxBufferGeometry(12, 12, 33);
      const material = new THREE.MeshLambertMaterial({ color: 0x333333 });
      const wheel = new THREE.Mesh(geometry, material);
      return wheel;
    }

    function createCar() {
      const car = new THREE.Group();

      const backWheel = createWheels();
      backWheel.position.y = 6;
      backWheel.position.x = -18;
      car.add(backWheel);

      const frontWheel = createWheels();
      frontWheel.position.y = 6;
      frontWheel.position.x = 18;
      car.add(frontWheel);

      const main = new THREE.Mesh(
        new THREE.BoxBufferGeometry(60, 15, 30),
        new THREE.MeshLambertMaterial({ color: 0xa52523 })
      );
      main.position.y = 12;
      car.add(main);

      const carFrontTexture = getCarFrontTexture();

      const carBackTexture = getCarFrontTexture();

      const carRightSideTexture = getCarSideTexture();

      const carLeftSideTexture = getCarSideTexture();
      carLeftSideTexture.center = new THREE.Vector2(0.5, 0.5);
      carLeftSideTexture.rotation = Math.PI;
      carLeftSideTexture.flipY = false;

      const cabin = new THREE.Mesh(new THREE.BoxBufferGeometry(33, 12, 24), [
        new THREE.MeshLambertMaterial({ map: carFrontTexture }),
        new THREE.MeshLambertMaterial({ map: carBackTexture }),
        new THREE.MeshLambertMaterial({ color: 0xffffff }), // top
        new THREE.MeshLambertMaterial({ color: 0xffffff }), // bottom
        new THREE.MeshLambertMaterial({ map: carRightSideTexture }),
        new THREE.MeshLambertMaterial({ map: carLeftSideTexture }),
      ]);
      cabin.position.x = -6;
      cabin.position.y = 25.5;
      car.add(cabin);

      return car;
    }
  </script>
```

```diff_javascript
...
    compassControl.addChild(compassButton);
    compassControl.setAlignment('top-right');
+
+   const scene = createScene();
+   const car = createCar();
+   scene.add(car);
...
```

おかげでだいぶ手間が省けました。ただしこれだけではまだ3Dのクルマが地図上に表示されないので、次はマーカーを追加します。

## マーカーの追加

**HERE Maps API for JavaScript**は様々なタイプのマーカーをサポートしていますが、今回は```H.map.DomMarker```を使います。

https://developer.here.com/documentation/maps/3.1.37.0/dev_guide/topics/marker-objects.html

下記コードを追加します。

```javascript
    let renderer;
    let camera;
    const position = {lat: 35.68, lng: 139.76};
    const pixelRatio = window.devicePixelRatio > 1 ? 2 : 1;

    function addMarker() {
      const outerElement = document.createElement('div');
      const innerElement = document.createElement('div');
      innerElement.style.width = '200px';
      innerElement.style.height = '200px';
      innerElement.style.transform = 'translate(-50%, -50%)';
      outerElement.appendChild(innerElement);
      const icon = new H.map.DomIcon(outerElement, {
        onAttach: function (clonedElement, domIcon, domMarker) {
          const innerElement = clonedElement.getElementsByTagName('div')[0];
          camera = createCamera(innerElement);
          renderer = createRenderer(innerElement, pixelRatio);
          renderer.render(scene, camera);
        },
      });
      const marker = new H.map.DomMarker(position, {icon});
      map.addObject(marker);
    }

    window.onload = function () {
      addMarker();
    };
```

```DomMarker```とは任意のDOM要素をマーカーとして地図に追加できるタイプのマーカーで、階層関係は```Map > DomMarker > DomIcon > DOM要素```になります。

その中身となるDOM要素は予めHTMLに存在するものでも問題ありませんが、今回はマーカーを追加するタイミングでコードで動的に生成しています。

そしてさらに```DomIcon```の```onAttach```というタイミングで、DOM要素の中に**Three.js**の```renderer```を入れ込みました。

その効果は以下のようになります。
![1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2958554/fcbb14bb-a042-6d31-7e2f-39ebc9b25d61.gif)
これで3Dのクルマをマーカーとして地図上に追加出来ました。マーカーなので位置は自動的に地図と一緒に移動できるのは当たり前ですが、向きはまだ全然地図と同期出来ていませんね。これではまだ真の3Dと言えません。

## 地図のカメラと同期させる

問題はクルマに向けているカメラが固定のままだったので、それを地図のカメラと同期させればいい感じになるはずです。

ここでまた```mapviewchange```イベントの出番です。

```diff_javascript
...
    function addMarker() {
      const outerElement = document.createElement('div');
      const innerElement = document.createElement('div');
      innerElement.style.width = '200px';
      innerElement.style.height = '200px';
      innerElement.style.transform = 'translate(-50%, -50%)';
      outerElement.appendChild(innerElement);
      const icon = new H.map.DomIcon(outerElement, {
        onAttach: function (clonedElement, domIcon, domMarker) {
          const innerElement = clonedElement.getElementsByTagName('div')[0];
          camera = createCamera(innerElement);
          renderer = createRenderer(innerElement, pixelRatio);
-         renderer.render(scene, camera);
+         const lookAt = map.getViewModel().getLookAtData();
+         updateIcon(lookAt);
        },
      });
      const marker = new H.map.DomMarker(position, {icon});
      map.addObject(marker);
    }

+   function updateIcon(lookAt) {
+     const {heading, incline, tilt} = lookAt;
+     car.rotation.y = heading / 180 * Math.PI;
+     const x = 200 * Math.sin(tilt / 180 * Math.PI);
+     const y = 200 * Math.cos(tilt / 180 * Math.PI);
+     const z = 0;
+     camera.position.set(x, y, z);
+     camera.up.set(-1, 0, 0);
+     camera.lookAt(0, 0, 0);
+     renderer.render(scene, camera);
+   }

    map.addEventListener('mapviewchange', function (evt) {
...
+     if (evt.modifiers & (
+       H.map.ChangeEvent.Type.HEADING |
+       H.map.ChangeEvent.Type.INCLINE |
+       H.map.ChangeEvent.Type.TILT
+     )) {
+       updateIcon(evt.newValue.lookAt);
+     }
    }
...
```

私は数学があまり得意な方ではないので、```updateIcon```内の計算式にたどり着くまで相当試行錯誤してました。効果は以下のようになります。
![2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2958554/e055ba1d-0246-819c-92cc-863f391d71de.gif)
すごくスムーズに動いてくれて感動！（実際はこのGIFよりもさらにスムーズ）

## 注意点

実はまだ注意点があります。

### 平行投影

まずは今回```OrthographicCamera```を使っているので、レンダリング時は平行投影になります。つまりクルマの大きさは地図のズームレベルによって変化しません。
![3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2958554/c842b203-7395-edb1-6854-011fcab20d01.gif)
これは意図的にこうしている部分もありまして、今回は完全なる3D地図を目指しているわけではなく、あくまでも3Dマーカーですので、マーカーは地図のズームレベルと関係なく常にサイズを一定に保つのが一般的かと思います。

### レイヤー関係

もう一つ注意点は、マーカーの場所を変えるとよりわかりやすくなります。
![4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2958554/a3a6fc22-5a68-8a0d-3d23-5174016afe0a.gif)
すごくズームインすると、今度は**HERE Maps API for JavaScript**がデフォルトで提供している3D Buildingが仇となって、位置関係がちぐはぐになってしまい、クルマが建物の上に乗っているように見えてしまいました。

これはマーカーレイヤーは常に3D Buildingを含む他のベース地図レイヤーよりも上でレンダリングされているのが原因かと推測しています。本格的な3D遮蔽処理までして直そうとするとすごく大掛かりになってしまうので、ここでは逆に3D Buildingの方を消すことで逃げます…

```javascript
    function setStyle(map) {
      const provider = map.getBaseLayer().getProvider();
      const style = provider.getStyle();
      const changeListener = e => {
        if (style.getState() === H.map.Style.State.READY) {
          style.removeEventListener('change', changeListener);
          style.removeProperty('layers.buildings.extrude');
        }
      }
      style.addEventListener('change', changeListener);
    }
    setStyle(map);
```

## おわりに

最終のコードをまとめて以下に置いておきました。ご参考になれば幸いです。

<p class="codepen" data-height="500" data-default-tab="js,result" data-slug-hash="NWBPBwp" data-editable="true" data-user="kairyu1103" style="height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/kairyu1103/pen/NWBPBwp">
  HERE Maps API for JavaScript - 3D Marker</a> by kairyu (<a href="https://codepen.io/kairyu1103">@kairyu1103</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>