簡単な地図を自作する(HTML/CSS/JS)

前書き

three.jsを使用して、簡単な地図を作る方法を掲載しています。

目次

  1. 地図を作る
  2. 参考

地図を作る

イラレで、各SVG画像のサイズ感を合わせて、書き出す
スタイルはインラインに記述されるようにする(iOS対策 2022.09
three.jsとGSAPを読み込む。
three.js
GSAP Installation
SVG画像から、メッシュを作成、配置(SVGLoader.js
レイキャストを設定(ある点から発射された光線を追跡し, 物体との衝突を検出する
アイコンにマウスを乗せたらカーソルを変更する
アイコンをクリックしたらイベント処理を行う
カメラの動作を制御(OrbitControls.js
水平方向への移動 拡大縮小
マウス 左ボタンを押しながら移動 ホイールを動かす
スマホ 2本の指を移動 2本の指を広げる、狭める
キーボード 左、右、上、下キー
HTMLのボタン 左、右、上、下ボタン
場所のボタン
+、-ボタン

<script defer src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"></script>
<script defer type="importmap">
{
  "imports": {
    "three": "./three_r142/build/three.module.js"
  }
}
</script>
<script defer type="module" src="名前.js"></script>

<div class="wrap js-wrap-a">
  <canvas class="canvas js-canvas-a"></canvas>
  <div class="op-zoom">
    <div class="op-icon js-plus">+</div>
    <div class="op-icon js-minus">-</div>
  </div>
  <div class="op-icon op-left js-left">◀</div>
  <div class="op-icon op-right js-right">▶</div>
  <div class="op-icon op-up js-up">▲</div>
  <div class="op-icon op-down js-down">▼</div>
  <img class="op-houi" src="<?= PATH; ?>assets/img/45/houi.svg" alt="">
</div>

<div class="nav-wrap">
  <div class="nav" data-pagenav="jingu">伊勢神宮</div>
  <div class="nav" data-pagenav="fujisan">富士山</div>
  <div class="nav" data-pagenav="skytree">スカイツリー</div>
  <div class="nav" data-pagenav="tanegasima">種子島</div>
</div>

.wrap{
  position: relative; padding: 75% 0 0; overflow: hidden; cursor: grab;
}
.canvas{
  position: absolute; top: 0; left: 0; width: 100%; height: 100%;
}
.op-icon{
  width: 1.75em; height: 1.75em; line-height: 1.75; color: #fff; font-weight: bold; text-align: center; background: #947343; border-radius: 3px; cursor: pointer;
}
.op-zoom{ position: absolute; right: 10px; bottom: 10px; display: flex; gap: 0 5px; }
.op-left{ position: absolute; top: 0; bottom: 0; left: 10px;  margin: auto; }
.op-right{ position: absolute; top: 0; right: 10px; bottom: 0; margin: auto; }
.op-up{ position: absolute; top: 10px; right: 0; left: 0; margin: auto; }
.op-down{ position: absolute; right: 0; bottom: 10px; left: 0; margin: auto; }
.op-houi{ position: absolute; top: 10px; right: 10px;width: 2em; }

.nav-wrap{
  display: flex; flex-wrap: wrap; justify-content: center; gap: 1em 1em; margin: 20px 0 0;
}
.nav{
  width: 8em; color: #fff; font-weight: bold; text-align: center; background-color: #947343; border-radius: 5px; cursor: pointer;
}

import * as THREE from 'three';
import { OrbitControls } from './three_r142/examples/jsm/controls/OrbitControls.js';
import { SVGLoader } from './three_r142/examples/jsm/loaders/SVGLoader.js';
import { gsap } from './gsap_3.10.4/esm/gsap-core.js';

// サイズ------------------
const wrap = document.querySelector(".js-wrap-a");
let wrapWidth = wrap.clientWidth;
let wrapHeight = wrap.clientHeight;

// レンダラー------------------
const canvas = document.querySelector(".js-canvas-a");
const renderer = new THREE.WebGLRenderer({
  canvas: canvas,
  antialias: true
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(wrapWidth, wrapHeight);

// シーン------------------
const scene = new THREE.Scene();
scene.background = new THREE.Color( 0xf7f1d8 );

// カメラ------------------
const camera = new THREE.OrthographicCamera( wrapWidth / - 2, wrapWidth / 2, wrapHeight / 2, wrapHeight / - 2, );
camera.position.set(0, 0, 100);

// カメラコントローラー
const controls = new OrbitControls( camera, renderer.domElement );
controls.enableRotate = false; //回転させない
controls.minZoom = 0.75; //最小縮小率
controls.maxZoom = 5; //最大拡大率
controls.listenToKeyEvents( window ); //windowにイベントリスナーを追加
controls.keys = { LEFT: 'ArrowLeft', UP: 'ArrowUp', RIGHT: 'ArrowRight', BOTTOM: 'ArrowDown' }  //キーボード
controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.DOLLY } //マウス
controls.touches = { TWO: THREE.TOUCH.DOLLY_PAN } //タッチ

// 物体 ------------------
// 準備
const imgPath = "パス";
const mapX = -300;
const mapY = 380;
//イラレの情報を元に、位置、大きさを指定
const svglist = [
  { ID: "nihon",EVENT: false, IMG: "tizu.svg", X: mapX, Y: mapY, Z: 1, WIDTH: 525.3, HEIGHT: 687.8 },
  { ID: "jingu", EVENT: true, IMG: "torii.svg", X: mapX + 329, Y: mapY - 411, Z: 2, WIDTH: 23.7, HEIGHT: 20.2 },
  { ID: "fujisan", EVENT: true, IMG: "yama.svg", X: mapX + 361, Y: mapY - 390, Z: 2, WIDTH: 31.8, HEIGHT: 20.2 },
  { ID: "skytree", EVENT: true, IMG: "tou.svg", X: mapX + 392.5, Y: mapY - 373, Z: 2, WIDTH: 9.8, HEIGHT: 30.4 },
  { ID: "tanegasima", EVENT: true, IMG: "rocket.svg", X: mapX + 223.5, Y: mapY - 489, Z: 2, WIDTH: 19, HEIGHT: 28.1 },
];

// svgからメッシュを作成
// グループを作成
const itemGroup = new THREE.Group();

// SVGの取り込み
const loadSVG = (item) => {
  const shapeGroup = new THREE.Group();
  const loader = new SVGLoader();
  loader.load( imgPath + item.IMG, (data) => {
    // シェイプを作成
    const svgPaths = data.paths;
    for ( let i = 0; i < svgPaths.length; i ++ ) {
      const fillColor = svgPaths[i].userData.style.fill;
      const svgShape = SVGLoader.createShapes(svgPaths[i]);
      for ( let j = 0; j < svgShape.length; j ++ ) {
        // メッシュ
        const geometry = new THREE.ShapeGeometry(svgShape[j]);
        const material = new THREE.MeshBasicMaterial( {
          color: new THREE.Color(fillColor),
          side: THREE.DoubleSide,
        });
        const mesh = new THREE.Mesh(geometry, material);
        // 設定
        mesh.userData.ID = item.ID;
        mesh.userData.EVENT = item.EVENT;
        // グループに追加
        shapeGroup.add( mesh );
      }
    }
    // 位置、向き
    shapeGroup.position.set(item.X, item.Y, item.Z);
    shapeGroup.rotation.x = Math.PI;
    // 設定
    shapeGroup.userData.EVENT = item.EVENT;    
    shapeGroup.userData.WIDTH = item.WIDTH;
    shapeGroup.userData.HEIGHT = item.HEIGHT;
    shapeGroup.userData.X = item.X;
    shapeGroup.userData.Y = item.Y;
    // グループに追加
    itemGroup.add( shapeGroup );
  });
}
// 配列の数だけ繰り返す
for ( let i = 0; i < svglist.length; i ++ ) {
  loadSVG(svglist[i]);
}
// シーンに追加
scene.add(itemGroup);

//レイキャスト(ある点から発射された光線を追跡し, 物体との衝突を検出する)------------------
const mouse = new THREE.Vector2(); // マウス座標管理用のベクトル
let intersects;

//レイキャストを生成
const raycaster = new THREE.Raycaster();
const handleRaycaster = () => {
  raycaster.setFromCamera(mouse, camera); // マウス位置からまっすぐに伸びる光線ベクトルを生成
  intersects = raycaster.intersectObjects(scene.children); // その光線とぶつかったオブジェクトを得る
}

//マウスの座標を変更
const handleMouseMove = (e) => {
  const wrapReact = wrap.getBoundingClientRect();
  const x = e.clientX - wrapReact.left;
  const y = e.clientY - wrapReact.top;
  mouse.x = (x / wrapWidth) * 2 - 1;
  mouse.y = - (y / wrapHeight) * 2 + 1;
}
canvas.addEventListener('mousemove', handleMouseMove);

//カーソルの切替
const handleCursor = () => {
  if(intersects.length > 0){ //レイキャストとぶつかった物体があれば
    if( intersects[0].object.userData.EVENT ){ //一番手前の物体
      wrap.style.cursor = 'pointer';
    }else{
      wrap.style.cursor = 'grab';
    }
  }else{
    wrap.style.cursor = 'grab';
  }
}

//場所のアイコンをクリックしたらアラートを出す
const itemClick = () => {
  if (intersects.length > 0) { //レイキャストとぶつかった物体があれば
    const id = intersects[0].object.userData.ID; //一番手前の物体
    if( intersects[0].object.userData.EVENT ){
      if(id == "jingu"){
        alert("伊勢神宮");
      }else if(id == "fujisan"){
        alert("富士山");
      }else if(id == "skytree"){
        alert("東京スカイツリー");
      }else if(id == "tanegasima"){
        alert("種子島宇宙センター");
      }
    }
  }
}
canvas.addEventListener('click',itemClick);

//カメラの拡大倍率に合わせて、場所のアイコンを縮小する
const itemZoom = () => {
  const zoom = controls.object.zoom
  const shrink = 1 / zoom;
  for ( let i = 0; i < itemGroup.children.length; i ++ ) {
    const item = itemGroup.children[i];
    if( item.userData.EVENT ){
      //縮小
      item.scale.x = shrink;
      item.scale.y = shrink;
      //中央寄せ
      item.position.x = item.userData.X + item.userData.WIDTH / 2 * (1 - shrink);
      item.position.y = item.userData.Y - item.userData.HEIGHT / 2 * (1 - shrink);
    }
  }
}
controls.addEventListener("change",itemZoom);

//拡大縮小ボタンをクリック
const btnPlus = document.querySelector(".js-plus");
const btnMinus = document.querySelector(".js-minus");
const mapZoom = (btn, direction) => {
  let interval;
  btn.addEventListener('pointerdown', () => {
    interval = setInterval( () => { 
      if( direction == "plus" ){
        controls.object.zoom = Math.min( controls.maxZoom, controls.object.zoom * 1.1 ); //最大値を上回らないようにする
      }else if( direction == "minus" ){
        controls.object.zoom = Math.max( controls.minZoom, controls.object.zoom * 0.9 ); //最小値を下回らないようにする
      }
      itemZoom();
    }, 50);
  });    
  btn.addEventListener('pointerup', () => clearInterval(interval) );
  btn.addEventListener('pointerleave', () => clearInterval(interval) );
}
mapZoom(btnPlus, "plus");
mapZoom(btnMinus, "minus");

//上下左右ボタンをクリック
const btnLeft = document.querySelector(".js-left");
const btnRight = document.querySelector(".js-right");
const btnUp = document.querySelector(".js-up");
const btnDown = document.querySelector(".js-down");
const mapMove = (btn, direction) => {
  const distance = controls.keyPanSpeed;
  let interval;
  btn.addEventListener('pointerdown', () => {
    interval = setInterval( () => {
      //カメラの位置と視点を動かす 
      if( direction == "left" ){
        controls.object.position.x -= distance;
        controls.target.x -= distance;  
      }else if( direction == "right" ){
        controls.object.position.x += distance;
        controls.target.x += distance;  
      }else if( direction == "up" ){
        controls.object.position.y += distance;
        controls.target.y += distance;  
      }else if( direction == "down" ){
        controls.object.position.y -= distance;
        controls.target.y -= distance;  
      }
    }, 50);
  });    
  btn.addEventListener('pointerup', () => clearInterval(interval) );
  btn.addEventListener('pointerleave', () => clearInterval(interval) );
}
mapMove(btnLeft, "left");
mapMove(btnRight, "right");
mapMove(btnUp, "up");
mapMove(btnDown, "down");

//ナビ------------------
document.querySelectorAll('[data-mapnav]').forEach((item) => {
  item.addEventListener('click', () => {
    const id = item.dataset.mapnav;
    for ( let i = 0; i < svglist.length; i ++ ) {
      if( svglist[i].ID == id){
        const pointX = svglist[i].X + svglist[i].WIDTH / 2;
        const pointY = svglist[i].Y - svglist[i].HEIGHT / 2;
        gsap.to( controls.object.position, { x: pointX });
        gsap.to( controls.target, { x: pointX });
        gsap.to( controls.object.position, { y: pointY });
        gsap.to( controls.target, { y: pointY });
      } 
    }
  });
});

//リサイズ------------------
const wrapResize = () =>{
  wrapWidth = wrap.clientWidth;
  wrapHeight = wrap.clientHeight;
  renderer.setSize(wrapWidth, wrapHeight);
  // camera.aspect = wrapWidth / wrapHeight;
  camera.updateProjectionMatrix();
}

//一定時間毎に処理------------------
let tick;
const switching = (e) => {
  if( e[0].isIntersecting ){ //見えてる時
    tick = () => {
      wrapResize(); //リサイズ
      controls.update(); //カメラコントローラー
      handleRaycaster(); //レイキャスト
      handleCursor(); //カーソル
      renderer.render(scene, camera); //レンダリング
      requestAnimationFrame(tick); //繰り返し
    }
    requestAnimationFrame(tick);
  }else{ //見えてない時
    tick = () => {
      cancelAnimationFrame(tick);
    }
  }
}

//見えているかどうか(Intersection Observer API)------------------
const createObserver = () => {
  let observer;
  const options = { root: null, rootMargin: "0%", threshold: 0 };
  observer = new IntersectionObserver(switching, options); //コールバック関数とオプションを渡す
  observer.observe(wrap); //要素の監視
}
createObserver();

参考

Three.jsでオブジェクトとの交差を調べる - ICS MEDIA

Three.jsでオブジェクトをクリック

three.js examples

関連記事

パーティクルを作成する(HTML/JS)

文字を立体で表示する(HTML/CSS/JS)

360度画像を表示する(HTML/JS)

うにょうにょ動かす(HTML/CSS/JS)

円を描くように要素を動かす(HTML/CSS/JS)

マウスの位置に合わせて要素を動かす(HTML/CSS/JS)

先頭に戻る
ページの先頭に戻る