Deck.GLのTripLayerでGTFS-JPのバス運行状況を可視化してみた

## 概要
[地域交通の未来と公共交通オープンデータ - HackMD](https://hackmd.io/s/BkKw84UPE)の発表資料「[地域交通の未来と公共交通オープンデータ](https://www.slideshare.net/niyalist/ss-145762801)」を見て、GTSF-JPに興味を持ちました。
GTFS-JPの仕様を確認したところ面白そうだったので、GTFS-JPデータを公開している[両備バス](https://www.ryobi-holdings.jp/bus/)の運行状況をDeck.GLのTriplayerで可視化してみました。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">両備バス(<a href="https://t.co/VclrIjVPxZ">https://t.co/VclrIjVPxZ</a>)のGTFS-JPを<a href="https://t.co/3DasovoRX8">https://t.co/3DasovoRX8</a>のTripLayerで可視化してみました。 <a href="https://t.co/2WJPtzZthN">pic.twitter.com/2WJPtzZthN</a></p>&mdash; t_mat (@t_mat) <a href="https://twitter.com/t_mat/status/1132329412030939136?ref_src=twsrc%5Etfw">May 25, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

なお、今回作成したコードはGithubにおいています。
[Github:gtfsjp_tmat_test](https://github.com/termat/gtfsjp_tmat_test)

## GTFS-JPの概要
GTFS（General Transit Feed Specification）は公共交通機関の時刻表と地理的情報に関するオープンフォーマットであり、GTFSを日本の「標準的なバス情報フォーマット」として拡張されたものがGTFS-JPです。
国土交通省・公共交通政策ホームページでは、「静的バス情報フォーマット（GTFS-JP）」と「動的バス情報フォーマット（GTFSリアルタイム）」についての仕様が示されています。

 - [GTFS-JP仕様書](http://www.mlit.go.jp/sogoseisaku/transport/sosei_transport_tk_000112.html)
 - [GTFS-JPサイト](https://www.gtfs.jp/)
 - [GTFS-Githubプロジェクト](https://github.com/google/transit)

## 静的バス情報フォーマット（GTFS-JP）
GTFS-JPは、バス路線の事業者やバス停、バス路線の情報を最大17個のCSVファイルで整理し、Zip形式で配布されます。 配布データは単なるCSVなので、テキスト処理で諸情報を入手することができます。

 - agency.txt（事業者情報）
 - stops.txt（停留所・標柱情報）
 - routes.txt（経路情報）
 - trips.txt（便情報）
 - office_jp.txt（営業所情報）
 - stop_times.txt（通過時刻情報）
 - calendar.txt（運行区分情報）
 - fareattributes.txt（運賃情報）
 - farerules.txt（運賃定義情報）
 - feed_info.txt（提供者情報）
 - agency_jp.txt（事業者追加情報）
 - routes_jp.txt（経路追加情報）
 - calendar_dates.txt（運行日情報）
 - shapes.txt（描写情報）
 - frequencies.txt（運行間隔情報）
 - transfers.txt（乗換情報）
 - translations.txt（翻訳者情報）

## 動的バス情報フォーマット（GTFSリアルタイム）
GTFSリアルタイムでは、遅延等のルート最新情報や⾞両位置情報、運⾏情報等のリアルタイム除法をProtocol Bufferというデータ構造が規定されたバイナリデータ（*.bin）で配布されます。 なお、GTFSリアルタイムについては、バイナリデータ読み取り用クラスを生成する「gtfs-realtime-bindings」がMavenRepositiryに登録されています。

 - [gtfs-realtime-bindings](https://mvnrepository.com/artifact/com.google.transit/gtfs-realtime-bindings/0.0.4)

## GTFS-JPからDB（Sqlite3）を生成する
仕様書に目を通したところで、[Bus-Vision](https://loc.bus-vision.jp/ryobi/view/opendata.html)から、GTFS-JPを配布している[両備バス](https://www.ryobi-holdings.jp/bus/)のデータをSqliteに導入しています。
処理の概要は以下のとおりです。なお、ORマッパーはomliteを使用しています。
※2019/06/05追記：都営バスGTFS-JPを読み込ませる際には、空カラム(,,）の処理が必要です。

```java
	private static Pattern csvDiv=Pattern.compile("\"([^\"\\\\]*(\\\\.[^\"\\\\]*)*)\"|([^,]+)|,|");

	/** GTFS-JPファイル(ZIPファイル)からCSVデータを取得してテーブルを作成*/
	public void buildGtfsDB(InputStream is) throws IOException,ParseException{
		ZipInputStream zis=new ZipInputStream(is);
		ZipEntry ze;
		byte[] buf= new byte[1024];
		StringBuffer sb=new StringBuffer();
		while ((ze=zis.getNextEntry()) != null) {
            int size = 0;
            while((size=zis.read(buf))>0){
            	String str=new String(buf,0,size);
            	sb.append(str);
            }
            zis.closeEntry();
            BufferedReader br=new BufferedReader(new StringReader(sb.toString()));
            String[][] csv=parseCsv(br);
            String name=ze.getName().replace(".txt", "").toLowerCase();
            List<Map<String,Object>> list=json2Csv(csv);
            try{
                create(name,list);
            }catch(SQLException e){
            	e.printStackTrace();
            }
            sb.delete(0, sb.length());
        }
	}

	private void create(String name,List<Map<String,Object>> list)throws SQLException{
		if(name.equals("agency")){
			create(this.agencyDao,list);
		}else if(name.equals("agency_jp")){
			create(this.agencyJpDao,list);
		}else if(name.equals("calendar")){
			create(this.calDao,list);
		}else if(name.equals("calendar_dates")){
			create(this.calDateDao,list);
		}else if(name.equals("fare_attributes")){
			create(this.fareAttrDao,list);
		}else if(name.equals("fare_rules")){
			create(this.fareRuleDao,list);
		}else if(name.equals("feed_info")){
			create(this.feedDao,list);
		}else if(name.equals("frequencies")){
			create(this.freqDao,list);
		}else if(name.equals("office_jp")){
			create(this.officeJpDao,list);
		}else if(name.equals("routes_jp")){
			create(this.routesJpDao,list);
		}else if(name.equals("routes")){
			create(this.routesDao,list);
		}else if(name.equals("shapes")){
			create(this.shapeDao,list);
		}else if(name.equals("stop_times")){
			create(this.stopTimeDao,list);
		}else if(name.equals("stops")){
			create(this.stopsDao,list);
		}else if(name.equals("transfers")){
			create(this.transfersDao,list);
		}else if(name.equals("translations")){
			create(this.translationslDao,list);
		}else if(name.equals("trips")){
			create(this.tripsDao,list);
		}
	}

	/** setter/getterを使うのはめんどいので、一度JSONを生成し、
		JSONからオブジェクトを生成してDBにぶっこんでます*/
	@SuppressWarnings({ "rawtypes", "unchecked" })
	private void create(Dao dao,List<Map<String,Object>> list)throws SQLException{
		final List array=new ArrayList();
        for(Map<String,Object> map : list){
        	String json=gson.toJson(map);
        	array.add(gson.fromJson(json,dao.getDataClass()));
        }
		TransactionManager.callInTransaction(dao.getConnectionSource(), new Callable<Void>(){
			public Void call() throws Exception {
				for(Object fp : array){
					dao.create(fp);
				}
				return null;
			}
		});
	}

	/** CSVファイルをパース*/
	private static String[][] parseCsv(BufferedReader reader)throws IOException{
		ArrayList<String[]> list=new ArrayList<String[]>();
		String line;
		while((line=reader.readLine())!=null){
			String[] sp=split(csvDiv,line);
			list.add(sp);
		}
		if(list.size()==0)return new String[0][0];
		return list.toArray(new String[list.size()][]);
	}

	private static String[] split(Pattern pattern,String line){
		Matcher matcher=pattern.matcher(line);
		List<String> list=new ArrayList<String>();
		int index=0;
		int com=0;
		while(index<line.length()){
			if(matcher.find(index+com)){
				String s=matcher.group().replaceAll("\"", "");
				index=matcher.end();
				list.add(s);
				com=1;
			}
		}
		return list.toArray(new String[list.size()]);
	}
```
## DBからTripLayer用データ（JSON）を生成する
次いで、Tripsからtrip_idを、Stop_timesからそのTripの停留所と発車時刻、Stopsから停留所の位置情報を取得して、TripLayer用データ（Json）を生成しています。
処理の概要は以下のとおりです。
なお、Tripデータは[経度、緯度、タイムスタンプ]の形で整理しており、タイムスタンプは32bit浮動小数点にする必要があるので、一度、Javaのシリアル値を生成した後、午前0時からの経過時間（分）に変換しています。また、今回は停留所のリストをバスの通行経路として扱いました。

```java

	/** DBからTripLayer用データを生成 */
	public String getTripData() throws SQLException,ParseException{
		Map<String,Object> map=new HashMap<String,Object>();
		List<GTrip> trips=getTrips();
		String ymd=getSurviceDate(trips.get(0));
		List<Map<String,List<Number[]>>> waypoints=new ArrayList<Map<String,List<Number[]>>>();
		long st=dateFormat.parse(ymd+" 00:00:00").getTime();
		for(GTrip t : trips){
			List<Number[]> wl=getWaypoints(t,st);
			Map<String,List<Number[]>> mm=new HashMap<String,List<Number[]>>();
			mm.put("segments", wl);
			waypoints.add(mm);
		}
		map.put("starttime", st);
		map.put("trips",waypoints);
		map.put("stops", stop2Map(getStops()));
		return gson.toJson(map);
	}

	private List<GStop> getStops()throws SQLException{
		return this.stopsDao.queryForAll();
	}

	private List<GTrip> getTrips()throws SQLException{
		return this.tripsDao.queryForAll();
	}

	/** Trip単位で通過経路（経度、緯度、時刻）のデータを生成 */
	private List<Number[]> getWaypoints(GTrip t,long sub)throws SQLException,ParseException{
		String ymd=getSurviceDate(t);
		QueryBuilder<GStopTime,Long> query=stopTimeDao.queryBuilder();
		query.where().eq("trip_id", t.trip_id);
		List<GStopTime> list=stopTimeDao.query(query.prepare());
		list.sort(new Comparator<GStopTime>(){
			@Override
			public int compare(GStopTime arg0, GStopTime arg1) {
				int v0=Integer.parseInt(arg0.stop_sequence);
				int v1=Integer.parseInt(arg1.stop_sequence);
				return (v0-v1);
			}
		});
		int n=list.size();
		List<Number[]> data=new ArrayList<Number[]>();
		for(int i=0;i<n;i++){
			GStopTime st=list.get(i);
			data.add(createWaypoint(st,ymd,sub));
		}
		return data;
	}

	private Number[] createWaypoint(GStopTime st,String ymd,long sub)throws ParseException,SQLException{
		long timestamp=getStopTimes(st,ymd);
		GStop sa=getStops(st.stop_id);
		return new Number[]{sa.stop_lon,sa.stop_lat,((double)(timestamp-sub))/(60.0*1000.0)};
	}

	private String getSurviceDate(GTrip t)throws SQLException{
		QueryBuilder<GCalendar,Long> query=calDao.queryBuilder();
		query.where().eq("service_id", t.service_id);
		List<GCalendar> list=calDao.query(query.prepare());
		String date=list.get(0).start_date;
		return date.substring(0, 4)+"-"+date.substring(4,6)+"-"+date.substring(6,8);
	}

	private GStop getStops(String stop_id)throws SQLException{
		QueryBuilder<GStop,Long> query=stopsDao.queryBuilder();
		query.where().eq("stop_id", stop_id);
		List<GStop> list=stopsDao.query(query.prepare());
		return list.get(0);
	}

	private long getStopTimes(GStopTime s,String ymd)throws ParseException{
		return dateFormat.parse(ymd+" "+s.arrival_time).getTime();
	}

```

## Deck.GLでTripLayer可視化
作成したJSONデータはTripLayerに合わせた形にしているので、そのまま読み込ましてTripLayerを作成しています。
今回は停留所のリストをそのまま通行経路としたため、直行便はほぼ一直線に目的地へ移動してしまいます。バス運行経路を正確に再現し、道路に沿って動くようにするにはShapes.txtのデータをトレースして[経度、緯度、タイプスタンプ]のデータのリストを生成する必要があります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/55735/caaae684-00a7-439c-c3e8-6867f1164c2b.png" width="900" />

```html
<!doctype html>
<html class="no-js" lang="ja">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="x-ua-compatible" content="ie=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <title>Traffic-Bus</title>
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/mapbox-gl/0.53.1/mapbox-gl.css" />
        <script src="https://code.jquery.com/jquery-3.4.0.js" integrity="sha256-DYZMCC8HTC+QDr5QNaIcfR7VSPtcISykd+6eSmBW5qo=" crossorigin="anonymous"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/mapbox-gl/0.53.1/mapbox-gl.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/deck.gl@7.0.0/dist.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/d3-scale/1.0.7/d3-scale.js"></script>
        <style type="text/css">
            html, body {
                padding: 0;
                margin: 0;
                width: 100%;
                height: 100%;
            }
            #panel {
                position: absolute;
                background: #ffffffaa;
                top: 0;
                left: 0;
                margin: 4px;
                padding: 4px;
                line-height: 1;
                width:260px;
                height:26px;
                z-index: 2;
                text-align: center;
                vertical-align: middle;
            }
            #tooltip {
                font-family: Helvetica, Arial, sans-serif;
                font-size: 12px;
                position: absolute;
                padding: 4px;
                margin: 8px;
                background: rgba(0, 0, 0, 0.8);
                color: #fff;
                max-width: 300px;
                z-index: 9;
                pointer-events: none;
            }
        </style>
    </head>
    <body>
        <div id="panel"></div>
        <div id="app" style="width:100%;height:100%;"></div>
        <div id="tooltip"></div>
    </body>
    <script type="text/javascript">
        const LAT=34.6;
        const LNG=135.5;
        let c_lon=0;
        let c_lat=0;
        let c_time=300;
        let triplayers=[];
        const deckgl = new deck.DeckGL({
            container: 'app',
            mapboxApiAccessToken: "アクセストークン",
            mapStyle: "mapbox://styles/mapbox/dark-v9",
            longitude: LNG,
            latitude: LAT,
            zoom: 11,
            pitch: 40,
            bearing: -10,
            onViewStateChange: ({viewState}) => {
                return viewState;
            }
        });
        const timeVal = () =>{
            return c_time;
        };

        const loadData = () => {
            d3.json("trips.json", (error, response)=>{
                setCenter(response.stops);
                let v_time=response.starttime;
                let trip=renderTripLayer(response.trips);
                let stop=renderStopLayer(response.stops);
                deckgl.setProps({
                    layers: [trip,stop],
                    viewState: {
                        longitude: c_lon,
                        latitude: c_lat,
                        zoom: 11,
                        transitionInterpolator: new FlyToInterpolator(),
                        transitionDuration: 3000
                    }
                });
                const update = () =>{
                    c_time +=0.1;
                    if(c_time>1439){
                        window.cancelAnimationFrame(thread);
                        return; 
                    }else{
                        trip.setState({time: c_time});
                    let da=new Date(v_time+c_time*60*1000);
                    $("#panel").text(da);
                    window.requestAnimationFrame(update);
                    }
                };
                let thread=window.requestAnimationFrame(update);
            });
        };
        const renderStopLayer = (data) => {
            const pointlayer = new deck.ScatterplotLayer({
                id: 'stop',
                data,
                fp64: false,
                opacity: 0.8,
                stroked: true,
                filled: true,
                radiusScale: 6,
                radiusMinPixels: 1,
                radiusMaxPixels: 100,
                lineWidthMinPixels: 1,
                getPosition: d => d.coordinates,
                getRadius: 8,
                getFillColor: [255, 140, 0],
                getLineColor: [0, 0, 0],
                pickable: true,
                onHover: updateTooltipStop
            });
            return pointlayer;
        };

        const renderTripLayer =(trips) => {
            const tripLayer = new TripsLayer({
                id: 'trips-layer',
                data: trips,
                getPath: d => d.segments,
                getColor: [253, 128, 93],
                opacity: 0.6,
                widthMinPixels: 3,
                rounded: true,
                trailLength: 15,
                currentTime: timeVal 
            });
            return tripLayer;
        }

        const setCenter=(feature) =>{
            const n=feature.length;
             for(let i=0;i<n;i++){
                c_lon +=feature[i].coordinates[0];
                c_lat +=feature[i].coordinates[1];
            }
            c_lon=c_lon/n;
            c_lat=c_lat/n;
        };

        const updateTooltipStop=({x, y, object}) => {
            const tooltip = document.getElementById("tooltip");
            if (object) {
                tooltip.style.visibility="visible";
                tooltip.style.top = y+"px";
                tooltip.style.left = x+"px";
                tooltip.innerHTML = "<p>"+object.name+"</p>";
            } else { 
                tooltip.style.visibility="hidden";
                tooltip.innerHTML = "";
            }
        };
        loadData();
    </script>
</html>
```

## 最後に
とりあえず、GTFS-JPからDBを作成し、Tripデータを作成して可視化するとこまで処理してみました。
GTFS-JPはCSVデータなので扱いが楽です。GTFSリアルタイムも「gtfs-realtime-bindings」を使用すると簡単にデータを処理できそうな感じです。また、両備バスの場合で、DB容量は約14MB（Sqlite）だったので、Android端末等に導入することもできそうだと思いました。
GTFS-JPの本格的な普及はこれからだと思いますが、少子高齢化や過疎化により地方の公共交通機関は色々な課題を抱えているので、かじっておくと何かしら地域の役に立つかも？と思いました。