Files
jrshikoku/components/Apps/FixedPositionBox/FixedTrainBox.tsx

1448 lines
46 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import lineColorList from "@/assets/originData/lineColorList";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
import { useStationList } from "@/stateBox/useStationList";
import { StationProps } from "@/lib/CommonTypes";
import { FC, useEffect, useRef, useState, useMemo } from "react";
import {
Text,
TouchableOpacity,
View,
Image,
LayoutAnimation,
ScrollView,
Platform,
PermissionsAndroid,
AppState,
} from "react-native";
import { getTrainType } from "@/lib/getTrainType";
import { trainDataType, trainPosition } from "@/lib/trainPositionTextArray";
import { StationNumberMaker } from "@/components/駅名表/StationNumberMaker";
import { lineListPair, stationIDPair } from "@/lib/getStationList";
import { findReversalPoints } from "@/lib/eachTrainInfoCoreLib/findReversalPoints";
import { CustomTrainData, trainTypeID } from "@/lib/CommonTypes";
import { getCurrentTrainData } from "@/lib/getCurrentTrainData";
import { Ionicons } from "@expo/vector-icons";
import dayjs from "dayjs";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useThemeColors } from "@/lib/theme";
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
import { resolveTrainDataIcon } from "@/lib/trainDataIcon";
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
import {
startTrainFollowActivity,
updateTrainFollowActivity,
endTrainFollowActivity,
isAvailable as isLiveActivityAvailable,
cancelLocationAnnouncements,
} from "expo-live-activity";
import {
DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE,
prepareBackgroundRikkaAnnouncements,
sendTrainPositionRikkaAnnouncement,
} from "@/lib/backgroundRikkaAnnouncements";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
type props = {
trainID: string;
};
type TrainPathEntry = {
raw: string;
station: string;
se: string;
time: string;
index: number;
isThrough: boolean;
hasTime: boolean;
};
const normalizeTrainPosLabel = (value: string) =>
value
.replace(
/(下り)|(上り)|\(下り\)|\(上り\)|(徳島線)|(高徳線)|(坂出方)|(児島方)/g,
""
)
.trim();
const isHiddenThroughPointEntry = (raw: string) => {
const [station = "", se = ""] = (raw || "").split(",");
return station.startsWith(".") && se.includes("通");
};
const calcDistanceMinute = (
time: string,
delayTime: number,
playbackCurrentTimeIso?: string | null
) => {
if (!time || time === "") return null;
const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
const hour = parseInt(time.split(":")[0], 10);
const target = now
.hour(hour < 4 ? hour + 24 : hour)
.minute(parseInt(time.split(":")[1], 10));
let diff = target.diff(now, "minute") + delayTime;
if (now.hour() < 4 && hour < 4) diff -= 1440;
return diff;
};
export const FixedTrain: FC<props> = ({ trainID }) => {
const { colors, fixed } = useThemeColors();
const {
setFixedPosition,
currentTrain,
getCurrentStationData,
getPosition,
fixedPositionSize,
setFixedPositionSize,
} = useCurrentTrain();
const { mapSwitch, iconSetting, playbackCurrentTimeIso } = useTrainMenu();
const { allCustomTrainData, allTrainDiagram, getTodayOperationByTrainId } =
useAllTrainDiagram();
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
const [liveNotifyId, setLiveNotifyId] = useState<string | null>(null);
const liveNotifyIdRef = useRef<string | null>(null);
const hasStartedRef = useRef(false);
const backgroundRikkaSignatureRef = useRef("");
const lastTrainPositionAnnouncementRef = useRef("");
const backgroundRikkaTrackingId = `train-${trainID}`;
const [train, setTrain] = useState<trainDataType>(null);
const [customData, setCustomData] = useState<CustomTrainData>(
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
);
const todayOperation = useMemo(
() => (getTodayOperationByTrainId(trainID) ?? []).filter((d) => d.state !== 100),
[getTodayOperationByTrainId, trainID]
);
const customTrainIcon = useMemo(() => {
const iconEntry = resolveTrainIconEntries({
trainNum: trainID,
customTrainData: customData,
todayOperation,
iconDisplayMode,
})[0];
return (
iconEntry?.vehicle_info_img ||
resolveTrainDataIcon(customData, iconDisplayMode)
);
}, [customData, iconDisplayMode, todayOperation, trainID]);
useEffect(() => {
setCustomData(
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
);
}, [currentTrain, trainID, allCustomTrainData]);
useEffect(() => {
const stationData = getCurrentStationData(trainID);
if (stationData) {
setTrain(stationData);
} else {
// バックグラウンドでは一時的にデータが消えることがある→フォアグラウンド時のみ終了
if (AppState.currentState === "active") {
alert("追跡していた列車が消えました。追跡を終了します。");
setFixedPosition({ type: null, value: null });
}
}
}, [trainID, currentTrain]);
const { getStationDataFromName, stationList, originalStationList } =
useStationList();
const [trainDataWidhThrough, setTrainDataWithThrough] = useState<string[]>(
[]
);
const computedTrainDataWithThrough = useMemo(() => {
const trainData =
allTrainDiagram[trainID]
?.split("#")
.filter((entry) => !isHiddenThroughPointEntry(entry)) ?? [];
if (trainData.length === 0) return [];
// 駅名ごとに駅情報を集約するマップを構築
const stationByNameMap = new Map();
stationList.forEach((lineStations) => {
lineStations.forEach((station) => {
if (!stationByNameMap.has(station.StationName)) {
stationByNameMap.set(station.StationName, []);
}
stationByNameMap.get(station.StationName).push(station);
});
});
const stopStationList = trainData.map((i) => {
const [station, se, time] = i.split(",");
const targetStations = stationByNameMap.get(station) || [];
// 各路線での該当駅をフィルタ(元のロジック維持)
return stationList.map((a) =>
targetStations.filter((d) => a.findIndex((s) => s.StationNumber === d.StationNumber) !== -1)
);
});
const allThroughStationList = stopStationList.map((i, index, array) => {
let allThroughStation = [];
if (index == array.length - 1) return;
const firstItem = array[index];
const secondItem = array[index + 1];
let betweenStationLine = "";
let baseStationNumberFirst = "";
let baseStationNumberSecond = "";
Object.keys(stationIDPair).forEach((d, index2) => {
if (!d) return;
const haveFirst = firstItem[index2];
const haveSecond = secondItem[index2];
if (haveFirst.length && haveSecond.length) {
betweenStationLine = d;
baseStationNumberFirst = haveFirst[0].StationNumber;
baseStationNumberSecond = haveSecond[0].StationNumber;
}
});
if (!betweenStationLine) return;
let reverse = false;
originalStationList[
lineListPair[stationIDPair[betweenStationLine]]
].forEach((d) => {
const isHiddenPassPoint = d.Station_JP?.startsWith(".");
if (isHiddenPassPoint) return;
if (
d.StationNumber > baseStationNumberFirst &&
d.StationNumber < baseStationNumberSecond
) {
allThroughStation.push(`${d.Station_JP},通過,`);
reverse = false;
} else {
if (
d.StationNumber < baseStationNumberFirst &&
d.StationNumber > baseStationNumberSecond
) {
allThroughStation.push(`${d.Station_JP},通過,`);
reverse = true;
}
}
});
if (reverse) allThroughStation.reverse();
return allThroughStation;
});
let mainArray = [...trainData];
let indexs = 0;
trainData.forEach((d, index) => {
indexs = indexs + 1;
if (!allThroughStationList[index]) return;
if (allThroughStationList[index].length == 0) return;
mainArray.splice(indexs, 0, ...allThroughStationList[index]);
indexs = indexs + allThroughStationList[index].length;
});
return mainArray;
}, [allTrainDiagram, stationList, trainID, originalStationList]);
useEffect(() => {
setTrainDataWithThrough(computedTrainDataWithThrough);
}, [computedTrainDataWithThrough]);
const [stopStationIDList, setStopStationList] = useState([]);
const computedStopStationIDList = useMemo(() => {
return trainDataWidhThrough.map((i) => {
const [station, se, time] = i.split(",");
const Stations = stationList.map((a) =>
a.filter((d) => d.StationName == station)
);
const StationNumbers =
Stations &&
Stations.reduce((newArray, e) => {
return newArray.concat(e);
}, []).map((d) => d.StationNumber);
return StationNumbers;
});
}, [trainDataWidhThrough, stationList]);
useEffect(() => {
setStopStationList(computedStopStationIDList);
}, [computedStopStationIDList]);
const [currentPosition, setCurrentPosition] = useState<string[]>([]);
const parsedTrainPath = useMemo<TrainPathEntry[]>(
() =>
trainDataWidhThrough.map((raw, index) => {
const [station = "", se = "", time = ""] = (raw || "").split(",");
return {
raw,
station,
se,
time,
index,
isThrough: se.includes("通"),
hasTime: time !== "",
};
}),
[trainDataWidhThrough]
);
useEffect(() => {
let position = getPosition(train);
if (stopStationIDList.length == 0) return;
if (position) {
if (position.length > 1) {
if (position[0] == "-Iyo") {
position[0] =
stopStationIDList[
stopStationIDList.findIndex((d) => d.includes("U14")) - 1
][0];
} else if (position[0] == "+Iyo") {
position[0] =
stopStationIDList[
stopStationIDList.findIndex((d) => d.includes("U14")) + 1
][0];
}
if (position[1] == "+Iyo") {
position[1] =
stopStationIDList[
stopStationIDList.findIndex((d) => d.includes("U14")) + 1
][0];
} else if (position[1] == "-Iyo") {
position[1] =
stopStationIDList[
stopStationIDList.findIndex((d) => d.includes("U14")) - 1
][0];
}
}
setCurrentPosition(position);
}
}, [train, stopStationIDList]);
const [currentPointData, setCurrentPointData] = useState<StationProps[]>([]);
const [nextStopStationData, setNextStopStationData] = useState<StationProps[]>([]);
const [untilStationData, setUntilStationData] = useState<string[]>([]);
const [probably, setProbably] = useState(false);
const [isCurrentPointDisplay, setIsCurrentPointDisplay] = useState(false);
const [currentDisplayIndex, setCurrentDisplayIndex] = useState(0);
const [tick, setTick] = useState(0);
useEffect(() => {
if (playbackCurrentTimeIso) return;
const interval = setInterval(() => setTick((value) => value + 1), 15000);
return () => clearInterval(interval);
}, [playbackCurrentTimeIso]);
useEffect(() => {
const points = findReversalPoints(currentPosition, stopStationIDList);
if (!points || points.length === 0) return;
const pointIndexes = points.reduce(
(acc: number[], isCurrent, index) => {
if (isCurrent) acc.push(index);
return acc;
},
[] as number[]
);
if (!pointIndexes.length) return;
const firstMatchedIndex = pointIndexes[0];
const isCurrentStationCluster = currentPosition.length <= 1;
const delayTime =
train?.delay == "入線" ? 0 : parseInt(String(train?.delay), 10) || 0;
let anchorIndex = firstMatchedIndex;
let usedTimeEstimation = false;
const shouldShowCurrentPoint = isCurrentStationCluster;
const hasIntermediateCurrentPoints = pointIndexes.length > 1;
if (!isCurrentStationCluster && hasIntermediateCurrentPoints) {
let upcomingTimedIndex = -1;
let lastPassedTimedIndex = -1;
for (
let searchCount = firstMatchedIndex;
searchCount < parsedTrainPath.length;
searchCount++
) {
const entry = parsedTrainPath[searchCount];
if (!entry?.raw || !entry.hasTime) continue;
const distanceMinute = calcDistanceMinute(
entry.time,
delayTime,
playbackCurrentTimeIso
);
if (distanceMinute == null) continue;
if (distanceMinute < 0) {
lastPassedTimedIndex = searchCount;
continue;
}
upcomingTimedIndex = searchCount;
break;
}
const baseIndex =
lastPassedTimedIndex >= 0 ? lastPassedTimedIndex + 1 : firstMatchedIndex;
if (upcomingTimedIndex >= 0) {
const hasUntimedGapBeforeUpcoming = parsedTrainPath
.slice(baseIndex, upcomingTimedIndex)
.some((entry) => entry?.raw && !entry.hasTime);
anchorIndex = hasUntimedGapBeforeUpcoming ? baseIndex : upcomingTimedIndex;
} else if (lastPassedTimedIndex >= 0) {
const lastPassedEntry = parsedTrainPath[lastPassedTimedIndex];
anchorIndex = lastPassedEntry?.isThrough
? Math.min(lastPassedTimedIndex + 1, parsedTrainPath.length - 1)
: lastPassedTimedIndex;
}
usedTimeEstimation = anchorIndex !== firstMatchedIndex;
}
const anchorEntry = parsedTrainPath[anchorIndex];
const currentPointName = anchorEntry?.station || "";
setCurrentPointData(
currentPointName ? getStationDataFromName(currentPointName) : []
);
const nextStopSearchStart = shouldShowCurrentPoint
? anchorIndex + 1
: anchorIndex;
const nextStopEntry = parsedTrainPath.find(
(entry, index) =>
index >= nextStopSearchStart && !!entry?.station && !entry.isThrough
);
const nextStopName = nextStopEntry?.station || "";
setNextStopStationData(
nextStopName ? getStationDataFromName(nextStopName) : []
);
const visibleStart = Math.max(anchorIndex - 1, 0);
const trainList = parsedTrainPath
.slice(visibleStart)
.map((entry) => entry.raw)
.filter((entry) => !!entry);
setProbably(usedTimeEstimation);
setIsCurrentPointDisplay(shouldShowCurrentPoint);
setCurrentDisplayIndex(Math.max(anchorIndex - visibleStart, 0));
setUntilStationData(trainList);
}, [
currentPosition,
parsedTrainPath,
playbackCurrentTimeIso,
stopStationIDList,
tick,
train?.delay,
getStationDataFromName,
]);
const [ToData, setToData] = useState("");
useEffect(() => {
if (customData.to_data && customData.to_data != "") {
setToData(customData.to_data);
} else {
if (trainDataWidhThrough.length == 0) return;
setToData(
trainDataWidhThrough[trainDataWidhThrough.length - 2].split(",")[0]
);
}
}, [customData, trainDataWidhThrough]);
const [station, setStation] = useState<StationProps[]>([]);
useEffect(() => {
const data = getStationDataFromName(ToData);
setStation(data);
}, [ToData]);
const lineColor =
customData.to_data_color && customData.to_data_color.length > 0
? customData.to_data_color[0]
: station.length > 0
? lineColorList[station[0]?.StationNumber?.slice(0, 1)]
: "black";
//const lineColor = "red";
const customTrainType = getTrainType({
type: customData.type,
whiteMode: true,
});
const trainNameText = `${customData.train_name}${
(customData.train_num_distance !== "" && !isNaN(parseInt(customData.train_num_distance)))
? ` ${parseInt(customData.train_id) - parseInt(customData.train_num_distance)}号`
: ""
}`;
// ── Station Progress for Live Notification ──
// 着のみエントリを除外(終着駅は着を許可、発・通編・通発編は保持)
const lastValidIdx = trainDataWidhThrough.reduce(
(last: number, d: string, i: number) => (d ? i : last), -1
);
const filteredTrainData = trainDataWidhThrough.filter((d, idx) => {
if (!d || isHiddenThroughPointEntry(d)) return false;
const [, se] = d.split(",");
if (!se) return true;
// 着を含み発を含まないエントリは終着駅のみ許可
if (se.includes("着") && !se.includes("発")) {
return idx === lastValidIdx;
}
return true;
});
const stationStops = filteredTrainData
.filter((d) => !d.split(",")[1]?.includes("通"))
.map((d) => d.split(",")[0]);
// 全駅リスト(通過駅含む、停車/通過フラグ+乗換色付き)
// 駅名→所属路線コードのマップ構築
const stationToLineCodes: Record<string, string[]> = {};
if (originalStationList) {
Object.keys(lineListPair).forEach((lineCode: string) => {
const lineName = lineListPair[lineCode];
const stations = originalStationList[lineName];
if (!stations) return;
stations.forEach((s: StationProps) => {
if (!stationToLineCodes[s.Station_JP]) stationToLineCodes[s.Station_JP] = [];
if (!stationToLineCodes[s.Station_JP].includes(lineCode)) {
stationToLineCodes[s.Station_JP].push(lineCode);
}
});
});
}
// 現在走行中の路線コード
const runningLineCode = station.length > 0
? station[0]?.StationNumber?.slice(0, 1) || ""
: "";
const allStations = filteredTrainData
.map((d) => {
const [name, se] = d.split(",");
const isStop = !se?.includes("通");
const lineCodes = stationToLineCodes[name] || [];
// 乗換色: 走行路線以外の路線色
const transferColors = lineCodes
.filter((c) => c !== runningLineCode)
.map((c) => lineColorList[c])
.filter(Boolean);
return {
name,
isStop,
...(transferColors.length > 0 ? { transferColors } : {}),
};
});
const shouldShowCurrentLabel =
isCurrentPointDisplay || currentPointData[0]?.Station_JP === train?.Pos;
const currentStationLabel = shouldShowCurrentLabel
? currentPointData[0]?.Station_JP || train?.Pos || ""
: train?.Pos || "";
const bannerStationData = shouldShowCurrentLabel
? currentPointData
: nextStopStationData;
// 全駅リスト中の現在地インデックス
const currentStationIndex = (() => {
const pos = currentStationLabel;
if (!pos) return 0;
// Pos は "駅名" (駅にいる時) or "駅A~駅B" (走行中) の形式
const posStations = pos.split("").map((s: string) =>
normalizeTrainPosLabel(s)
);
// 完全一致
const firstIdx = allStations.findIndex((s) => s.name === posStations[0]);
if (firstIdx >= 0) return firstIdx;
// 部分一致フォールバック
const partialIdx = allStations.findIndex((s) =>
posStations[0].includes(s.name) || s.name.includes(posStations[0])
);
if (partialIdx >= 0) return partialIdx;
return 0;
})();
const nextStationIndex = (() => {
const name = nextStopStationData[0]?.Station_JP;
if (!name) return -1;
const idx = stationStops.indexOf(name);
if (idx >= 0) return idx;
// 部分一致フォールバック
return stationStops.findIndex((s) => s === name || name.includes(s) || s.includes(name));
})();
// ── Live Notification ──
useEffect(() => {
liveNotifyIdRef.current = liveNotifyId;
}, [liveNotifyId]);
useEffect(() => {
return () => {
if (liveNotifyIdRef.current) {
endTrainFollowActivity(liveNotifyIdRef.current).catch(() => {});
}
};
}, []);
useEffect(() => {
if (!liveNotifyId || !train) return;
const delayNum = train.delay === "入線" ? 0 : parseInt(String(train.delay)) || 0;
const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻";
const positionStatus = shouldShowCurrentLabel ? "ただいま" : "次は";
updateTrainFollowActivity(liveNotifyId, {
currentStation: currentStationLabel,
nextStation: nextStopStationData[0]?.Station_JP || "",
delayMinutes: delayNum,
scheduledArrival: "",
trainNumber: trainID,
trainType: customTrainType.shortName,
trainName: trainNameText,
trainTypeColor: customTrainType.color,
lineColor,
destination: ToData,
positionStatus,
delayStatus,
stationStops,
nextStationIndex: nextStationIndex >= 0 ? nextStationIndex : undefined,
allStations,
currentStationIndex,
}).catch(() => {});
}, [
train,
nextStopStationData,
liveNotifyId,
stationStops,
nextStationIndex,
currentStationIndex,
currentStationLabel,
shouldShowCurrentLabel,
]);
// バナー表示と同時にLive Activityを自動開始
useEffect(() => {
if (
!isLiveActivityAvailable() ||
hasStartedRef.current ||
!train ||
!nextStopStationData[0]
) {
return;
}
hasStartedRef.current = true;
const startActivity = async () => {
if (Platform.OS === 'android' && Platform.Version >= 33) {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) return;
}
const delayNum = train?.delay === "入線" ? 0 : parseInt(String(train?.delay)) || 0;
const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻";
const positionStatus = shouldShowCurrentLabel ? "ただいま" : "次は";
try {
const id = await startTrainFollowActivity({
trainNumber: trainID,
lineName: "",
destination: ToData,
currentStation: currentStationLabel,
nextStation: nextStopStationData[0]?.Station_JP || "",
delayMinutes: delayNum,
scheduledArrival: "",
trainType: customTrainType.shortName,
trainName: trainNameText,
trainTypeColor: customTrainType.color,
lineColor,
positionStatus,
delayStatus,
stationStops,
nextStationIndex: nextStationIndex >= 0 ? nextStationIndex : undefined,
allStations,
currentStationIndex,
});
setLiveNotifyId(id);
} catch (e) {
console.warn('[LiveNotify] start error:', e);
hasStartedRef.current = false;
}
};
startActivity();
}, [
train,
nextStopStationData,
currentStationLabel,
shouldShowCurrentLabel,
ToData,
trainID,
customTrainType.shortName,
trainNameText,
customTrainType.color,
lineColor,
stationStops,
nextStationIndex,
allStations,
currentStationIndex,
]);
// iOSへ残り停車駅の位置トリガーとりっかちゃん通知音を登録する。
useEffect(() => {
if (Platform.OS !== "ios" || playbackCurrentTimeIso) return;
const upcomingStations = allStations
.slice(currentStationIndex + 1)
.map((entry, offset) => ({ entry, offset }))
.filter(({ entry }) => entry.isStop)
.map(({ entry, offset }) => {
const stationData = getStationDataFromName(entry.name)[0];
if (
!stationData ||
!Number.isFinite(stationData.lat) ||
!Number.isFinite(stationData.lng)
) {
return null;
}
return {
identifier: `${currentStationIndex + 1 + offset}-${stationData.StationNumber || entry.name}`,
stationName: entry.name,
latitude: stationData.lat,
longitude: stationData.lng,
};
})
.filter((station): station is NonNullable<typeof station> => station != null)
.slice(0, 20);
if (upcomingStations.length === 0) return;
const signature = [
backgroundRikkaTrackingId,
currentStationIndex,
...upcomingStations.map((station) => station.identifier),
].join(":");
if (backgroundRikkaSignatureRef.current === signature) return;
const controller = new AbortController();
Promise.all([
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_ANNOUNCEMENT).catch(() => "false"),
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_TRIGGER_SOURCE).catch(
() => DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE
),
])
.then(async ([value, source]) => {
const enabled = value === true || value === "true";
if (
!enabled ||
source === "trainPosition" ||
controller.signal.aborted
) {
backgroundRikkaSignatureRef.current = "";
await cancelLocationAnnouncements(backgroundRikkaTrackingId);
return;
}
backgroundRikkaSignatureRef.current = signature;
const result = await prepareBackgroundRikkaAnnouncements({
trackingId: backgroundRikkaTrackingId,
stations: upcomingStations,
signal: controller.signal,
});
console.info(
`[BackgroundRikka] scheduled=${result.scheduled} failed=${result.failedStations.length}`
);
})
.catch((error) => {
backgroundRikkaSignatureRef.current = "";
if (!controller.signal.aborted) {
console.warn("[BackgroundRikka] Failed to schedule announcements", error);
}
});
return () => controller.abort();
}, [
allStations,
backgroundRikkaTrackingId,
currentStationIndex,
getStationDataFromName,
playbackCurrentTimeIso,
]);
// 検証用: 列車走行位置から算出した次駅が変わった瞬間に通知する。
useEffect(() => {
if (Platform.OS !== "ios") return;
const stationName = nextStopStationData[0]?.Station_JP;
if (!stationName) return;
const controller = new AbortController();
Promise.all([
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_ANNOUNCEMENT).catch(() => "false"),
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_TRIGGER_SOURCE).catch(
() => DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE
),
])
.then(async ([enabledValue, source]) => {
const enabled =
enabledValue === true || enabledValue === "true";
if (
!enabled ||
source !== "trainPosition" ||
controller.signal.aborted
) {
lastTrainPositionAnnouncementRef.current = "";
return;
}
const announcementKey = `${trainID}:${stationName}`;
if (
lastTrainPositionAnnouncementRef.current === announcementKey
) {
return;
}
lastTrainPositionAnnouncementRef.current = announcementKey;
await cancelLocationAnnouncements(backgroundRikkaTrackingId);
if (controller.signal.aborted) return;
await sendTrainPositionRikkaAnnouncement({
stationName,
trainId: trainID,
signal: controller.signal,
});
})
.catch((error) => {
lastTrainPositionAnnouncementRef.current = "";
if (!controller.signal.aborted) {
console.warn(
"[BackgroundRikka] Train-position announcement failed",
error
);
}
});
return () => controller.abort();
}, [
backgroundRikkaTrackingId,
nextStopStationData,
train?.Pos,
trainID,
]);
useEffect(() => {
return () => {
cancelLocationAnnouncements(backgroundRikkaTrackingId).catch(() => {});
};
}, [backgroundRikkaTrackingId]);
return (
<View
style={{ display: "flex", flexDirection: "column", flex: 1 }}
pointerEvents="box-none"
>
<View
style={{
flex: 1,
flexDirection: fixedPositionSize === 226 ? "column" : "row",
backgroundColor: "black",
//borderBottomColor: "black",
//borderBottomWidth: 2,
}}
>
<View
style={{
flexDirection: fixedPositionSize === 226 ? "row" : "column",
flex: 1,
backgroundColor: colors.background,
height: fixedPositionSize === 226 ? 200 : 50,
overflow: "hidden",
}}
>
<View
style={{
flex: fixedPositionSize === 226 ? 5 : 1,
flexDirection: "row",
}}
>
<View
style={{
backgroundColor: customTrainType.color,
flexDirection: "row",
alignContent: "center",
alignSelf: "center",
alignItems: "center",
height: "100%",
}}
>
<Image
source={{ uri: customTrainIcon }}
width={fixedPositionSize === 226 ? 23 : 14}
height={fixedPositionSize === 226 ? 26 : 17}
style={{ margin: 5 }}
/>
<View
style={{
flexDirection: fixedPositionSize === 226 ? "column" : "row",
alignContent: "center",
alignSelf: "center",
alignItems: "center",
maxWidth: fixedPositionSize === 226 ? 80 : 100,
}}
>
<Text
style={{
fontSize: trainNameText.length > 4 ? 12 : 14,
fontFamily: customTrainType.fontFamily,
fontWeight: customTrainType.fontFamily ? undefined : "bold",
marginTop: customTrainType.fontFamily ? 3 : 0,
color: fixed.textOnPrimary,
textAlignVertical: "center",
textAlign: "left",
}}
>
{customTrainType.shortName}
{customTrainType.fontFamily === "JR-WEST-PLUS" ? (
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
</Text>
{customData.train_name && (
<Text
style={{
fontSize: trainNameText.length > 4 ? 8 : 14,
color: fixed.textOnPrimary,
maxWidth: fixedPositionSize === 226 ? 200 : 60,
textAlignVertical: "center",
}}
>
{trainNameText}
</Text>
)}
</View>
<View
style={{
backgroundColor: customTrainType.color,
width: 10,
borderLeftColor: customTrainType.color,
borderTopColor: lineColor,
borderBottomColor: lineColor,
borderTopWidth: fixedPositionSize === 226 ? 50 : 14,
borderBottomWidth: fixedPositionSize === 226 ? 50 : 14,
borderLeftWidth: fixedPositionSize === 226 ? 30 : 10,
borderRightWidth: 0,
//height: fixedPositionSize === 226 ? 20 : 100,
height: "100%",
}}
></View>
</View>
<View
style={{
flexDirection: "row",
alignContent: "center",
alignSelf: "center",
height: "100%",
backgroundColor: lineColor,
flex: 1,
}}
>
<View
style={{
flexDirection: "row",
alignContent: "center",
alignSelf: "center",
alignItems: "center",
}}
>
<StationNumberMaker
currentStation={station}
singleSize={18}
useEach={true}
/>
<Text
style={{
fontSize: customData?.to_data?.length > 4 ? 9 : 12,
color: fixed.textOnPrimary,
fontWeight: "bold",
textAlignVertical: "center",
margin: 0,
padding: 0,
height: "100%",
}}
>
{ToData}
</Text>
</View>
</View>
</View>
{fixedPositionSize === 226 && (
<View
style={{
backgroundColor: colors.background,
width: 10,
borderLeftColor: "black",
borderTopColor: lineColor,
borderBottomColor: colors.background,
borderRightColor: "black",
borderTopWidth: 50,
borderBottomWidth: 0,
borderLeftWidth: 0,
borderRightWidth: 20,
}}
></View>
)}
<View
style={{
backgroundColor: "black",
flex: fixedPositionSize === 226 ? 4 : 1,
flexDirection: "row",
alignItems: "center",
}}
>
<View style={{ flexDirection: "column" }}>
<Text
style={{
fontSize: 10,
fontWeight: "bold",
color: "white",
marginHorizontal: 5,
paddingVertical: 0,
marginVertical: -1,
}}
>
{shouldShowCurrentLabel ? "ただいま" : "次は"}
</Text>
{probably && (
<Text
style={{
fontSize: 5,
color: "white",
fontWeight: "bold",
marginHorizontal: 5,
paddingVertical: 0,
marginVertical: -1,
}}
>
(時刻推定)
</Text>
)}
</View>
<StationNumberMaker
currentStation={bannerStationData}
singleSize={20}
useEach={true}
/>
<Text
style={{
fontSize: 18,
fontWeight: "bold",
color: "white",
flex: 1,
}}
>
{bannerStationData[0]?.Station_JP || "不明"}
</Text>
{fixedPositionSize !== 226 && (
<View
style={{
backgroundColor: colors.background,
width: 10,
borderLeftColor: "black",
borderTopColor: "black",
borderBottomColor: colors.background,
borderRightColor: colors.background,
borderTopWidth: 21,
borderBottomWidth: 0,
borderLeftWidth: 0,
borderRightWidth: 7,
}}
></View>
)}
</View>
</View>
<CurrentPositionBox
train={train}
lineColor={lineColor}
trainDataWithThrough={untilStationData}
currentDisplayIndex={currentDisplayIndex}
isSmall={fixedPositionSize !== 226}
/>
</View>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
borderTopColor: "black",
borderTopWidth: 2,
}}
pointerEvents="box-none"
>
<TouchableOpacity
style={{
flexDirection: "row",
alignItems: "center",
}}
onPress={() => {
setFixedPosition({ type: null, value: null });
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
backgroundColor: "black",
paddingHorizontal: 5,
height: 26,
}}
>
<Ionicons name="lock-closed" size={15} color={fixed.textOnPrimary} />
<Text
style={{
color: fixed.textOnPrimary,
fontSize: 15,
paddingRight: 5,
}}
>
列車追跡中
</Text>
<Ionicons name="close" size={15} color={fixed.textOnPrimary} />
</View>
<View
style={{
backgroundColor: "#0000",
width: 6,
borderLeftColor: "black",
borderTopColor: "black",
borderBottomColor: "#0000",
borderRightColor: "#0000",
borderBottomWidth: 26,
borderLeftWidth: 10,
borderRightWidth: 0,
borderTopWidth: 0,
height: 26,
}}
/>
</TouchableOpacity>
<TouchableOpacity
style={{
flexDirection: "row",
alignItems: "center",
}}
onPress={() => {
LayoutAnimation.configureNext({
duration: 200,
update: { type: "easeInEaseOut", springDamping: 0.4 },
});
if (fixedPositionSize === 226) {
setFixedPositionSize(mapSwitch == "true" ? 76 : 80);
} else {
setFixedPositionSize(226);
}
}}
>
<View
style={{
backgroundColor: "#0000",
width: 6,
borderLeftColor: "#0000",
borderTopColor: "black",
borderBottomColor: "#0000",
borderRightColor: "black",
borderBottomWidth: 26,
borderLeftWidth: 0,
borderRightWidth: 10,
borderTopWidth: 0,
height: 26,
}}
/>
<View
style={{
flexDirection: "row",
alignItems: "center",
backgroundColor: "black",
paddingHorizontal: 5,
height: 26,
}}
>
<Ionicons
name={fixedPositionSize == 226 ? "chevron-up" : "chevron-down"}
size={15}
color="white"
/>
<Text
style={{
color: "white",
paddingRight: 5,
backgroundColor: "black",
fontSize: 15,
}}
>
{fixedPositionSize == 226 ? "列車情報縮小" : "列車情報展開"}
</Text>
</View>
</TouchableOpacity>
</View>
</View>
);
};
const CurrentPositionBox = ({
train,
lineColor,
trainDataWithThrough,
currentDisplayIndex,
isSmall,
}) => {
const { colors } = useThemeColors();
let firstText = "";
let secondText = "";
let marginText = "";
const { isBetween, Pos: PosData } = trainPosition(train);
if (isBetween === true) {
const { from, to } = PosData;
firstText = normalizeTrainPosLabel(from);
secondText = normalizeTrainPosLabel(to);
marginText = "→";
} else {
const { Pos } = PosData;
if (Pos !== "") {
firstText = normalizeTrainPosLabel(Pos);
}
}
const delayTime = train?.delay == "入線" ? 0 : parseInt(train?.delay);
return (
<View
style={{
flex: isSmall ? 1 : 3,
backgroundColor: colors.background,
flexDirection: "row",
}}
>
{isSmall && (
<View style={{ flexDirection: "column" }}>
<View
style={{
backgroundColor: colors.background,
width: 10,
borderLeftColor: lineColor,
borderTopColor: lineColor,
borderBottomColor: colors.background,
borderRightColor: colors.background,
borderTopWidth: 28,
borderBottomWidth: 0,
borderLeftWidth: 0,
borderRightWidth: 10,
}}
></View>
<View
style={{
backgroundColor: colors.background,
width: 10,
borderLeftColor: colors.background,
borderTopColor: colors.background,
borderBottomColor: colors.background,
borderRightColor: colors.background,
borderTopWidth: 18,
borderBottomWidth: 0,
borderLeftWidth: 0,
borderRightWidth: 10,
}}
></View>
</View>
)}
<ScrollView
style={{ flex: 1, flexDirection: "row" }}
horizontal
overScrollMode="always"
>
{trainDataWithThrough.length > 0 &&
(() => {
// 着→発ペアを同一駅で統合(EachStopListと同様)
const merged: { d: string; arrivalTime: string | null }[] = [];
let mergedCurrentDisplayIndex = Math.max(currentDisplayIndex, 0);
for (let i = 0; i < trainDataWithThrough.length; i++) {
const d = trainDataWithThrough[i];
if (!d) continue;
const [st, se] = d.split(",");
if (se?.includes("着") && !se?.includes("発")) {
const next = trainDataWithThrough[i + 1];
if (next) {
const [nextSt, nextSe] = next.split(",");
if (nextSt === st && nextSe?.includes("発")) {
// この着エントリは次の発エントリで統合するためスキップ
continue;
}
}
}
if (se?.includes("発") && i > 0) {
const prev = trainDataWithThrough[i - 1];
if (prev) {
const [prevSt, prevSe, prevTime] = prev.split(",");
if (prevSt === st && prevSe?.includes("着")) {
merged.push({ d, arrivalTime: prevTime });
if (i === currentDisplayIndex || i - 1 === currentDisplayIndex) {
mergedCurrentDisplayIndex = merged.length - 1;
}
continue;
}
}
}
merged.push({ d, arrivalTime: null });
if (i === currentDisplayIndex) {
mergedCurrentDisplayIndex = merged.length - 1;
}
}
return merged.map(({ d, arrivalTime }, index) => (
<EachStopData
d={d}
index={index}
key={d + "FixedTrainBoxEachStopData"}
delayTime={delayTime}
isSmall={isSmall}
secondText={secondText}
currentDisplayIndex={mergedCurrentDisplayIndex}
arrivalTime={arrivalTime}
/>
));
})()}
</ScrollView>
</View>
);
};
type eachStopType = {
d: string;
delayTime: number;
isSmall: boolean;
index: number;
secondText: string;
currentDisplayIndex: number;
arrivalTime?: string | null;
};
const EachStopData: FC<eachStopType> = (props) => {
const { colors } = useThemeColors();
const { playbackCurrentTimeIso } = useTrainMenu();
const {
d,
delayTime,
isSmall,
index,
secondText,
currentDisplayIndex,
arrivalTime,
} = props;
if (!d) return null;
if (d == "") return null;
const [station, se, time] = d.split(",");
const calcMinute = (t: string) => {
if (!t || t === "") return null;
const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
const hour = parseInt(t.split(":")[0]);
const dt = now
.hour(hour < 4 ? hour + 24 : hour)
.minute(parseInt(t.split(":")[1]));
let diff = dt.diff(now, "minute") + delayTime;
if (now.hour() < 4 && hour < 4) diff -= 1440;
return diff;
};
const distanceMinute = calcMinute(time) ?? 0;
const arrivalMinute = arrivalTime ? calcMinute(arrivalTime) : null;
return (
<>
<View
style={{
flexDirection: "column",
backgroundColor: se.includes("通") ? "#6e6e6e77" : "#6e6e6eff",
borderRadius: 30,
marginHorizontal: isSmall ? 2 : 4,
marginVertical: isSmall ? 0 : 2,
padding: isSmall ? 2 : 4,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
}}
key={d + "CurrentPositionBox"}
>
{station.split("").map((i, index, array) => {
return (
<Text
key={i + index}
style={{
fontSize:
array.length < 5 ? (isSmall ? 5 : 12) : isSmall ? 3 : 10,
color: "white",
margin: 0,
padding: 0,
fontWeight: "bold",
}}
>
{i}
</Text>
);
})}
<View style={{ flex: 1 }} />
{!isSmall && arrivalMinute != null && (
<Text
style={{
fontSize: 9,
color: colors.text,
backgroundColor: colors.background,
fontWeight: "bold",
opacity: 0.5,
}}
>
{arrivalMinute}
</Text>
)}
{isSmall ||
(time != "" && (
<Text
style={{
fontSize: isSmall ? 8 : 12,
color: colors.text,
backgroundColor: colors.background,
fontWeight: "bold",
}}
>
{distanceMinute}
</Text>
))}
<Text
style={{
fontSize: isSmall ? 8 : 14,
color:
index === currentDisplayIndex && secondText === ""
? "#ffe852ff"
: se.includes("通")
? "#020202ff"
: "white",
marginTop: isSmall ? 0 : 3,
height: isSmall ? "auto" : 17,
fontWeight: "bold",
}}
>
{index === currentDisplayIndex && secondText === ""
? "→"
: se.includes("通")
? null
: "●"}
</Text>
</View>
{index === 0 && secondText !== "" && (
<View
style={{
flexDirection: "column",
backgroundColor: "#0000",
borderRadius: 10,
marginHorizontal: isSmall ? 2 : 4,
padding: isSmall ? 2 : 4,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
}}
>
<View style={{ flex: 1 }} />
<Ionicons
name="arrow-forward"
size={isSmall ? 8 : 14}
color={colors.icon}
style={{ marginTop: isSmall ? 0 : 3 }}
/>
</View>
)}
</>
);
};