3Dマップ上にJSONで読み込んだアイコンを表示させ、絞り込みを連動させた話(その1)

4604 0

某大手商社の採用サイトのトップページのプログラミング(のみ)を担当した時のお話。

仕様としてはこんな感じでした。

  • 今後の更新や変更に備え、表示するデータをJSONで管理
  • そのJSONデータを元に、マップ(3Dとフォールバック用2D)上にアイコンを表示
  • 表示されるアイコンは絞り込み検索によって表示/非表示切り替え
  • 絞り込み検索はAND検索
  • これらをすべてフロントで

なんでこんな仕様に?と思われる方もいらっしゃると思いますが、まぁその辺りは色々都合があったのでしょう。
「データベース使ったほうが良いのでは?」なんて提案してみましたが、仕様に変更無しだったので、このまま作成することに。

幸いJSONのデータ形式もこちらの主導で作成させていただけたので 結構自由度は高く、自分にとってやりやすいかたちにできたのが幸いでした。

ベース設計

その他、細かな仕様として

  • マップ上に、地点に応じたアイコンが並ぶ。(3D時は自転もするのでそれにちゃんと追従しつつ破綻しないように)

  • アイコンにマウスオンするとその支社名、コメントをする人のプロフィールを表示。

  • アイコンクリックでモーダルウィンドウオープン。モーダル内は画像や動画+テキスト等のhtmlを表示させる。

  • 日本のみ構成が少し違い、アイコンクリックで、まず各支社の一覧->その支社ごとに別のモーダルコンテンツを表示させる。

  • 画面左の「MEET OUR PEOPLE」をクリックすると絞り込み検索画面を表示。
    絞り込みはチェックボックス形式で、チェックを入れたり外したりする毎にHIT件数を変化させつつ、マップ上のアイコンも絞り込んだ結果のみ表示する。

  • 3D2Dの2つのhtmlファイル
  • 絞り込み5項目に対応したJSON設計
  • 日本とその他の国で表示内容が少し違う為、別々のJSONデータを用意
  • アイコンをクリックした際にはAjaxで該当のファイル(html)を読み出し、モーダルで表示
  • 絞り込むプログラム+絞り込みに対応したアイコン表示/非表示切り替え
  • 絞り込みはチェックボックスに変更がある毎に行う->絞り込み後の該当数を表示
  • Ajax+モダール、絞り込み検索+該当数は共通、マップ部分のみ別のため、共通箇所のcommon.jsとそれぞれのjsファイルに分割して作成。
  • 3Dと2Dマップを切り替えても絞り込み項目は引き継ぎ

JSONデータはこのような形にした。

{
	"points":[
		{
			"name": "名前",
			"content": "マウスオン時に表示するテキスト",
			"file": "マウスオン時のサムネイル画像パス",
			"gender": "性別のindex", 
			"career": "キャリアのindex",
			"backdround": ["0","3","4"], // 複数ある場合もある
			"division": "事業別のindex",
			"type": "コンテンツタイプのindex",
			"country": "国名",
			"position": { // アイコンの位置座標
				"lat": "54.077086",
				"lng": "4.317058"
			}
		},
		{
			"...": ""
		}
	]
}

pointsという配列の中に、各位置座標に応じたデータを格納していく形状です。

ただ、後々JSONデータに各データが何番目の項目か、を判別する「index」の項目があったほうが良かったかなーと思いました。 (データ追加、削除した時にややこしくなりそうだったので入れなかったのですが、絞り込み検索する際にあったほうが便利ではありました。)

マップ

3Dとなると、three.jsでつくるのが自分としてはいちばん慣れているので、とりあえずさっくりと作成。
しかしながら3Dオブジェクト上にアイコンを作成した上で、そのアイコンに対応したクリックイベントを付与するというところがなかなかの勘所。

一先ず慣れているGoogleMapで先に大まかなかたちを作成し、これを3Dに置き換えるという流れをとりました。(正確なアイコンの位置を先に決めて、それを3D化した際に微調整する流れ)。

2DマップはGoogleMap APIで作成したので、難しいことは何もなく、それこそあっという間に完成。

ポイントとしては、日本用と海外用で別々のJSONファイルを作成した為、その両方が読み込まれなければエラーやバグが発生します。
そこでjQuery.when()を利用して両方が読み込まれてからの処理にしました。

var def = new $.Deferred;
var def2 = new $.Deferred;

loadAjax(0,false,def);
loadAjax(0,1,def2);

$.when( def,def2 ).done(function(){
	// Mapとマーカー(アイコン)の生成
});

common.js側ではAjaxのラッパー関数のようなものを用意しておいて、
すべてのAjaxの処理は引数だけでハンドリングするようにしました。

function LoadAjax(typeNum,fileNum,def){
	var setType = [
		'json',
		'html',
		'japan'
	];
	var type = setType[typeNum];
	var request_url;

	if( type === 'json' ){
		var file = (fileNum) ? 'js/data-tokyo' : 'js/data';
		request_url = domein+file+'.json';
	}else if( type === 'html' ){
		var type = 'html';
		var dir = 'dirname/';
		request_url = domein+dir+'member'+fileNum+'.html';
	}else if( type === 'japan' ){
		var type = 'html';
		var dir = 'dirname/';
		request_url = domein+dir+'japan'+fileNum+'.html';
	}else{
		return;
	}

	$.ajax({
		type: 'GET',
		url: request_url,
		dataType: type
	}).done(function(response){
		if( type === 'json' ){
			loadJSON(response,1,def)
		}else if( type === 'html' ){
			loadContent(response,0,def)
		}else{
			return;
		}
	}).fail(function(response){
		errorMessage();
		return def.reject();
	});
}

絞り込み検索もあるので、読み込んだJSONデータは一旦メモリ(変数)にキャッシュして保持することにしました。(場合によっては結構な頻度でアクセスしますし)

データをキャッシュすることでマップのアイコン表示の際もデータの取り回しがスムーズに行きます。

var Data = COMMON.dataList;  // COMMON.dataListはcommon.js側でキャッシュしたJSONデータ
var length = Data.length;

for (var i = 0; i < length; i++) {
	if( Data[i].position.lat && Data[i].position.lng ){
		latlngs[i] = {
			lat: Data[i].position.lat,
			lng: Data[i].position.lng
		};
	}
}
var latlng = new google.maps.LatLng( latlngs[0].lat,latlngs[0].lng );

2D3Dともこうしてアイコンの座標位置を指定することにしました。

3Dマップ

地球はTHREE.SphereGeometry()THREE.Mesh()でテクスチャをつければ良いだけなのですが、 リアリティを追求する為、実は雲を更に上に被せる(地球の自転とずれて動くようになる)ようにSphereGeometryを2つ配置しています。

アイコンは当初、地球部分と同じく3Dオブジェクトで生成していたのですが、あまりに取り扱いが面倒で、DOMへ変更することに。

また、各地点の座標を3Dマップ上にコンバートする必要がある。こちらを参考に、微修正して実装。

2D overlays in three.js

http://jsdo.it/makc/hw0L?lang=en
SphereGeometry上にマーカーを配置する上で、
  1. SphereGeometryを回す
  2. cameraを回す

のふた通りの選択肢があります。
実はそれぞれにちょっと気をつけなければならないポイントがあります。

1.の場合、DirectionalLightが常に適切な位置を照らす為、地球の裏側に回っても画面上の狙った範囲が照らされます。
ただし、マーカーが追従しません(笑)。地球だけが回り、アイコンは常に画面上の同じ位置に留まっています。

2.の場合、カメラが場所を変える為、地球のSphereとアイコンは常に正しい位置関係になります。 しかし、ライトも(自分から見ると)動いてしまう為、地球の裏側に行くと真っ暗になります(笑)。

Sphereに対してアイコンをグループ付けすることはできなくもないですが、結構面倒な処理が必要だったので 2のカメラを回す方式にし、カメラの位置とライトの位置を連動させることで、狙った動きになるようにしました。

最終的にだいたいこんな感じのコードにしました。参考までにコードを載せておきます。2年くらい前のコードなのであくまで参考程度に…
(※3Dマップでは細かなアニメーションの為にtween.jsを使用)

var container;
var camera;
var scene;
var geometry;
var globeMaterial;
var markers = new Array();
var markerLocations = new Array();

var mesh;
var mesh2;
var controls;
var renderer;
var raycaster = new THREE.Raycaster();
var direcLight;
var rotation;

var width;
var height;
var aspect;

//For tweening
var tween;
var initialPosition;
var targetPosition;

var panned = false;
var radius = 150;

function Balloon( html , classNum ) {
	THREE.Object3D.call( this );

	this.popup = document.createElement( 'div' );
	if(classNum === 0){
		this.popup.classList.add( 'balloon','jp' );
	}else{
		this.popup.classList.add( 'balloon' );
	}
	this.popup.innerHTML = html;

	this.addEventListener( 'added', (function () {
		container.appendChild( this.popup );
	}).bind( this ));
	this.addEventListener( 'removed', (function () {
		container,removeChild( this.popup );
	}).bind( this ));
}

Balloon.prototype = Object.create( THREE.Object3D.prototype );
Balloon.prototype.constructor = Balloon;

Balloon.prototype.updateMatrixWorld = (function () {
	var screenVector = new THREE.Vector3 ();
	var raycaster = new THREE.Raycaster ();

	return function( force ) {
		THREE.Object3D.prototype.updateMatrixWorld.call( this, force );

		screenVector.set( 0, 0, 0 ); this.localToWorld( screenVector );
		raycaster.ray.direction.copy( screenVector );

		raycaster.ray.origin.set( 0, 0, 0 ); camera.localToWorld( raycaster.ray.origin );
		raycaster.ray.direction.sub( raycaster.ray.origin );

		var distance = raycaster.ray.direction.length();
		raycaster.ray.direction.normalize();

		var intersections = raycaster.intersectObject( scene, true );
		if( intersections.length && ( intersections[0].distance < distance )) {
			// overlay anchor is obscured
			this.popup.style.display = 'none';
		} else {
			// overlay anchor is visible
			screenVector.project( camera );

			this.popup.style.display = '';
			this.popup.style.left = Math.round((screenVector.x + 1) * window.innerWidth / 2 - 26) + 'px';
			this.popup.style.top = Math.round((1 - screenVector.y) * (window.innerHeight * 2.2) / 2 - 38) + 'px';
		}
	};
})();


function latLongToVector3(lat, lon, radius, heigth) {
	var phi = (lat)*Math.PI/180;
	var theta = (lon-180)*Math.PI/180;
	var x = -(radius+heigth) * Math.cos(phi) * Math.cos(theta);
	var y = (radius) * Math.sin(phi);
	var z = (radius) * Math.cos(phi) * Math.sin(theta);
 
	return new THREE.Vector3(x,y,z);
}

function _createPoint(marker,latitude, longitude) {
	if (latitude === void 0) { latitude = 0; }
	if (longitude === void 0) { longitude = 0; }
	// 緯度経度から位置を設定
	marker.position.copy(latLongToVector3(latitude + 30, longitude, radius + 2, 2));
	return marker;
}

function resized(){
	width = window.innerWidth;
	height = window.innerHeight*2.2;
	renderer.setSize(width, height);
	aspect = width / height;
	camera.aspect = aspect;
	camera.updateProjectionMatrix();
}

function Initialize() {
	/*
		球体部分はどうあってもWebGLで作成する必要がある。
		MouseEnter,Clickイベント時のみDOMを表示、生成するようなかたちに
	*/
	var isRotating = true;
	var def = new $.Deferred;
	var def2 = new $.Deferred;

	loadAjax(0,false,def);
	loadAjax(0,1,def2);

	$.when( def,def2 ).done(function(){
		scene = new THREE.Scene();
		width = window.innerWidth;
		height = window.innerHeight*2.2
		aspect = width / height;
		var fov    = 40;
		var near   = 1;
		var far    = 1000;
		container = document.getElementById('globe');
		camera = new THREE.PerspectiveCamera( fov, aspect, near, far );
		camera.position.set( 1, 1, 550 );

		// lights
		direcLight = new THREE.DirectionalLight( 0xffffff, 1 );
		scene.add( direcLight );

		var AmbtLight = new THREE.AmbientLight( 0xe9eaff, .3 );
		scene.add( AmbtLight );

		var texture = THREE.ImageUtils.loadTexture('images/earth.jpg');
		var texture2 = THREE.ImageUtils.loadTexture('images/cloud.png');

		//Globe Geometry and material (球の形状と材料)
		globeMaterial = new THREE.MeshPhongMaterial({
			color: 0xEEEEEE,
			map: texture,
			bumpMap: texture,
			bumpScale: .05
		});
		var cloudMaterial = new THREE.MeshPhongMaterial({
			map: texture2,
			transparent: true,
			bumpMap: texture2,
			bumpScale: 0.05
		});
		//球
		var sphereGeometry = new THREE.SphereGeometry(radius,30,30);
		var sphere2 =  new THREE.SphereGeometry(151,31,31);
		mesh = new THREE.Mesh(sphereGeometry, globeMaterial);
		mesh2 =  new THREE.Mesh(sphere2, cloudMaterial);
		scene.add(mesh);
		scene.add(mesh2);

		//マーカー
		var Data = COMMON.dataList; // COMMON.dataListはcommon.js側でキャッシュしたJSONデータ
		var length = Data.length;

		for (var i = 0; i &lt; length; i++) {
			if( Data&#91;i&#93;.position.lat &#038;&#038; Data&#91;i&#93;.position.lng ){
				if(i === 0){
					markers&#91;i&#93; = new Balloon('<div id="japanCount"></div><div class="marker"><p class="country">'+Data[i].name+'</p></div>', 0);
				}else{
					markers[i] = new Balloon('<div class="marker"><img class="prof" src="'+Data&#91;i&#93;.file+'"><p class="country">'+Data[i].country+'</p><p class="desc">'+Data[i].content+'<span class="name">'+Data[i].name+'</span></p></div>', 1);
				}
				if(i === 0){
					var marker = _createPoint(markers[i],Data[i].position.lat-27 ,Data[i].position.lng+2)
					markerLocations[i] = {lat: Data[i].position.lat, lng: Data[i].position.lng};
				}else{
					var marker = _createPoint(markers[i],Data[i].position.lat,Data[i].position.lng)
					markerLocations[i] = {lat: Data[i].position.lat, lng: Data[i].position.lng};
				}
				mesh.add( marker );
			}
		}

		mesh.overdraw = true;
		renderer = new THREE.WebGLRenderer({ alpha: true });
		renderer.setSize(width, height);

		controls = new THREE.TrackballControls( camera, container );
		controls.noPan = true; //パンを不可に
		controls.noZoom = true;

		container.appendChild(renderer.domElement);
		animate();

		window.addEventListener('resize', resized, false );

		markers = length;
		MT.NarrowData.init();// common.jsの絞り込み検索initialize

		markerEvents();
	}); // $.when[END]
}

function animate() {
	rotation = requestAnimationFrame(animate);
	TWEEN.update();

	if(!controls.isMouseDown){
		mesh.rotation.set(
			mesh.rotation.x,
			mesh.rotation.y + .0003,
			mesh.rotation.z
		);
	}
		
	mesh2.rotation.set(
		mesh2.rotation.x,
		mesh2.rotation.y + .0005,
		mesh2.rotation.z
	);

	// カメラ位置からライトの位置を計算
	var lightPos = {
		x: camera.position.x - 450,
		y: camera.position.y + 300,
		z: camera.position.z
	}
	direcLight.position.copy( lightPos ); //ライトの位置を現在のカメラ位置に対応した位置へ
	renderer.render(scene, camera);
	controls.update();
}

function clearAnimation(target){
	cancelAnimationFrame(target);
}

…ここまでで結構長くなってしまったので、絞り込み検索に関しては次回。

Comments

Add a Comment

メールアドレスが公開されることはありません。

認証コード * Time limit is exhausted. Please reload CAPTCHA.

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください