GTFS-JPについてのメモ

地域交通の未来と公共交通オープンデータ - HackMDの発表資料「地域公共交通の未来と公共交通オープンデータ」を見て、GTSF-JPに興味を持ちました。 GTFS-JPの仕様書を確認し、Deck.GLで少し使ってみたので、メモしておきます。

GTFS-JPの概要

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

静的バス情報フォーマット(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に登録されています。 mvnrepository.com

とりあえず、試してみる

仕様書に目を通したところで、Bus-Vision | 両備バス | 岡電バス | 中鉄バス | 岡山 | バスロケから、静的データ、リアルタイムデータを配布している両備バスhttps://www.ryobi-holdings.jp/bus/)のデータを取得して触ってみました。

GTFS-JPからJSONを生成

手っ取り早く試すため、下記のコードを作成し、GTFS-JPのZIPファイルをJSONに変換しました。

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.net.URL;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.transit.realtime.GtfsRealtime.FeedEntity;
import com.google.transit.realtime.GtfsRealtime.FeedMessage;

public class GtfsJpParser {
    private static Pattern csvDiv=Pattern.compile("\"([^\"\\\\]*(\\\\.[^\"\\\\]*)*)\"|([^,]+)|,|");

    /*
    * GTFSリアルタイムの処理(出力テスト)
   */
    public static void getJsonGtfsjpRealtime(String _url) throws IOException{
        URL url = new URL(_url);
        FeedMessage feed = FeedMessage.parseFrom(url.openStream());
        for (FeedEntity entity : feed.getEntityList()) {
          if (entity.hasTripUpdate()) {
            System.out.println(entity.getTripUpdate());
          }
        }
    }

    /*
    * GTFS-JPの処理(URL)
   */
    public static String getJsonGtfsjpStatic(String _url) throws IOException,ParseException{
        URL url=new URL(_url);
        InputStream is=url.openStream();
        return getJsonGtfsjpStatic(is);
    }

    /*
    * GTFS-JPの処理(ファイル)
   */
    public static String getJsonGtfsjpStatic(File f) throws IOException,ParseException{
        FileInputStream is=null;
        try{
            is=new FileInputStream(f);
            return getJsonGtfsjpStatic(is);
        }finally{
            is.close();
        }
    }

    public static String getJsonGtfsjpStatic(InputStream is) throws IOException,ParseException{
        ZipInputStream zis=new ZipInputStream(is);
        ZipEntry ze;
        byte[] buf= new byte[1024];
        StringBuffer sb=new StringBuffer();
        Map<String,Object> map=new HashMap<String,Object>();
        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);
            map.put(ze.getName().replace(".txt", ""),json2Csv(csv));
            sb.delete(0, sb.length());
        }
        Gson gson=new GsonBuilder().setPrettyPrinting().create();
        return gson.toJson(map);
    }

    private static List<Map<String,Object>> json2Csv(String[][] csv)throws ParseException{
        List<Map<String,Object>> list=new ArrayList<Map<String,Object>>();
        String[] title=csv[0];
        for(int i=1;i<csv.length;i++){
            list.add(createMap(title,csv[i]));
        }
        return list;
    }

    private static Map<String,Object> createMap(String[] title,String[] val)throws ParseException{
        Map<String,Object> ret=new HashMap<String,Object>();
        for(int i=0;i<val.length;i++){
            if(title[i]==null)continue;
            if(title[i].endsWith("_lon")||title[i].endsWith("_lat")){
                ret.put(title[i], Double.parseDouble(val[i]));
            }else if(title[i].endsWith("_date")){
                ret.put(title[i], val[i]);
            }else if(title[i].endsWith("_time")){
                ret.put(title[i], val[i]);
            }else{
                ret.put(title[i], val[i]);
            }
        }
        return ret;
    }

    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()]);
    }
}

Deck.GLで可視化

下記のコードを作成し、バス停とShape.txtで規定されている路線をDeck.GLで可視化してみました。 なお、GTFS-JPデータのサイズは、両備バスのデータをJSONに変換した場合で50MBくらいになりますので、本来はDBに格納して利用するのが正しいやりかただと思います。

f:id:termat:20190519154821p:plain

<!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: #ffffff00;
                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="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;
        const route_name={};

        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 loadData = () => {
            d3.json("okayama_ryoubi.json", (error, response)=>{
                setRouteName(response.routes);
                let stop=renderStopLayer(getStopData(response.stops));
                let shape=renderRouteLayer(getRouteData(response.shapes));
                deckgl.setProps({
                    layers: [shape,stop],
                    viewState: {
                        longitude: c_lon,
                        latitude: c_lat,
                        zoom: 11,
                        transitionInterpolator: new FlyToInterpolator(),
                        transitionDuration: 3000
                    }
                })
            });
        };
        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: 10,
                getFillColor: [255, 140, 0],
                getLineColor: [0, 0, 0],
                pickable: true,
                onHover: updateTooltipStop
            });
            return pointlayer;
        };
        const renderRouteLayer = (data) => {
            const pathLayer = new deck.PathLayer({
                id: 'route',
                data,
                pickable: true,
                widthScale: 20,
                widthMinPixels: 2,
                getPath: d => d.path,
                getColor: d => d.color,
                getWidth: 4,
                pickable: false,
            });
            return pathLayer;
        };

        const getStopData=(feature) =>{
            let data=[];
            const n=feature.length;
             for(let i=0;i<n;i++){
                let obj={};
                obj.coordinates=[feature[i].stop_lon,feature[i].stop_lat];
                c_lon +=feature[i].stop_lon;
                c_lat +=feature[i].stop_lat;
                obj.name=feature[i].stop_name;
                obj.zone_id=feature[i].zone_id;
                obj.stop_id=feature[i].stop_id;
                data.push(obj);
            }
            c_lon=c_lon/n;
            c_lat=c_lat/n;
            return data;
        };
        const getRouteData=(feature) =>{
            let data=[];
            const n=feature.length;
            let path=[];
            let sec=0;
            for(let i=0;i<n;i++){
                let c=Number(feature[i].shape_pt_sequence);
                if(c==sec+1){
                    sec++;
                    path.push([feature[i].shape_pt_lon,feature[i].shape_pt_lat]);
                }else{
                    let obj={};
                    obj.path=path;
                    obj.color=[0,0,255];
                    obj.id=feature[i].shape_id;
                    data.push(obj);
                    sec=c;
                    path=[];
                }
            }
            if(path.length>0){
                let obj={};
                obj.path=path;
                obj.color=[0,0,255];
                obj.id=feature[feature.length-1].shape_id;
                data.push(obj);
            }
            return data;
        };
        const setRouteName=(feature) =>{
            const n=feature.length;
            for(let i=0;i<n;i++){
                let id=feature[i].route_id;
                let name=feature[i].route_long_name;
                id=id.substr(id.length-10,10);
                if(!(id in route_name)){
                    route_name[id]=name;
                }
            }
        };

        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+"<br />"+object.stop_id+"</p>";
            } else { 
                tooltip.style.visibility="hidden";
                tooltip.innerHTML = "";
            }
        };
        const updateTooltipRoute=({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>"+route_name[object.id]+"</p>";
            } else { 
                tooltip.style.visibility="hidden";
                tooltip.innerHTML = "";
            }
        };
        loadData();
    </script>
</html>

さいごに

だいたい、GTFS-JPの概要を把握できたので、今後、DBを作成し、Deck.GLのTripsLayerを試したみたいと思います。