Files
jrshikoku/lib/elesiteTrainOrder.ts

278 lines
8.4 KiB
TypeScript

import dosan from "@/assets/originData/dosan";
import dosan2 from "@/assets/originData/dosan2";
import koutoku from "@/assets/originData/koutoku";
import naruto from "@/assets/originData/naruto";
import seto from "@/assets/originData/seto";
import tokushima from "@/assets/originData/tokushima";
import trainList from "@/assets/originData/trainList";
import uwajima from "@/assets/originData/uwajima";
import uwajima2 from "@/assets/originData/uwajima2";
import yosan from "@/assets/originData/yosan";
import type { ElesiteData } from "@/types/unyohub";
type HeadingDirection = "left" | "right";
export type ElesiteLineGroup = {
key: string;
lineCode: string | null;
lineLabel: string | null;
leftStation: string | null;
rightStation: string | null;
timetableUrl: string | null;
lastReportedAt: string | null;
entries: ElesiteData[];
formations: string[];
formationText: string | null;
hasFormations: boolean;
};
// マリンライナー(3xxxM)用: JR西日本区間の駅は originData にないのでここで定義
const MARINE_STATION_SEQUENCE = [
"岡山", "大元", "備前西市", "妹尾", "早島", "茶屋町", "植松", "木見", "上の町",
"児島", "坂出", "鴨川", "国分", "端岡", "鬼無", "高松",
];
const STATION_SEQUENCES: string[][] = [
...[yosan, uwajima, uwajima2, dosan, dosan2, koutoku, tokushima, naruto, seto].map(
(stations) => stations.map((station) => station.Station_JP),
),
MARINE_STATION_SEQUENCE,
];
const uniqueNonEmpty = (values: Array<string | null | undefined>): string[] => {
const seen = new Set<string>();
return values.filter((value): value is string => {
const normalized = value?.trim();
if (!normalized || seen.has(normalized)) return false;
seen.add(normalized);
return true;
});
};
const getElesiteQueryParam = (url: string, key: string): string | null => {
if (!url) return null;
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = url.match(new RegExp(`[?&]${escapedKey}=([^&#]+)`));
if (!match) return null;
try {
return decodeURIComponent(match[1].replace(/\+/g, " "));
} catch {
return match[1];
}
};
const getElesiteMatchedTrain = (
entry: ElesiteData,
trainNumber: string,
) =>
entry.trains?.find(
(train) => train.train_number.trim() === trainNumber.trim(),
);
const getElesiteLineMeta = (
entry: ElesiteData,
trainNumber: string,
): Omit<ElesiteLineGroup, "entries" | "formations" | "formationText" | "hasFormations" | "lastReportedAt"> => {
const matchedTrain = getElesiteMatchedTrain(entry, trainNumber);
const timetableUrl = matchedTrain?.timetable_url?.trim() || null;
const lineCode = timetableUrl
? getElesiteQueryParam(timetableUrl, "rosen_code")
: null;
const lineLabel = timetableUrl
? getElesiteQueryParam(timetableUrl, "rosen_name")
: null;
const leftStation = entry.formation_config?.left_station?.trim() || null;
const rightStation = entry.formation_config?.right_station?.trim() || null;
const fallbackKey = [lineLabel, leftStation, rightStation]
.filter(Boolean)
.join("::") || "unknown";
return {
key: lineCode || fallbackKey,
lineCode,
lineLabel,
leftStation,
rightStation,
timetableUrl,
};
};
const getElesiteEntryFormations = (entry: ElesiteData): string[] =>
uniqueNonEmpty(entry.formation_config?.units?.map((unit) => unit.formation) ?? []);
const getRouteEndpoints = (
trainNumber: string,
): { firstStation: string; lastStation: string } | null => {
const diagram = trainList[trainNumber.trim()];
if (!diagram) return null;
const stations = diagram
.split("#")
.map((stop) => stop.split(",")[0]?.trim())
.filter((station): station is string => !!station);
if (stations.length < 2) return null;
return {
firstStation: stations[0],
lastStation: stations[stations.length - 1],
};
};
const getMatchingStationSequence = (
leftStation: string,
rightStation: string,
firstStation: string,
lastStation: string,
): string[] | null =>
STATION_SEQUENCES.find(
(sequence) =>
sequence.includes(leftStation) &&
sequence.includes(rightStation) &&
sequence.includes(firstStation) &&
sequence.includes(lastStation),
) ??
STATION_SEQUENCES.find(
(sequence) =>
sequence.includes(leftStation) && sequence.includes(rightStation),
) ??
null;
export const inferElesiteHeadingDirection = (
entry: ElesiteData,
trainNumber: string,
): HeadingDirection | null => {
const matchedTrain = entry.trains?.find(
(train) => train.train_number.trim() === trainNumber.trim(),
);
const headingTo = matchedTrain?.nav?.heading_to;
if (headingTo === "left" || headingTo === "right") {
return headingTo;
}
const route = getRouteEndpoints(trainNumber);
if (!route) return null;
const leftStation = entry.formation_config?.left_station;
const rightStation = entry.formation_config?.right_station;
if (!leftStation || !rightStation) return null;
const sequence = getMatchingStationSequence(
leftStation,
rightStation,
route.firstStation,
route.lastStation,
);
if (!sequence) return null;
const firstIndex = sequence.indexOf(route.firstStation);
const lastIndex = sequence.indexOf(route.lastStation);
const leftIndex = sequence.indexOf(leftStation);
const rightIndex = sequence.indexOf(rightStation);
if ([firstIndex, lastIndex, leftIndex, rightIndex].some((index) => index < 0)) {
return null;
}
const trainDelta = lastIndex - firstIndex;
const formationDelta = rightIndex - leftIndex;
if (trainDelta === 0 || formationDelta === 0) return null;
return Math.sign(trainDelta) === Math.sign(formationDelta)
? "right"
: "left";
};
export const getElesiteLeftSideRank = (
entry: ElesiteData,
trainNumber: string,
): number => {
const matchedTrain = entry.trains?.find(
(train) => train.train_number.trim() === trainNumber.trim(),
);
if (!matchedTrain?.nav) return 2;
const headingDirection = inferElesiteHeadingDirection(entry, trainNumber);
if (!headingDirection) {
const isLeftSide =
(matchedTrain.nav.heading_to === "left") ===
(matchedTrain.nav.is_leading === true);
return isLeftSide ? 0 : 1;
}
const isLeftSide =
(headingDirection === "left") === (matchedTrain.nav.is_leading === true);
return isLeftSide ? 0 : 1;
};
export const sortElesiteEntriesByTrainNumber = (
entries: ElesiteData[],
trainNumber: string,
): ElesiteData[] =>
[...entries].sort(
(a, b) =>
getElesiteLeftSideRank(a, trainNumber) -
getElesiteLeftSideRank(b, trainNumber),
);
export const buildElesiteLineGroups = (
entries: ElesiteData[],
trainNumber: string,
): ElesiteLineGroup[] => {
const groups = new Map<string, ElesiteData[]>();
const lineOrder: string[] = [];
for (const entry of entries) {
const key = getElesiteLineMeta(entry, trainNumber).key;
if (!groups.has(key)) {
groups.set(key, []);
lineOrder.push(key);
}
groups.get(key)?.push(entry);
}
return lineOrder.map((key) => {
const groupedEntries = groups.get(key) ?? [];
const sortedEntries = sortElesiteEntriesByTrainNumber(groupedEntries, trainNumber);
const lineMetas = sortedEntries.map((entry) => getElesiteLineMeta(entry, trainNumber));
const formations = uniqueNonEmpty(
sortedEntries.flatMap((entry) => getElesiteEntryFormations(entry)),
);
return {
key,
lineCode: lineMetas.map((meta) => meta.lineCode).find(Boolean) ?? null,
lineLabel: lineMetas.map((meta) => meta.lineLabel).find(Boolean) ?? null,
leftStation: lineMetas.map((meta) => meta.leftStation).find(Boolean) ?? null,
rightStation: lineMetas.map((meta) => meta.rightStation).find(Boolean) ?? null,
timetableUrl:
lineMetas.map((meta) => meta.timetableUrl).find(Boolean) ?? null,
lastReportedAt:
sortedEntries
.map((entry) => entry.report_info?.last_reported_at)
.filter((value): value is string => !!value)
.sort()
.at(-1) ?? null,
entries: sortedEntries,
formations,
formationText: formations.length > 0 ? formations.join("+") : null,
hasFormations: formations.length > 0,
};
});
};
export const getElesiteSummaryByTrainNumber = (
entries: ElesiteData[],
trainNumber: string,
): string | null => {
const lineGroups = buildElesiteLineGroups(entries, trainNumber).filter(
(group) => group.hasFormations,
);
return lineGroups[0]?.formationText ?? null;
};