制作事例

Published on 2017/1/8

Naokazu Terada

制作事例

2017/1/8

Naokazu Terada

この記事では、2016年11月に公開された「森のDNA図鑑 仁保の森 2016」というウェブサイトの制作過程の話をしています。

森のDNA図鑑 仁保の森 2016


前回の記事で、サイトの目的や背景、当日の撮影など素材準備までの話をしました。今回は、技術的な話を中心にしたいと思います。

前回の記事 → 森のDNA図鑑のできるまで 1/2

Dependencies

まず、Gemfileとpackage.jsonを晒します。各ツールやライブラリに言及しながら、どうやって作ったか具体的なコードも交えながら書いていきます。

Gemfile

source 'https://rubygems.org'

# For faster file watcher updates on Windows:
gem 'wdm', '~> 0.1.0', platforms: [:mswin, :mingw]

# Windows does not come with time zone data
gem 'tzinfo-data', platforms: [:mswin, :mingw, :jruby]

# Middleman Gems
gem 'middleman', '>= 4.0.0'
gem 'middleman-livereload'
gem 'middleman-deploy', github: 'middleman-contrib/middleman-deploy', branch: 'master'

package.json

{
"name": "dna-of-forests",
...
"devDependencies": {
"babel-core": "^6.17.0",
"babel-loader": "^6.2.5",
"babel-polyfill": "^6.16.0",
"babel-preset-es2015": "^6.16.0",
"clean-webpack-plugin": "^0.1.13",
"css-loader": "^0.25.0",
"node-sass": "^3.10.1",
"pug": "^2.0.0-beta6",
"sass-loader": "^4.0.2",
"vue-loader": "^9.7.0",
"webpack": "^1.13.2"
},
"dependencies": {
"flickity": "^2.0.5",
"js-cookie": "^2.1.3",
"json-loader": "^0.5.4",
"lodash": "^4.17.0",
"mobile-detect": "^1.3.3",
"three-js": "^79.0.0",
"vue": "^2.0.3",
"vue-router": "^2.0.1"
}
}

Middleman

KARAPPOでは、静的サイトジェネレーターの Middlemanを良く使います。4.x系からはExternal Pipeline という仕様が採用され、npmの恩恵を享受できるようになりました。今回SPA1な作りでいけそうだったため使いませんでしたが、YAMLで書ける Data Files は本当に重宝しています。YAMLはJSONよりシンプルで、コメントも書けるし、データ・フォーマットの中では一番好きです。

GithubにMiddlemanプロジェクトのテンプレート的なのを公開してるので、興味ある人は参考にして下さい2。

Vue.js

Vue.jsは、UIパーツの構造やスタイル、機能をコンポーネント化して管理(したり他にも色々)できるフレームワークです。今回初めて導入しました。ずっと気になっていたのですが、VirtualDOMを採用した2.x系がちょうど10月に正式リリースされるというナイスなタイミングでした。Riot.jsとも迷いましたが、他のフレームワークとの比較という記事で言及されているトランジッションAPIの有無とルーター機能の優位性が、今回の案件では効いてきそうだと思ったので導入を決めました 3 。

コンポーネントと独自フォーマット

Vue.jsやRiot.js、React などに共通する概念に、コンポーネント化があります。そのうち、Web Components に集約されていく気もしますが、今のところPolyfillなしでは使えないので、ライブラリ毎に独自フォーマットが定義される結果になっている状況です。

ReactにはJSXという独自フォーマットがあり、Javascriptを拡張してPHPのようにコード内に構造(HTML)を記述します。Riot.jsはWeb Components-Likeを謳っていて、HTML内のscriptタグにコードを埋め込むようです(スタイルは別途定義なのかは、実際に使ってないので不明)。これに対してVue.jsのSingle File Components(.vue)は、構造(template)とスタイル(style)とコード(script)を1つのファイルにまとめて書ける独自フォーマットで、個人的にはこれがかなり筋がいい気がしています。

例えば、音関係はボタンそのものとaudio要素、ホバー時のスタイルにcookieやウィンドウのフォーカスが外れた時の処理など、全てsound-button内に閉じ込めることができてスッキリしました4。

sound-button.vue


a(v-on:click="click") div span SOUND audio(loop) source(src='/dna-of-forests/sound/forest.mp3' type='audio/mp3') &nbsp; <style lang="sass?indentedSyntax" scoped> a<br /> >div<br /> height: 100%<br /> background-position: center<br /> background-repeat: no-repeat<br /> background-image: url(/dna-of-forests/img/gnav/sound-on@2x.png)<br /> background-size: 18px 15px<br /> transition-duration: 0.2s<br /> opacity: 0.5<br /> >span<br /> display: none<br /> &:hover<br /> >div<br /> opacity: 0.9<br /> &.off<br /> >div<br /> background-image: url(/dna-of-forests/img/gnav/sound-off@2x.png)<br /> background-size: 17px 15px<br /> </style> <script> import Vue from 'vue'; import MobileDetect from 'mobile-detect'; import Cookies from 'js-cookie'; export default Vue.extend({ mounted: function() { var md = new MobileDetect(window.navigator.userAgent); this.sound = this.$el.querySelector('audio'); this.sound.volume = 0.3; if(md.mobile()){ // モバイルのときだけ、ウィンドウが背面にいくとサウンド停止(戻ってくると復帰) window.addEventListener('blur', ()=> { if(!this.sound.paused){ this.pauseByWindowBlur = true; this.toggleSound(false); } else{ this.pauseByWindowBlur = false; } }, false); window.addEventListener('focus', ()=> { if(this.pauseByWindowBlur){ this.toggleSound(true); } }, false); } this.toggleSound(!(md.mobile() || parseInt(Cookies.get('isSoundOn'),10)==0)); }, methods: { click(){ this.toggleSound(this.$el.className === 'off'); }, toggleSound(_on){ if(_on){ this.sound.play(); Cookies.set('isSoundOn', 1); this.$el.className = ''; } else { this.sound.pause(); Cookies.set('isSoundOn', 0); this.$el.className = 'off'; } } }, data: function(){ return { pauseByWindowBlur: false }; } }); </script>

そして、こいつを使うときには


てな感じで書きます。

JSXと違って、同じファイル内でtemplate, style, scriptの3つのスコープがしっかり区切られて可読性が高いです。また、属性にlang="pug"と書けば、生のHTMLの代わりにPug(旧Jade)形式で記述することもできて、そこもJSXより好きなところです。注意点は、sassを使いたい場合はlang="sass?indentedSyntax"と書かないといけないところです。単にlang="sass"だと、scssで解釈されます。

style要素にscoped属性をつけると、レンダリングされた時にインスタンス毎にハッシュクラスを作って紐付けてくれるので、styleの影響範囲をモジュール内に収められるのも良かったです。

DNAカラーバーコードの描画

各サンプルのDNA配列をカラーバーにして表示する部分は、パノラマページ、リストページ、ドロワー部分でそれぞれ微妙に違う表現になっていますが、これらは、A・T・G・C5の並んだ文字列を与えるとcanvasにバーコードとして描画するというシンプルなコンポーネントを使って実装しています。パノラマでは、描画したものをBase64にしてbackground-imageに指定し、cssのkeyframeとbackground-positionでアニメーションさせています。

vue-router

vue-routerという本家のルーターがあるのも良かった点です6。ルートにはパラメータを含めることもできるので、マーカー毎にindexを振って固有のURLを発行することができました。ルート設定の部分を下に貼っておきます。panorama、list、aboutなど、ページ毎にコンポーネント化し、topページはpanoramaページにmodalがかぶさるだけなので、同じインスタンスを共有するということもvue-routerの設定でできました。

const router = new VueRouter({
routes: [
{ path: '/', component: require('./components/page/panorama.vue') },
{ path: '/panorama', component: require('./components/page/panorama.vue') },
{ path: '/panorama/:index', component: require('./components/page/panorama.vue') },
{ path: '/list', component: require('./components/page/list.vue') },
{ path: '/list/:index', component: require('./components/page/list.vue') },
{ path: '/about', component: require('./components/page/about.vue') }
]
})

また、ドロワーが開いている状態で、リスト/パノラマを切替ても同じマーカーIDが引き継がれるようにして、URL的にも辻褄が合うようにしたのですが、このあたりは、Vue.jsのリアクティブシステムが大活躍です。こういうのをjQueryで実装するとコードが汚くなって萎えます…。

http://special.ycam.jp/dna-of-forests/#/list/s-15

上の状態で画面左のグローバルナビからパノラマビューに切り替えると、下のようにパノラマビューの方で15のマメヅタが選択された状態に遷移します。

http://special.ycam.jp/dna-of-forests/#/panorama/s-15

具体的には、こんなコードで実装しました。該当部分のみ抜粋です。

global-nav.vue


nav router-link.top(to='/' v-bind:exact="true") div span TOP router-link.panorama(v-bind:to="linkUrl('/panorama')") div span 360 router-link.list(v-bind:to="linkUrl('/list')") div span LIST router-link.about(to='/about') div span ABOUT   <style lang="sass?indentedSyntax" scoped> /* 省略 */<br /> </style> <script> import Vue from 'vue'; // 登録 Vue.component('sound-button', require('./sound-button.vue')); Vue.component('lang-button', require('./lang-button.vue')); export default Vue.extend({ methods: { linkUrl(default_path) { if(this.$route.params.index){ return default_path+'/'+this.$route.params.index; } else { return default_path; } } } }); </script>

Three.js

WebGLを使った表現にはThree.jsを使っています。THREE.SphereGeometryにtextureとしてvideo要素の内容を貼っています。動画は2秒間の動画をループ再生しています。3840x1920pxの4K動画なので、あまり長尺だとデータサイズ的に流石に厳しかったためです。動画は、時間をずらした2つの動画をアルファをつけてフェードイン・フェードアウトさせて重ね、継ぎ目に違和感が出ないようにしました。

マーカーの配置

マーカーの座標は、先の記事で紹介したYCAMの方に撮ってもらった360°パノラマ動画と見比べながら、手作業で決めていきました。まず緯度経度(latitude, longtitude)を合わせ、その後カメラからの距離(radius)を目視で設定していきました。Sphereの大きさやマーカーの大きさなどをできるだけ実際のサイズに合わせて、直感的に捉えられるようにしながら、なんとかやりました…。工夫した点としては、下記のようなコードでガイドラインを引いて少しでも調整しやすくしたことでしょうか。

// 10°区切りのメッシュグリッド
var sphere = new THREE.Mesh(
new THREE.SphereGeometry( 500, 36, 18 ),
new THREE.MeshPhongMaterial({
color: 0xffffff,
wireframe: true
})
);
this.scene.add(sphere);

// 3軸ガイド
var axis = new THREE.AxisHelper(300);
axis.position.set(0,-10,0); // 原点だとカメラと同じ視点になるので表示されないので少しずらす
this.scene.add(axis);

そして、できた設定値を、今度は空間内に実際にマーカーを配置するために、XYZの座標に変換する必要があったので、下記のようなコードで変換します。

// 緯度経度とカメラからの距離を空間座標に変換
geoCoordsToVector(latitude, longitude, radius) {

// 仰角
var phi = (latitude) * Math.PI / 180;
// 方位角
var theta = (longitude - 180) * Math.PI / 180;

var x = -(radius) * 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);
}

このように、サンプル・マーカーは内部的には3D空間内の座標を持っていますが、実際に描画されているマーカーはHTML要素で、canvasの上に重ねています。描画クオリティを考慮の上、この仕様にしました。

描画更新の度に、下記の関数を使って平面上の位置を計算し、アンカー位置を更新しています。

// canvas平面上の位置に変換
verctorToXY(vector) {

var vector = vector.clone();
var widthHalf = 0.5*this.renderer.context.canvas.width;
var heightHalf = 0.5*this.renderer.context.canvas.height;

// 対角線上の位置も返してしまわないように判定
if(this.frustum.containsPoint(vector)) {

// Within camera
vector.project(this.camera);

vector.x = ( vector.x * widthHalf ) + widthHalf;
vector.y = - ( vector.y * heightHalf ) + heightHalf;

return {
x: vector.x/window.devicePixelRatio,
y: vector.y/window.devicePixelRatio
};
}
return null;
}

ポイントとしては、9行目でカメラ視野に入っているかどうかを判定している部分でしょうか。ここで、視野内にあるものだけ座標を返すようにしました。

以上、「森のDNA図鑑 仁保の森 2016」の制作過程を色々寄り道しながらまとめてみました。SPAや3Dなど、普段あまり使わないフォーマットで技術的なトライも出来たし、面白いサイトになったのではないかと思っています。この記事の内容が、少しでも誰かのお役に立てば嬉しいです。

フィードバックなどがあれば、@naokazu_terada までお願いします。 Please contact me (@naokazu_terada) if you have a question or feedback.

追記: ソースコードをGithubに公開しました。興味のある人は御覧ください。

  1. Single Page Application
  2. ちなみにMiddlemanにはProject Templates というのも用意されてるんですが、Middlemanをグローバルにインストールしたくないので使ってません。いつもこのrepositoryをcloneするところから始めてます(アナログ…)。
  3. 今読み返すとこの記事、Riot.js側のアップデートにともなってか、更新されていました。こうしたドキュメントの充実度もVeu.jsの凄さですね。プロダクトオーナーのEvan YouさんがPatreonで支持を集めてフルタイムコミッターになるなど、今熱いプロダクトです。
  4. コンポーネントという概念がなければ、HTMLの構造はHTMLファイルに、スタイルシートはCSSファイルに、コードはJavascriptファイルに、とバラバラに書かなくてはならず、自分で書いたものでもパッと該当箇所を探し当てるのが難しかったりします。
  5. DNAを構成する4つの塩基
  6. Reactだと何を使えばよいのか迷った経験があります。