Help us understand the problem. What is going on with this article?

Leafletを使ってオンラインでインタラクティブな座席表を作る

はじめてのleaflet.js
leafletの使い方を学びながら、座席表を作ります。

はじめに

GW前に会社の座席表の更新作業がありました。80人くらいです(はぁ)。
更新作業は、いつもどおりExcelに座席Noが入っていて、名前、社員番号を入れていくスタイル。座席Noはレイアウト図(製図みたいなもの)に書いてあって、画像を見ながら人を当てていくという苦行でした。

この作業は二度とやりたくないなと思いましたので、GWを使って座席表の仕組みを作ってみることにしました。

ゴール

座席表を作ります。

  • すでに所有しているオフィスの図面(画像)を使います。新しく製図をするとか、特別なソフトウェアにデータを移しなおすのはやりません。
  • 座席にマウスを当てると、誰が座っているのか情報を出します。人を検索できるようにします。検索したらその人の座席が分かるようにします

使ったもの

ライブラリ系

画像

座席レイアウトっぽい画像はこれを使いました。自分の会社の大会議室の9個分だけ取ってきたもの。座席番号は自分で書きました。
https://kanaxx.github.io/seatingchart/seatsample.png

アイコン系はいつもどおりいらすとやさんから借りてきました。

座席表にある名前は、疑似個人情報データ生成サービスで作りました。同性同名の方がいたらごめんなさい。

参考にしたサンプル

■本家のマニュアルにあるサンプル
マニュアル https://leafletjs.com/examples/choropleth/
動くサンプル https://leafletjs.com/examples/choropleth/example.html

■leaflet-fusesearchプラグインのサンプル
マニュアル https://github.com/naomap/leaflet-fusesearch
動くサンプル http://dev.cartocite.fr/CultureNantes/

fusesearchプラグインに付属していたサンプルはとってもよくできていて、ソースを読んで改造したら、座席表ができてしまいました。ありがとう:grinning:

できたもの

こんな感じのものができました。2番目のサンプルそのもの感があります。
https://kanaxx.github.io/seatingchart/

左上のプラスマイナスで、画像を拡大/縮小ができます。
部署単位で表示・非表示を切り替えるパネルがあります。
座席をクリックすると席の情報が表示できます。

image.png

虫眼鏡アイコンで文字列検索ができます。
検索結果をマウスで選択すると、その人の座席まで地図が移動します。

image.png

コードの解説

コードはこちらに置いてあります。
https://github.com/kanaxx/kanaxx.github.io/blob/master/seatingchart/index.html

まずはleafletで画像を表示

地図を表示するための空の

を作りidを振っておきます <div id="map">。L.map()メソッドの第一引数に、divのidの値を渡すとLeafletが地図を表示してくれます。
    //シートの画像
    var image = {
        url:    'seatsample.png',
        width:  1151,
        height: 560
    };

    var bounds = L.latLngBounds(
        [0, 0],
        [image.height, image.width]
    );

    var map = L.map('map', {
        crs: L.CRS.Simple,
        maxBounds: bounds.pad(1)
        ,zoom:0
        ,maxZoom:5
        ,minZoom:0
    });

    map.fitBounds(bounds);
    L.imageOverlay(image.url, bounds).addTo(map);

まず、leafletを使い始めて最初に陥る混乱は、座標系が緯度(Latitude)、経度(Longitude)の順であることです。普通のx,y座標だと横縦の順ですが、leafletは地図なので縦横です。leafletのマニュアルにあるLatLngは緯度経度のことです。

簡単に画像にまとめるとこんな感じです。
image.png

青が地図の緯度経度の座標で、[縦位置, 横位置]
画像の左下を[0,0]と置いて1番目の数が縦位置、2番目の数値が横位置。
上に行くと緯度の値が増え、右にいくと経度の値が増える。

赤が通常のウェブの座標系(横位置x,縦位置y)
divの左上を(0,0)と置いて、1番目の数が横位置、2番目の数値が縦位置を表します。
下にいくとyの値が増え、右にいくとxの値が増える。

setBoundsメソッドを実行すると、表示する画像の領域を指定することができます。試しに下のコードを実行し、縦横を半分の領域に画像を設定するとこんな感じになります。縮尺が変わった感じでしょうかね。

    var bounds = L.latLngBounds(
        [0, 0],
        [image.height/2, image.width/2]
    );

image.png

クリックして座標が確認できるツール置いときました。
https://kanaxx.github.io/seatingchart/check_event.html

座の位置を調べる

表示されている地図(画像)の状況で、席の座標を調べます。上で用意した座標を調べるツールで座席の画像を順番にクリックしていくのが簡単です。今回はこんな感じに座標の位置が決まりました。

    var seats = {
        "A-1":[144,424],
        "A-2":[144,270],
        "A-3":[144,116],
        "B-1":[504,426],
        "B-2":[504,274],
        "B-3":[504,116],
        "C-1":[864,428],
        "C-2":[864,270],
        "C-3":[864,116]
    }

この座標の数値は、webのpixel単位ではなくて地図の緯度経度系(latlng)です。ただし、緯度経度の順ではなくて経度緯度の順番で定義しています。理由は、地図にポイントを置くときにGeoJsonのgeometry pointオブジェクトを作るのですが、このpointの定義がxyの順なのです(:warning: ここでもう一回混乱しました)

■参考マニュアル
https://s.kitazaki.name/docs/geojson-spec-ja.html#id10

部門のリストを作る

部署ごとに席のマークを変えるため、部署の変数にアイコンを定義しています。部署ごとにLeafletのlayerを作り、地図に重ねます。layerのON/OFFを切り替えることで部署のアイコンを非表示にできます。空き席があると困るのでdpt00を空き席用の部署として作り、画像を用意しました。

    var deptList = {
        dpt00 : {name: "空き", icon:"checkbox_unchecked.png"},
        dpt01 : {name: "開発部", icon:"small_star2_skyblue.png"},
        dpt02 : {name: "営業部", icon:"money_yen_coin3.png"},
        dpt03 : {name: "総務部", icon:"simple_leaf3.png"},
    };

javascriptの変数自体はとてもシンプルです。

人のリストを作る

今回は、座席番号に人の情報を紐づけたjavascript変数を定義しています。基本的には、一人が複数の座席を持たないはずなのでこんな感じで。ジャイアンみたいな社員がいる場合には、社員番号と席番号をくっ付ける形式がよいかもしれません。C-1は空き席を表現するため、キーなしの状態にしています。

    var staffList = {
        "A-1":{name:"長島太翼", empId:"s001", deptId:"dpt01", tel:"090-1111-1212"},
        "A-2":{name:"千田一輝", empId:"s002", deptId:"dpt02", tel:"090-2222-2121"},
        "A-3":{name:"山崎年紀", empId:"s003", deptId:"dpt03", tel:"090-3333-0000"},
        "B-1":{name:"太田正勝", empId:"s004", deptId:"dpt01", tel:"090-4444-0000"},
        "B-2":{name:"中谷朱里", empId:"s005", deptId:"dpt02", tel:"090-5555-0000"},
        "B-3":{name:"南部遥華", empId:"s006", deptId:"dpt02", tel:"080-1111-3456"},
        // "C-1":{name:"三橋義雄", empId:"s007", deptId:"dpt02", tel:"090-000-0000"},
        "C-2":{name:"小沼咲雪菜", empId:"s008", deptId:"dpt01", tel:"080-2222-2345"},
        "C-3":{name:"長島太翼", empId:"s001", deptId:"dpt01", tel:"080-3333-1234"},
    };

GeoJsonを作る

Leaflet.jsで地図にプロットを簡単にしてくれるGeoJSONと同質のオブジェクトを作ります。
fusesearch付属のサンプルでは座席(id)と座標(geometory)、その席に座っている人の情報(property)を1つの大きなGeoJSONデータとして作っていましたが、メンテナンス性が悪いので、席、人、部署をバラバラにしたものをjavascriptの中で組み立てることにしました。
本番稼働時には、座席と社員の情報を管理したデータベースを作ってそこからGeoJSON形式で出力するAPIを作ると思います。

    seatJson = {"type":"FeatureCollection","features":[]};
    for( s in seats){
        f = {"type":"Feature"};
        f.id = s;

        geo = {"type":"Point","coordinates":[]};
        geo.coordinates=seats[s];
        f.geometry = geo;

        staff = staffList[s];
        if(staff){
            dept = deptList[staff.deptId];
            staff.dept = dept;
            f.properties = staff;
        }else{
            staff = {};
            staff.name = '空き';
            staff.deptId = 'dpt00';
            staff.dept = deptList['dpt00'];
            f.properties = staff;
        }
        seatJson.features.push(f);
    }

一番外側にfeatureの配列があり、featureの中には、idgeometrypropertiesがあります。geometryはtype=pointとして2点座標を渡す形で表現します(ここが緯度経度じゃなくて経度緯度なのです)。

fusesearchプラグイン付属のサンプルのGeoJSONファイル。大きな1つのファイルでもOKです。
http://dev.cartocite.fr/CultureNantes/data/lieux_culture_nantes.json

Iconインスタンスを作る

部署ごとに定義したicon画像のファイルをLeafletで使えるようにIconのインスタンスに変換しておく処理。正確にはsetupIconsは関数なので、setupIcons変数に入っている関数が実行されたら戻ってくるオブジェクトにiconが入っている感じ。

    var setupIcons = function() {
        var icons = {};
        for (var dptId in deptList) {
            var icon = deptList[dptId].icon;
            var url = "icons/" + icon;
            var icon = L.icon({
                iconUrl: url,
                iconSize: [30, 30],
                //iconAnchor: [20, 20],
                popupAnchor: [0, -28]
            });
            icons[dptId] = icon;
        }
        return icons;
    };

■参考マニュアル
https://leafletjs.com/reference-1.4.0.html#icon

部署ごとのレイヤーと右の検索パネルを作る

部署ごとにアイコンのON/OFFを切り替えるために、部署ごとにlayerを作ってそこにアイコンをはめていくための下地作り。

L.control.layers()で作ったControl.Layersには、addBaseLayerとaddOverlayというメソッドがあり、addBaseLayerで追加するとラジオボタンでの選択式、addOverlayで追加するとチェックボックス選択式のコントロールが生成され、その選択状態とレイヤーの表示状態がリンクする。
今回は、部署ごとにfeatureGroupというレイヤーを作り、Control.LayersにOverlayとして登録していくことで、部署単位での表示・非表示を実現しています。

FeatureGroupは、markerなどを置くのに便利なレイヤーのクラスでlayerの一種です。bindPopupこのコードでは部署ごとのFeatureGroupを作ってlayers変数に貯めておくところまでです。

最後にdisplayFeatures関数を呼び出しアイコンの表示を行います。

    var layers = {},
        allDeptLayer = L.layerGroup(),
        layerCtrl = L.control.layers();

    for (var dptId in deptList) {
        var layer = L.featureGroup();
        layers[dptId] = layer;
        allDeptLayer.addLayer(layer);

        //検索パネル用
        var dpt = deptList[dptId],
            desc = '<img class="layer-control-img" src="icons/' + dpt.icon + '" width="20"> ' + dpt.name;
        layerCtrl.addOverlay(layer, desc);
    }
    layerCtrl.addTo(map);
    allDeptLayer.addTo(map);

    var icons = setupIcons();
    displayFeatures(seatJson.features, layers, icons);

■参考マニュアル
https://leafletjs.com/examples/layers-control/
https://leafletjs.com/reference-1.4.0.html#control-layers
https://leafletjs.com/reference-1.4.0.html#featuregroup

FuseSearch用のレイヤーを登録

ここはさらっと。
虫眼鏡マークと検索ボックス、検索結果を作ってくれるFuseSearch用のコードです。

showResultFctはカスタマイズ可能です。geojsonに入っているfeatureが渡ってくるので、featureとpropertiesあたりを駆使して見やすい表示を組み立ててください。
fuseSearchCtrl.indexFeaturesは、たくさんのfeatureから文字列検索を可能にするプロパティを指定してindex作成をするメソッドです。今回は、社員名、社員ID、部署IDと部署名で検索可能にしています。ネストしたプロパティもちゃんと動きました。

    var options = {
        position: 'topright',
        title: '検索',
        placeholder: '社員名、部署名',
        maxResultLength: 15,
        threshold: 0.5,
        showInvisibleFeatures: true,
        showResultFct: function(feature, container) {

            //検索結果に表示する内容を決める
            //geojsonのfeatureとcontainerが渡されるので頑張って組み立てる
            props = feature.properties;
            var name = L.DomUtil.create('b', null, container);
            name.innerHTML = props.name;
            container.appendChild(L.DomUtil.create('br', null, container));
            var info = '' + props.dept.name + ', ' + props.empId;
            container.appendChild(document.createTextNode(info));
        }
    };
    var fuseSearchCtrl = L.control.fuseSearch(options);

    var props = ['name', 'empId', 'deptId', 'dept.name'];
    fuseSearchCtrl.indexFeatures(seatJson.features, props);
    map.addControl(fuseSearchCtrl);

Featureを地図上にマッピング

いままで準備してきたlayer上geojsonのFeatureを置くところです。

呼び出しは1行だけで、feature(座席の座標と人の情報)の配列、部署ごとのLayerの配列、部署ごとのiconの配列を全部渡して委譲する形です。丸投げですね。

    //呼び出し元
    displayFeatures(seatJson.features, layers, icons);

■実際にマップにアイコンを乗せる部分のコード
getJson形式のデータを使って地図上にアイコンを乗っけるには、L.geoJsonメソッドを使います。一番簡単にgeoJson情報をマップに乗っけるには、次の一行だけでできてしまいます。

L.geoJson(seatJson).addTo(map);

image.png

この方法だと全部のfeatureが(しかもデフォルトのアイコンで)乗っかります。意図したものと違います。一括ではできないのでgeoJsonのfeatureを1つずつ処理することにします。
getJsonメソッドの第二引数には、optionとしてオブジェクトを渡すことができます。pointToLayer:onEachFeature:はoptionオブジェクトのキーで値には関数を渡しています。

pointToLayerに渡した関数は、geoJsonメソッドがmarkerをpointする前に呼ばれます。渡した関数が気の利いたマーカーを返すようにしておけば、featureを地図に置くときのmarkerをカスタマイズできるということですね。部署IDを使ってmarkerの画像を切り替えるように仕込んであります。
onEachFeatureも同じような理屈です。自作したonEachFeature関数(名)を渡しています。

geoJsonメソッドが返すレイヤー(1個のfeatureを乗っけた新しいレイヤー)を、layersに登録してある部署単位のレイヤー(実態はfeatureGroupレイヤー)に追加します。これで、地図にプロットするのは完成です。

2019/4/30
よくよく考えると、席に部署のアイコンを表示しても意味が薄いので、社員ごとのアイコンを表示するように変更しました。
zoomしたときにアイコンのサイズを変えるために、return markersに変更しました。

    function displayFeatures(features, layers, icons) {

        var markers = [];

        for (var id in features) {
            var feat = features[id];
            var dptId = feat.properties.deptId;

            //座席を地図上にポイントするのはL.geoJsonを呼び出すだけ
            var site = L.geoJson(feat, {
                pointToLayer: function(feature, latLng) {
                    var photo = icons[dptId];
                    //社員番号が取れる場合=空き席ではない場合は、
                    //photoフォルダから社員ID.pngを取り出す
                    if(feature.properties.empId){
                        photo = L.icon({
                            iconUrl: 'photo/' + feature.properties.empId + '.png',
                            iconSize: iconsize(),
                            iconAnchor: [20, 20],
                            popupAnchor: [0, -30]
                        });
                    }
                    var marker = L.marker(latLng, {
                        icon: photo,
                        keyboard: false,
                        riseOnHover: true
                    });
                    markers.push(marker);
                    return marker;
                },
                onEachFeature: onEachFeature
            });
            var layer = layers[dptId];
            if (layer !== undefined) {
                layer.addLayer(site);
            }
        }
        return markers;
    }

■参考マニュアル
https://leafletjs.com/reference-1.4.0.html#geojson

最後にマウスクリックした処理

自作のonEachFeature関数は、getJsonメソッドからonEachFeatureのイベントで呼び出されます。geoJsonメソッド内で、新しく作ったレイヤーにfeatureを追加するたびに呼び出されます。

A Function that will be called once for each created Feature, after it has been created and styled. Useful for attaching events and popups to features. The default is to do nothing with the newly created layers:

featureとlayerを受け取れるので、featureのpropertiesからデータを取り出し文字列を組み立てて、LayerクラスのbindPopupメソッドに表示したい文字列を渡すだけです。

    function onEachFeature(feature, layer) {
        // Keep track of the layer(marker)
        feature.layer = layer;

        var props = feature.properties;
        if (props) {
            var desc = '<span id="feature-popup">';
            desc += '<strong>' + props.name + '</strong><br/>';
            desc += '<em>部署:' + props.dept.name + '</em></br>'            

            var tel = props.tel;
            if (tel) {
                desc += tel + '</br>';
            }

            desc += '</span>';
            layer.bindPopup(desc);
        }
    }

こんな感じのポップアップ吹き出しが設定できます。表示したい内容をカスタマイズする場合には、このメソッドを書き換えればよいです。

image.png

■参考マニュアル
https://leafletjs.com/reference-1.4.0.html#geojson-oneachfeature
https://leafletjs.com/reference-1.4.0.html#layer-bindpopup

さらに、ズームしたらアイコンのサイズを変える

縮小状態でアイコンが大きく見え過ぎるので、ズームでアイコンのサイズを変えてみます。

地図に乗っかっているiconは、markerのインスタンスです。markerはsetIconメソッドを持っているので、marker全員の把握していれば作ったあとからでも変更は可能です。ということで、markerを作っているdisplayFeatures関数から作ったmarkerの配列を戻すように変更しました。

呼び出し方はこう変わります。
(displayFeaturesの実装もちょっと変わります)

    var markers = displayFeatures(seatJson.features, layers, icons);

mapのzoomendイベントで、zoom値を使いアイコンのサイズを変更することにします。
やっていることはすごくシンプルで、markerインスタンスを全部ループしながら、新しいIconのインスタンスを生成しsetIconしているだけです。iconsizeを決める関数は、試行錯誤の結果、いい感じの数値が返るように実装したのでzoomの値に依存しまくります。あまり頭のいい方法ではないですが。

    //iconのサイズを決める関数。leaflet.mapのzoom値に依存しまくる
    function iconsize(x){
        if( x == -2 ){ return [15,15]; }
        if( x == -1 ){ return [30,30]; }
        if( x == 0 ){ return [60,60]; }
        if( x == 1 ){ return [150,150]; }
        return [30,30];
    }

    //zoomendに仕掛けるイベントハンドラ関数
    function onZoom(e){
        z = map.getZoom();
        // console.log(z);
        newsize = iconsize(z);
        for( m in markers){
            marker = markers[m];
            src = marker._icon.currentSrc;

            var icon = L.icon({
                iconUrl: src,
                iconSize: newsize,
                popupAnchor: [0, -10]
            });
            marker.setIcon(icon);
        }
    }

    //イベントハンドラ登録
    map.on('click', onMapClick).on('zoomend', onZoom);

最後にmapにイベントハンドラを登録して完了です。

■参考マニュアル
https://leafletjs.com/reference-1.4.0.html#map-zoomend
https://leafletjs.com/reference-1.4.0.html#marker-seticon

まとめ

今回はleafletの勉強がてら座席表を作ってみました。まぁ、自分で作った部分は少なくてサンプルコードをなめまわし、マニュアルと比較対照しながらで何をやっているのかを理解するだけでしたけど。

GWの残った仕事は、80人分の座席をマウスでクリックして座標を特定することと、Excelに溜まっている社員の情報と座席の関連をjson化することです。つらい。

次回以降は、座席情報を簡単に編集できるような仕組みを考えます。Firebaseか何かにデータを入れて変更するようなものがよいかな。何でつくろうかな。

参考にした資料

Excel管理の座席表をLeafletでWeb化した話
https://engineering.linecorp.com/ja/blog/floor-map-management-system-on-web-with-leaflet/

leaflet > Search & popups プラグイン群 (2018.6.13)
https://qiita.com/sugasaki/items/750ca1c899eea9a08e8c

GeoJSONのスキーマ日本語訳
https://s.kitazaki.name/docs/geojson-spec-ja.html

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした