1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【中編】GTFSを用いた交通情報表示アプリケーションを開発してみた【Unity+ARCore】

Last updated at Posted at 2025-11-13

はじめに

本記事は中編です。前編をご覧になっていない方は先に前編をご覧になることをお勧めします。

本記事でできること

Andoidスマートフォンで交通情報が表示されるARアプリを作成できる。
GTFSを用いて、実際の交通情報をAR上に表示することができる。

Videotogif (1).gif

目次

実装

中編の今回は、交通情報のデータ形式であるGTFSから交通情報の取得、UI上でのアイコンの表示を行います。

交通情報の取得/表示

では、GTFSを用いた交通情報の取得を実装していきます。前編の事前準備で取得した「公共交通オープンデータセンター」(ODPT)のアカウントが認証されたらログインし、ODPTセンター用アクセストークンからアクセストークンを取得してください。

情報辞書の作成

アクセストークンが取得出来たら、駅アイコンの表示に移ります。
まずは、ODPTから取得した駅情報を登録しておく辞書Scriptable Objectを作成します。

辞書スクリプト
Transit Static DB Asset.cs
using System;
using System.Collections.Generic;
using UnityEngine;

public enum DirectionKind { Up, Down }

[Serializable]
public class StationDef
{
    public string stationId;          // 一意ID(例: "odpt.Station:TokyoMetro.Chiyoda.KitaAyase")
    public string nameJa;
    public string nameEn;
    [Tooltip("緯度(latitude)")]
    public double lat;
    [Tooltip("経度(longitude)")]
    public double lon;
    public string parentStationId;    // 複合駅の親(任意)
}

[Serializable]
public class RouteBranchKey
{
    public string routeId;            // 例: "TokyoMetro.Chiyoda"
    public string branchId;           // 例: "Main" / "KitaAyase"
}

[Serializable]
public class DirectionMapEntry
{
    public RouteBranchKey line;
    public DirectionKind direction;
    [Tooltip("駅の通し順(上り/下りそれぞれの正準順序)")]
    public List<string> orderedStationIds = new List<string>();
}

public enum CheckpointTag { BranchNode, Hub, ShortTurn, LineUnique }

[Serializable]
public class CheckpointEntry
{
    public RouteBranchKey line;
    public DirectionKind direction;
    [Tooltip("次の停車 1〜3 駅と順序照合する“指紋”。並走識別用に分岐・大ハブ・短絡終点などを選定")]
    public List<string> checkpointStationIds = new List<string>();
    public List<CheckpointTag> tags = new List<CheckpointTag>(); // 任意
}

[Serializable]
public class DirectionCanonEntry
{
    public RouteBranchKey line;
    public DirectionKind direction;
    [Tooltip("この方面(終点グループ)に束ねる終点ID集合。表示ラベルの正規化用")]
    public List<string> destinationStationIds = new List<string>();
    [Tooltip("UI表示用の方面ラベル(例:常磐線(取手方面))")]
    public string labelJa;
    public string labelEn;
}

[Serializable]
public class MajorStationsEntry
{
    public RouteBranchKey line;
    public DirectionKind direction;
    [Tooltip("速さ/到達度の補助比較で見る“主要駅”2〜3駅")]
    public List<string> majorStationIds = new List<string>();
}

[Serializable]
public class FareRuleEntry
{
    public string operatorId;         // 例: "JR-East", "TokyoMetro", "Odakyu"
    public string trainTypeId;        // 例: "LimitedExpress", "Romancecar", "Rapid", "Local"
    public bool isExtraFare;          // 別料金なら true
}

[CreateAssetMenu(fileName = "TransitStaticDB", menuName = "Transit/Static DB")]
public class TransitStaticDBAsset : ScriptableObject
{
    [Header("Stations")] //駅情報
    public List<StationDef> stations = new List<StationDef>();

    [Header("Topology: Direction Maps")]  //各路線の停車順
    public List<DirectionMapEntry> directionMaps = new List<DirectionMapEntry>();

    [Header("Checkpoints (signature for direction disambiguation)")]  //チェックポイント
    public List<CheckpointEntry> checkpoints = new List<CheckpointEntry>();

    [Header("Direction Canon (Destination → Bucket)")]  //終点
    public List<DirectionCanonEntry> directionCanons = new List<DirectionCanonEntry>();

    [Header("Major Stations per direction")]  //主要駅
    public List<MajorStationsEntry> majorStations = new List<MajorStationsEntry>();

    [Header("Fare Rules")] //追加料金や車種など
    public List<FareRuleEntry> fareRules = new List<FareRuleEntry>();
}

登録する内容と使用する用途としては

  1. 駅情報:駅IDや緯度経度、名前などを保存。駅の方角判定や絞り込みに使用
    スクリーンショット 2025-11-07 173622.png

  2. 各路線の停車順:各路線の上りや下りの停車順を保存。上り下りの方面判定時に次に止まる駅がどこかで方面判定の補助を行う
    スクリーンショット 2025-11-07 173730.png

  3. チェックポイント:他路線に直通する分岐駅などを保存。千代田線→常磐線のような他路線に直通する時の方面判定や、山手線と京浜東北線など、一部区間が同じ駅順の路線の判定に使用
    スクリーンショット 2025-11-11 135111.png

  4. 終点:各路線の終点を保存。方角判定に使用
    スクリーンショット 2025-11-07 173826.png

以下今回は使用しなかったデータ
5. 主要駅:その路線が停車する主要駅(乗降者数上位や乗り換え多数駅など)を保存。今後、乗り換え案内をする際に、主要駅への到着速度の速さなどで優先順位付けに使用予定
6. 追加料金や車種など:特急など追加料金がかかるか、各駅停車か快速かなどの車種といったその他雑多な情報を保存。車種の表示や乗り換え案内時の料金表示に使用予定。

駅情報にParent Stationを作成し、複数路線の駅を一つにまとめる方法を考えましたが、StationId末尾の駅名から正規化を行う方法をとったため、Parent Stationも不要です。

DBAssetの作成ができたら次は情報の登録をしていきます。1路線だけや特定の駅だけなら手打ちも不可能では無いですが、今回は自動ツールを作成しました。

登録ツールサンプル 登録部分のみ
c# OdptAutoSeed_OperatorsLines.cs
     private async System.Threading.Tasks.Task RunSeedForSelection()
     {
         try
         {
             if (db == null) throw new InvalidOperationException("Static DB Asset is null.");
             EditorPrefs.SetString(EPF_KEY, consumerKey ?? "");
             Undo.RecordObject(db, "ODPT Auto-Seed");

             // (1) Stations backfill per operator
             Dictionary<string, StationInfo> stationMap = null;
             if (alsoSeedStations)
             {
                 stationMap = new Dictionary<string, StationInfo>(StringComparer.Ordinal);
                 float step = 1f / Mathf.Max(1, _selectedOpIds.Count);
                 int i = 0;
                 foreach (var opId in _selectedOpIds)
                 {
                     EditorUtility.DisplayProgressBar("ODPT ▸ Stations", $"Loading stations: {Short(opId)}", step * i);
                     var url = $"{baseUrl}/odpt:Station?odpt:operator={Escape(opId)}&acl:consumerKey={Escape(consumerKey)}";
                     var arr = await GetJsonArray(url);
                     var sub = BuildStationMap(arr);
                     foreach (var kv in sub) stationMap[kv.Key] = kv.Value;
                     i++;
                 }
                 EditorUtility.ClearProgressBar();
             }

             // (2) Seed lines
             int seeded = 0;
             foreach (var railId in _selectedRailIds.ToArray())
             {
                 if (!_railById.TryGetValue(railId, out var rw)) continue;
                 var ids = rw.stationOrder?.Where(s => !string.IsNullOrEmpty(s)).ToList();
                 if (ids == null || ids.Count < 2)
                 { Debug.LogWarning($"[AutoSeed] Skip {Short(railId)}: stationOrder missing/short."); continue; }

                 var key = new RouteBranchKey { routeId = Short(railId), branchId = "Main" };

                 // Up/Down maps
                 UpsertDirectionMap(db, key, DirectionKind.Up, ids);
                 var rev = new List<string>(ids); rev.Reverse();
                 UpsertDirectionMap(db, key, DirectionKind.Down, rev);

                 // Canon labels (ja)
                 string upTerminal = ids[^1];
                 string downTerminal = ids[0];
                 string titleJa = rw.titleJa ?? Short(railId);
                 string destUpJa = ResolveTitleJa(stationMap, upTerminal) ?? Short(upTerminal);
                 string destDnJa = ResolveTitleJa(stationMap, downTerminal) ?? Short(downTerminal);
                 UpsertCanon(db, key, DirectionKind.Up, new[] { upTerminal }, $"{titleJa}{destUpJa}方面)");
                 UpsertCanon(db, key, DirectionKind.Down, new[] { downTerminal }, $"{titleJa}{destDnJa}方面)");

                 // Checkpoints (sliding)
                 UpsertCheckpointsSliding(db, key, DirectionKind.Up, ids, checkpointWindow, checkpointStep);
                 UpsertCheckpointsSliding(db, key, DirectionKind.Down, rev, checkpointWindow, checkpointStep);

                 // Major
                 UpsertMajor(db, key, DirectionKind.Up, PickMajors(ids));
                 UpsertMajor(db, key, DirectionKind.Down, PickMajors(rev));

                 // Stations backfill
                 if (alsoSeedStations && stationMap != null) UpsertStations(db, ids, stationMap);

                 seeded++;
             }

             EditorUtility.SetDirty(db);
             AssetDatabase.SaveAssets();
             Debug.Log($"[AutoSeed] Seeded {seeded} lines into StaticDBAsset.");
         }
         catch (Exception ex)
         {
             EditorUtility.ClearProgressBar();
             Debug.LogError($"[AutoSeed] Seed error: {ex.Message}\n{ex.StackTrace}");
         }
     }

警告
JRや京王、京急などは通常ライセンスでは使用できないため、APIのURLやライセンスの確認を行ってください。

スクリーンショット 2025-11-12 101251.png

辞書を確認すると各路線の停車順にUpDownがそれぞれ入力されています。しかし、取得した駅順をUp、その逆をDownとしているため上り方面=Up、下り方面=Downとなっていない路線もあります。後述するDirection Engineで方面判定をし、停車順は補助の役割としたため、登録時の精度はあまり関係ありませんが方面判定と同様にOdpt:RailDirectionのAPIを用いたり、「走っている路線が多い駅から少ない駅へ向かうのが下り(=Down)」のような判断基準を設けておくと精度が上がり、エラー改善やDirection Engineが機能しないときのセーフティとして利用できるかもしれません。

注釈
今回は京急やJRなど通常ライセンスでは登録できない路線への直通時も方面判定を行う都合上、Direction Engineを作成、軽量化のために登録ツールの精度は無視しましたが、Direction Engineを作成せずに辞書の登録情報で判断する方法も良いと思います。

次発/方面の計算

辞書登録が終わったら、次に各路線の次発表記を行うために時刻表を読むTimetable Engine、方面判定を行うDirection Engineの二つを実装します。これらを用いることで、各駅の上り方面と下り方面それぞれの次発を表示できるようになります。

また、それぞれの設定を行うConfig ScriptableObjectも作成しました。DirectionEngineのコンフィグは次発の消去時間や同じ方角へ行く列車の優先順位付け、TitmetableEngineのコンフィグは時刻表取得のインターバルや何分後先の列車まで情報を取得するかなどの設定を行えます。
今回は、時間の都合上TimetableConfigに駅の自動入力を実装できず、取得する駅を手打ちしましたが、本番では辞書から自動で行えると良いと思います。
スクリーンショット 2025-11-12 144805.png
スクリーンショット 2025-11-12 144652.png

UI生成/最寄駅探索の実装

次発・方面探索の実装が終わったら、次はUIの表示と最寄り駅の探索を行います。
今回、最寄り駅のみに絞っているのは、利用する際に最寄駅から乗車する場面が最も多くなるという想定と、数を絞らないと次発計算が膨大な量になってしまい、アプリの処理が滞ることから最寄り駅をUIとして表示し、次点に近い準最寄り駅はUI非表示で次発計算だけ行う仕組みにしました。

UIのレイアウトや切り替えを行うStopBadgeUI、UI生成を行うStopIconSpawner、最寄りと準最寄り駅を切り替えるNearestStationBinderを実装します。

StopBadgeUIサンプル 表示部分のみ
c# StopBadgeUI.cs
 void ShowCurrentDeparture()
 {
     EnsureJaNameLut(); // 日本語 LUT の初期化/再試行

     var sidList = StationIdSet();
     
     // 最寄り駅か次点駅かを判定(GameObject名から判定)
     string stationType = GetStationType();
     string stationTagForLog = DebugStationTagAscii(); // ログ用に一度だけ定義
     
     if (!TryGetGroupsForStations(sidList, out var groups) || groups == null || groups.Count == 0)
     {
         if (line1Text) line1Text.text = "—";
         if (line2Text) line2Text.text = string.Empty;
         // データがない場合でもログ出力(次点駅の確認用)
         Debug.Log($"[Rotation:{stationType}:{stationTagForLog}] No data available (groups={groups?.Count ?? 0}, stations={string.Join(",", sidList)})");
         return;
     }

     HashSet<string> allowedRails = null;
     if (lineIds != null && lineIds.Count > 0)
         allowedRails = new HashSet<string>(lineIds, StringComparer.Ordinal);

     var bestByRailUp = new Dictionary<string, ResolvedRow>(StringComparer.Ordinal);
     var bestByRailDown = new Dictionary<string, ResolvedRow>(StringComparer.Ordinal);
     var railsSet = new HashSet<string>(StringComparer.Ordinal);

     foreach (var kv in groups)
     {
         if (!kv.Value.HasValue) continue;
         var r = kv.Value.Value;
         if (r.bucket == DirectionBucket.Unknown) continue;

         SplitKey(kv.Key, out string rail, out int buck);
         if (allowedRails != null && !allowedRails.Contains(rail)) continue;

         railsSet.Add(rail);
         if (buck == (int)DirectionBucket.Up) bestByRailUp[rail] = r;
         else if (buck == (int)DirectionBucket.Down) bestByRailDown[rail] = r;
     }

     TryGetTopRowsForStations_Unlimited(sidList, out var topAll);

     var byRailBucket = new Dictionary<string, Dictionary<DirectionBucket, List<ResolvedRow>>>(StringComparer.Ordinal);
     void AddTopCandidate(ResolvedRow rr)
     {
         var gkey = BuildGroupKey(rr, allowUnknown: false);
         if (string.IsNullOrEmpty(gkey)) return;
         SplitKey(gkey, out string rail, out int buck);
         var bucket = (DirectionBucket)buck;
         if (bucket == DirectionBucket.Unknown) return;
         if (allowedRails != null && !allowedRails.Contains(rail)) return;

         if (!byRailBucket.TryGetValue(rail, out var map))
         {
             map = new Dictionary<DirectionBucket, List<ResolvedRow>>();
             byRailBucket[rail] = map;
         }
         if (!map.TryGetValue(bucket, out var list))
         {
             list = new List<ResolvedRow>();
             map[bucket] = list;
         }
         list.Add(rr);
     }

     if (topAll != null)
     {
         foreach (var rr in topAll) AddTopCandidate(rr);
         foreach (var map in byRailBucket.Values)
             foreach (var kv in map)
                 kv.Value.Sort((a, b) => a.departureLocal.CompareTo(b.departureLocal));
     }

     var railsUnion = new HashSet<string>(railsSet, StringComparer.Ordinal);
     foreach (var rail in byRailBucket.Keys) railsUnion.Add(rail);

     List<string> railKeys;
     if (lineIds != null && lineIds.Count > 0)
     {
         railKeys = new List<string>(lineIds.Count);
         foreach (var r in lineIds) if (railsUnion.Contains(r)) railKeys.Add(r);
         if (railKeys.Count == 0)
         {
             railKeys = new List<string>(railsUnion);
             railKeys.Sort(StringComparer.Ordinal);
         }
     }
     else
     {
         railKeys = new List<string>(railsUnion);
         railKeys.Sort(StringComparer.Ordinal);
     }

     string MakeUniq(in ResolvedRow x) => $"{x.departureLocal.ToUnixTimeSeconds()}|{x.destinationStationId}|{x.platform}|{x.trainTypeId}";
     var seen = new HashSet<String>(StringComparer.Ordinal);

     ResolvedRow? PickFirstSameDirection(string rail, DirectionBucket want)
     {
         if (want == DirectionBucket.Down && bestByRailDown.TryGetValue(rail, out var bd))
         {
             if (!seen.Contains(MakeUniq(bd)) && bd.departureLocal >= _clock.NowUtc()) return bd;
         }
         if (want == DirectionBucket.Up && bestByRailUp.TryGetValue(rail, out var bu))
         {
             if (!seen.Contains(MakeUniq(bu)) && bu.departureLocal >= _clock.NowUtc()) return bu;
         }
         if (byRailBucket.TryGetValue(rail, out var map) && map.TryGetValue(want, out var list))
         {
             foreach (var r in list)
             {
                 if (r.departureLocal < _clock.NowUtc()) continue;
                 if (!seen.Contains(MakeUniq(r))) return r;
             }
         }
         return null;
     }

     var selected = new List<(string key, ResolvedRow row)>();
     // 路線ごとに上り→下りの順で追加(_orderの順序に合わせる)
     foreach (var rail in railKeys)
     {
         var u = PickFirstSameDirection(rail, DirectionBucket.Up);
         if (u.HasValue) { var uv = u.Value; selected.Add((BuildGroupKey(uv, false), uv)); seen.Add(MakeUniq(uv)); }

         var d = PickFirstSameDirection(rail, DirectionBucket.Down);
         if (d.HasValue) { var dv = d.Value; selected.Add((BuildGroupKey(dv, false), dv)); seen.Add(MakeUniq(dv)); }
     }

     int railCount = railKeys.Count;
     int desiredSlots = Mathf.Max(1, railCount * 2);

     if (selected.Count == 0)
     {
         if (TryGetRowEarliestAny(groups, out var any) && any.HasValue && any.Value.bucket != DirectionBucket.Unknown)
         {
             var a = any.Value;
             selected.Add((BuildGroupKey(a, false), a));
         }
     }

     var haveUp = new HashSet<string>(StringComparer.Ordinal);
     var haveDown = new HashSet<string>(StringComparer.Ordinal);
     foreach (var it in selected)
     {
         SplitKey(it.key, out string r, out int buck);
         if (buck == (int)DirectionBucket.Up) haveUp.Add(r);
         else if (buck == (int)DirectionBucket.Down) haveDown.Add(r);
     }
     // 路線ごとに上り→下りの順で追加(_orderの順序に合わせる)
     foreach (var rail in railKeys)
     {
         if (!haveUp.Contains(rail))
         {
             var u = PickFirstSameDirection(rail, DirectionBucket.Up);
             if (u.HasValue) { var uv = u.Value; selected.Add((BuildGroupKey(uv, false), uv)); haveUp.Add(rail); }
         }
         if (!haveDown.Contains(rail))
         {
             var d = PickFirstSameDirection(rail, DirectionBucket.Down);
             if (d.HasValue) { var dv = d.Value; selected.Add((BuildGroupKey(dv, false), dv)); haveDown.Add(rail); }
         }
     }

     if (selected.Count > desiredSlots)
     {
         var byRailIndexList = new Dictionary<string, List<int>>(StringComparer.Ordinal);
         for (int i = 0; i < selected.Count; i++)
         {
             SplitKey(selected[i].key, out var r, out _);
             if (!byRailIndexList.TryGetValue(r, out var list))
             {
                 list = new List<int>();
                 byRailIndexList[r] = list;
             }
             list.Add(i);
         }

         var keep = new HashSet<int>();
         foreach (var kv in byRailIndexList)
         {
             var idxs = kv.Value.OrderBy(i => selected[i].row.departureLocal).ToList();
             for (int t = 0; t < Mathf.Min(2, idxs.Count); t++) keep.Add(idxs[t]);
         }

         var removalCandidates = Enumerable.Range(0, selected.Count)
                                           .Where(i => !keep.Contains(i))
                                           .OrderByDescending(i => selected[i].row.departureLocal)
                                           .ToList();
         int toRemove = selected.Count - desiredSlots;
         for (int c = 0; c < toRemove && c < removalCandidates.Count; c++)
         {
             int idxRm = removalCandidates[c];
             selected[idxRm] = default;
         }
         selected = selected.Where(t => t.key != null).ToList();
     }

     // _orderの順序に合わせてselectedを並び替える
     MaybeRebuildOrderFromCache_Multi(); // _orderを最新の状態に更新
     var orderedSelected = new List<(string key, ResolvedRow row)>();
     var selectedDict = new Dictionary<string, (string key, ResolvedRow row)>(StringComparer.Ordinal);
     foreach (var item in selected)
     {
         if (!string.IsNullOrEmpty(item.key) && !selectedDict.ContainsKey(item.key))
             selectedDict[item.key] = item;
     }

     // _orderの順序に従ってselectedを並び替え
     foreach (var orderKey in _order)
     {
         if (selectedDict.TryGetValue(orderKey, out var item))
             orderedSelected.Add(item);
     }
     // _orderにない項目も追加(フォールバック)
     foreach (var item in selected)
     {
         if (!string.IsNullOrEmpty(item.key) && !orderedSelected.Any(x => string.Equals(x.key, item.key, StringComparison.Ordinal)))
             orderedSelected.Add(item);
     }

     // _orderのインデックスとして_cursorGroupを使用
     int wrap = _order.Count > 0 ? _order.Count : Mathf.Clamp(orderedSelected.Count, 1, desiredSlots);
     int orderIdx = Mathf.Clamp(_cursorGroup % wrap, 0, wrap - 1);
     
     // _order[orderIdx]のキーに基づいてorderedSelectedから選ぶ
     string targetKey = (orderIdx < _order.Count) ? _order[orderIdx] : null;
     var pick = orderedSelected.FirstOrDefault(x => string.Equals(x.key, targetKey, StringComparison.Ordinal));
     
     // フォールバック: キーが見つからない場合はインデックスで選ぶ
     if (string.IsNullOrEmpty(pick.key) && orderedSelected.Count > 0)
     {
         int fallbackIdx = Mathf.Clamp(orderIdx % orderedSelected.Count, 0, orderedSelected.Count - 1);
         pick = orderedSelected[fallbackIdx];
     }

     // 最終フォールバック: まだ空の場合は最初の項目を選ぶ
     if (string.IsNullOrEmpty(pick.key) && orderedSelected.Count > 0)
     {
         pick = orderedSelected[0];
     }

     // データがない場合は空表示
     if (string.IsNullOrEmpty(pick.key))
     {
         if (line1Text) line1Text.text = "—";
         if (line2Text) line2Text.text = string.Empty;
         return;
     }

     // ====== 文字列組み立て ======
     string arrow = ColoredArrowByType(pick.row.trainTypeId, pick.row.bucket); // 色=種別 / 形=上下

     // ★ 日本語優先:DBの日本語名を使い、無ければ destinationNameJa をチェック(CJK 判定)
     string dest = DestinationDisplayNamePreferJa(pick.row.destinationStationId, pick.row.destinationNameJa);

     string line1 = $"{arrow} 行き先:{dest}";
     string remaining = FormatRemainingSegment(pick.row.departureLocal);
     string hhmm = pick.row.departureLocal.ToLocalTime().ToString("HH:mm");
     string delayText = MaybeGrayOnTime(ComputeDelayTextFor(pick.row));
     string line2 = $"{remaining} {hhmm} {delayText}";

     if (line1Text) line1Text.text = line1;
     if (line2Text) line2Text.text = line2;

     if (debugLogs)
     {
         var rawA = pick.row.destinationNameJa ?? "(null)";
         var rawB = pick.row.destinationStationId ?? "(null)";
         Debug.Log($"[Badge:{DebugStationTagAscii()}] destNameJa='{rawA}' destId='{rawB}' -> disp='{dest}'");
     }

     string railwayId = ExtractRailwayFromKey(pick.key);
     if (string.IsNullOrEmpty(railwayId)) railwayId = GuessRailwayId(pick.row);
     if (!string.IsNullOrEmpty(railwayId) && railwayId != _lastSyncedRailwayId)
     {
         _lastSyncedRailwayId = railwayId;
         SetActiveLine(railwayId, freezeCycle: true, holdSec: 1.2f);
     }

     // _orderのインデックスを1進める(次の路線・方向に進む)
     _cursorGroup = (orderIdx + 1) % wrap;

     // ローテーション情報をLogCatに出力(ASCII文字のみ)
     SplitKey(pick.key, out string currentRail, out int currentBucket);
     string directionStr = currentBucket == (int)DirectionBucket.Up ? "Up" : (currentBucket == (int)DirectionBucket.Down ? "Down" : "Unknown");
     
     // stationTypeとstationTagForLogは既にメソッドの最初で定義済み
     
     string orderStr = string.Join(" -> ", _order.Select(k => {
         SplitKey(k, out string r, out int b);
         string d = b == (int)DirectionBucket.Up ? "U" : (b == (int)DirectionBucket.Down ? "D" : "?");
         return $"{Short(r)}({d})";
     }));
     
     string nextKey = _cursorGroup < _order.Count ? _order[_cursorGroup] : null;
     string nextInfo = "N/A";
     if (!string.IsNullOrEmpty(nextKey))
     {
         SplitKey(nextKey, out string nextRail, out int nextBucket);
         string nextDir = nextBucket == (int)DirectionBucket.Up ? "U" : (nextBucket == (int)DirectionBucket.Down ? "D" : "?");
         nextInfo = $"{Short(nextRail)}({nextDir})";
     }
     
     Debug.Log($"[Rotation:{stationType}:{stationTagForLog}] Route={Short(currentRail)} Dir={directionStr} Pos={orderIdx + 1}/{_order.Count} Next={nextInfo} Order=[{orderStr}]");

     if (debugLogs)
     {
         var tag = DebugStationTagAscii();
         Debug.Log($"[Badge:{tag}] Show: key='{pick.key}' line1='{CleanForLog(line1)}' line2='{CleanForLog(line2)}'");
     }
 }
StopIconSpawnerサンプル 生成部分のみ
c# StopIconSpawner.cs
    /// <summary>
    /// 最寄り駅のアンカーとUIオーバーレイを生成・更新する
    /// StopBadgeUIコンポーネントがあれば駅情報を設定し、TimetableEngineへの監視も開始する
    /// </summary>
    void SpawnNearestUI()
    {
        if (_nearest == null) return;
        if (!overlayPrefab || !overlayCanvas)
        {
            Debug.LogError("[IconSpawn] overlayPrefab / overlayCanvas が未設定です。");
            return;
        }

        // アンカー
        if (_nearestAnchor) Destroy(_nearestAnchor.gameObject);
        _nearestAnchor = new GameObject($"Anchor_{SafeName(_nearest)}").transform;
        _nearestAnchor.SetParent(anchorsRoot, false);
        var pos = Geo.ToUnityPosition(originLat, originLon, _nearest.lat, _nearest.lon, yHeight);
        _nearestAnchor.localPosition = pos;
        _nearestAnchor.localRotation = Quaternion.identity;

        // Overlay
        if (_nearestOverlay) Destroy(_nearestOverlay.gameObject);
        _nearestOverlay = Instantiate(overlayPrefab, overlayCanvas.transform);
        _nearestOverlay.Bind(overlayCanvas, worldCamera, _nearestAnchor);
        _nearestOverlay.SetForcedHidden(false);

        // StopBadgeUI へ反映(最寄り駅として識別)
        UpdateBadgeUI(_nearestOverlay, _nearest, isNearest: true);

        // TimetableEngine 監視指示
        EnsureTimetableWatching(_nearest.id);

        // 次点駅のUIも生成
        SpawnRunnerUI();

        if (useGeospatialTranslation && TryGetCameraLatLon(out _, out _))
            UpdateTranslationByGeospatial(smooth: false);
    }

    /// <summary>
    /// 次点駅のアンカーとUIオーバーレイを非表示で生成・更新する
    /// </summary>
    void SpawnRunnerUI()
    {
        // 次点駅がない場合は既存の次点駅UIを破棄
        if (_runner == null)
        {
            if (_runnerAnchor) { Destroy(_runnerAnchor.gameObject); _runnerAnchor = null; }
            if (_runnerOverlay) { Destroy(_runnerOverlay.gameObject); _runnerOverlay = null; }
            _runnerStationId = null;
            return;
        }

        if (!overlayPrefab || !overlayCanvas) return;

        // 次点駅が変更された場合、旧次点駅のUIを破棄(駅IDで判定)
        string currentRunnerId = _runner?.id ?? "";
        bool runnerChanged = string.IsNullOrEmpty(_runnerStationId) || 
            !string.Equals(_runnerStationId, currentRunnerId, StringComparison.Ordinal);
        
        if (runnerChanged)
        {
            if (_runnerAnchor) Destroy(_runnerAnchor.gameObject);
            if (_runnerOverlay) Destroy(_runnerOverlay.gameObject);
            _runnerStationId = currentRunnerId;
        }

        // アンカー
        if (_runnerAnchor == null)
        {
            _runnerAnchor = new GameObject($"Anchor_{SafeName(_runner)}").transform;
            _runnerAnchor.SetParent(anchorsRoot, false);
        }
        var pos = Geo.ToUnityPosition(originLat, originLon, _runner.lat, _runner.lon, yHeight);
        _runnerAnchor.localPosition = pos;
        _runnerAnchor.localRotation = Quaternion.identity;

        // Overlay(非表示で生成)
        if (_runnerOverlay == null)
        {
            _runnerOverlay = Instantiate(overlayPrefab, overlayCanvas.transform);
            _runnerOverlay.Bind(overlayCanvas, worldCamera, _runnerAnchor);
            _runnerOverlay.SetForcedHidden(true);  // 非表示で生成
        }
        else
        {
            // 既存のUIを更新
            _runnerOverlay.Bind(overlayCanvas, worldCamera, _runnerAnchor);
            _runnerOverlay.SetForcedHidden(true);  // 非表示を維持
        }

        // StopBadgeUI へ反映(次点駅として識別)
        UpdateBadgeUI(_runnerOverlay, _runner, isNearest: false);

        // TimetableEngine 監視指示(次点駅も監視対象に追加)
        EnsureTimetableWatching(_runner.id);

        // 次点駅のUI生成を確認するログ(常に出力)
        Debug.Log($"[IconSpawn] Runner UI spawned (hidden): {_runner.id}, overlay={(_runnerOverlay != null ? _runnerOverlay.name : "null")}, badge={(_runnerOverlay != null && _runnerOverlay.GetComponentInChildren<MonoBehaviour>(true) != null ? "found" : "not found")}");
        if (debugLogs) Debug.Log($"[IconSpawn] Runner UI spawned (hidden): {_runner.id}");
    }

    /// <summary>
    /// StopBadgeUIコンポーネントに駅情報を設定する
    /// </summary>
    /// <param name="overlay">UIオーバーレイ</param>
    /// <param name="station">駅データ</param>
    /// <param name="isNearest">最寄り駅の場合true、次点駅の場合false</param>
    void UpdateBadgeUI(StopBillboardOverlay overlay, StationDataLoader.StationDef station, bool isNearest = true)
    {
        if (overlay == null || station == null) return;

        // StopBadgeUI を検索
        MonoBehaviour badge = null;
        {
            var all = overlay.GetComponents<MonoBehaviour>();
            for (int i = 0; i < all.Length; i++)
                if (all[i] != null && all[i].GetType().Name == "StopBadgeUI") { badge = all[i]; break; }
            if (badge == null)
            {
                all = overlay.GetComponentsInChildren<MonoBehaviour>(true);
                for (int i = 0; i < all.Length; i++)
                    if (all[i] != null && all[i].GetType().Name == "StopBadgeUI") { badge = all[i]; break; }
            }
        }

        if (badge != null)
        {
            // SetStation(string name, List<string> lines)
            var mi = badge.GetType().GetMethod("SetStation", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
            if (mi != null)
            {
                try { mi.Invoke(badge, new object[] { station.name ?? StationDataLoader.ShortId(station.id), station.lines ?? new List<string>() }); } catch { }
            }

            // stationIds(IList)または stationId に id を入れる
            var fi = badge.GetType().GetField("stationIds", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
            if (fi != null && typeof(System.Collections.IList).IsAssignableFrom(fi.FieldType))
            {
                try
                {
                    var list = fi.GetValue(badge) as System.Collections.IList;
                    if (list != null) { list.Clear(); if (!string.IsNullOrEmpty(station.id)) list.Add(station.id); }
                }
                catch { }
            }
            else
            {
                var fi1 = badge.GetType().GetField("stationId", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
                if (fi1 != null) { try { fi1.SetValue(badge, station.id); } catch { } }
            }

            // あるなら強制更新系を呼ぶ(無ければ無視される)
            TryInvokeNoArg(badge, "RefreshNow", out _);
            TryInvokeNoArg(badge, "ForceRefresh", out _);
            
            // GameObject名に識別子を追加(ログ出力用)
            string stationTypeTag = isNearest ? "[最寄り]" : "[次点]";
            if (overlay != null && overlay.gameObject != null && !overlay.gameObject.name.Contains(stationTypeTag))
            {
                overlay.gameObject.name = $"{stationTypeTag}{overlay.gameObject.name}";
            }
        }
        else
        {
            if (debugLogs) Debug.LogWarning("[IconSpawn] StopBadgeUI component not found on overlayPrefab.");
        }
    }

NearestStationBinderサンプル 切り替え部分のみ
c# NearestStationBinder.cs
    public void RegisterStation(StopBadgeUI badge, double lat, double lon, string stationKey = null)
    {
        if (!badge) return;

        if (!StationDataResolver.IsValidLatLon(lat, lon))
        {
            if (verboseLogs)
                Debug.LogWarning($"[Binder] skip Register (no-geo) go='{badge.gameObject.name}' lat={lat:F6} lon={lon:F6} key='{SafeKey(stationKey)}'");
            return;
        }

        for (int i = 0; i < stations.Count; i++)
            if (stations[i]?.badge == badge && stations[i].stationKey == stationKey) return;

        stations.Add(new StationEntry { badge = badge, lat = lat, lon = lon, stationKey = stationKey });
        SetVisible(badge, false);

        if (verboseLogs)
        {
            Debug.Log($"[Binder] Register: key='{SafeKey(stationKey)}' go='{badge.gameObject.name}' lat={lat:F6} lon={lon:F6}");
            DumpStations();
        }
    }

    /// <summary>
    /// 駅の登録を解除する
    /// </summary>
    /// <param name="badge">解除する駅のUIバッジ</param>
    public void UnregisterStation(StopBadgeUI badge)
    {
        if (!badge) return;
        for (int i = stations.Count - 1; i >= 0; i--)
        {
            if (stations[i]?.badge == badge)
            {
                stations.RemoveAt(i);
                if (_activeIdx == i) _activeIdx = -1;
                if (_standbyIdx == i) _standbyIdx = -1;
                if (_activeIdx > i) _activeIdx--;
                if (_standbyIdx > i) _standbyIdx--;
            }
        }
    }

    /// <summary>
    /// 現在位置を注入し、最寄り駅と次点駅を計算してUIの表示を更新する
    /// ヒステリシス機能により、頻繁な切り替えを防止する
    /// </summary>
    /// <param name="lat">現在地の緯度</param>
    /// <param name="lon">現在地の経度</param>
    public void InjectLatLon(double lat, double lon)
    {
        if (stations == null || stations.Count == 0)
        {
            if (verboseLogs) Debug.LogWarning("[Binder] InjectLatLon received but stations is EMPTY");
            return;
        }

        if (verboseLogs && Time.time - _lastLogTime >= Mathf.Max(0.1f, minLogIntervalSec))
        {
            _lastLogTime = Time.time;
            Debug.Log($"[Binder][pos] lat={lat:F6} lon={lon:F6} candidates={stations.Count}");
        }

        bool FilterStation(StationEntry s) => s != null && s.badge != null;

        if (!NearestStationCalculator.CalculateNearest(
            lat, lon,
            stations,
            s => s.lat,
            s => s.lon,
            out var result,
            FilterStation))
        {
            return;
        }

        int best = result.nearest != null ? stations.IndexOf(result.nearest) : -1;
        int second = result.second != null ? stations.IndexOf(result.second) : -1;
        float bestD = result.nearestDistance;
        float secondD = result.secondDistance;
        
        if (best < 0) return;

        bool shouldSwitchActive = false;
        if (_activeIdx < 0) shouldSwitchActive = true;
        else if (best != _activeIdx)
        {
            var cur = stations[_activeIdx];
            float dCur = (float)GeoUtil.HaversineMeters(lat, lon, cur.lat, cur.lon);
            shouldSwitchActive = NearestStationCalculator.ShouldSwitchWithHysteresis(
                bestD, dCur, switchHysteresisMeters, _lastSwitchTime, minDwellSeconds);
        }

        int newStandbyIdx = (second >= 0 && second != best) ? second : -1;

        if (shouldSwitchActive)
        {
            var prev = _activeIdx;
            _activeIdx = best;
            _lastSwitchTime = Time.time;

            if (verboseLogs)
            {
                var tagNow = BadgeTag(stations[_activeIdx]);
                if (prev >= 0)
                    Debug.Log($"[Binder][nearest] SWITCH {BadgeTag(stations[prev])}{tagNow}  d={bestD:F1}m");
                else
                    Debug.Log($"[Binder][nearest] SWITCH → {tagNow}  d={bestD:F1}m");
            }
        }

        if (newStandbyIdx != _standbyIdx)
        {
            _standbyIdx = newStandbyIdx;
            if (verboseLogs && _standbyIdx >= 0)
                Debug.Log($"[Binder][standby] {BadgeTag(stations[_standbyIdx])}  d={secondD:F1}m");
        }

        // 最寄り駅と次点駅の表示を更新
        for (int i = 0; i < stations.Count; i++)
        {
            var s = stations[i];
            if (s?.badge == null) continue;
            // 最寄り駅は表示、次点駅は非表示、その他は非表示
            if (i == _activeIdx) SetVisible(s.badge, true);
            else if (i == _standbyIdx) SetVisible(s.badge, false);  // 次点駅は非表示で保持
            else SetVisible(s.badge, false);
        }
    }

実装出来たらそれぞれオブジェクトにアタッチしていきます。また、これら3スクリプトのほかに、
LineIconSet 路線ごとのアイコン画像辞書
MetroStation ToeiStation 事業者ごとの駅ローダー
NearestStationCalculator 最寄り駅の計算
StopBillboardOverlay アイコンプレハブの制御
といった付属スクリプトも作成しました。
スクリーンショット 2025-11-12 154913.png
スクリーンショット 2025-11-12 154851.png
スクリーンショット 2025-11-12 154904.png

テスト

全てのスクリプトのアタッチが完了したら、テストを行います。
無事成功するとこのように駅名、路線に対応させた画像と行き先、方面を表す矢印や各種情報が新たに表示されます。

注釈
今回、自己位置推定の精度が一定値以上になったらアイコンの方角を更新するという機構を導入したのですが、精度としきい値の都合上アイコンが画面内に入ると同時に動いてしまったり、全く違う方角に表示されてしまうことが多いです。Geospatial以外の自己位置推定機能の追加をして併用したり、しきい値の見直しが必要です。

まとめ

今回は、GTFSを用いた交通情報の取得からUI表示までを行いました。
特に、次発を表示する際に方面判定が出来ないことが多く、APIだけでなく辞書登録した停車順なども駆使して方面判定を安定化させるのが大変でした。例えば千代田線ならJR常磐線-千代田線-京急多摩線といったように直通している路線込みで1路線として停車順を登録すれば、もっと簡単かつ軽量に安定して方面判定を行えたのかなと思います。

次回は、OpenTripPlannerを用いた経路探索の実装~UI表示を行います。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?