Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ The Worker is the workspace root package. `stationapi`, `preprocessor`, and `dat
- **Lines** – `line`, `lines`, `linesByName`. Results include company data and computed line symbols based on repository helpers.
- **Routes** – `routes`, `connectedRoutes`, `estimateArrivalTimes`, `trainRoute`. Paging tokens are currently empty (pagination not implemented).
- **`trainRoute`** – Takes the line group's stops from the repository *before* any enrichment, slices them to the requested `fromStationId`–`toStationId` range (reversing when the request runs backwards), and only then attaches lines, companies, station numbers, train types, and nearby bus routes. Enrichment is per-station and independent, so slicing first does not change any segment; enriching the whole line group first made a three-station request cost the same as a 250-station one. Keep the order — the cost of this query must stay proportional to the requested range, not to the line group.
- **Coordinate lookups** – `index::nearest` (k nearest, used by `stationsNearby`) and `index::within_radius` (everything inside a radius, used by the nearby-bus-stop enrichment) both go through a per-transport-type grid index (`Grid`, CSR over 0.05° cells) instead of scanning the whole station table. `nearest` searches a radius, widens it while fewer than `limit` stations fall inside, and stops once the radius covers the index — anything outside a radius that already holds `limit` hits cannot be in the top `limit`. It orders purely by distance: `stationsNearby` is specified as nearest-first, so rail and bus are not separated even when `transportType` is omitted (the pre-Workers SQL sorted on `transport_type` first, which put a rail station kilometres away ahead of a bus stop at the same address). Ties on distance break on `station_cd` so the order does not depend on an unstable sort. Every station lookup by coordinates runs on every request that enriches rail stations with nearby bus routes, so keep new coordinate queries on the grid rather than adding another full scan.
- **Coordinate lookups** – `index::nearest` (k nearest, used by `stationsNearby`) and `index::within_radius` (everything inside a radius, used by the nearby-bus-stop enrichment) both go through a per-transport-type grid index (`Grid`, CSR over 0.05° cells) instead of scanning the whole station table. `nearest` searches a radius, widens it while fewer than `limit` stations fall inside, and stops once the radius covers the index — anything outside a radius that already holds `limit` hits cannot be in the top `limit`. With `transportType` omitted it returns rail stations first and bus stops after, each group sorted by distance — the pre-Workers SQL's `ORDER BY transport_type, distance`. The limit applies to the merged order, so `nearest` fills it with rail and only asks the bus grid for the remaining slots; a location with `limit` rail stations returns no bus stops at all. Ties on distance break on `station_cd` so the order does not depend on an unstable sort. Every station lookup by coordinates runs on every request that enriches rail stations with nearby bus routes, so keep new coordinate queries on the grid rather than adding another full scan.
- **Train types** – `stationTrainTypes`, `routeTypes`. Train types aggregate by line group and include related lines plus optional train type metadata. Rail variants use `TrainTypeKind::{Default, Branch, Rapid, Express, LimitedExpress, HighSpeedRapid, CommuterRapid}` (0-6); bus variants use `BusRoute` (7), which represents a `(route_id, shape_id)` operation pattern (e.g. 循環 / 短ターン / 支線) generated automatically from the configured GTFS bus feeds (Toei Bus, Seibu Bus, Keio Bus) and the converted Tokyu Bus JSON.
- **Default rail train types** – `preprocessor` fills every active rail line containing at least one station with no `station_station_types` row with a deterministic, complete all-stop group. The generated rows exist only in `generated/*.csv`; canonical CSV files remain unchanged. `type_cd=100` represents 「普通」 and `type_cd=101` represents 「各駅停車」. An existing 100/101 assignment on the line takes precedence; otherwise the label is selected per line through `LOCAL_SERVICE_RAIL_LINE_IDS` in `preprocessor/src/rail.rs`. Generated `line_group_cd` values use `1,000,000,000 + line_cd`; generation fails on a collision. Bus lines are excluded and continue to use their GTFS-derived `BusRoute` groups.
- **GTFS bus integration** – `preprocessor/src/gtfs/` reads the GTFS feeds into an in-memory representation and then projects them onto the shared `stations` / `lines` / `types` / `station_station_types` tables (`gtfs/integrate.rs`). Only routes, stops, trips, and stop_times are read; calendar, shapes, feed_info, and agencies do not affect the output. Every configured GTFS feed is imported, including Seibu Bus and Keio Bus (both downloaded from ODPT with `ODPT_ACCESS_TOKEN`). Tokyu Bus ordinary-route `BusroutePattern`, `BusstopPole`, and `BusTimetable` JSON are converted into the same representation; pattern IDs become `shape_id` values so route variants remain queryable as bus TrainTypes. The Tokyu-operated Ota, Shinagawa, and Meguro community buses use their official GTFS feeds and matching JSON routes are excluded to prevent duplicates. `ODPT_ACCESS_TOKEN` is required for authenticated sources; without it those feeds are skipped with a warning rather than failing the build. Stops whose Tokyu JSON records omit coordinates remain available to name and route queries but not coordinate searches. `transport_type` (0: rail, 1: bus) on both `stations` and `lines` keeps rail and bus records queryable side by side. GTFS IDs are namespaced per feed before import to avoid cross-operator collisions. `line_cd` (100,000,000+), `station_cd` / `station_g_cd` (200,000,000+), and bus `type_cd` / `line_group_cd` (100,000,000+) are all deterministic fnv1a hashes that stay clear of the rail data ranges. Disable the entire bus pipeline with `DISABLE_BUS_FEATURE=true`.
Expand Down
2 changes: 1 addition & 1 deletion docs/nearby-bus-stops.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ enum TransportType {
| **Bus** | バス停のみを返す |
| **RailAndBus** | 鉄道駅とバス停の両方を返す。`lines`配列にも近傍バス路線を含める |

**注**: `stationsNearby` の並びは常に近い順です。`transportType` を指定しない場合も鉄道とバスを分けず、距離だけで並べます
**注**: `stationsNearby` は鉄道駅を先に、バス停を後に返します。並びは種別ごとに距離の昇順です。`limit` は種別ごとではなく並べた後の全体に掛かるため、鉄道駅だけで `limit` 件そろう地点ではバス停は返りません

## 対象API

Expand Down
100 changes: 70 additions & 30 deletions src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -688,12 +688,13 @@ fn grid_of(want: i32) -> &'static Grid {

static EMPTY_GRID: OnceLock<Grid> = OnceLock::new();

/// 最近傍 limit 件を距離の昇順で返す。路線を引けない駅は除く。
/// 最近傍 limit 件を返す。路線を引けない駅は除く。
///
/// `want` は種別の絞り込み。未指定 (RailAndBus) でも並びは距離順で、
/// 鉄道とバスを区別しない。移行前の SQL は `transport_type` を第 1 キーに
/// していたため、10m 先のバス停より 5km 先の鉄道駅が先に来ていた。
/// `stationsNearby` は近い順が仕様なので、種別で先に分けない。
/// `want` は種別の絞り込み。指定した場合は距離の昇順。未指定 (RailAndBus) の
/// 場合は鉄道駅を先に、バス停を後に並べ、それぞれの中を距離の昇順にする
/// (`stationsNearby` の仕様)。件数の上限は混ぜた後の並びに掛かるので、鉄道駅が
/// limit 件そろえばバス停は返らない。移行前の SQL が `transport_type` を第 1 キー、
/// 距離を第 2 キーにしていたのと同じ並び。
pub fn nearest(
lat: f64,
lon: f64,
Expand All @@ -707,16 +708,11 @@ pub fn nearest(
return Vec::new();
}
let Some(want) = want else {
// 全体の上位 limit 件は種別ごとの上位 limit 件の和集合に必ず含まれる
// (ある駅より近い駅が limit 件未満なら、同じ種別の中でも limit 件未満)。
// 種別ごとに引いてから距離で混ぜ直す。
// 鉄道駅が先に並ぶので、上限に届くまでの残り枠だけがバス停に回る。
// 残り枠より遠いバス停は採用されないため、引くのも残り枠ぶんでよい。
let mut out = nearest_of_type(lat, lon, limit, TransportType::Rail as i32);
out.extend(nearest_of_type(lat, lon, limit, TransportType::Bus as i32));
if limit < out.len() {
out.select_nth_unstable_by(limit, by_distance_then_station_cd);
out.truncate(limit);
}
out.sort_unstable_by(by_distance_then_station_cd);
let rest = limit.saturating_sub(out.len());
out.extend(nearest_of_type(lat, lon, rest, TransportType::Bus as i32));
return out;
};
nearest_of_type(lat, lon, limit, want)
Expand Down Expand Up @@ -1210,12 +1206,19 @@ mod tests {
.map(|s| (s, haversine_km(lat, lon, s.lat, s.lon)))
.collect();

// 種別を指定してもしなくても並びは距離順 (`stationsNearby` の仕様)
// 種別を指定しない場合は鉄道が先、バスが後。その中では距離順
// (`stationsNearby` の仕様)。種別を指定した場合は第 1 キーが定数に
// なるので、同じ比較関数で距離順になる。
let cmp = |a: &(&'static StationRecord, f64), b: &(&'static StationRecord, f64)| {
(a.0.transport_type as i32)
.cmp(&(b.0.transport_type as i32))
.then_with(|| by_distance_then_station_cd(a, b))
};
if limit < scored.len() {
scored.select_nth_unstable_by(limit, by_distance_then_station_cd);
scored.select_nth_unstable_by(limit, cmp);
scored.truncate(limit);
}
scored.sort_unstable_by(by_distance_then_station_cd);
scored.sort_unstable_by(cmp);
scored
}

Expand All @@ -1228,12 +1231,18 @@ mod tests {
"件数が違う ({lat}, {lon}) want={want:?} limit={limit}"
);
for (i, (e, a)) in expected.iter().zip(actual.iter()).enumerate() {
// 距離が同着の駅は全件走査側の並びが決まらないので距離だけを見る
// 種別・距離・station_cd の 3 キーで並びが決まり切るので、駅まで一致する
assert!(
(e.1 - a.1).abs() < 1e-9,
e.0.station_cd == a.0.station_cd
&& e.0.transport_type == a.0.transport_type
&& (e.1 - a.1).abs() < 1e-9,
"{i} 件目が違う ({lat}, {lon}) want={want:?} limit={limit}: \
期待 {} 実際 {}",
期待 {} {:?} {} 実際 {} {:?} {}",
e.0.station_cd,
e.0.transport_type,
e.1,
a.0.station_cd,
a.0.transport_type,
a.1
);
}
Expand Down Expand Up @@ -1421,28 +1430,59 @@ mod tests {
}
}

/// `stationsNearby` は近い順が仕様。種別を指定しない場合も、鉄道とバスを
/// 分けずに距離だけで並べる。移行前の SQL は `transport_type` を第 1 キーに
/// していたため、近いバス停より遠い鉄道駅が先に来ていた。
/// `stationsNearby` の並びは鉄道駅が先、バス停が後。種別を指定しない場合も
/// 種別で分かれ、距離の昇順はそれぞれの中だけで成り立つ。
#[test]
fn nearest_orders_by_distance_regardless_of_transport_type() {
fn nearest_puts_rail_before_bus() {
let step = (stations().len() / 40).max(1);
for record in stations().iter().step_by(step) {
for limit in [5usize, 50] {
let hits = nearest(record.lat, record.lon, limit, None);
for pair in hits.windows(2) {
let (former, latter) = (pair[0].0.transport_type, pair[1].0.transport_type);
assert!(
pair[0].1 <= pair[1].1,
"({}, {}) limit={limit}: 距離の昇順になっていない ({} -> {}, 種別 {:?} -> {:?})",
(former as i32) <= (latter as i32),
"({}, {}) limit={limit}: バス停が鉄道駅より先に来ている \
({former:?} -> {latter:?})",
record.lat,
record.lon,
pair[0].1,
pair[1].1,
pair[0].0.transport_type,
pair[1].0.transport_type
);
if former as i32 == latter as i32 {
assert!(
pair[0].1 <= pair[1].1,
"({}, {}) limit={limit}: 種別 {former:?} の中が距離の昇順に \
なっていない ({} -> {})",
record.lat,
record.lon,
pair[0].1,
pair[1].1
);
}
}
}
}
}

/// 鉄道駅だけで上限に届く地点では、どれだけ近くてもバス停は返らない。
/// 上限は種別ごとではなく、混ぜた後の並びに掛かる。
#[test]
fn nearest_fills_the_limit_with_rail_before_bus() {
// 東京駅前。周囲にはバス停も鉄道駅も多数ある
let (lat, lon) = (35.681382, 139.766084);
let rail = nearest(lat, lon, 5, Some(TransportType::Rail as i32));
assert_eq!(rail.len(), 5, "鉄道駅が 5 件そろう地点で測る");
// DISABLE_BUS_FEATURE で組んだ鉄道のみのデータでは確かめようがない
if nearest(lat, lon, 5, Some(TransportType::Bus as i32)).is_empty() {
return;
}

let hits = nearest(lat, lon, 5, None);
// all() は空でも通るので、上限まで埋まっていることを先に確かめる
assert_eq!(hits.len(), 5, "鉄道駅で上限が埋まる地点で 5 件返らない");
assert!(
hits.iter()
.all(|(record, _)| record.transport_type == TransportType::Rail),
"鉄道駅で上限が埋まる地点にバス停が混ざっている"
);
}
}
71 changes: 69 additions & 2 deletions stationapi/src/domain/repository/station_repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ pub trait StationRepository: Send + Sync + 'static {
&self,
station_group_id_vec: &[u32],
) -> Result<Vec<Station>, DomainError>;
/// 座標の近傍から最大 `limit` 件返す。`transport_type` を指定した場合は
/// 距離の昇順。指定しない場合は鉄道駅を先、バス停を後に並べ、それぞれの中を
/// 距離の昇順にする (`stationsNearby` の仕様)。件数の上限は並べた後に掛かる
/// ので、鉄道駅だけで `limit` 件そろえばバス停は返らない。
async fn get_by_coordinates(
&self,
latitude: f64,
Expand Down Expand Up @@ -259,8 +263,18 @@ mod tests {
})
.collect();

// 距離でソート
result.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap());
// trait の契約どおり、鉄道を先・バスを後にしてから距離でソートする。
// 種別を指定した場合は第 1 キーが定数になるので距離順になる。
result.sort_by(|a, b| {
(a.transport_type as i32)
.cmp(&(b.transport_type as i32))
.then_with(|| {
a.distance
.partial_cmp(&b.distance)
.unwrap_or(std::cmp::Ordering::Equal)
})
.then_with(|| a.station_cd.cmp(&b.station_cd))
});

// 制限があれば適用
if let Some(limit) = limit {
Expand Down Expand Up @@ -520,6 +534,19 @@ mod tests {
MockStationRepository { stations }
}

/// 指定した座標に種別つきの駅を置いたモック。並び順の検証に使う。
/// 経度は東京駅に固定し、緯度だけを動かす。
fn mixed_repository(stations_spec: &[(i32, TransportType, f64)]) -> MockStationRepository {
let mut stations = HashMap::new();
for &(station_cd, transport_type, lat) in stations_spec {
let mut station =
create_test_station(station_cd, &format!("駅{station_cd}"), 500, lat, 139.767125);
station.transport_type = transport_type;
stations.insert(station_cd as u32, station);
}
MockStationRepository { stations }
}

/// 東京駅から北へおよそ meters メートルの緯度。
fn lat_north_of_tokyo(meters: f64) -> f64 {
35.681236 + meters / 111_195.0
Expand Down Expand Up @@ -702,6 +729,46 @@ mod tests {
assert!(result[0].distance.is_some());
}

/// 種別を指定しない座標検索は鉄道駅が先、バス停が後。10m 先のバス停より
/// 500m 先の鉄道駅が先に来る (`stationsNearby` の仕様)。
#[tokio::test]
async fn test_get_by_coordinates_puts_rail_before_bus() {
let repo = mixed_repository(&[
(901, TransportType::Bus, lat_north_of_tokyo(10.0)),
(902, TransportType::Bus, lat_north_of_tokyo(20.0)),
(101, TransportType::Rail, lat_north_of_tokyo(500.0)),
(102, TransportType::Rail, lat_north_of_tokyo(400.0)),
]);

let result = repo
.get_by_coordinates(35.681236, 139.767125, None, None)
.await
.unwrap();

// 鉄道 2 件が先、その中では近い順。バス停はその後
let ids: Vec<i32> = result.iter().map(|s| s.station_cd).collect();
assert_eq!(ids, vec![102, 101, 901, 902]);
}

/// 件数の上限は種別ごとではなく、並べた後の全体に掛かる。鉄道駅だけで
/// 埋まる地点ではバス停は返らない。
#[tokio::test]
async fn test_get_by_coordinates_fills_the_limit_with_rail_first() {
let repo = mixed_repository(&[
(901, TransportType::Bus, lat_north_of_tokyo(10.0)),
(101, TransportType::Rail, lat_north_of_tokyo(500.0)),
(102, TransportType::Rail, lat_north_of_tokyo(400.0)),
]);

let result = repo
.get_by_coordinates(35.681236, 139.767125, Some(2), None)
.await
.unwrap();

let ids: Vec<i32> = result.iter().map(|s| s.station_cd).collect();
assert_eq!(ids, vec![102, 101]);
}

#[tokio::test]
async fn test_get_by_name() {
let repo = MockStationRepository::new();
Expand Down
Loading