Compare commits
71 Commits
patch/6.x
...
feature/fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec4db3de9b | ||
|
|
6a66429431 | ||
|
|
c2d3645b86 | ||
|
|
ea94e4cf0d | ||
|
|
4a7e481bfd | ||
|
|
720123b1e5 | ||
|
|
faf452166c | ||
|
|
0eb7d70caa | ||
|
|
4296eada04 | ||
|
|
983d48a1fe | ||
|
|
556f98faac | ||
|
|
08e052f291 | ||
|
|
ab92cc7a85 | ||
|
|
d50d77aa44 | ||
|
|
3ea8300846 | ||
|
|
beb9b21e1c | ||
|
|
7d7b1849dd | ||
|
|
30d1111768 | ||
|
|
cc15e6a1ee | ||
|
|
66650764df | ||
|
|
191dd76627 | ||
|
|
affe907dfd | ||
|
|
50822c6c74 | ||
|
|
2142d90141 | ||
|
|
9cc7b0d4af | ||
|
|
0917bc0a74 | ||
|
|
0f52441b17 | ||
|
|
bf27904d7c | ||
|
|
3a182d4650 | ||
|
|
a16588c70f | ||
|
|
cf1b2f763e | ||
|
|
381873b926 | ||
|
|
9c14a871e8 | ||
|
|
d7f227d5e5 | ||
|
|
c1accbb204 | ||
|
|
ac2548e7b6 | ||
|
|
87f1cf2b1e | ||
|
|
c49aeeb331 | ||
|
|
506dc7157e | ||
|
|
66f5744d51 | ||
|
|
d4a9c4d7d8 | ||
|
|
f2d0b060b6 | ||
|
|
38191be0d3 | ||
|
|
df2e4145a2 | ||
|
|
d6ab19d4b1 | ||
|
|
7b7ec45bfa | ||
|
|
29941f515f | ||
|
|
625ee1d786 | ||
|
|
a9bb366308 | ||
|
|
657ee7494b | ||
|
|
b60a43f25c | ||
|
|
7004eeefad | ||
|
|
413ef4acb3 | ||
|
|
7f3a1493ef | ||
|
|
8e64932a01 | ||
|
|
9036e7a8c1 | ||
|
|
1bf4a6991d | ||
|
|
03b9080c06 | ||
|
|
4952e32e65 | ||
|
|
ff46c6ac8f | ||
|
|
70bbb4ed5a | ||
|
|
0a4c61071d | ||
|
|
7a58a9524a | ||
|
|
6bcb3fcaf1 | ||
|
|
d921d7f8b6 | ||
|
|
0a677c908d | ||
|
|
a42c0871bd | ||
|
|
4eea97ed1f | ||
|
|
5b0de88218 | ||
|
|
765b0d72b7 | ||
|
|
0e9b049707 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -52,3 +52,6 @@ Thumbs.db
|
||||
*.log
|
||||
*.tmp
|
||||
.cache/
|
||||
|
||||
android/
|
||||
ios/
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { CSSProperties } from "react";
|
||||
import { BackHandler, View, ViewProps } from "react-native";
|
||||
import { Alert, BackHandler, View, ViewProps } from "react-native";
|
||||
import { WebView } from "react-native-webview";
|
||||
import { BigButton } from "./components/atom/BigButton";
|
||||
import { useFocusEffect, useNavigation } from "@react-navigation/native";
|
||||
@@ -33,7 +33,15 @@ export default ({ route }) => {
|
||||
source={{ uri }}
|
||||
allowsBackForwardNavigationGestures
|
||||
ref={webViewRef}
|
||||
onNavigationStateChange={(navState) => setCanGoBack(navState.canGoBack)}
|
||||
onNavigationStateChange={(navState) => {
|
||||
setCanGoBack(navState.canGoBack);
|
||||
if (navState.url === "https://unyohub.2pd.jp/integration/succeeded.php") {
|
||||
goBack();
|
||||
Alert.alert("鉄道運用HUBへの投稿完了", "運用HUBからのこのアプリへのデータ反映には暫く時間がかかりますので、しばらくお待ちください。", [
|
||||
{ text: "完了" },
|
||||
]);
|
||||
}
|
||||
}}
|
||||
onMessage={(event) => {
|
||||
const { data } = event.nativeEvent;
|
||||
const { type } = JSON.parse(data);
|
||||
|
||||
8
app.json
8
app.json
@@ -452,6 +452,14 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"kotlinVersion": "1.9.25"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
BIN
assets/icons/elesite_logo.png
Normal file
BIN
assets/icons/elesite_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/icons/hub_logo.png
Normal file
BIN
assets/icons/hub_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@@ -70,22 +70,22 @@ export const EachStopList: FC<props> = ({
|
||||
string
|
||||
]; // 阿波池田,発,6:21,1
|
||||
let beforeSameStationData = null;
|
||||
// 運休系でない通常の発のみ、前の着を統合する
|
||||
// 休編(非推奨)は発着が不明なため、次の発と統合する
|
||||
if ((se.includes("発") && !se.includes("休")) || se === "休編") {
|
||||
// 発(通常発・休発・休発編)の場合、前の着(通常着・休着・休着編)と統合する
|
||||
if (se.includes("発")) {
|
||||
if (index > 0) {
|
||||
const beforeData = array[index - 1].split(",") as [string, seTypes, string];
|
||||
if (beforeData[0] == station) {
|
||||
// 前が着(通常着でも休着でも)の場合は統合
|
||||
if (beforeData[0] == station && beforeData[1].includes("着")) {
|
||||
beforeSameStationData = beforeData;
|
||||
}
|
||||
}
|
||||
}
|
||||
let afterSameStationData = null;
|
||||
// 運休系でない通常の着のみ、次の発と統合する
|
||||
// 運休着(休着、休着編)は独立して表示する必要がある
|
||||
if (se.includes("着") && !se.includes("休")) {
|
||||
// 着(通常着・休着・休着編)の場合、次の発(通常発・休発・休発編)と統合される(非表示)
|
||||
if (se.includes("着")) {
|
||||
const afterData = array[index + 1]?.split(",") as [string, seTypes, string];
|
||||
if (afterData && afterData[0] == station) {
|
||||
// 次が発(通常発でも休発でも)なら、この着を非表示にして次の発で両方表示
|
||||
if (afterData && afterData[0] == station && afterData[1].includes("発")) {
|
||||
afterSameStationData = afterData;
|
||||
return <></>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import React, { FC, useMemo } from "react";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import React, { FC, useMemo, useState } from "react";
|
||||
import { Text, View, TextStyle, TouchableOpacity } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { migrateTrainName } from "../../../lib/eachTrainInfoCoreLib/migrateTrainName";
|
||||
import { TrainIconStatus } from "./trainIconStatus";
|
||||
@@ -19,6 +13,8 @@ import { getStringConfig } from "@/lib/getStringConfig";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { getPDFViewURL } from "@/lib/getPdfViewURL";
|
||||
import type { NavigateFunction } from "@/types";
|
||||
import { useUnyohub } from "@/stateBox/useUnyohub";
|
||||
import { useElesite } from "@/stateBox/useElesite";
|
||||
|
||||
type Props = {
|
||||
data: { trainNum: string; limited: string };
|
||||
@@ -52,11 +48,24 @@ export const HeaderText: FC<Props> = ({
|
||||
}) => {
|
||||
const { limited, trainNum } = data;
|
||||
|
||||
const { height, width } = useWindowDimensions();
|
||||
const { updatePermission } = useTrainMenu();
|
||||
const { allCustomTrainData, getTodayOperationByTrainId } =
|
||||
useAllTrainDiagram();
|
||||
const { expoPushToken } = useNotification();
|
||||
const {
|
||||
getUnyohubByTrainNumber,
|
||||
getUnyohubEntriesByTrainNumber,
|
||||
useUnyohub: unyohubEnabled,
|
||||
} = useUnyohub();
|
||||
const { getElesiteEntriesByTrainNumber, useElesite: elesiteEnabled } =
|
||||
useElesite();
|
||||
|
||||
// 追加ソースのON/OFFをここで管理(将来ソースが増えたらここに足す)
|
||||
const additionalSources = {
|
||||
unyohub: unyohubEnabled,
|
||||
elesite: elesiteEnabled,
|
||||
};
|
||||
const hasAdditionalSources = Object.values(additionalSources).some(Boolean);
|
||||
|
||||
// 列車名、種別、フォントの取得
|
||||
const [
|
||||
@@ -68,7 +77,10 @@ export const HeaderText: FC<Props> = ({
|
||||
priority,
|
||||
uwasa,
|
||||
trainInfoUrl,
|
||||
directions,
|
||||
customTrainData,
|
||||
] = useMemo(() => {
|
||||
const result = customTrainDataDetector(trainNum, allCustomTrainData);
|
||||
const {
|
||||
type,
|
||||
train_name,
|
||||
@@ -78,15 +90,14 @@ export const HeaderText: FC<Props> = ({
|
||||
uwasa,
|
||||
train_info_url,
|
||||
to_data,
|
||||
} = customTrainDataDetector(trainNum, allCustomTrainData);
|
||||
directions,
|
||||
} = result;
|
||||
const [typeString, fontAvailable, isOneMan] = getStringConfig(
|
||||
type,
|
||||
trainNum,
|
||||
);
|
||||
switch (true) {
|
||||
case train_name !== "":
|
||||
// 特急の場合は、列車名を取得
|
||||
// 列番対称データがある場合はそれから列車番号を取得
|
||||
return [
|
||||
typeString,
|
||||
train_name +
|
||||
@@ -99,6 +110,8 @@ export const HeaderText: FC<Props> = ({
|
||||
priority,
|
||||
uwasa,
|
||||
train_info_url,
|
||||
directions,
|
||||
result,
|
||||
];
|
||||
case trainData[trainData.length - 1] === undefined:
|
||||
return [
|
||||
@@ -110,9 +123,10 @@ export const HeaderText: FC<Props> = ({
|
||||
priority,
|
||||
uwasa,
|
||||
train_info_url,
|
||||
directions,
|
||||
result,
|
||||
];
|
||||
case to_data && to_data !== "":
|
||||
// 行先がある場合は、行先を取得
|
||||
return [
|
||||
typeString,
|
||||
to_data + "行き",
|
||||
@@ -122,9 +136,10 @@ export const HeaderText: FC<Props> = ({
|
||||
priority,
|
||||
uwasa,
|
||||
train_info_url,
|
||||
directions,
|
||||
result,
|
||||
];
|
||||
default:
|
||||
// 行先がある場合は、行先を取得
|
||||
return [
|
||||
typeString,
|
||||
migrateTrainName(
|
||||
@@ -136,6 +151,8 @@ export const HeaderText: FC<Props> = ({
|
||||
priority,
|
||||
uwasa,
|
||||
train_info_url,
|
||||
directions,
|
||||
result,
|
||||
];
|
||||
}
|
||||
}, [trainData]);
|
||||
@@ -143,9 +160,41 @@ export const HeaderText: FC<Props> = ({
|
||||
const todayOperation = getTodayOperationByTrainId(trainNum).filter(
|
||||
(d) => d.state !== 100,
|
||||
);
|
||||
|
||||
let iconTrainDirection =
|
||||
parseInt(trainNum.replace(/[^\d]/g, "")) % 2 == 0 ? true : false;
|
||||
if (directions != undefined) {
|
||||
iconTrainDirection = directions ? true : false;
|
||||
}
|
||||
|
||||
const unyohubFormation = getUnyohubByTrainNumber(trainNum);
|
||||
const unyohubEntries = getUnyohubEntriesByTrainNumber(trainNum);
|
||||
const elesiteEntries = getElesiteEntriesByTrainNumber(trainNum);
|
||||
|
||||
// 車番(formations)が空でないエントリが1件以上あれば「運用Hub情報あり」と判定
|
||||
const hasUnyohubFormation = unyohubEntries.some(
|
||||
(e) => !!e.formations && e.formations.trim() !== "",
|
||||
);
|
||||
const hasElesiteFormation = elesiteEntries.some(
|
||||
(e) => (e.formation_config?.units?.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
const hasExtraInfo =
|
||||
priority > 200 ||
|
||||
todayOperation?.length > 0 ||
|
||||
hasUnyohubFormation ||
|
||||
hasElesiteFormation;
|
||||
|
||||
const [isWrapped, setIsWrapped] = useState(false);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{ padding: 10, flexDirection: "row", alignItems: "center" }}
|
||||
style={{
|
||||
padding: 10,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
onTouchStart={() =>
|
||||
scrollHandlers.ref.current?.scrollTo({ y: 0, animated: true })
|
||||
}
|
||||
@@ -155,12 +204,36 @@ export const HeaderText: FC<Props> = ({
|
||||
navigate={navigate}
|
||||
from={from}
|
||||
todayOperation={todayOperation}
|
||||
direction={iconTrainDirection}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: "white",
|
||||
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
|
||||
fontWeight: !fontAvailable ? "bold" : undefined,
|
||||
marginRight: 5,
|
||||
}}
|
||||
>
|
||||
{isWrapped ? typeName.replace(/(.{2})/g, "$1\n").trim() : typeName}
|
||||
</Text>
|
||||
{isOneMan && <OneManText />}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
borderRadius: 5,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexShrink: 1,
|
||||
flexWrap: "wrap",
|
||||
...(trainInfoUrl
|
||||
? {
|
||||
borderWidth: 0,
|
||||
@@ -180,64 +253,57 @@ export const HeaderText: FC<Props> = ({
|
||||
}}
|
||||
disabled={!trainInfoUrl}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: "white",
|
||||
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
|
||||
fontWeight: !fontAvailable ? "bold" : undefined,
|
||||
marginRight: 5,
|
||||
}}
|
||||
>
|
||||
{typeName}
|
||||
</Text>
|
||||
{isOneMan && <OneManText />}
|
||||
<Text
|
||||
style={{
|
||||
...textConfig,
|
||||
...(trainName.length > 10 ? { fontSize: 14 } : {}),
|
||||
maxWidth: width * 0.6,
|
||||
...(trainName.length > 10 ? { fontSize: 16 } : {}),
|
||||
flexShrink: 1,
|
||||
}}
|
||||
onTextLayout={(e) => {
|
||||
if (e.nativeEvent.lines.length > 1) setIsWrapped(true);
|
||||
}}
|
||||
>
|
||||
{trainName}
|
||||
<InfogramText infogram={infogram} />
|
||||
</Text>
|
||||
<InfogramText infogram={infogram} />
|
||||
{/* {trainInfoUrl && (
|
||||
<MaterialCommunityIcons
|
||||
name={"open-in-new"}
|
||||
color="white"
|
||||
size={15}
|
||||
/>
|
||||
)} */}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ flex: 1 }} />
|
||||
<TouchableOpacity
|
||||
onLongPress={() => {
|
||||
if (!updatePermission) return;
|
||||
const uri = `https://jr-shikoku-data-system.pages.dev/trainData/${trainNum}?userID=${expoPushToken}&from=eachTrainInfo`;
|
||||
navigate("generalWebView", { uri, useExitButton: false });
|
||||
SheetManager.hide("EachTrainInfo");
|
||||
}}
|
||||
disabled={!updatePermission}
|
||||
>
|
||||
<Text style={textConfig}>
|
||||
{showHeadStation.map((d) => `${headStation[d].id} + `)}
|
||||
{trainNum}
|
||||
{showTailStation.map((d) => ` + ${tailStation[d].id}`)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={textConfig}>
|
||||
{showHeadStation.map((d) => `${headStation[d].id} + `)}
|
||||
{trainNum}
|
||||
{showTailStation.map((d) => ` + ${tailStation[d].id}`)}
|
||||
</Text>
|
||||
<MaterialCommunityIcons
|
||||
name="database"
|
||||
color={
|
||||
priority > 200 || todayOperation?.length > 0 ? "yellow" : "white"
|
||||
}
|
||||
color={hasExtraInfo ? "yellow" : "white"}
|
||||
size={30}
|
||||
style={{ margin: 5 }}
|
||||
onPress={() => {
|
||||
const uri = `https://jr-shikoku-data-system.pages.dev/trainData/${trainNum}?userID=${expoPushToken}&from=eachTrainInfo`;
|
||||
navigate("generalWebView", { uri, useExitButton: false });
|
||||
SheetManager.hide("EachTrainInfo");
|
||||
if (hasAdditionalSources) {
|
||||
(SheetManager.show as any)("TrainDataSources", {
|
||||
payload: {
|
||||
trainNum,
|
||||
unyohubEntries,
|
||||
elesiteEntries,
|
||||
todayOperation,
|
||||
navigate,
|
||||
expoPushToken,
|
||||
priority,
|
||||
direction: iconTrainDirection,
|
||||
customTrainData,
|
||||
typeName,
|
||||
trainName,
|
||||
departureStation: trainData[0]?.split(",")[0] ?? "",
|
||||
destinationStation:
|
||||
trainData[trainData.length - 1]?.split(",")[0] ?? "",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 追加ソースが全てオフ → 元の挙動(直接 DB ページを開く)
|
||||
const uri = `https://jr-shikoku-data-system.pages.dev/trainData/${trainNum}?userID=${expoPushToken}&from=eachTrainInfo`;
|
||||
navigate("generalWebView", { uri, useExitButton: false });
|
||||
SheetManager.hide("EachTrainInfo");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -17,17 +17,18 @@ type Props = {
|
||||
navigate: NavigateFunction;
|
||||
from: string;
|
||||
todayOperation: OperationLogs[];
|
||||
direction?: boolean;
|
||||
};
|
||||
type apt = {
|
||||
name: GlyphNames;
|
||||
color: string;
|
||||
};
|
||||
export const TrainIconStatus: FC<Props> = (props) => {
|
||||
const { data, navigate, from, todayOperation } = props;
|
||||
const { data, navigate, from, todayOperation, direction } = props;
|
||||
const [anpanmanStatus, setAnpanmanStatus] = useState<apt>();
|
||||
const { allCustomTrainData } = useAllTrainDiagram();
|
||||
const [trainIconData, setTrainIcon] = useState<
|
||||
{ vehicle_info_img: string; vehicle_info_url: string }[]
|
||||
{ vehicle_info_img: string;vehicle_info_right_img: string; vehicle_info_url: string }[]
|
||||
>([]);
|
||||
useEffect(() => {
|
||||
if (!data.trainNum) return;
|
||||
@@ -79,11 +80,12 @@ export const TrainIconStatus: FC<Props> = (props) => {
|
||||
})
|
||||
.map((op) => ({
|
||||
vehicle_info_img: op.vehicle_img || vehicle_info_img,
|
||||
vehicle_info_right_img: op.vehicle_img_right || op.vehicle_img || vehicle_info_img,
|
||||
vehicle_info_url: op.vehicle_info_url,
|
||||
})) || [];
|
||||
setTrainIcon(returnData);
|
||||
} else if (vehicle_info_img) {
|
||||
setTrainIcon([{ vehicle_info_img, vehicle_info_url }]);
|
||||
setTrainIcon([{ vehicle_info_img, vehicle_info_right_img: vehicle_info_img, vehicle_info_url }]);
|
||||
}
|
||||
|
||||
switch (data.trainNum) {
|
||||
@@ -113,30 +115,6 @@ export const TrainIconStatus: FC<Props> = (props) => {
|
||||
}
|
||||
});
|
||||
break;
|
||||
case "2074D":
|
||||
case "2076D":
|
||||
case "2080D":
|
||||
case "2082D":
|
||||
case "2071D":
|
||||
case "2073D":
|
||||
case "2079D":
|
||||
case "2081D":
|
||||
fetch(
|
||||
`https://n8n.haruk.in/webhook/dosan-anpanman-first?trainNum=${
|
||||
data.trainNum
|
||||
}&month=${dayjs().format("M")}&day=${dayjs().format("D")}`
|
||||
)
|
||||
.then((d) => d.json())
|
||||
.then((d) => {
|
||||
if (d.trainStatus == "〇") {
|
||||
//setAnpanmanStatus({name:"checkmark-circle-outline",color:"blue"});
|
||||
} else if (d.trainStatus == "▲") {
|
||||
setAnpanmanStatus({ name: "warning-outline", color: "yellow" });
|
||||
} else if (d.trainStatus == "×") {
|
||||
//setAnpanmanStatus({ name: "close-circle-outline", color: "red" });
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, [data.trainNum, allCustomTrainData, todayOperation]);
|
||||
const [move, setMove] = useState(true);
|
||||
@@ -151,7 +129,7 @@ export const TrainIconStatus: FC<Props> = (props) => {
|
||||
return (
|
||||
<>
|
||||
{trainIconData.map(
|
||||
({ vehicle_info_img: trainIcon, vehicle_info_url: address }, index) => (
|
||||
({ vehicle_info_img: trainIcon, vehicle_info_right_img: trainIconRight, vehicle_info_url: address }, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
navigate("howto", {
|
||||
@@ -164,7 +142,7 @@ export const TrainIconStatus: FC<Props> = (props) => {
|
||||
>
|
||||
{move ? (
|
||||
<Image
|
||||
source={{ uri: trainIcon }}
|
||||
source={{ uri: direction ? trainIcon : trainIconRight || trainIcon }}
|
||||
style={{
|
||||
height: index > 0 ? 15 : 30,
|
||||
width: index > 0 ? 12 : 24,
|
||||
|
||||
1169
components/ActionSheetComponents/TrainDataSources.tsx
Normal file
1169
components/ActionSheetComponents/TrainDataSources.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import { TrainMenuLineSelector } from "./TrainMenuLineSelector";
|
||||
import { TrainIconUpdate } from "./TrainIconUpdate";
|
||||
import { SpecialTrainInfo } from "./SpecialTrainInfo";
|
||||
import { Social } from "./SocialMenu";
|
||||
import { TrainDataSources } from "./TrainDataSources";
|
||||
|
||||
registerSheet("EachTrainInfo", EachTrainInfo);
|
||||
registerSheet("JRSTraInfo", JRSTraInfo);
|
||||
@@ -14,5 +15,6 @@ registerSheet("TrainMenuLineSelector", TrainMenuLineSelector);
|
||||
registerSheet("TrainIconUpdate", TrainIconUpdate);
|
||||
registerSheet("SpecialTrainInfo", SpecialTrainInfo);
|
||||
registerSheet("Social", Social);
|
||||
registerSheet("TrainDataSources", TrainDataSources);
|
||||
|
||||
export {};
|
||||
|
||||
@@ -92,6 +92,7 @@ export const AllTrainDiagramView: FC = () => {
|
||||
const { train_info_img, train_name, type, train_num_distance, to_data } =
|
||||
customTrainDataDetector(id, allCustomTrainData);
|
||||
const todayOperation = getTodayOperationByTrainId(id).filter(d=> d.state !== 100);
|
||||
const [isWrapped, setIsWrapped] = useState(false);
|
||||
|
||||
const [typeString, fontAvailable, isOneMan] = getStringConfig(type, id);
|
||||
|
||||
@@ -157,22 +158,44 @@ export const AllTrainDiagramView: FC = () => {
|
||||
)}
|
||||
</View>
|
||||
|
||||
{typeString && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{typeString && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: "white",
|
||||
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
|
||||
fontWeight: !fontAvailable ? "bold" : undefined,
|
||||
marginRight: 5,
|
||||
}}
|
||||
>
|
||||
{isWrapped
|
||||
? typeString.replace(/(.{2})/g, "$1\n").trim()
|
||||
: typeString}
|
||||
</Text>
|
||||
)}
|
||||
{isOneMan && <OneManText />}
|
||||
</View>
|
||||
{trainNameString && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
color: "white",
|
||||
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
|
||||
fontWeight: !fontAvailable ? "bold" : undefined,
|
||||
marginRight: 5,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
onTextLayout={(e) => {
|
||||
if (e.nativeEvent.lines.length > 1) {
|
||||
setIsWrapped(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{typeString}
|
||||
</Text>
|
||||
)}
|
||||
{isOneMan && <OneManText />}
|
||||
{trainNameString && (
|
||||
<Text style={{ fontSize: 20, fontWeight: "bold", color: "white" }}>
|
||||
{trainNameString}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Sign from "@/components/駅名表/Sign";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AS } from "@/storageControl";
|
||||
import {
|
||||
useWindowDimensions,
|
||||
@@ -13,6 +13,18 @@ import Carousel, { ICarouselInstance } from "react-native-reanimated-carousel";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { StationNumber } from "../StationPagination";
|
||||
import { SimpleDot } from "../SimpleDot";
|
||||
import Sortable from "react-native-sortables";
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
FadeOutDown,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSortMode } from "./useSortMode";
|
||||
import { StationSource } from "@/types";
|
||||
|
||||
export const CarouselBox = ({
|
||||
originalStationList,
|
||||
listUpStation,
|
||||
@@ -20,23 +32,108 @@ export const CarouselBox = ({
|
||||
setListIndex,
|
||||
listIndex,
|
||||
navigate,
|
||||
stationListMode,
|
||||
isSearchMode
|
||||
stationSource,
|
||||
}: {
|
||||
originalStationList: any;
|
||||
listUpStation: any[][];
|
||||
nearPositionStation: any[][];
|
||||
setListIndex: (i: number) => void;
|
||||
listIndex: number;
|
||||
navigate: any;
|
||||
stationSource: StationSource;
|
||||
}) => {
|
||||
const carouselRef = useRef<ICarouselInstance>(null);
|
||||
const { height, width } = useWindowDimensions();
|
||||
const { width } = useWindowDimensions();
|
||||
const [dotButton, setDotButton] = useState(false);
|
||||
const carouselBadgeScrollViewRef = useRef<ScrollView>(null);
|
||||
// listIndex が -1 になってもカルーセルが表示中は直前の値を維持する
|
||||
const lastValidListIndexRef = useRef(0);
|
||||
if (listIndex >= 0) lastValidListIndexRef.current = listIndex;
|
||||
|
||||
// グリッド定数(ソートモードと座標計算で共用)
|
||||
const origW = width * 0.8;
|
||||
const origH = (origW / 20) * 9;
|
||||
const cols = 3;
|
||||
const gridPad = 8;
|
||||
const gridGap = 8;
|
||||
const cellW = (width - gridPad * 2 - gridGap * (cols - 1)) / cols;
|
||||
const cellH = (cellW / origW) * origH;
|
||||
const carouselHeight = origH + 10;
|
||||
const rows = Math.ceil(listUpStation.length / cols);
|
||||
const gridHeight = rows * cellH + Math.max(0, rows - 1) * gridGap + gridPad * 2;
|
||||
|
||||
const {
|
||||
uiMode,
|
||||
startSortMode,
|
||||
exitSortMode,
|
||||
sortGridRenderItem,
|
||||
onSortDragEnd,
|
||||
} = useSortMode({
|
||||
listUpStation,
|
||||
setListIndex,
|
||||
width,
|
||||
origW,
|
||||
origH,
|
||||
cols,
|
||||
gridPad,
|
||||
gridGap,
|
||||
cellW,
|
||||
cellH,
|
||||
carouselHeight,
|
||||
stationSource,
|
||||
});
|
||||
|
||||
// ソートモード中かどうか
|
||||
const isSortMode = uiMode !== "carousel";
|
||||
|
||||
// コンテナ高さ(カルーセル ↔ グリッドで可変)
|
||||
const containerHeight = useSharedValue(carouselHeight);
|
||||
const containerHeightStyle = useAnimatedStyle(() => ({ height: containerHeight.value }));
|
||||
|
||||
// ドットエリアのフェード
|
||||
const dotsOpacity = useSharedValue(1);
|
||||
const dotsAnimStyle = useAnimatedStyle(() => ({ opacity: dotsOpacity.value }));
|
||||
|
||||
// カルーセル ↔ グリッドのフェード
|
||||
const [isGridMounted, setIsGridMounted] = useState(false);
|
||||
const carouselOpacity = useSharedValue(1);
|
||||
const gridOpacity = useSharedValue(0);
|
||||
const carouselAnimStyle = useAnimatedStyle(() => ({ opacity: carouselOpacity.value }));
|
||||
const gridAnimStyle = useAnimatedStyle(() => ({ opacity: gridOpacity.value }));
|
||||
|
||||
useEffect(() => {
|
||||
const duration = 250;
|
||||
if (isSortMode) {
|
||||
setIsGridMounted(true); // フェードイン前にマウント
|
||||
dotsOpacity.value = withTiming(0, { duration });
|
||||
carouselOpacity.value = withTiming(0, { duration });
|
||||
gridOpacity.value = withTiming(1, { duration });
|
||||
containerHeight.value = withTiming(gridHeight, { duration });
|
||||
} else {
|
||||
dotsOpacity.value = withTiming(1, { duration });
|
||||
carouselOpacity.value = withTiming(1, { duration });
|
||||
containerHeight.value = withTiming(carouselHeight, { duration });
|
||||
gridOpacity.value = withTiming(0, { duration }, (finished) => {
|
||||
if (finished) runOnJS(setIsGridMounted)(false); // フェードアウト完了後にアンマウント
|
||||
});
|
||||
}
|
||||
}, [isSortMode]);
|
||||
|
||||
// ドットのスクロール追従
|
||||
useEffect(() => {
|
||||
if (!carouselBadgeScrollViewRef.current) return;
|
||||
const dotSize = dotButton ? 28 : 24;
|
||||
const scrollToIndex = dotSize * listIndex - width / 2 + dotSize - 5;
|
||||
carouselBadgeScrollViewRef.current.scrollTo({
|
||||
x: scrollToIndex,
|
||||
animated: true,
|
||||
carouselBadgeScrollViewRef.current.scrollTo({ x: scrollToIndex, animated: true });
|
||||
}, [listIndex, dotButton, width]);
|
||||
|
||||
// ドット表示設定の読み込み
|
||||
useEffect(() => {
|
||||
AS.getItem("CarouselSettings/activeDotSettings").then((data) => {
|
||||
setDotButton(data === "true");
|
||||
});
|
||||
}, [listIndex, dotButton, width, carouselBadgeScrollViewRef]);
|
||||
}, []);
|
||||
|
||||
const oPSign = () => {
|
||||
const payload = {
|
||||
currentStation: listUpStation[listIndex],
|
||||
@@ -49,33 +146,29 @@ export const CarouselBox = ({
|
||||
//@ts-ignore
|
||||
SheetManager.show("StationDetailView", { payload });
|
||||
};
|
||||
|
||||
const oLPSign = () => {
|
||||
// 駅がある場合はどのモードでもグリッドビューに切り替える
|
||||
if (
|
||||
listUpStation.length > 0 &&
|
||||
listUpStation[0][0].StationNumber !== "null"
|
||||
) {
|
||||
startSortMode(listIndex);
|
||||
return;
|
||||
}
|
||||
// 駅なし:長押しでドット表示切り替え
|
||||
LayoutAnimation.configureNext({
|
||||
duration: 600,
|
||||
update: { type: "spring", springDamping: 0.5 },
|
||||
});
|
||||
AS.setItem(
|
||||
"CarouselSettings/activeDotSettings",
|
||||
!dotButton ? "true" : "false"
|
||||
);
|
||||
AS.setItem("CarouselSettings/activeDotSettings", !dotButton ? "true" : "false");
|
||||
setDotButton(!dotButton);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
AS.getItem("CarouselSettings/activeDotSettings").then((data) => {
|
||||
setDotButton(data === "true");
|
||||
});
|
||||
}, []);
|
||||
const RenderItem = ({ item, index }) => {
|
||||
return (
|
||||
const RenderItem = useCallback(
|
||||
({ item }) => (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#0000",
|
||||
width,
|
||||
flexDirection: "row",
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
}}
|
||||
style={{ backgroundColor: "#0000", width, flexDirection: "row" }}
|
||||
key={item[0].StationNumber}
|
||||
>
|
||||
<View style={{ flex: 1 }} />
|
||||
@@ -96,15 +189,13 @@ export const CarouselBox = ({
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<Text style={{ color: "#0099CC", fontSize: 20 }}>
|
||||
{!!isSearchMode ? "路線検索モードです。入力欄に駅名やナンバリングを入力したり、上に並んでいる路線を選んでみましょう!" :stationListMode == "position"
|
||||
{stationSource.type === "search"
|
||||
? (stationSource.query || stationSource.lineId)
|
||||
? "該当する駅が見つかりませんでした。"
|
||||
: "駅名・ナンバリングを入力するか、路線を選んでください。"
|
||||
: stationSource.type === "position"
|
||||
? "現在地の近くに駅がありません。"
|
||||
: "お気に入りリストがありません。お気に入りの駅を追加しよう!"}
|
||||
</Text>
|
||||
@@ -113,74 +204,141 @@ export const CarouselBox = ({
|
||||
)}
|
||||
<View style={{ flex: 1 }} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[width, nearPositionStation, oPSign, oLPSign, stationSource]
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, paddingTop: 10 }}>
|
||||
<Carousel
|
||||
ref={carouselRef}
|
||||
data={
|
||||
listUpStation.length > 0
|
||||
? listUpStation
|
||||
: [[{ StationNumber: "null" }]]
|
||||
}
|
||||
height={(((width / 100) * 80) / 20) * 9 + 10}
|
||||
pagingEnabled={true}
|
||||
snapEnabled={true}
|
||||
loop={false}
|
||||
width={width}
|
||||
style={{ width: width, alignContent: "center" }}
|
||||
mode="parallax"
|
||||
modeConfig={{
|
||||
parallaxScrollingScale: 1,
|
||||
parallaxScrollingOffset: 100,
|
||||
parallaxAdjacentItemScale: 0.8,
|
||||
}}
|
||||
scrollAnimationDuration={600}
|
||||
onSnapToItem={setListIndex}
|
||||
renderItem={RenderItem}
|
||||
overscrollEnabled={false}
|
||||
defaultIndex={listIndex >= listUpStation.length ? 0 : listIndex}
|
||||
/>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignContent: "center",
|
||||
alignItems: "center",
|
||||
paddingVertical: 2,
|
||||
paddingHorizontal: 10,
|
||||
minWidth: width,
|
||||
}}
|
||||
ref={(scrollViewRef) => {
|
||||
// ScrollViewのrefを保存
|
||||
if (scrollViewRef) {
|
||||
carouselBadgeScrollViewRef.current = scrollViewRef;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{originalStationList &&
|
||||
listUpStation.map((d, index) => {
|
||||
const active = index == listIndex;
|
||||
const numberKey = d[0].StationNumber + index;
|
||||
return dotButton ? (
|
||||
<StationNumber
|
||||
onPress={() => setListIndex(index)}
|
||||
currentStation={d}
|
||||
active={active}
|
||||
key={numberKey}
|
||||
/>
|
||||
) : (
|
||||
<SimpleDot
|
||||
onPress={() => setListIndex(index)}
|
||||
active={active}
|
||||
key={numberKey}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
{/* カルーセル / グリッド(同じ高さ領域を共用・クロスフェード) */}
|
||||
<Animated.View style={[{ overflow: "visible" }, containerHeightStyle]}>
|
||||
{/* カルーセル */}
|
||||
<Animated.View
|
||||
style={[{ position: "absolute", width }, carouselAnimStyle]}
|
||||
pointerEvents={isSortMode ? "none" : "auto"}
|
||||
>
|
||||
<Carousel
|
||||
ref={carouselRef}
|
||||
data={listUpStation.length > 0 ? listUpStation : [[{ StationNumber: "null" }]]}
|
||||
height={carouselHeight}
|
||||
pagingEnabled={true}
|
||||
snapEnabled={true}
|
||||
loop={false}
|
||||
width={width}
|
||||
style={{ width, alignContent: "center" }}
|
||||
mode="parallax"
|
||||
modeConfig={{
|
||||
parallaxScrollingScale: 1,
|
||||
parallaxScrollingOffset: 100,
|
||||
parallaxAdjacentItemScale: 0.8,
|
||||
}}
|
||||
scrollAnimationDuration={600}
|
||||
onSnapToItem={setListIndex}
|
||||
renderItem={RenderItem}
|
||||
overscrollEnabled={false}
|
||||
defaultIndex={
|
||||
lastValidListIndexRef.current >= listUpStation.length
|
||||
? 0
|
||||
: lastValidListIndexRef.current
|
||||
}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* グリッド:ソートモード中のみマウント */}
|
||||
{isGridMounted && (
|
||||
<Animated.View
|
||||
style={[
|
||||
{ position: "absolute", width, paddingHorizontal: gridPad, overflow: "visible" },
|
||||
gridAnimStyle,
|
||||
]}
|
||||
>
|
||||
<Sortable.Grid
|
||||
columns={cols}
|
||||
columnGap={gridGap}
|
||||
rowGap={gridGap}
|
||||
data={listUpStation}
|
||||
renderItem={sortGridRenderItem}
|
||||
keyExtractor={(item) => item[0].StationNumber}
|
||||
onDragEnd={onSortDragEnd}
|
||||
sortEnabled={stationSource.type === "favorite"}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* ドットエリア:ソートモード時はフェードアウト */}
|
||||
<Animated.View style={dotsAnimStyle} pointerEvents={isSortMode ? "none" : "auto"}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignContent: "center",
|
||||
alignItems: "center",
|
||||
paddingVertical: 2,
|
||||
paddingHorizontal: 10,
|
||||
minWidth: width,
|
||||
}}
|
||||
ref={(scrollViewRef) => {
|
||||
if (scrollViewRef) {
|
||||
carouselBadgeScrollViewRef.current = scrollViewRef;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{originalStationList &&
|
||||
listUpStation.map((d, index) => {
|
||||
const active = index == listIndex;
|
||||
const numberKey = d[0].StationNumber + index;
|
||||
return dotButton ? (
|
||||
<StationNumber
|
||||
onPress={() => setListIndex(index)}
|
||||
currentStation={d}
|
||||
active={active}
|
||||
key={numberKey}
|
||||
/>
|
||||
) : (
|
||||
<SimpleDot
|
||||
onPress={() => setListIndex(index)}
|
||||
active={active}
|
||||
key={numberKey}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
|
||||
{/* 並び替えコントロール:ソートモード時に最下部からスライドイン */}
|
||||
{isSortMode && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
exiting={FadeOutDown.duration(200)}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 6,
|
||||
paddingBottom: 4,
|
||||
}}
|
||||
>
|
||||
<Text style={{ flex: 1, color: "#0099CC", fontSize: 14 }}>
|
||||
{stationSource.type === "favorite" ? "長押しでドラッグして並び替え" : "タップして駅を選択"}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={exitSortMode}
|
||||
disabled={uiMode === "sort-exiting"}
|
||||
style={{
|
||||
backgroundColor: uiMode === "sort-exiting" ? "#88c8e8" : "#0099CC",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "white", fontWeight: "bold" }}>完了</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,19 +9,27 @@ import {
|
||||
} from "react-native";
|
||||
import Ionicons from "react-native-vector-icons/Ionicons";
|
||||
import { SearchUnitBox } from "@/components/Menu/RailScope/SearchUnitBox";
|
||||
import { StationSource } from "@/types";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
|
||||
export const CarouselTypeChanger = ({
|
||||
locationStatus,
|
||||
position,
|
||||
stationListMode,
|
||||
setStationListMode,
|
||||
stationSource,
|
||||
setStationSource,
|
||||
closeSearch,
|
||||
setSelectedCurrentStation,
|
||||
mapMode,
|
||||
setMapMode,
|
||||
isSearchMode,
|
||||
setisSearchMode,
|
||||
input,
|
||||
setInput,
|
||||
}: {
|
||||
locationStatus: boolean | null;
|
||||
position: any;
|
||||
stationSource: StationSource;
|
||||
setStationSource: (s: StationSource) => void;
|
||||
closeSearch: () => void;
|
||||
setSelectedCurrentStation: (i: number) => void;
|
||||
mapMode: boolean;
|
||||
setMapMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const returnToDefaultMode = () => {
|
||||
LayoutAnimation.configureNext({
|
||||
@@ -55,16 +63,15 @@ export const CarouselTypeChanger = ({
|
||||
key={"carouselTypeChanger"}
|
||||
>
|
||||
<SearchUnitBox
|
||||
isSearchMode={isSearchMode}
|
||||
setisSearchMode={setisSearchMode}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
stationSource={stationSource}
|
||||
setStationSource={setStationSource}
|
||||
closeSearch={closeSearch}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor:
|
||||
stationListMode == "position" ? "#0099CC" : "#0099CC80",
|
||||
stationSource.type === "position" ? "#0099CC" : "#0099CC80",
|
||||
padding: 5,
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
@@ -78,15 +85,15 @@ export const CarouselTypeChanger = ({
|
||||
onPressIn={() => {
|
||||
if (!position) return;
|
||||
returnToDefaultMode();
|
||||
setStationListMode("position");
|
||||
AS.setItem("stationListMode", "position");
|
||||
setStationSource({ type: "position" });
|
||||
AS.setItem(STORAGE_KEYS.STATION_LIST_MODE, "position");
|
||||
setSelectedCurrentStation(0);
|
||||
}}
|
||||
onPress={() => {
|
||||
if (!position) return;
|
||||
returnToDefaultMode();
|
||||
setStationListMode("position");
|
||||
AS.setItem("stationListMode", "position");
|
||||
setStationSource({ type: "position" });
|
||||
AS.setItem(STORAGE_KEYS.STATION_LIST_MODE, "position");
|
||||
setSelectedCurrentStation(0);
|
||||
}}
|
||||
>
|
||||
@@ -129,7 +136,7 @@ export const CarouselTypeChanger = ({
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor:
|
||||
stationListMode == "favorite" ? "#0099CC" : "#0099CC80",
|
||||
stationSource.type === "favorite" ? "#0099CC" : "#0099CC80",
|
||||
padding: 5,
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
@@ -141,16 +148,14 @@ export const CarouselTypeChanger = ({
|
||||
}}
|
||||
onPressIn={() => {
|
||||
returnToDefaultMode();
|
||||
// お気に入りリスト更新
|
||||
setStationListMode("favorite");
|
||||
AS.setItem("stationListMode", "favorite");
|
||||
setStationSource({ type: "favorite" });
|
||||
AS.setItem(STORAGE_KEYS.STATION_LIST_MODE, "favorite");
|
||||
setSelectedCurrentStation(0);
|
||||
}}
|
||||
onPress={() => {
|
||||
returnToDefaultMode();
|
||||
// お気に入りリスト更新
|
||||
setStationListMode("favorite");
|
||||
AS.setItem("stationListMode", "favorite");
|
||||
setStationSource({ type: "favorite" });
|
||||
AS.setItem(STORAGE_KEYS.STATION_LIST_MODE, "favorite");
|
||||
setSelectedCurrentStation(0);
|
||||
}}
|
||||
>
|
||||
|
||||
120
components/Menu/Carousel/GridMiniSign.tsx
Normal file
120
components/Menu/Carousel/GridMiniSign.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { FC } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import lineColorList from "@/assets/originData/lineColorList";
|
||||
|
||||
type Props = {
|
||||
item: any[]; // StationProps[] (路線をまたぐ同名駅の配列)
|
||||
width: number;
|
||||
height: number;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* グリッド表示専用の軽量駅カード。
|
||||
* Sign コンポーネントを使わず、駅番号・駅名をシンプルに描画するだけで
|
||||
* hooks・Lottie・前後駅計算などを一切持たない純粋な表示コンポーネント。
|
||||
*/
|
||||
export const GridMiniSign: FC<Props> = React.memo(({ item, width, height, onPress }) => {
|
||||
const station = item[0];
|
||||
const lineId = station.StationNumber?.slice(0, 1) ?? "Y";
|
||||
const lineNum = station.StationNumber?.slice(1) ?? "";
|
||||
const lineColor = lineColorList[lineId] ?? "#0099CC";
|
||||
const nameLen = station.Station_JP?.length ?? 0;
|
||||
const nameFontSize = nameLen <= 3 ? 22 : nameLen <= 5 ? 16 : 12;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.85}
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
borderColor: "#0099CC",
|
||||
borderWidth: 1,
|
||||
backgroundColor: "white",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* 駅番号バッジ */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "8%",
|
||||
right: "8%",
|
||||
width: height * 0.28,
|
||||
height: height * 0.28,
|
||||
borderRadius: height * 0.14,
|
||||
borderColor: lineColor,
|
||||
borderWidth: 2,
|
||||
backgroundColor: "white",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: height * 0.1,
|
||||
fontWeight: "bold",
|
||||
color: "black",
|
||||
textAlign: "center",
|
||||
lineHeight: height * 0.12,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
adjustsFontSizeToFit
|
||||
>
|
||||
{lineId + "\n" + lineNum}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 駅名(日本語・英語) */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "10%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: "28%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: nameFontSize,
|
||||
fontWeight: "bold",
|
||||
color: "#005170",
|
||||
textAlign: "center",
|
||||
}}
|
||||
adjustsFontSizeToFit
|
||||
numberOfLines={1}
|
||||
>
|
||||
{station.Station_JP}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 8,
|
||||
color: "#005170",
|
||||
textAlign: "center",
|
||||
marginTop: 2,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
>
|
||||
{station.Station_EN}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 下帯 */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "26%",
|
||||
backgroundColor: "#0099CC",
|
||||
}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
97
components/Menu/Carousel/SortGridCard.tsx
Normal file
97
components/Menu/Carousel/SortGridCard.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useItemContext } from "react-native-sortables";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
import { GridMiniSign } from "./GridMiniSign";
|
||||
|
||||
type Props = {
|
||||
item: any;
|
||||
cellW: number;
|
||||
cellH: number;
|
||||
startX: number; // 入場開始位置(カルーセル上の元座標)
|
||||
startY: number;
|
||||
exitX: number; // 退場先位置(タップ選択後のカルーセル座標)
|
||||
exitY: number;
|
||||
startScale: number;
|
||||
isExiting: boolean;
|
||||
exitDelay: number;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
/** カルーセル中央 → グリッドセルへ飛ぶアニメーション付きカード */
|
||||
export const SortGridCard = React.memo(function SortGridCard({
|
||||
item,
|
||||
cellW,
|
||||
cellH,
|
||||
startX,
|
||||
startY,
|
||||
exitX,
|
||||
exitY,
|
||||
startScale,
|
||||
isExiting,
|
||||
exitDelay,
|
||||
onPress,
|
||||
}: Props) {
|
||||
const { activationAnimationProgress } = useItemContext();
|
||||
|
||||
const tx = useSharedValue(startX);
|
||||
const ty = useSharedValue(startY);
|
||||
const sc = useSharedValue(startScale);
|
||||
|
||||
// 入場: カルーセル位置からグリッドセル位置へ
|
||||
useEffect(() => {
|
||||
tx.value = withSpring(0, { damping: 16, stiffness: 110 });
|
||||
ty.value = withSpring(0, { damping: 16, stiffness: 110 });
|
||||
sc.value = withSpring(1, { damping: 16, stiffness: 110 });
|
||||
}, []);
|
||||
|
||||
// 退場: グリッドセル位置からカルーセル位置へ戻る
|
||||
useEffect(() => {
|
||||
if (!isExiting) return;
|
||||
tx.value = withDelay(exitDelay, withSpring(exitX, { damping: 16, stiffness: 110 }));
|
||||
ty.value = withDelay(exitDelay, withSpring(exitY, { damping: 16, stiffness: 110 }));
|
||||
sc.value = withDelay(exitDelay, withSpring(startScale, { damping: 16, stiffness: 110 }));
|
||||
}, [isExiting]);
|
||||
|
||||
const animStyle = useAnimatedStyle(() => {
|
||||
const p = activationAnimationProgress.value;
|
||||
return {
|
||||
opacity: interpolate(p, [0, 1], [1, 0.85]),
|
||||
shadowOpacity: interpolate(p, [0, 1], [0, 0.4]),
|
||||
shadowRadius: interpolate(p, [0, 1], [0, 10]),
|
||||
elevation: interpolate(p, [0, 1], [1, 12]),
|
||||
transform: [
|
||||
{ translateX: tx.value },
|
||||
{ translateY: ty.value },
|
||||
{ scale: sc.value * interpolate(p, [0, 1], [1, 1.06]) },
|
||||
] as any,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
width: cellW,
|
||||
height: cellH,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
},
|
||||
animStyle,
|
||||
]}
|
||||
>
|
||||
<GridMiniSign
|
||||
item={item}
|
||||
width={cellW}
|
||||
height={cellH}
|
||||
onPress={onPress}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
});
|
||||
|
||||
133
components/Menu/Carousel/useSortMode.tsx
Normal file
133
components/Menu/Carousel/useSortMode.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AS } from "@/storageControl";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
|
||||
import { SortGridCard } from "./SortGridCard";
|
||||
import { CarouselUIMode, StationSource } from "@/types";
|
||||
|
||||
type SortModeConfig = {
|
||||
listUpStation: any[][];
|
||||
setListIndex: (i: number) => void;
|
||||
width: number;
|
||||
origW: number;
|
||||
origH: number;
|
||||
cols: number;
|
||||
gridPad: number;
|
||||
gridGap: number;
|
||||
cellW: number;
|
||||
cellH: number;
|
||||
carouselHeight: number;
|
||||
stationSource: StationSource;
|
||||
};
|
||||
|
||||
/** カルーセルの並び替えモードに関わる状態・ロジックをまとめたカスタムフック */
|
||||
export function useSortMode({
|
||||
listUpStation,
|
||||
setListIndex,
|
||||
width,
|
||||
origW,
|
||||
origH,
|
||||
cols,
|
||||
gridPad,
|
||||
gridGap,
|
||||
cellW,
|
||||
cellH,
|
||||
carouselHeight,
|
||||
stationSource,
|
||||
}: SortModeConfig) {
|
||||
const { setFavoriteStation } = useFavoriteStation();
|
||||
// "carousel" | "sort" | "sort-exiting" の 3 値で UI モードを管理
|
||||
const [uiMode, setUiMode] = useState<CarouselUIMode>("carousel");
|
||||
// ソート開始時のカルーセル位置を保存(setListIndex(-1) される前の値)
|
||||
const sortModeStartIndexRef = useRef(0);
|
||||
// ソート終了後に移動するインデックス(タップで上書き可、デフォルト 0)
|
||||
const exitTargetIndexRef = useRef(0);
|
||||
|
||||
// carousel に戻ったら指定インデックスへ移動
|
||||
useEffect(() => {
|
||||
if (uiMode === "carousel") {
|
||||
setListIndex(exitTargetIndexRef.current);
|
||||
}
|
||||
}, [uiMode]);
|
||||
|
||||
/** 並び替えモード開始(現在のカルーセル位置を渡す) */
|
||||
const startSortMode = useCallback((currentIndex: number) => {
|
||||
sortModeStartIndexRef.current = currentIndex;
|
||||
exitTargetIndexRef.current = 0; // デフォルトは先頭
|
||||
setListIndex(-1); // 未選択状態にして LED を非表示
|
||||
setUiMode("sort");
|
||||
}, [setListIndex]);
|
||||
|
||||
/** 退場アニメーション完了後にモードを終了 */
|
||||
const exitSortMode = useCallback(() => {
|
||||
setUiMode("sort-exiting");
|
||||
// 退場スプリングが収束するまで待ってから carousel へ
|
||||
setTimeout(() => {
|
||||
setUiMode("carousel");
|
||||
}, listUpStation.length * 40 + 500);
|
||||
}, [listUpStation.length]);
|
||||
|
||||
/** Sortable.Grid の renderItem(useCallback でメモ化) */
|
||||
const sortGridRenderItem = useCallback(
|
||||
({ item, index }: { item: any; index: number }) => {
|
||||
const col = index % cols;
|
||||
const row = Math.floor(index / cols);
|
||||
// カルーセルでの card 中心位置 → グリッドセルの中心位置 との差分を初期オフセットに
|
||||
const carouselCardCenterX =
|
||||
(index - sortModeStartIndexRef.current) * width + width / 2 - gridPad;
|
||||
const carouselCardCenterY = carouselHeight / 2;
|
||||
const cellCenterX = col * (cellW + gridGap) + cellW / 2;
|
||||
const cellCenterY = row * (cellH + gridGap) + cellH / 2;
|
||||
const startX = carouselCardCenterX - cellCenterX;
|
||||
const startY = carouselCardCenterY - cellCenterY;
|
||||
// 退場先: タップで選択されたカードが画面中央に来るカルーセル配置
|
||||
const exitCarouselCardCenterX =
|
||||
(index - exitTargetIndexRef.current) * width + width / 2 - gridPad;
|
||||
const exitX = exitCarouselCardCenterX - cellCenterX;
|
||||
const exitY = carouselCardCenterY - cellCenterY; // Y は変わらない
|
||||
return (
|
||||
<SortGridCard
|
||||
key={item[0].StationNumber}
|
||||
item={item}
|
||||
cellW={cellW}
|
||||
cellH={cellH}
|
||||
startX={startX}
|
||||
startY={startY}
|
||||
exitX={exitX}
|
||||
exitY={exitY}
|
||||
startScale={origW / cellW}
|
||||
isExiting={uiMode === "sort-exiting"}
|
||||
exitDelay={Math.min(index * 40, 180)}
|
||||
onPress={() => {
|
||||
exitTargetIndexRef.current = index;
|
||||
exitSortMode();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[cellW, cellH, gridGap, gridPad, origW, origH, width, carouselHeight, uiMode]
|
||||
);
|
||||
|
||||
/** Sortable.Grid の onDragEnd */
|
||||
const onSortDragEnd = useCallback(
|
||||
(newOrder: { indexToKey: string[] }) => {
|
||||
// お気に入りモード以外はデータを書き換えない(安全策)
|
||||
if (stationSource.type !== "favorite") return;
|
||||
const newList = newOrder.indexToKey.map(
|
||||
(key) => listUpStation.find((s) => s[0].StationNumber === key) ?? []
|
||||
);
|
||||
setFavoriteStation(newList);
|
||||
AS.setItem(STORAGE_KEYS.FAVORITE_STATION, JSON.stringify(newList));
|
||||
},
|
||||
[listUpStation, setFavoriteStation, stationSource]
|
||||
);
|
||||
|
||||
return {
|
||||
uiMode,
|
||||
startSortMode,
|
||||
exitSortMode,
|
||||
sortGridRenderItem,
|
||||
onSortDragEnd,
|
||||
};
|
||||
}
|
||||
@@ -12,38 +12,47 @@ import Ionicons from "react-native-vector-icons/Ionicons";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import lineColorList from "@/assets/originData/lineColorList";
|
||||
import { lineList_LineWebID, stationIDPair } from "@/lib/getStationList";
|
||||
import { StationSource } from "@/types";
|
||||
|
||||
export const SearchUnitBox = ({
|
||||
isSearchMode,
|
||||
setisSearchMode,
|
||||
input,
|
||||
setInput,
|
||||
stationSource,
|
||||
setStationSource,
|
||||
closeSearch,
|
||||
}: {
|
||||
stationSource: StationSource;
|
||||
setStationSource: (s: StationSource) => void;
|
||||
closeSearch: () => void;
|
||||
}) => {
|
||||
const { height, width } = useWindowDimensions();
|
||||
const isSearch = stationSource.type === "search";
|
||||
const query = isSearch ? stationSource.query : "";
|
||||
const lineId = isSearch ? stationSource.lineId : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: !!isSearchMode ? 0 : 60,
|
||||
bottom: isSearch ? 0 : 60,
|
||||
right: 0,
|
||||
padding: !!isSearchMode ? 5 : 10,
|
||||
margin: !!isSearchMode ? 0 : 10,
|
||||
padding: isSearch ? 5 : 10,
|
||||
margin: isSearch ? 0 : 10,
|
||||
backgroundColor: "#0099CC",
|
||||
borderRadius: !!isSearchMode ? 5 : 50,
|
||||
width: !!isSearchMode ? width : 50,
|
||||
borderRadius: isSearch ? 5 : 50,
|
||||
width: isSearch ? width : 50,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
disabled={!!isSearchMode}
|
||||
disabled={isSearch}
|
||||
onPress={() => {
|
||||
LayoutAnimation.configureNext({
|
||||
duration: 100,
|
||||
update: { type: "easeInEaseOut", springDamping: 0.6 },
|
||||
});
|
||||
setisSearchMode(true);
|
||||
setStationSource({ type: "search", query: "", lineId: undefined });
|
||||
}}
|
||||
>
|
||||
{!isSearchMode && <Ionicons name="search" size={30} color="white" />}
|
||||
{!!isSearchMode && (
|
||||
{!isSearch && <Ionicons name="search" size={30} color="white" />}
|
||||
{isSearch && (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#0099CC",
|
||||
@@ -61,7 +70,7 @@ export const SearchUnitBox = ({
|
||||
duration: 100,
|
||||
update: { type: "easeInEaseOut", springDamping: 0.6 },
|
||||
});
|
||||
setisSearchMode(false);
|
||||
closeSearch();
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
@@ -87,13 +96,15 @@ export const SearchUnitBox = ({
|
||||
<TextInput
|
||||
placeholder="駅名や駅ナンバリングを入力してフィルタリングします。"
|
||||
onEndEditing={() => {}}
|
||||
onChange={(ret) => setInput(ret.nativeEvent.text)}
|
||||
value={input}
|
||||
onChange={(ret) =>
|
||||
setStationSource({ type: "search", query: ret.nativeEvent.text, lineId })
|
||||
}
|
||||
value={query}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{input && (
|
||||
{query && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setInput("") }
|
||||
onPress={() => setStationSource({ type: "search", query: "", lineId })}
|
||||
style={{
|
||||
padding: 3,
|
||||
borderRadius: 15,
|
||||
@@ -109,48 +120,44 @@ export const SearchUnitBox = ({
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{!input && (
|
||||
{!query && (
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
{Object.keys(lineList_LineWebID).map((d) => (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor:
|
||||
lineColorList[stationIDPair[lineList_LineWebID[d]]],
|
||||
padding: 5,
|
||||
marginHorizontal: 2,
|
||||
borderRadius: 10,
|
||||
borderColor: "white",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
alignItems: "center",
|
||||
opacity:
|
||||
isSearchMode == stationIDPair[lineList_LineWebID[d]]
|
||||
? 1
|
||||
: !isSearchMode
|
||||
? 1
|
||||
: 0.5,
|
||||
zIndex: 10,
|
||||
}}
|
||||
onPress={() => {
|
||||
const id = stationIDPair[lineList_LineWebID[d]];
|
||||
const s = isSearchMode == id ? undefined : id;
|
||||
if (!s) return;
|
||||
setisSearchMode(s);
|
||||
}}
|
||||
key={stationIDPair[lineList_LineWebID[d]]}
|
||||
>
|
||||
<Text
|
||||
{Object.keys(lineList_LineWebID).map((d) => {
|
||||
const buttonLineId = stationIDPair[lineList_LineWebID[d]];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: 20,
|
||||
flex: 1,
|
||||
backgroundColor: lineColorList[buttonLineId],
|
||||
padding: 5,
|
||||
marginHorizontal: 2,
|
||||
borderRadius: 10,
|
||||
borderColor: "white",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
alignItems: "center",
|
||||
opacity: !lineId ? 1 : lineId === buttonLineId ? 1 : 0.5,
|
||||
zIndex: 10,
|
||||
}}
|
||||
onPress={() => {
|
||||
// 同じ路線を再タップしても変化なし(元の挙動を維持)
|
||||
if (lineId === buttonLineId) return;
|
||||
setStationSource({ type: "search", query, lineId: buttonLineId });
|
||||
}}
|
||||
key={buttonLineId}
|
||||
>
|
||||
{stationIDPair[lineList_LineWebID[d]]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: 20,
|
||||
}}
|
||||
>
|
||||
{buttonLineId}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
425
components/Settings/DataSourceSettings.tsx
Normal file
425
components/Settings/DataSourceSettings.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { View, Text, ScrollView, StyleSheet, Image, TouchableOpacity, Linking } from "react-native";
|
||||
import { Switch } from "react-native-elements";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
|
||||
import { AS } from "../../storageControl";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
|
||||
const HUB_LOGO_PNG = require("@/assets/icons/hub_logo.png");
|
||||
const ELESITE_LOGO_PNG = require("@/assets/icons/elesite_logo.png");
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* DataSourceAccordionCard */
|
||||
/* ------------------------------------------------------------------ */
|
||||
type Feature = { icon: string; label: string; text: string };
|
||||
|
||||
type DataSourceAccordionCardProps = {
|
||||
/** ロゴ画像 (require) */
|
||||
logo: any;
|
||||
/** アクセントカラー */
|
||||
accentColor: string;
|
||||
/** データソース名 */
|
||||
title: string;
|
||||
/** 1行サブタイトル */
|
||||
tagline: string;
|
||||
/** スイッチの値 */
|
||||
enabled: boolean;
|
||||
/** スイッチ変更ハンドラ */
|
||||
onToggle: (v: boolean) => void;
|
||||
/** 説明文 */
|
||||
description: string;
|
||||
/** 機能リスト */
|
||||
features: Feature[];
|
||||
/** フッターリンクラベル */
|
||||
linkLabel: string;
|
||||
/** フッターリンク URL */
|
||||
linkUrl: string;
|
||||
};
|
||||
|
||||
const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
|
||||
logo,
|
||||
accentColor,
|
||||
title,
|
||||
tagline,
|
||||
enabled,
|
||||
onToggle,
|
||||
description,
|
||||
features,
|
||||
linkLabel,
|
||||
linkUrl,
|
||||
detailLabel,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<View style={[styles.accordionCard, enabled && styles.accordionCardEnabled]}>
|
||||
{/* ── ヘッダー行(常時表示) ── */}
|
||||
<View style={styles.accordionHeader}>
|
||||
{/* 左:ロゴ */}
|
||||
<Image source={logo} style={styles.accordionLogo} />
|
||||
|
||||
{/* 中央:タイトル+タグライン */}
|
||||
<View style={styles.accordionTitles}>
|
||||
<Text style={styles.accordionTitle}>{title}</Text>
|
||||
<Text style={styles.accordionTagline}>{tagline}</Text>
|
||||
</View>
|
||||
|
||||
{/* 右:スイッチ */}
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={onToggle}
|
||||
color={accentColor}
|
||||
style={styles.accordionSwitch}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* スイッチ状態テキスト */}
|
||||
<View style={styles.accordionStatusRow}>
|
||||
<View style={[styles.statusDot, { backgroundColor: enabled ? accentColor : "#ccc" }]} />
|
||||
<Text style={[styles.statusText, { color: enabled ? accentColor : "#aaa" }]}>
|
||||
{enabled ? "有効 — 編成データを取得します" : "無効 — データを取得しません"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* ── 展開トリガー ── */}
|
||||
<TouchableOpacity
|
||||
style={styles.accordionToggleRow}
|
||||
onPress={() => setExpanded((v) => !v)}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<Text style={styles.accordionToggleLabel}>
|
||||
{expanded ? "詳細を閉じる" : (detailLabel ?? `${title} について`)}
|
||||
</Text>
|
||||
<MaterialCommunityIcons
|
||||
name={expanded ? "chevron-up" : "chevron-down"}
|
||||
size={16}
|
||||
color="#888"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* ── 展開コンテンツ ── */}
|
||||
{expanded && (
|
||||
<View style={styles.accordionBody}>
|
||||
{/* 説明文 */}
|
||||
<Text style={styles.bodyDesc}>{description}</Text>
|
||||
|
||||
{/* 機能リスト */}
|
||||
<View style={styles.bodyFeatures}>
|
||||
{features.map((f) => (
|
||||
<View key={f.icon} style={styles.featureRow}>
|
||||
<View style={styles.featureIcon}>
|
||||
<MaterialCommunityIcons name={f.icon as any} size={14} color="#444" />
|
||||
</View>
|
||||
<Text style={styles.featureLabel}>{f.label}</Text>
|
||||
<Text style={styles.featureText}>{f.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* リンク */}
|
||||
<TouchableOpacity
|
||||
style={styles.bodyLink}
|
||||
onPress={() => Linking.openURL(linkUrl)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="open-in-new" size={13} color="#555" />
|
||||
<Text style={styles.bodyLinkText}>{linkLabel}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 定数 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const UNYOHUB_FEATURES: Feature[] = [
|
||||
{ icon: "calendar-today", label: "運用データ", text: "本日・過去数日から投稿があった運用の継続予測運用情報を表示" },
|
||||
{ icon: "map-outline", label: "対象エリア", text: "JR四国全線" },
|
||||
{ icon: "train", label: "対象運用", text: "JR四国管内営業列車及び貨物列車に対応、臨時列車/突発運用は非対応" },
|
||||
{ icon: "plus", label: "追加機能", text: "前日、当日、翌日の運用の投稿が可能" },
|
||||
];
|
||||
|
||||
const ELESITE_FEATURES: Feature[] = [
|
||||
{ icon: "calendar-today", label: "運用データ", text: "当日に報告のあった運用情報のみ表示" },
|
||||
{ icon: "map-outline", label: "対象エリア", text: "予讃線/瀬戸大橋線(なお直通している特急などの列番は含みます)" },
|
||||
{ icon: "train", label: "対象運用", text: "JR四国管内営業列車対応、臨時列車/突発運用は非対応" },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* DataSourceSettings */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export const DataSourceSettings = () => {
|
||||
const navigation = useNavigation();
|
||||
const { updatePermission, dataSourcePermission } = useTrainMenu();
|
||||
const canAccess = updatePermission || Object.values(dataSourcePermission).some(Boolean);
|
||||
const [useUnyohub, setUseUnyohub] = useState(false);
|
||||
const [useElesite, setUseElesite] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
AS.getItem(STORAGE_KEYS.USE_UNYOHUB).then((value) => {
|
||||
setUseUnyohub(value === true || value === "true");
|
||||
});
|
||||
AS.getItem(STORAGE_KEYS.USE_ELESITE).then((value) => {
|
||||
setUseElesite(value === true || value === "true");
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleUnyohub = (value: boolean) => {
|
||||
setUseUnyohub(value);
|
||||
AS.setItem(STORAGE_KEYS.USE_UNYOHUB, value.toString());
|
||||
};
|
||||
|
||||
const handleToggleElesite = (value: boolean) => {
|
||||
setUseElesite(value);
|
||||
AS.setItem(STORAGE_KEYS.USE_ELESITE, value.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SheetHeaderItem
|
||||
title="情報ソース設定"
|
||||
LeftItem={{
|
||||
title: "戻る",
|
||||
onPress: () => navigation.goBack(),
|
||||
position: "left",
|
||||
}}
|
||||
/>
|
||||
{!canAccess ? (
|
||||
<View style={styles.noPermissionContainer}>
|
||||
<Text style={styles.noPermissionText}>この設定にアクセスする権限がありません。</Text>
|
||||
<Text style={styles.noPermissionSubText}>鉄道運用Hubまたはアプリ管理者の権限が必要です。</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView style={styles.content} contentContainerStyle={styles.contentInner}>
|
||||
<Text style={styles.sectionTitle}>外部データソース</Text>
|
||||
|
||||
<DataSourceAccordionCard
|
||||
logo={HUB_LOGO_PNG}
|
||||
accentColor="#0099CC"
|
||||
title="鉄道運用Hub"
|
||||
tagline="コミュニティによる列車運用情報サービス"
|
||||
enabled={useUnyohub}
|
||||
onToggle={handleToggleUnyohub}
|
||||
description={
|
||||
"鉄道運用Hubはオープンソースのユーザー投稿型鉄道運用情報データベースアプリケーションです。JR 四国をはじめ全国多数の路線系統に対応しています。\n\nデータがある列車では地図上にアイコンでマークが表示され、列車情報画面の編成表示も更新されます。"
|
||||
}
|
||||
features={UNYOHUB_FEATURES}
|
||||
linkLabel="unyohub.2pd.jp を開く(JR四国)"
|
||||
linkUrl="https://unyohub.2pd.jp/railroad_shikoku/"
|
||||
/>
|
||||
|
||||
<DataSourceAccordionCard
|
||||
logo={ELESITE_LOGO_PNG}
|
||||
accentColor="#44bb44"
|
||||
title="えれサイト"
|
||||
tagline="コミュニティによる列車運用情報サービス"
|
||||
enabled={useElesite}
|
||||
onToggle={handleToggleElesite}
|
||||
description={
|
||||
"えれサイトは、鉄道運用情報を共有するためのサイトです。皆様からの投稿を通じて、鉄道運行に関する情報を共有するサイトです。JR 四国の特急・普通列車を中心に対応しています。\n\nデータがある列車では地図上に緑色の「E」バッジが表示され、列車情報画面の編成表示も更新されます。"
|
||||
}
|
||||
features={ELESITE_FEATURES}
|
||||
linkLabel="elesite-next.com を開く"
|
||||
linkUrl="https://www.elesite-next.com/"
|
||||
/>
|
||||
|
||||
<View style={styles.infoSection}>
|
||||
<Text style={styles.infoText}>
|
||||
外部のコミュニティデータソースとの連携を管理します。
|
||||
{"\n\n"}
|
||||
データの正確性は保証されません。また、これらの連携情報を利用する時点でそれぞれのサイトの利用規約に同意したものとします。{"\n\n"}外部ソースはJR四国非公式アプリが管理していないデータであるため、お問い合わせは各サービスの窓口までお願いいたします。
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
/* ── 権限なし ── */
|
||||
noPermissionContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f8f8fc",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 30,
|
||||
gap: 10,
|
||||
},
|
||||
noPermissionText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
textAlign: "center",
|
||||
},
|
||||
noPermissionSubText: {
|
||||
fontSize: 13,
|
||||
color: "#666",
|
||||
textAlign: "center",
|
||||
},
|
||||
/* ── レイアウト ── */
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0099CC",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f8f8fc",
|
||||
},
|
||||
contentInner: {
|
||||
paddingHorizontal: 14,
|
||||
paddingBottom: 40,
|
||||
gap: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
color: "#888",
|
||||
letterSpacing: 0.5,
|
||||
marginTop: 20,
|
||||
marginLeft: 4,
|
||||
},
|
||||
/* ── アコーディオンカード ── */
|
||||
accordionCard: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e4e4e4",
|
||||
overflow: "hidden",
|
||||
},
|
||||
accordionCardEnabled: {
|
||||
borderColor: "#0099CC44",
|
||||
},
|
||||
accordionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 14,
|
||||
paddingBottom: 6,
|
||||
gap: 10,
|
||||
},
|
||||
accordionLogo: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
flexShrink: 0,
|
||||
},
|
||||
accordionTitles: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
accordionTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
color: "#111",
|
||||
},
|
||||
accordionTagline: {
|
||||
fontSize: 11,
|
||||
color: "#888",
|
||||
},
|
||||
accordionSwitch: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
accordionStatusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: 14,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
statusDot: {
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 4,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 12,
|
||||
fontWeight: "500",
|
||||
},
|
||||
/* ── 展開トリガー ── */
|
||||
accordionToggleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: "#ebebeb",
|
||||
},
|
||||
accordionToggleLabel: {
|
||||
fontSize: 12,
|
||||
color: "#666",
|
||||
fontWeight: "500",
|
||||
},
|
||||
/* ── 展開コンテンツ ── */
|
||||
accordionBody: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: "#ebebeb",
|
||||
padding: 14,
|
||||
gap: 10,
|
||||
backgroundColor: "#fafafa",
|
||||
},
|
||||
bodyDesc: {
|
||||
fontSize: 12,
|
||||
color: "#444",
|
||||
lineHeight: 19,
|
||||
},
|
||||
bodyFeatures: {
|
||||
gap: 7,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: "#e4e4e4",
|
||||
paddingTop: 8,
|
||||
},
|
||||
featureRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: 6,
|
||||
},
|
||||
featureIcon: {
|
||||
width: 22,
|
||||
alignItems: "center",
|
||||
paddingTop: 1,
|
||||
flexShrink: 0,
|
||||
},
|
||||
featureLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
width: 62,
|
||||
flexShrink: 0,
|
||||
},
|
||||
featureText: {
|
||||
fontSize: 12,
|
||||
color: "#555",
|
||||
flex: 1,
|
||||
lineHeight: 17,
|
||||
},
|
||||
bodyLink: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: "#e4e4e4",
|
||||
paddingTop: 8,
|
||||
marginTop: 2,
|
||||
},
|
||||
bodyLinkText: {
|
||||
fontSize: 12,
|
||||
color: "#555",
|
||||
},
|
||||
/* ── 注意書き ── */
|
||||
infoSection: {
|
||||
backgroundColor: "#fff3cd",
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 13,
|
||||
color: "#856404",
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
214
components/Settings/FelicaHistoryPage.tsx
Normal file
214
components/Settings/FelicaHistoryPage.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { SheetHeaderItem } from "../atom/SheetHeaderItem";
|
||||
import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
|
||||
import type { FelicaCardInfo, FelicaHistoryEntry } from "../../modules/expo-felica-reader/src";
|
||||
|
||||
// 処理種別コード → ラベル
|
||||
const PROCESS_TYPE_LABEL: Record<number, string> = {
|
||||
0x01: "改札入場",
|
||||
0x02: "改札出場",
|
||||
0x03: "乗継入場",
|
||||
0x04: "乗継出場",
|
||||
0x0f: "バス乗降",
|
||||
0x14: "タクシー",
|
||||
0x46: "物販",
|
||||
0x62: "チャージ",
|
||||
};
|
||||
|
||||
function formatTime(hour: number, minute: number): string {
|
||||
if (hour === 0 && minute === 0) return "--:--";
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function processLabel(processType: number): string {
|
||||
return PROCESS_TYPE_LABEL[processType] ?? `0x${processType.toString(16).toUpperCase().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function HistoryRow({ entry, index }: { entry: FelicaHistoryEntry; index: number }) {
|
||||
const dateStr = `${entry.year}/${String(entry.month).padStart(2, "0")}/${String(entry.day).padStart(2, "0")}`;
|
||||
const entryTime = formatTime(entry.entryHour, entry.entryMinute);
|
||||
const exitTime = formatTime(entry.exitHour, entry.exitMinute);
|
||||
const label = processLabel(entry.processType);
|
||||
|
||||
return (
|
||||
<View style={[styles.row, index % 2 === 0 ? styles.rowEven : styles.rowOdd]}>
|
||||
<View style={styles.rowLeft}>
|
||||
<Text style={styles.dateText}>{dateStr}</Text>
|
||||
<Text style={styles.labelText}>{label}</Text>
|
||||
{entry.inStationCode !== 0 || entry.outStationCode !== 0 ? (
|
||||
<Text style={styles.stationText}>
|
||||
{`No.${entry.inStationCode} → No.${entry.outStationCode}`}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={styles.timeText}>{entryTime} → {exitTime}</Text>
|
||||
</View>
|
||||
<View style={styles.rowRight}>
|
||||
<Text style={styles.balanceText}>
|
||||
¥{entry.balance.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function FelicaHistoryPage() {
|
||||
const { goBack } = useNavigation();
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [result, setResult] = useState<FelicaCardInfo | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await ExpoFelicaReader.scan();
|
||||
console.log("NFC Scan Result:", data);
|
||||
setResult(data);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "読み取りに失敗しました");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SheetHeaderItem
|
||||
title="ICカード残高・履歴"
|
||||
LeftItem={{ title: "< 設定", onPress: goBack, position: "left" }}
|
||||
/>
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
|
||||
{/* スキャンボタン */}
|
||||
<TouchableOpacity
|
||||
style={[styles.scanButton, scanning && styles.scanButtonDisabled]}
|
||||
onPress={handleScan}
|
||||
disabled={scanning}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{scanning ? (
|
||||
<>
|
||||
<ActivityIndicator color="white" style={{ marginBottom: 6 }} />
|
||||
<Text style={styles.scanButtonText}>
|
||||
{Platform.OS === "ios" ? "カードをかざしてください…" : "スキャン中…"}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.scanButtonText}>NFCカードをスキャン</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* エラー */}
|
||||
{error && (
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 結果 */}
|
||||
{result && (
|
||||
<>
|
||||
{/* 残高カード */}
|
||||
<View style={styles.balanceCard}>
|
||||
<Text style={styles.balanceLabel}>残高</Text>
|
||||
<Text style={styles.balanceAmount}>
|
||||
{result.balance >= 0 ? `¥${result.balance.toLocaleString()}` : "読み取り失敗"}
|
||||
</Text>
|
||||
<Text style={styles.idmText}>IDm: {result.idm}</Text>
|
||||
</View>
|
||||
|
||||
{/* 履歴リスト */}
|
||||
<Text style={styles.sectionTitle}>
|
||||
利用履歴{result.history.length > 0 ? `(${result.history.length}件)` : ""}
|
||||
</Text>
|
||||
{result.history.length === 0 ? (
|
||||
<Text style={styles.emptyText}>履歴を取得できませんでした</Text>
|
||||
) : (
|
||||
result.history.map((entry, i) => (
|
||||
<HistoryRow key={i} entry={entry} index={i} />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { height: "100%", backgroundColor: "#0099CC" },
|
||||
scroll: { flex: 1, backgroundColor: "white" },
|
||||
scrollContent: { paddingBottom: 32 },
|
||||
|
||||
scanButton: {
|
||||
margin: 16,
|
||||
paddingVertical: 18,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#0099CC",
|
||||
alignItems: "center",
|
||||
},
|
||||
scanButtonDisabled: { backgroundColor: "#88ccee" },
|
||||
scanButtonText: { color: "white", fontSize: 18, fontWeight: "bold" },
|
||||
|
||||
errorBox: {
|
||||
margin: 16,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#fdecea",
|
||||
borderWidth: 1,
|
||||
borderColor: "#f44336",
|
||||
},
|
||||
errorText: { color: "#c62828", fontSize: 14 },
|
||||
|
||||
balanceCard: {
|
||||
margin: 16,
|
||||
padding: 20,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "#e3f2fd",
|
||||
borderWidth: 1,
|
||||
borderColor: "#0099CC",
|
||||
alignItems: "center",
|
||||
},
|
||||
balanceLabel: { fontSize: 14, color: "#555", marginBottom: 4 },
|
||||
balanceAmount: { fontSize: 36, fontWeight: "bold", color: "#0099CC" },
|
||||
idmText: { fontSize: 11, color: "#888", marginTop: 6 },
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
marginHorizontal: 16,
|
||||
marginTop: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
emptyText: { color: "#999", marginHorizontal: 16, marginTop: 8 },
|
||||
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
alignItems: "center",
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: "#ddd",
|
||||
},
|
||||
rowEven: { backgroundColor: "white" },
|
||||
rowOdd: { backgroundColor: "#f5f9fc" },
|
||||
rowLeft: { flex: 1 },
|
||||
rowRight: { alignItems: "flex-end" },
|
||||
|
||||
dateText: { fontSize: 12, color: "#666" },
|
||||
labelText: { fontSize: 15, fontWeight: "bold", color: "#333", marginTop: 2 },
|
||||
stationText: { fontSize: 12, color: "#777", marginTop: 1 },
|
||||
timeText: { fontSize: 12, color: "#999", marginTop: 1 },
|
||||
balanceText: { fontSize: 16, fontWeight: "bold", color: "#0099CC" },
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { View, Text, TouchableOpacity, ScrollView } from "react-native";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { SwitchArea } from "../atom/SwitchArea";
|
||||
import { CheckBox } from "react-native-elements";
|
||||
import { Button, CheckBox } from "react-native-elements";
|
||||
import { TripleSwitchArea } from "../atom/TripleSwitchArea";
|
||||
import { SheetHeaderItem } from "../atom/SheetHeaderItem";
|
||||
|
||||
@@ -24,12 +24,13 @@ export const LayoutSettings = ({
|
||||
headerSize,
|
||||
setHeaderSize,
|
||||
}) => {
|
||||
const { goBack } = useNavigation();
|
||||
const { goBack, navigate } = useNavigation() as any;
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: "#0099CC" }}>
|
||||
<SheetHeaderItem title="レイアウト設定" LeftItem={{ title: "< 設定", onPress: goBack }} />
|
||||
<ScrollView style={{ flex: 1, backgroundColor: "white" }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Button title="ICカード残高・履歴" onPress={() => navigate("FelicaHistoryPage")} />
|
||||
<SwitchArea
|
||||
str="列車アイコン表示"
|
||||
bool={iconSetting}
|
||||
|
||||
@@ -16,8 +16,9 @@ import TouchableScale from "react-native-touchable-scale";
|
||||
import { SwitchArea } from "../atom/SwitchArea";
|
||||
import { useNotification } from "../../stateBox/useNotifications";
|
||||
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
|
||||
import { useTrainMenu } from "../../stateBox/useTrainMenu";
|
||||
|
||||
const versionCode = "6.2.1"; // Update this version code as needed
|
||||
const versionCode = "6.2.1.1"; // Update this version code as needed
|
||||
|
||||
export const SettingTopPage = ({
|
||||
testNFC,
|
||||
@@ -27,7 +28,11 @@ export const SettingTopPage = ({
|
||||
}) => {
|
||||
const { width } = useWindowDimensions();
|
||||
const { expoPushToken } = useNotification();
|
||||
const { updatePermission, dataSourcePermission } = useTrainMenu();
|
||||
const navigation = useNavigation();
|
||||
// admin またはいずれかのソース権限を持つ場合のみ表示
|
||||
const canAccessDataSourceSettings =
|
||||
updatePermission || Object.values(dataSourcePermission).some(Boolean);
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: "#0099CC" }}>
|
||||
<SheetHeaderItem title="アプリの設定画面" LeftItem={{
|
||||
@@ -108,6 +113,14 @@ export const SettingTopPage = ({
|
||||
navigation.navigate("setting", { screen: "LayoutSettings" })
|
||||
}
|
||||
/>
|
||||
{canAccessDataSourceSettings && (
|
||||
<SettingList
|
||||
string="情報ソース設定"
|
||||
onPress={() =>
|
||||
navigation.navigate("setting", { screen: "DataSourceSettings" })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{Platform.OS === "android" ? (
|
||||
<SettingList
|
||||
string="ウィジェット設定"
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "react-native";
|
||||
import { createStackNavigator } from "@react-navigation/stack";
|
||||
import { TransitionPresets } from "@react-navigation/stack";
|
||||
//import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
|
||||
import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
|
||||
import * as Updates from "expo-updates";
|
||||
import { AS } from "../../storageControl";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
@@ -24,6 +24,8 @@ import { FavoriteSettings } from "./FavoriteSettings";
|
||||
import { WidgetSettings } from "./WidgetSettings";
|
||||
import { NotificationSettings } from "./NotificationSettings";
|
||||
import { LauncherIconSettings } from "./LauncherIconSettings";
|
||||
import { DataSourceSettings } from "./DataSourceSettings";
|
||||
import { FelicaHistoryPage } from "./FelicaHistoryPage";
|
||||
|
||||
const Stack = createStackNavigator();
|
||||
export default function Setting(props) {
|
||||
@@ -51,8 +53,13 @@ export default function Setting(props) {
|
||||
AS.getItem(STORAGE_KEYS.UI_SETTING).then(setUiSetting);
|
||||
}, []);
|
||||
const testNFC = async () => {
|
||||
//const result = await ExpoFelicaReader.scan();
|
||||
//alert(result);
|
||||
console.log("Testing NFC...");
|
||||
const result = await ExpoFelicaReader.scan().then(x=>{
|
||||
console.log("NFC Scan Result:", x);
|
||||
return x;
|
||||
});
|
||||
console.log("NFC Result:", result);
|
||||
alert(`IDm: ${result.idm}\n残高: ${result.balance}円\nシステムコード: ${result.systemCode}`);
|
||||
};
|
||||
const updateAndReload = () => {
|
||||
Promise.all([
|
||||
@@ -116,8 +123,6 @@ export default function Setting(props) {
|
||||
setTrainPosition={setTrainPosition}
|
||||
uiSetting={uiSetting}
|
||||
setUiSetting={setUiSetting}
|
||||
testNFC={testNFC}
|
||||
updateAndReload={updateAndReload}
|
||||
headerSize={headerSize}
|
||||
setHeaderSize={setHeaderSize}
|
||||
/>
|
||||
@@ -167,6 +172,28 @@ export default function Setting(props) {
|
||||
}}
|
||||
component={FavoriteSettings}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="DataSourceSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
}}
|
||||
component={DataSourceSettings}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="FelicaHistoryPage"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
}}
|
||||
component={FelicaHistoryPage}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ export const ExGridSimpleViewItem: FC<{
|
||||
type: trainData?.type,
|
||||
whiteMode: true,
|
||||
});
|
||||
// 列車名、種別、フォントの取得
|
||||
const [trainName] = useMemo(() => {
|
||||
// 行き先(駅名)の取得
|
||||
const [destinationName] = useMemo(() => {
|
||||
// to_dataが設定されていればそれを優先
|
||||
if (trainData?.to_data) {
|
||||
return [trainData.to_data];
|
||||
@@ -75,6 +75,9 @@ export const ExGridSimpleViewItem: FC<{
|
||||
return [migrateTrainName(trainName)];
|
||||
}
|
||||
}, [d.array, trainData]);
|
||||
|
||||
// 列車名の取得(上部表示用)
|
||||
const trainName = trainData?.train_name || "";
|
||||
const timeArray = d.time.split(":").map((s) => parseInt(s));
|
||||
const formattedTime = dayjs()
|
||||
.set("hour", timeArray[0])
|
||||
@@ -146,7 +149,7 @@ export const ExGridSimpleViewItem: FC<{
|
||||
// to_dataがある場合は、to_dataから駅名を抽出して色を判定
|
||||
const stationNameForColor = trainData?.to_data
|
||||
? trainData.to_data.replace(/行き$/, "") // 「行き」を除去
|
||||
: trainName;
|
||||
: destinationName;
|
||||
|
||||
const Stations = stationList
|
||||
.map((a) => a.filter((d) => d.StationName == stationNameForColor))
|
||||
@@ -161,7 +164,7 @@ export const ExGridSimpleViewItem: FC<{
|
||||
);
|
||||
setStationColor(stationLineColor || ["gray"]);
|
||||
}
|
||||
}, [stationList, trainName, trainData]);
|
||||
}, [stationList, destinationName, trainData]);
|
||||
// if(typeString == "回送"){
|
||||
// return<></>;
|
||||
// }
|
||||
@@ -237,7 +240,7 @@ export const ExGridSimpleViewItem: FC<{
|
||||
textDecorationLine: isCancelled ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
{trainName}
|
||||
{destinationName}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }} />
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TrainName } from "@/components/発車時刻表/LED_inside_Component/Tra
|
||||
import { TrainPosition } from "@/components/発車時刻表/LED_inside_Component/TrainPosition";
|
||||
import { StationPosPushDialog } from "@/components/発車時刻表/LED_inside_Component/TrainPositionDataPush";
|
||||
import { StationPosDeleteDialog } from "@/components/発車時刻表/LED_inside_Component/TrainPositionDataDelete";
|
||||
import { ScrollingDescription } from "@/components/発車時刻表/LED_inside_Component/ScrollingDescription";
|
||||
import { useStationList } from "@/stateBox/useStationList";
|
||||
import useInterval from "@/lib/useInterval";
|
||||
import dayjs from "dayjs";
|
||||
@@ -197,17 +198,19 @@ export const EachData: FC<Props> = (props) => {
|
||||
trainID={d.train}
|
||||
type={train.type}
|
||||
isThrew={d.isThrough}
|
||||
se={d.se}
|
||||
/>
|
||||
<LastStation
|
||||
lastStation={d.lastStation}
|
||||
ToData={train.to_data}
|
||||
Station_JP={station.Station_JP}
|
||||
se={d.se}
|
||||
/>
|
||||
<PlatformNumber platform={d.platformNum} />
|
||||
<PlatformNumber platform={d.platformNum} se={d.se} />
|
||||
{timeDisplay ? (
|
||||
<DependTime time={d.time} isDelay={isDelay} />
|
||||
<DependTime time={d.time} isDelay={isDelay} se={d.se} />
|
||||
) : (
|
||||
<StatusAndDelay trainDelayStatus={trainDelayStatus} />
|
||||
<StatusAndDelay trainDelayStatus={trainDelayStatus} se={d.se} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{!!isDepartureNow && (
|
||||
@@ -243,7 +246,20 @@ export const EachData: FC<Props> = (props) => {
|
||||
/>
|
||||
)}
|
||||
{trainDescriptionSwitch && !!train.train_info && (
|
||||
<Description info={train.train_info} key={d.train + "-description"} />
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
alignContent: "center",
|
||||
alignItems: "center",
|
||||
width: "94%",
|
||||
marginVertical: 5,
|
||||
marginHorizontal: "3%",
|
||||
backgroundColor: "#000",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
key={d.train + "-description"}
|
||||
>
|
||||
<ScrollingDescription description={train.train_info} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{trainDescriptionSwitch && !!train.uwasa && (
|
||||
<Description info={train.uwasa} key={d.train + "-uwasa"} />
|
||||
|
||||
@@ -7,13 +7,17 @@ const descriptionStyle: TextStyle = {
|
||||
type Props = {
|
||||
time: string;
|
||||
isDelay?: boolean;
|
||||
se?: string;
|
||||
};
|
||||
export const DependTime: FC<Props> = ({ time, isDelay, se }) => {
|
||||
const isCanceled = se?.includes("休");
|
||||
return (
|
||||
<View style={{ flex: 4 }}>
|
||||
<Text
|
||||
style={{ ...descriptionStyle, color: isCanceled ? "#999" : isDelay ? "#ffd16fff" : "white", textDecorationLine: isCanceled ? "line-through" : "none" }}
|
||||
>
|
||||
{time}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
export const DependTime: FC<Props> = ({ time, isDelay }) => (
|
||||
<View style={{ flex: 4 }}>
|
||||
<Text
|
||||
style={{ ...descriptionStyle, color: isDelay ? "#ffd16fff" : "white" }}
|
||||
>
|
||||
{time}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -5,17 +5,33 @@ type Props = {
|
||||
lastStation: string;
|
||||
ToData: string;
|
||||
Station_JP: string;
|
||||
se?: string;
|
||||
};
|
||||
export const LastStation: FC<Props> = ({ lastStation, ToData, Station_JP }) => {
|
||||
export const LastStation: FC<Props> = ({ lastStation, ToData, Station_JP, se }) => {
|
||||
const isEdit = !ToData ? false : ToData !== lastStation;
|
||||
const string = isEdit ? ToData : lastStation;
|
||||
const isCanceled = se?.includes("休");
|
||||
|
||||
return (
|
||||
<View style={{ flex: 4, flexDirection: "row" }}>
|
||||
{isCanceled && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: parseInt("12%"),
|
||||
color: "#ff6b6b",
|
||||
fontWeight: "bold",
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
運休
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: lastStation?.length > 4 ? parseInt("12%") : parseInt("16%"),
|
||||
color: isEdit ? "#ffd16fff" : "white",
|
||||
color: isCanceled ? "#999" : isEdit ? "#ffd16fff" : "white",
|
||||
fontWeight: "bold",
|
||||
textDecorationLine: isCanceled ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
{string === Station_JP ? "当駅止" : string}
|
||||
|
||||
@@ -6,11 +6,13 @@ const descriptionStyle: TextStyle = {
|
||||
};
|
||||
type Props = {
|
||||
platform: string;
|
||||
se?: string;
|
||||
};
|
||||
export const PlatformNumber: FC<Props> = ({ platform }) => {
|
||||
export const PlatformNumber: FC<Props> = ({ platform, se }) => {
|
||||
const isCanceled = se?.includes("休");
|
||||
return (
|
||||
<View style={{ flex: 2 }}>
|
||||
<Text style={{ ...descriptionStyle, color: "white", paddingLeft: 1 }}>
|
||||
<Text style={{ ...descriptionStyle, color: isCanceled ? "#999" : "white", paddingLeft: 1, textDecorationLine: isCanceled ? "line-through" : "none" }}>
|
||||
{platform}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
127
components/発車時刻表/LED_inside_Component/ScrollingDescription.tsx
Normal file
127
components/発車時刻表/LED_inside_Component/ScrollingDescription.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { FC, useEffect, useRef, useState } from "react";
|
||||
import { Animated, Text, View, LayoutChangeEvent } from "react-native";
|
||||
|
||||
type Props = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const ScrollingDescription: FC<Props> = ({ description }) => {
|
||||
const scrollX = useRef(new Animated.Value(0)).current;
|
||||
const [textWidth, setTextWidth] = useState(0);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
// 改行を削除して1行にする
|
||||
const singleLineDescription = description?.replace(/\n/g, " ") || "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!singleLineDescription || textWidth === 0 || containerWidth === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// テキストが画面幅より短い場合はスクロールしない
|
||||
if (textWidth <= containerWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 初期位置を設定(画面の右端から開始)
|
||||
scrollX.setValue(containerWidth);
|
||||
|
||||
const distance = textWidth + containerWidth;
|
||||
const duration = distance * 6; // スクロール速度
|
||||
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.delay(500), // 最初に0.5秒待つ
|
||||
Animated.timing(scrollX, {
|
||||
toValue: -textWidth - 20,
|
||||
duration: duration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.delay(500),
|
||||
// 瞬時に右端に戻る
|
||||
Animated.timing(scrollX, {
|
||||
toValue: containerWidth,
|
||||
duration: 0,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
animation.start();
|
||||
|
||||
return () => {
|
||||
animation.stop();
|
||||
scrollX.setValue(containerWidth);
|
||||
};
|
||||
}, [singleLineDescription, textWidth, containerWidth]);
|
||||
|
||||
const handleTextLayout = (event: LayoutChangeEvent) => {
|
||||
const { width } = event.nativeEvent.layout;
|
||||
if (width > 0) {
|
||||
setTextWidth(width);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerLayout = (event: LayoutChangeEvent) => {
|
||||
const { width } = event.nativeEvent.layout;
|
||||
if (width > 0) {
|
||||
setContainerWidth(width);
|
||||
}
|
||||
};
|
||||
|
||||
if (!singleLineDescription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 20,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
onLayout={handleContainerLayout}
|
||||
>
|
||||
{/* 測定用の透明なテキスト(画面外、幅制限なし) */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -1000,
|
||||
left: 0,
|
||||
width: 9999, // 十分な幅を確保してテキストが折り返されないようにする
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
onLayout={handleTextLayout}
|
||||
>
|
||||
{singleLineDescription}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 実際に表示されるスクロールテキスト(幅制限なし) */}
|
||||
<Animated.View
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: [{ translateX: scrollX }],
|
||||
width: 9999, // テキストが折り返されないように十分な幅を確保
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
color: "#d3a203",
|
||||
}}
|
||||
>
|
||||
{singleLineDescription}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -6,11 +6,13 @@ const descriptionStyle: TextStyle = {
|
||||
};
|
||||
type Props = {
|
||||
trainDelayStatus: string;
|
||||
se?: string;
|
||||
};
|
||||
export const StatusAndDelay: FC<Props> = ({ trainDelayStatus }) => {
|
||||
export const StatusAndDelay: FC<Props> = ({ trainDelayStatus, se }) => {
|
||||
const isCanceled = se?.includes("休");
|
||||
return (
|
||||
<View style={{ flex: 4 }}>
|
||||
<Text style={{ ...descriptionStyle, color: "#ffd16fff", paddingLeft: 1 }}>
|
||||
<Text style={{ ...descriptionStyle, color: isCanceled ? "#999" : "#ffd16fff", paddingLeft: 1, textDecorationLine: isCanceled ? "line-through" : "none" }}>
|
||||
{trainDelayStatus}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -9,9 +9,10 @@ type Props = {
|
||||
trainID: string;
|
||||
type: trainTypeID;
|
||||
isThrew: boolean;
|
||||
se?: string;
|
||||
};
|
||||
export const TrainName: FC<Props> = (props) => {
|
||||
const { trainName, trainNumDistance, trainIDSwitch, trainID, type, isThrew } =
|
||||
const { trainName, trainNumDistance, trainIDSwitch, trainID, type, isThrew, se } =
|
||||
props;
|
||||
const { name, color } = getTrainType({ type });
|
||||
const TrainNumber =
|
||||
@@ -23,13 +24,15 @@ export const TrainName: FC<Props> = (props) => {
|
||||
parseInt(trainNumDistance)
|
||||
}号`
|
||||
: "";
|
||||
const isCanceled = se?.includes("休");
|
||||
return (
|
||||
<View style={{ flex: 9, flexDirection: "row", alignItems: "center" }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: trainName.length > 6 ? parseInt("11%") : parseInt("15%"),
|
||||
color: color,
|
||||
color: isCanceled ? "#999" : color,
|
||||
fontWeight: "bold",
|
||||
textDecorationLine: isCanceled ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
{trainIDSwitch
|
||||
|
||||
@@ -31,6 +31,12 @@ export const API_ENDPOINTS = {
|
||||
|
||||
/** 位置情報問題データ */
|
||||
POSITION_PROBLEMS: 'https://n8n.haruk.in/webhook/jrshikoku-position-problems',
|
||||
|
||||
/** 鉄道運用Hub運用データ */
|
||||
UNYOHUB_DATA: 'https://jr-shikoku-api-data-storage.haruk.in/thirdparty/unyohub-unyo.json',
|
||||
|
||||
/** えれサイト運用データ */
|
||||
ELESITE_DATA: 'https://jr-shikoku-api-data-storage.haruk.in/thirdparty/elesite-unyo.json',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -80,6 +80,19 @@ export const STORAGE_KEYS = {
|
||||
|
||||
/** 奇妙な列車通知 */
|
||||
STRANGE_TRAIN: 'strangeTrain',
|
||||
|
||||
// 情報ソース設定系
|
||||
/** 鉄道運用Hub使用設定 */
|
||||
USE_UNYOHUB: 'useUnyohub',
|
||||
|
||||
/** 鉄道運用Hubデータ */
|
||||
UNYOHUB_DATA: 'unyohubData',
|
||||
|
||||
/** えれサイト使用設定 */
|
||||
USE_ELESITE: 'useElesite',
|
||||
|
||||
/** えれサイトデータ */
|
||||
ELESITE_DATA: 'elesiteData',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,6 +70,7 @@ export type CustomTrainData = {
|
||||
lastStation: string;
|
||||
isThrough: boolean;
|
||||
platformNum: string | null;
|
||||
se?: string;
|
||||
};
|
||||
|
||||
export type StationProps = {
|
||||
@@ -93,6 +94,7 @@ export type OperationLogs = {
|
||||
train_ids?: string[];
|
||||
unit_ids?: string[];
|
||||
vehicle_img: string;
|
||||
vehicle_img_right: string;
|
||||
vehicle_info_url: string;
|
||||
related_train_ids?: string[];
|
||||
state: number | null;
|
||||
|
||||
@@ -55,6 +55,12 @@ export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
|
||||
if (IntH < 4 && currentHour >= 4) {
|
||||
targetDate = targetDate.add(1, 'day');
|
||||
}
|
||||
// 遅延によって日を跨いだ場合の対応:
|
||||
// 現在時刻が深夜(0〜3時台)で、定刻が夕方以降(4時以上)の場合、
|
||||
// 遅延で日付が繰り上がっているので1日戻して当日として扱う
|
||||
if (currentHour < 4 && IntH >= 4) {
|
||||
targetDate = targetDate.subtract(1, 'day');
|
||||
}
|
||||
|
||||
if (date.isAfter(targetDate)) return false;
|
||||
if (targetDate.diff(date) < baseTime * 60 * 60 * 1000) return true;
|
||||
@@ -74,6 +80,7 @@ export const getTime: getTimeProps = (stationDiagram, station) => {
|
||||
isThrough: false,
|
||||
train: trainNum,
|
||||
platformNum: null,
|
||||
se: undefined,
|
||||
};
|
||||
stationDiagram[trainNum].split("#").forEach((data) => {
|
||||
const [stationName, type, time, platformNum] = data.split(",");
|
||||
@@ -83,6 +90,7 @@ export const getTime: getTimeProps = (stationDiagram, station) => {
|
||||
}
|
||||
if (stationName === station.Station_JP) {
|
||||
trainData.platformNum = platformNum;
|
||||
trainData.se = type;
|
||||
if (type.match("発")) {
|
||||
trainData.time = time;
|
||||
} else if (type.match("通")) {
|
||||
@@ -99,6 +107,7 @@ export const getTime: getTimeProps = (stationDiagram, station) => {
|
||||
lastStation: trainData.lastStation,
|
||||
isThrough: trainData.isThrough,
|
||||
platformNum: trainData.platformNum,
|
||||
se: trainData.se,
|
||||
};
|
||||
})
|
||||
.filter((d) => d.time);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AppState, AppStateStatus, Platform } from "react-native";
|
||||
|
||||
type Control = {
|
||||
start: () => void;
|
||||
@@ -12,10 +13,15 @@ type Fn = () => void;
|
||||
export const useInterval = (fn: Fn, interval: number, autostart = true) => {
|
||||
const onUpdateRef = useRef<Fn>();
|
||||
const [state, setState] = useState("RUNNING");
|
||||
// ユーザー操作によるSTOP(AppStateによる一時停止と区別する)
|
||||
const userStoppedRef = useRef(!autostart);
|
||||
|
||||
const start = () => {
|
||||
userStoppedRef.current = false;
|
||||
setState("RUNNING");
|
||||
};
|
||||
const stop = () => {
|
||||
userStoppedRef.current = true;
|
||||
setState("STOPPED");
|
||||
};
|
||||
useEffect(() => {
|
||||
@@ -23,22 +29,48 @@ export const useInterval = (fn: Fn, interval: number, autostart = true) => {
|
||||
}, [fn]);
|
||||
useEffect(() => {
|
||||
if (autostart) {
|
||||
userStoppedRef.current = false;
|
||||
setState("RUNNING");
|
||||
}else{
|
||||
} else {
|
||||
userStoppedRef.current = true;
|
||||
setState("STOPPED");
|
||||
}
|
||||
}, [autostart]);
|
||||
|
||||
// バックグラウンド移行時に停止、フォアグラウンド復帰時に即時実行して再開
|
||||
useEffect(() => {
|
||||
let timerId;
|
||||
if (Platform.OS === "web") return;
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
if (nextAppState === "active") {
|
||||
if (!userStoppedRef.current) {
|
||||
// 復帰直後に即時フェッチして最新データを取得
|
||||
onUpdateRef.current?.();
|
||||
setState("RUNNING");
|
||||
}
|
||||
} else if (nextAppState === "background" || nextAppState === "inactive") {
|
||||
if (!userStoppedRef.current) {
|
||||
// バックグラウンド中はインターバルを停止してムダなfetchエラーを防ぐ
|
||||
setState("STOPPED");
|
||||
}
|
||||
}
|
||||
};
|
||||
const subscription = AppState.addEventListener("change", handleAppStateChange);
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let timerId: ReturnType<typeof setInterval> | undefined;
|
||||
if (state === "RUNNING") {
|
||||
timerId = setInterval(() => {
|
||||
onUpdateRef.current?.();
|
||||
}, interval);
|
||||
} else {
|
||||
timerId && clearInterval(timerId);
|
||||
if (timerId) clearInterval(timerId);
|
||||
}
|
||||
return () => {
|
||||
timerId && clearInterval(timerId);
|
||||
if (timerId) clearInterval(timerId);
|
||||
};
|
||||
}, [interval, state]);
|
||||
return [state, { start, stop }];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
72
lib/webview/stationData.ts
Normal file
72
lib/webview/stationData.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 駅情報データ (みどりの窓口・ICカード対応状況)
|
||||
* Feature フィールドは WebView 内で JSON.parse() して使用する。
|
||||
*/
|
||||
export interface StationDataItem {
|
||||
StationName: string;
|
||||
StationNumber: string;
|
||||
/** JSON文字列: { Midori: { style: "normal"|"plus"|"none" }, IC: boolean } */
|
||||
Feature: string;
|
||||
}
|
||||
|
||||
export const STATION_DATA: StationDataItem[] = [
|
||||
// ── 予讃線 ─────────────────────────────
|
||||
{ StationName: "高松", StationNumber: "Y00", Feature: '{"Midori":{"style":"normal"},"IC":true}' },
|
||||
{ StationName: "香西", StationNumber: "Y01", Feature: '{"Midori":{"style":"none"},"IC":true}' },
|
||||
{ StationName: "鬼無", StationNumber: "Y02", Feature: '{"Midori":{"style":"none"},"IC":true}' },
|
||||
{ StationName: "端岡", StationNumber: "Y03", Feature: '{"Midori":{"style":"none"},"IC":true}' },
|
||||
{ StationName: "国分", StationNumber: "Y04", Feature: '{"Midori":{"style":"none"},"IC":true}' },
|
||||
{ StationName: "讃岐府中", StationNumber: "Y05", Feature: '{"Midori":{"style":"none"},"IC":true}' },
|
||||
{ StationName: "鴨川", StationNumber: "Y06", Feature: '{"Midori":{"style":"none"},"IC":true}' },
|
||||
{ StationName: "八十場", StationNumber: "Y07", Feature: '{"Midori":{"style":"none"},"IC":true}' },
|
||||
{ StationName: "坂出", StationNumber: "Y08", Feature: '{"Midori":{"style":"normal"},"IC":true}' },
|
||||
{ StationName: "宇多津", StationNumber: "Y09", Feature: '{"Midori":{"style":"normal"},"IC":true}' },
|
||||
{ StationName: "丸亀", StationNumber: "Y10", Feature: '{"Midori":{"style":"normal"},"IC":true}' },
|
||||
{ StationName: "讃岐塩屋", StationNumber: "Y11", Feature: '{"Midori":{"style":"none"},"IC":true}' },
|
||||
{ StationName: "多度津", StationNumber: "Y12", Feature: '{"Midori":{"style":"normal"},"IC":true}' },
|
||||
{ StationName: "詫間", StationNumber: "Y14", Feature: '{"Midori":{"style":"plus"},"IC":true}' },
|
||||
{ StationName: "観音寺", StationNumber: "Y19", Feature: '{"Midori":{"style":"normal"},"IC":true}' },
|
||||
{ StationName: "川之江", StationNumber: "Y22", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "伊予三島", StationNumber: "Y23", Feature: '{"Midori":{"style":"normal"},"IC":false}' },
|
||||
{ StationName: "新居浜", StationNumber: "Y29", Feature: '{"Midori":{"style":"normal"},"IC":false}' },
|
||||
{ StationName: "伊予西条", StationNumber: "Y31", Feature: '{"Midori":{"style":"normal"},"IC":false}' },
|
||||
{ StationName: "壬生川", StationNumber: "Y36", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "今治", StationNumber: "Y40", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "伊予北条", StationNumber: "Y48", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "松山", StationNumber: "Y55", Feature: '{"Midori":{"style":"normal"},"IC":false}' },
|
||||
// ── 内子線・海線 ─────────────────────────
|
||||
{ StationName: "内子", StationNumber: "U10", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "伊予大洲", StationNumber: "U14", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "伊予大洲", StationNumber: "S18", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "八幡浜", StationNumber: "U18", Feature: '{"Midori":{"style":"normal"},"IC":false}' },
|
||||
{ StationName: "宇和島", StationNumber: "U28", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
// ── 土讃線 ─────────────────────────────
|
||||
{ StationName: "多度津", StationNumber: "D12", Feature: '{"Midori":{"style":"normal"},"IC":true}' },
|
||||
{ StationName: "善通寺", StationNumber: "D14", Feature: '{"Midori":{"style":"plus"},"IC":true}' },
|
||||
{ StationName: "琴平", StationNumber: "D15", Feature: '{"Midori":{"style":"plus"},"IC":true}' },
|
||||
{ StationName: "阿波池田", StationNumber: "D22", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "土佐山田", StationNumber: "D37", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "後免", StationNumber: "D40", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "高知", StationNumber: "D45", Feature: '{"Midori":{"style":"normal"},"IC":false}' },
|
||||
// ── 予土線 ─────────────────────────────
|
||||
{ StationName: "高知", StationNumber: "K00", Feature: '{"Midori":{"style":"normal"},"IC":false}' },
|
||||
{ StationName: "朝倉", StationNumber: "K05", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "須崎", StationNumber: "K19", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "窪川", StationNumber: "K26", Feature: '{"Midori":{"style":"normal"},"IC":false}' },
|
||||
// ── 高徳線 ─────────────────────────────
|
||||
{ StationName: "高松", StationNumber: "T28", Feature: '{"Midori":{"style":"normal"},"IC":true}' },
|
||||
{ StationName: "栗林公園北口", StationNumber: "T26", Feature: '{"Midori":{"style":"none"},"IC":true}' },
|
||||
{ StationName: "栗林", StationNumber: "T25", Feature: '{"Midori":{"style":"plus"},"IC":true}' },
|
||||
{ StationName: "屋島", StationNumber: "T24", Feature: '{"Midori":{"style":"none"},"IC":true}' },
|
||||
{ StationName: "志度", StationNumber: "T19", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "三本松", StationNumber: "T12", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "徳島", StationNumber: "T00", Feature: '{"Midori":{"style":"normal"},"IC":false}' },
|
||||
// ── 鳴門線 ─────────────────────────────
|
||||
{ StationName: "鳴門", StationNumber: "N10", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
// ── 牟岐線 ─────────────────────────────
|
||||
{ StationName: "阿南", StationNumber: "", Feature: '{"Midori":{"style":"normal"},"IC":false}' },
|
||||
{ StationName: "牟岐", StationNumber: "", Feature: '{"Midori":{"style":"normal"},"IC":false}' },
|
||||
// ── 徳島線 ─────────────────────────────
|
||||
{ StationName: "鴨島", StationNumber: "B09", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
{ StationName: "穴吹", StationNumber: "B16", Feature: '{"Midori":{"style":"plus"},"IC":false}' },
|
||||
];
|
||||
455
lib/webview/trainIconMap.ts
Normal file
455
lib/webview/trainIconMap.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* 列番号 → 列車画像URL のマッピングデータ
|
||||
*
|
||||
* "__anpanman__" は動的URLのセンチネル値。
|
||||
* WebView inject 側で アンパンマン列車判定 URL に変換される。
|
||||
* (https://n8n.haruk.in/webhook/anpanman-pictures.png?trainNum=<列番>)
|
||||
*/
|
||||
|
||||
const AP = "__anpanman__";
|
||||
|
||||
/** 完全一致マッピング (列番 → URL | "__anpanman__") */
|
||||
export const TRAIN_ICON_MAP: Record<string, string> = {
|
||||
// ── しおかぜ ───────────────────────────────────────
|
||||
// 8000 ノーマル
|
||||
"2M": "https://storage.haruk.in/s8000nr.png",
|
||||
"4M": "https://storage.haruk.in/s8000nr.png",
|
||||
"6M": "https://storage.haruk.in/s8000nr.png",
|
||||
"14M": "https://storage.haruk.in/s8000nr.png",
|
||||
"16M": "https://storage.haruk.in/s8000nr.png",
|
||||
"18M": "https://storage.haruk.in/s8000nr.png",
|
||||
"26M": "https://storage.haruk.in/s8000nr.png",
|
||||
"28M": "https://storage.haruk.in/s8000nr.png",
|
||||
"30M": "https://storage.haruk.in/s8000nr.png",
|
||||
"1M": "https://storage.haruk.in/s8000nr.png",
|
||||
"3M": "https://storage.haruk.in/s8000nr.png",
|
||||
"5M": "https://storage.haruk.in/s8000nr.png",
|
||||
"13M": "https://storage.haruk.in/s8000nr.png",
|
||||
"15M": "https://storage.haruk.in/s8000nr.png",
|
||||
"17M": "https://storage.haruk.in/s8000nr.png",
|
||||
"25M": "https://storage.haruk.in/s8000nr.png",
|
||||
"27M": "https://storage.haruk.in/s8000nr.png",
|
||||
"29M": "https://storage.haruk.in/s8000nr.png",
|
||||
// 8000 アンパン
|
||||
"10M": AP,
|
||||
"22M": AP,
|
||||
"9M": AP,
|
||||
"21M": AP,
|
||||
// 8600
|
||||
"8M": "https://storage.haruk.in/s8600.png",
|
||||
"12M": "https://storage.haruk.in/s8600.png",
|
||||
"20M": "https://storage.haruk.in/s8600.png",
|
||||
"24M": "https://storage.haruk.in/s8600.png",
|
||||
"7M": "https://storage.haruk.in/s8600.png",
|
||||
"11M": "https://storage.haruk.in/s8600.png",
|
||||
"19M": "https://storage.haruk.in/s8600.png",
|
||||
"23M": "https://storage.haruk.in/s8600.png",
|
||||
|
||||
// ── いしづち ───────────────────────────────────────
|
||||
// 8000 ノーマル
|
||||
"1004M": "https://storage.haruk.in/s8000no.png",
|
||||
"1006M": "https://storage.haruk.in/s8000no.png",
|
||||
"1014M": "https://storage.haruk.in/s8000no.png",
|
||||
"1016M": "https://storage.haruk.in/s8000no.png",
|
||||
"1018M": "https://storage.haruk.in/s8000no.png",
|
||||
"1026M": "https://storage.haruk.in/s8000no.png",
|
||||
"1028M": "https://storage.haruk.in/s8000no.png",
|
||||
"1030M": "https://storage.haruk.in/s8000no.png",
|
||||
"1001M": "https://storage.haruk.in/s8000no.png",
|
||||
"1003M": "https://storage.haruk.in/s8000no.png",
|
||||
"1005M": "https://storage.haruk.in/s8000no.png",
|
||||
"1013M": "https://storage.haruk.in/s8000no.png",
|
||||
"1015M": "https://storage.haruk.in/s8000no.png",
|
||||
"1017M": "https://storage.haruk.in/s8000no.png",
|
||||
"1025M": "https://storage.haruk.in/s8000no.png",
|
||||
"1027M": "https://storage.haruk.in/s8000no.png",
|
||||
"1029M": "https://storage.haruk.in/s8000no.png",
|
||||
// 8000 アンパン
|
||||
"1010M": AP,
|
||||
"1022M": AP,
|
||||
"1009M": AP,
|
||||
"1021M": AP,
|
||||
// 8600
|
||||
"1008M": "https://storage.haruk.in/s8600_isz.png",
|
||||
"1012M": "https://storage.haruk.in/s8600_isz.png",
|
||||
"1020M": "https://storage.haruk.in/s8600_isz.png",
|
||||
"1024M": "https://storage.haruk.in/s8600_isz.png",
|
||||
"1007M": "https://storage.haruk.in/s8600_isz.png",
|
||||
"1011M": "https://storage.haruk.in/s8600_isz.png",
|
||||
"1019M": "https://storage.haruk.in/s8600_isz.png",
|
||||
"1023M": "https://storage.haruk.in/s8600_isz.png",
|
||||
// MEXP
|
||||
"1092M": "https://storage.haruk.in/s8000nr.png",
|
||||
"1091M": "https://storage.haruk.in/s8600_isz.png",
|
||||
// 三桁いしづち アンパン
|
||||
"1041M": AP,
|
||||
"1044M": AP,
|
||||
// 三桁いしづち 8600
|
||||
"1043M": "https://storage.haruk.in/s8600_isz.png",
|
||||
"1042M": "https://storage.haruk.in/s8600_isz.png",
|
||||
"1046M": "https://storage.haruk.in/s8600_isz.png",
|
||||
|
||||
// ── 南風 ───────────────────────────────────────────
|
||||
// 2700 ノーマル
|
||||
"34D": "https://storage.haruk.in/s2700.png",
|
||||
"38D": "https://storage.haruk.in/s2700.png",
|
||||
"40D": "https://storage.haruk.in/s2700.png",
|
||||
"42D": "https://storage.haruk.in/s2700.png",
|
||||
"46D": "https://storage.haruk.in/s2700.png",
|
||||
"50D": "https://storage.haruk.in/s2700.png",
|
||||
"52D": "https://storage.haruk.in/s2700.png",
|
||||
"54D": "https://storage.haruk.in/s2700.png",
|
||||
"58D": "https://storage.haruk.in/s2700.png",
|
||||
"31D": "https://storage.haruk.in/s2700.png",
|
||||
"35D": "https://storage.haruk.in/s2700.png",
|
||||
"39D": "https://storage.haruk.in/s2700.png",
|
||||
"41D": "https://storage.haruk.in/s2700.png",
|
||||
"43D": "https://storage.haruk.in/s2700.png",
|
||||
"47D": "https://storage.haruk.in/s2700.png",
|
||||
"51D": "https://storage.haruk.in/s2700.png",
|
||||
"53D": "https://storage.haruk.in/s2700.png",
|
||||
"55D": "https://storage.haruk.in/s2700.png",
|
||||
// 2700 アンパン
|
||||
"32D": AP,
|
||||
"36D": AP,
|
||||
"44D": AP,
|
||||
"48D": AP,
|
||||
"56D": AP,
|
||||
"33D": AP,
|
||||
"37D": AP,
|
||||
"45D": AP,
|
||||
"49D": AP,
|
||||
"57D": AP,
|
||||
|
||||
// ── うずしお ───────────────────────────────────────
|
||||
// 2700
|
||||
"3004D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3006D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3010D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3014D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3016D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3022D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3028D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3003D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3007D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3013D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3019D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3025D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3031D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3008D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3020D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3026D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3001D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3005D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3011D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3017D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3023D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
"3029D": "https://storage.haruk.in/s2700_uzu.png",
|
||||
// 2600
|
||||
"3002D": AP,
|
||||
"3012D": AP,
|
||||
"3018D": AP,
|
||||
"3024D": AP,
|
||||
"3030D": AP,
|
||||
"3009D": AP,
|
||||
"3015D": AP,
|
||||
"3021D": AP,
|
||||
"3027D": AP,
|
||||
"3033D": AP,
|
||||
|
||||
// ── マリンライナー ─────────────────────────────────
|
||||
"3104M": "https://storage.haruk.in/s5001.png",
|
||||
"3106M": "https://storage.haruk.in/s5001.png",
|
||||
"3108M": "https://storage.haruk.in/s5001.png",
|
||||
"3110M": "https://storage.haruk.in/s5001.png",
|
||||
"3112M": "https://storage.haruk.in/s5001.png",
|
||||
"3114M": "https://storage.haruk.in/s5001.png",
|
||||
"3116M": "https://storage.haruk.in/s5001.png",
|
||||
"3118M": "https://storage.haruk.in/s5001.png",
|
||||
"3120M": "https://storage.haruk.in/s5001.png",
|
||||
"3122M": "https://storage.haruk.in/s5001.png",
|
||||
"3124M": "https://storage.haruk.in/s5001.png",
|
||||
"3126M": "https://storage.haruk.in/s5001.png",
|
||||
"3128M": "https://storage.haruk.in/s5001.png",
|
||||
"3130M": "https://storage.haruk.in/s5001.png",
|
||||
"3132M": "https://storage.haruk.in/s5001.png",
|
||||
"3134M": "https://storage.haruk.in/s5001.png",
|
||||
"3136M": "https://storage.haruk.in/s5001.png",
|
||||
"3138M": "https://storage.haruk.in/s5001.png",
|
||||
"3140M": "https://storage.haruk.in/s5001.png",
|
||||
"3142M": "https://storage.haruk.in/s5001.png",
|
||||
"3144M": "https://storage.haruk.in/s5001.png",
|
||||
"3146M": "https://storage.haruk.in/s5001.png",
|
||||
"3148M": "https://storage.haruk.in/s5001.png",
|
||||
"3150M": "https://storage.haruk.in/s5001.png",
|
||||
"3152M": "https://storage.haruk.in/s5001.png",
|
||||
"3154M": "https://storage.haruk.in/s5001.png",
|
||||
"3156M": "https://storage.haruk.in/s5001.png",
|
||||
"3158M": "https://storage.haruk.in/s5001.png",
|
||||
"3160M": "https://storage.haruk.in/s5001.png",
|
||||
"3162M": "https://storage.haruk.in/s5001.png",
|
||||
"3164M": "https://storage.haruk.in/s5001.png",
|
||||
"3166M": "https://storage.haruk.in/s5001.png",
|
||||
"3168M": "https://storage.haruk.in/s5001.png",
|
||||
"3170M": "https://storage.haruk.in/s5001.png",
|
||||
"3105M": "https://storage.haruk.in/s5001.png",
|
||||
"3107M": "https://storage.haruk.in/s5001.png",
|
||||
"3109M": "https://storage.haruk.in/s5001.png",
|
||||
"3111M": "https://storage.haruk.in/s5001.png",
|
||||
"3113M": "https://storage.haruk.in/s5001.png",
|
||||
"3115M": "https://storage.haruk.in/s5001.png",
|
||||
"3117M": "https://storage.haruk.in/s5001.png",
|
||||
"3119M": "https://storage.haruk.in/s5001.png",
|
||||
"3121M": "https://storage.haruk.in/s5001.png",
|
||||
"3123M": "https://storage.haruk.in/s5001.png",
|
||||
"3125M": "https://storage.haruk.in/s5001.png",
|
||||
"3127M": "https://storage.haruk.in/s5001.png",
|
||||
"3129M": "https://storage.haruk.in/s5001.png",
|
||||
"3131M": "https://storage.haruk.in/s5001.png",
|
||||
"3133M": "https://storage.haruk.in/s5001.png",
|
||||
"3135M": "https://storage.haruk.in/s5001.png",
|
||||
"3137M": "https://storage.haruk.in/s5001.png",
|
||||
"3139M": "https://storage.haruk.in/s5001.png",
|
||||
"3141M": "https://storage.haruk.in/s5001.png",
|
||||
"3143M": "https://storage.haruk.in/s5001.png",
|
||||
"3145M": "https://storage.haruk.in/s5001.png",
|
||||
"3147M": "https://storage.haruk.in/s5001.png",
|
||||
"3149M": "https://storage.haruk.in/s5001.png",
|
||||
"3151M": "https://storage.haruk.in/s5001.png",
|
||||
"3153M": "https://storage.haruk.in/s5001.png",
|
||||
"3155M": "https://storage.haruk.in/s5001.png",
|
||||
"3157M": "https://storage.haruk.in/s5001.png",
|
||||
"3159M": "https://storage.haruk.in/s5001.png",
|
||||
"3161M": "https://storage.haruk.in/s5001.png",
|
||||
"3163M": "https://storage.haruk.in/s5001.png",
|
||||
"3165M": "https://storage.haruk.in/s5001.png",
|
||||
"3167M": "https://storage.haruk.in/s5001.png",
|
||||
"3169M": "https://storage.haruk.in/s5001.png",
|
||||
"3175M": "https://storage.haruk.in/s5001.png",
|
||||
// マリンライナー(快速)
|
||||
"3102M": "https://storage.haruk.in/s5001k.png",
|
||||
"3101M": "https://storage.haruk.in/s5001k.png",
|
||||
"3103M": "https://storage.haruk.in/s5001k.png",
|
||||
"3171M": "https://storage.haruk.in/s5001k.png",
|
||||
"3173M": "https://storage.haruk.in/s5001k.png",
|
||||
|
||||
// ── サンライズ瀬戸 ─────────────────────────────────
|
||||
"5032M": "https://storage.haruk.in/w285.png",
|
||||
"5031M": "https://storage.haruk.in/w285.png",
|
||||
"8041M": "https://storage.haruk.in/w285.png",
|
||||
"8031M": "https://storage.haruk.in/w285.png",
|
||||
|
||||
// ── 宇和海 ─────────────────────────────────────────
|
||||
// 2000 ノーマル
|
||||
"1052D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1054D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1056D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1060D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1062D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1064D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1068D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1070D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1072D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1076D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1078D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1080D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1082D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1051D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1055D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1057D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1061D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1063D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1065D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1069D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1071D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1073D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1075D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1077D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1079D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
"1081D": "https://storage.haruk.in/s2000_uwa.png",
|
||||
// 2000 アンパン
|
||||
"1058D": AP,
|
||||
"1066D": AP,
|
||||
"1074D": AP,
|
||||
"1053D": AP,
|
||||
"1059D": AP,
|
||||
"1067D": AP,
|
||||
|
||||
// ── しまんと ───────────────────────────────────────
|
||||
"2002D": "https://storage.haruk.in/s2000_smn.png",
|
||||
"2004D": "https://storage.haruk.in/s2000_smn.png",
|
||||
"2001D": "https://storage.haruk.in/s2000_smn.png",
|
||||
"2003D": "https://storage.haruk.in/s2000_smn.png",
|
||||
|
||||
// ── あしずり ───────────────────────────────────────
|
||||
// 2000
|
||||
"2074D": "https://storage.haruk.in/s2000_smn.png",
|
||||
"2076D": "https://storage.haruk.in/s2000_smn.png",
|
||||
"2080D": "https://storage.haruk.in/s2000_smn.png",
|
||||
"2082D": "https://storage.haruk.in/s2000_smn.png",
|
||||
"2071D": "https://storage.haruk.in/s2000_smn.png",
|
||||
"2073D": "https://storage.haruk.in/s2000_smn.png",
|
||||
"2079D": "https://storage.haruk.in/s2000_smn.png",
|
||||
"2081D": "https://storage.haruk.in/s2000_smn.png",
|
||||
// 2700
|
||||
"2072D": "https://storage.haruk.in/s2700_asi.png",
|
||||
"2078D": "https://storage.haruk.in/s2700_asi.png",
|
||||
"2084D": "https://storage.haruk.in/s2700_asi.png",
|
||||
"2075D": "https://storage.haruk.in/s2700_asi.png",
|
||||
"2077D": "https://storage.haruk.in/s2700_asi.png",
|
||||
"2083D": "https://storage.haruk.in/s2700_asi.png",
|
||||
|
||||
// ── 剣山 ───────────────────────────────────────────
|
||||
"4002D": "https://storage.haruk.in/s185tu.png",
|
||||
"4004D": "https://storage.haruk.in/s185tu.png",
|
||||
"4006D": "https://storage.haruk.in/s185tu.png",
|
||||
"4001D": "https://storage.haruk.in/s185tu.png",
|
||||
"4003D": "https://storage.haruk.in/s185tu.png",
|
||||
"4005D": "https://storage.haruk.in/s185tu.png",
|
||||
"4007D": "https://storage.haruk.in/s185tu.png",
|
||||
|
||||
// ── よしのがわトロッコ ─────────────────────────────
|
||||
"8452D": "https://storage.haruk.in/s185to_ai.png",
|
||||
"8451D": "https://storage.haruk.in/s185to_ai.png",
|
||||
|
||||
// ── 岡山高松/琴平アントロ ──────────────────────────
|
||||
"8176D": "https://storage.haruk.in/s32to4.png",
|
||||
"8179D": "https://storage.haruk.in/s32to4.png",
|
||||
"8277D": "https://storage.haruk.in/s32to4.png",
|
||||
"8278D": "https://storage.haruk.in/s32to4.png",
|
||||
|
||||
// ── 千年ものがたり ─────────────────────────────────
|
||||
"8021D": "https://storage.haruk.in/s185mm1.png",
|
||||
"8022D": "https://storage.haruk.in/s185mm1.png",
|
||||
|
||||
// ── 夜明けものがたり ───────────────────────────────
|
||||
"8082D": "https://storage.haruk.in/s185ym1.png",
|
||||
"8083D": "https://storage.haruk.in/s185ym1.png",
|
||||
"8073D": "https://storage.haruk.in/s185ym1.png",
|
||||
"8074D": "https://storage.haruk.in/s185ym1.png",
|
||||
|
||||
// ── ラ・マルどこまでも ─────────────────────────────
|
||||
"9253M": "https://storage.haruk.in/w213w.png",
|
||||
"9256M": "https://storage.haruk.in/w213w.png",
|
||||
|
||||
// ── 貨物 ───────────────────────────────────────────
|
||||
"74": "https://storage.haruk.in/ef210a.png",
|
||||
"75": "https://storage.haruk.in/ef210a.png",
|
||||
"70": "https://storage.haruk.in/ef210a.png",
|
||||
"71": "https://storage.haruk.in/ef210a.png",
|
||||
"73": "https://storage.haruk.in/ef210a.png",
|
||||
"76": "https://storage.haruk.in/ef210a.png",
|
||||
"3070": "https://storage.haruk.in/ef210a.png",
|
||||
"3071": "https://storage.haruk.in/ef210a.png",
|
||||
"3072": "https://storage.haruk.in/ef210a.png",
|
||||
"3073": "https://storage.haruk.in/ef210a.png",
|
||||
"3076": "https://storage.haruk.in/ef210a.png",
|
||||
"3077": "https://storage.haruk.in/ef210a.png",
|
||||
"3078": "https://storage.haruk.in/ef210a.png",
|
||||
"3079": "https://storage.haruk.in/ef210a.png",
|
||||
"8070": "https://storage.haruk.in/ef210a.png",
|
||||
"8071": "https://storage.haruk.in/ef210a.png",
|
||||
"8072": "https://storage.haruk.in/ef210a.png",
|
||||
"8077": "https://storage.haruk.in/ef210a.png",
|
||||
|
||||
// ── 伊予灘ものがたり ───────────────────────────────
|
||||
"8091D": "https://storage.haruk.in/s185iyor.png",
|
||||
"8093D": "https://storage.haruk.in/s185iyor.png",
|
||||
"8092D": "https://storage.haruk.in/s185iyoy.png",
|
||||
"8094D": "https://storage.haruk.in/s185iyoy.png",
|
||||
|
||||
// ── 高徳線・徳島線・牟岐線・鳴門線 キハ40・47 ────
|
||||
"4303D": "https://storage.haruk.in/s40.png",
|
||||
"371D": "https://storage.haruk.in/s40.png",
|
||||
"316D": "https://storage.haruk.in/s40.png",
|
||||
"362D": "https://storage.haruk.in/s40.png",
|
||||
"4376D": "https://storage.haruk.in/s40.png",
|
||||
"951D": "https://storage.haruk.in/s40.png",
|
||||
"953D": "https://storage.haruk.in/s40.png",
|
||||
"955D": "https://storage.haruk.in/s40.png",
|
||||
"973D": "https://storage.haruk.in/s40.png",
|
||||
"975D": "https://storage.haruk.in/s40.png",
|
||||
"977D": "https://storage.haruk.in/s40.png",
|
||||
"979D": "https://storage.haruk.in/s40.png",
|
||||
"981D": "https://storage.haruk.in/s40.png",
|
||||
"950D": "https://storage.haruk.in/s40.png",
|
||||
"968D": "https://storage.haruk.in/s40.png",
|
||||
"970D": "https://storage.haruk.in/s40.png",
|
||||
"972D": "https://storage.haruk.in/s40.png",
|
||||
"974D": "https://storage.haruk.in/s40.png",
|
||||
"976D": "https://storage.haruk.in/s40.png",
|
||||
"980D": "https://storage.haruk.in/s40.png",
|
||||
"982D": "https://storage.haruk.in/s40.png",
|
||||
|
||||
// ── 1000形 ─────────────────────────────────────────
|
||||
"4311D": "https://storage.haruk.in/s1000.png",
|
||||
"363D": "https://storage.haruk.in/s1000.png",
|
||||
"356D": "https://storage.haruk.in/s1000.png",
|
||||
"4374D": "https://storage.haruk.in/s1000.png",
|
||||
"433D": "https://storage.haruk.in/s1000.png",
|
||||
"4447D": "https://storage.haruk.in/s1000.png",
|
||||
"451D": "https://storage.haruk.in/s1000.png",
|
||||
"450D": "https://storage.haruk.in/s1000.png",
|
||||
"4458D": "https://storage.haruk.in/s1000.png",
|
||||
"474D": "https://storage.haruk.in/s1000.png",
|
||||
|
||||
// ── 1200形 ─────────────────────────────────────────
|
||||
"4301D": "https://storage.haruk.in/s1200n.png",
|
||||
"4327D": "https://storage.haruk.in/s1200n.png",
|
||||
"4329D": "https://storage.haruk.in/s1200n.png",
|
||||
"4343D": "https://storage.haruk.in/s1200n.png",
|
||||
"353D": "https://storage.haruk.in/s1200n.png",
|
||||
"355D": "https://storage.haruk.in/s1200n.png",
|
||||
"367D": "https://storage.haruk.in/s1200n.png",
|
||||
"310D": "https://storage.haruk.in/s1200n.png",
|
||||
"4326D": "https://storage.haruk.in/s1200n.png",
|
||||
"4334D": "https://storage.haruk.in/s1200n.png",
|
||||
"4342D": "https://storage.haruk.in/s1200n.png",
|
||||
"358D": "https://storage.haruk.in/s1200n.png",
|
||||
"364D": "https://storage.haruk.in/s1200n.png",
|
||||
"4453D": "https://storage.haruk.in/s1200n.png",
|
||||
"4455D": "https://storage.haruk.in/s1200n.png",
|
||||
"4457D": "https://storage.haruk.in/s1200n.png",
|
||||
"463D": "https://storage.haruk.in/s1200n.png",
|
||||
"475D": "https://storage.haruk.in/s1200n.png",
|
||||
"477D": "https://storage.haruk.in/s1200n.png",
|
||||
"485D": "https://storage.haruk.in/s1200n.png",
|
||||
"4430D": "https://storage.haruk.in/s1200n.png",
|
||||
"434D": "https://storage.haruk.in/s1200n.png",
|
||||
"438D": "https://storage.haruk.in/s1200n.png",
|
||||
"4460D": "https://storage.haruk.in/s1200n.png",
|
||||
"4464D": "https://storage.haruk.in/s1200n.png",
|
||||
"4466D": "https://storage.haruk.in/s1200n.png",
|
||||
"478D": "https://storage.haruk.in/s1200n.png",
|
||||
"484D": "https://storage.haruk.in/s1200n.png",
|
||||
"957D": "https://storage.haruk.in/s1200n.png",
|
||||
"4959D": "https://storage.haruk.in/s1200n.png",
|
||||
"4963D": "https://storage.haruk.in/s1200n.png",
|
||||
"4967D": "https://storage.haruk.in/s1200n.png",
|
||||
"4971D": "https://storage.haruk.in/s1200n.png",
|
||||
"952D": "https://storage.haruk.in/s1200n.png",
|
||||
"4954D": "https://storage.haruk.in/s1200n.png",
|
||||
"4958D": "https://storage.haruk.in/s1200n.png",
|
||||
"4962D": "https://storage.haruk.in/s1200n.png",
|
||||
"4966D": "https://storage.haruk.in/s1200n.png",
|
||||
|
||||
// ── 半定期臨時 ─────────────────────────────────────
|
||||
"9174M": "https://storage.haruk.in/s5001.png",
|
||||
"9395D": "https://storage.haruk.in/s1500.png",
|
||||
};
|
||||
|
||||
/** 正規表現パターンマッチング (完全一致で未ヒットの場合に評価) */
|
||||
export const TRAIN_ICON_REGEX: Array<{ pattern: string; url: string }> = [
|
||||
// 高徳線 普通
|
||||
{ pattern: "^(4|5)3\\d\\dD$", url: "https://storage.haruk.in/s1500.png" },
|
||||
{ pattern: "^3\\d\\dD$", url: "https://storage.haruk.in/s1500.png" },
|
||||
// 徳島線 普通
|
||||
{ pattern: "^(4|5)4\\d\\dD$", url: "https://storage.haruk.in/s1500.png" },
|
||||
{ pattern: "^4\\d\\dD$", url: "https://storage.haruk.in/s1500.png" },
|
||||
// 鳴門線 普通
|
||||
{
|
||||
pattern: "^(4|5)9(5|6|7|8)\\dD$",
|
||||
url: "https://storage.haruk.in/s1500.png",
|
||||
},
|
||||
{ pattern: "^9(5|6|7|8)\\dD$", url: "https://storage.haruk.in/s1500.png" },
|
||||
];
|
||||
35
lib/webview/trainTypeConfig.ts
Normal file
35
lib/webview/trainTypeConfig.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 列車種別ごとの表示設定
|
||||
*
|
||||
* - typeColor : nameReplace で使う種別ラベルの文字色
|
||||
* - borderColor: setNewTrainItem で使う枠線色
|
||||
* - bgColor : setNewTrainItem で使う背景色
|
||||
* - label : 種別ラベル文字列
|
||||
* - isWanman : ワンマン列車かどうか
|
||||
*/
|
||||
export interface TrainTypeConfig {
|
||||
label: string;
|
||||
typeColor: string;
|
||||
borderColor: string;
|
||||
bgColor: string;
|
||||
isWanman: boolean;
|
||||
}
|
||||
|
||||
export const TRAIN_TYPE_CONFIG: Record<string, TrainTypeConfig> = {
|
||||
Normal: { label: "普通", typeColor: "black", borderColor: "black", bgColor: "#ffffffcc", isWanman: false },
|
||||
OneMan: { label: "普通", typeColor: "black", borderColor: "black", bgColor: "#ffffffcc", isWanman: true },
|
||||
Rapid: { label: "快速", typeColor: "rgba(0, 140, 255, 1)", borderColor: "rgba(0, 140, 255, 1)", bgColor: "#ffffffcc", isWanman: false },
|
||||
OneManRapid: { label: "快速", typeColor: "rgba(0, 140, 255, 1)", borderColor: "rgba(0, 140, 255, 1)", bgColor: "#ffffffcc", isWanman: true },
|
||||
LTDEXP: { label: "特急", typeColor: "red", borderColor: "red", bgColor: "#ffffffcc", isWanman: false },
|
||||
NightLTDEXP: { label: "寝台特急", typeColor: "#d300b0ff", borderColor: "#d300b0ff", bgColor: "#ffffffcc", isWanman: false },
|
||||
SPCL: { label: "臨時", typeColor: "#008d07ff", borderColor: "#008d07ff", bgColor: "#ffffffcc", isWanman: false },
|
||||
SPCL_Normal: { label: "臨時", typeColor: "#008d07ff", borderColor: "#008d07ff", bgColor: "#ffffffcc", isWanman: false },
|
||||
SPCL_Rapid: { label: "臨時快速", typeColor: "rgba(0, 81, 255, 1)", borderColor: "#0051ffff", bgColor: "#ffffffcc", isWanman: false },
|
||||
SPCL_EXP: { label: "臨時特急", typeColor: "#a52e2eff", borderColor: "#a52e2eff", bgColor: "#ffffffcc", isWanman: false },
|
||||
Party: { label: "団体臨時", typeColor: "#ff7300ff", borderColor: "#ff7300ff", bgColor: "#ffd0a9ff", isWanman: false },
|
||||
Freight: { label: "貨物", typeColor: "#00869ecc", borderColor: "#00869ecc", bgColor: "#c7c7c7cc", isWanman: false },
|
||||
Forwarding: { label: "回送", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc", isWanman: false },
|
||||
Trial: { label: "試運転", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc", isWanman: false },
|
||||
Construction: { label: "工事", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc", isWanman: false },
|
||||
FreightForwarding: { label: "単機回送", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc", isWanman: false },
|
||||
};
|
||||
137
menu.tsx
137
menu.tsx
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState, useEffect, useLayoutEffect, FC } from "react";
|
||||
import React, { useRef, useState, useEffect, useCallback, useMemo, FC } from "react";
|
||||
import { Platform, View, ScrollView, LayoutAnimation } from "react-native";
|
||||
import Constants from "expo-constants";
|
||||
import {
|
||||
@@ -26,6 +26,7 @@ import { STORAGE_KEYS } from "@/constants";
|
||||
import { lineList_LineWebID } from "@/lib/getStationList";
|
||||
import { StationProps } from "@/lib/CommonTypes";
|
||||
import { LocationObject } from "expo-location";
|
||||
import { StationSource } from "@/types";
|
||||
configureReanimatedLogger({
|
||||
level: ReanimatedLogLevel.error, // Set the log level to error
|
||||
strict: true, // Reanimated runs in strict mode by default
|
||||
@@ -43,15 +44,29 @@ export const Menu: FC<props> = (props) => {
|
||||
const { navigate } = useNavigation();
|
||||
const { favoriteStation } = useFavoriteStation();
|
||||
const { originalStationList, getStationDataFromNameBase } = useStationList();
|
||||
const [stationListMode, setStationListMode] = useState<
|
||||
"position" | "favorite"
|
||||
>("position");
|
||||
const [stationSource, _setStationSource] = useState<StationSource>({ type: "position" });
|
||||
// 検索モードを閉じたときに戻るソースを記憶
|
||||
const prevNonSearchTypeRef = useRef<"position" | "favorite">("position");
|
||||
|
||||
// stationSource を更新するラッパー(position/favorite に切り替えた際に戻り先を記録)
|
||||
const setStationSource = useCallback((source: StationSource) => {
|
||||
if (source.type !== "search") prevNonSearchTypeRef.current = source.type;
|
||||
_setStationSource(source);
|
||||
}, []);
|
||||
|
||||
// 検索モードを閉じて直前の position/favorite に戻る
|
||||
const closeSearch = useCallback(() => {
|
||||
_setStationSource({ type: prevNonSearchTypeRef.current });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
AS.getItem(STORAGE_KEYS.STATION_LIST_MODE)
|
||||
.then((res) => setStationListMode(res))
|
||||
.catch(() => {
|
||||
// AS.setItem(STORAGE_KEYS.STATION_LIST_MODE, "position");
|
||||
});
|
||||
.then((res) => {
|
||||
if (res === "position" || res === "favorite") {
|
||||
setStationSource({ type: res });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const mapsRef = useRef(null);
|
||||
const returnToTop = (bool = true) => {
|
||||
@@ -97,7 +112,7 @@ export const Menu: FC<props> = (props) => {
|
||||
useEffect(() => {
|
||||
if (!position) return () => {};
|
||||
makeCurrentStation(position);
|
||||
}, [position, stationListMode]);
|
||||
}, [position, stationSource.type]);
|
||||
|
||||
const makeCurrentStation = (location: LocationObject) => {
|
||||
if (!originalStationList) return () => {};
|
||||
@@ -171,66 +186,59 @@ export const Menu: FC<props> = (props) => {
|
||||
|
||||
const [listIndex, setListIndex] = useState(0);
|
||||
|
||||
const [listUpStation, setListUpStation] = useState<StationProps[][]>([]);
|
||||
const [isSearchMode, setisSearchMode] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
useLayoutEffect(() => {
|
||||
if (!!isSearchMode) {
|
||||
const returnData = [];
|
||||
if (!input || input == "") {
|
||||
// listUpStation を useMemo で派生(useLayoutEffect + setState を排除)
|
||||
// stationSource が変わるたびに再計算。input も query として統合済みなので
|
||||
// テキスト入力のたびに正しくリストが更新される(旧来のバグを修正)。
|
||||
const listUpStation = useMemo<StationProps[][]>(() => {
|
||||
if (stationSource.type === "search") {
|
||||
const { query, lineId } = stationSource;
|
||||
const returnData: StationProps[][] = [];
|
||||
// query も lineId も未入力の初期状態では空リストを返す
|
||||
if (!query && !lineId) return returnData;
|
||||
if (!query) {
|
||||
// lineId のみ指定:その路線の全駅
|
||||
Object.keys(lineList_LineWebID).forEach((d) => {
|
||||
originalStationList[d].forEach((D) => {
|
||||
if (
|
||||
isSearchMode &&
|
||||
isSearchMode != stationIDPair[lineList_LineWebID[d]]
|
||||
)
|
||||
return;
|
||||
const latlng = [D.lat, D.lng];
|
||||
if (latlng.length == 0) return null;
|
||||
if (D.StationNumber == undefined) {
|
||||
return null;
|
||||
}
|
||||
originalStationList[d]?.forEach((D) => {
|
||||
if (lineId !== stationIDPair[lineList_LineWebID[d]]) return;
|
||||
if (!D.StationNumber) return;
|
||||
returnData.push([D]);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
const hoge = getStationDataFromNameBase(input);
|
||||
hoge.forEach((d, index, array) => {
|
||||
const stationName = d.Station_JP;
|
||||
if (
|
||||
returnData.findIndex((d1) => d1[0].Station_JP == stationName) != -1
|
||||
)
|
||||
return;
|
||||
returnData.push(array.filter((d2) => d2.Station_JP == stationName));
|
||||
const found = getStationDataFromNameBase(query);
|
||||
found.forEach((d, _, array) => {
|
||||
const name = d.Station_JP;
|
||||
if (returnData.findIndex((r) => r[0].Station_JP === name) !== -1) return;
|
||||
returnData.push(array.filter((d2) => d2.Station_JP === name));
|
||||
});
|
||||
}
|
||||
if (JSON.stringify(returnData) == JSON.stringify(listUpStation)) return;
|
||||
setListUpStation(returnData);
|
||||
} else if (stationListMode == "position") {
|
||||
const returnData = nearPositionStation.filter((d) => d != undefined);
|
||||
if (JSON.stringify(returnData) == JSON.stringify(listUpStation)) return;
|
||||
setListUpStation(returnData);
|
||||
return returnData;
|
||||
} else if (stationSource.type === "position") {
|
||||
return nearPositionStation.filter((d) => d != undefined);
|
||||
} else {
|
||||
const returnData = favoriteStation.filter((d) => d != undefined);
|
||||
if (JSON.stringify(returnData) == JSON.stringify(listUpStation)) return;
|
||||
setListUpStation(returnData);
|
||||
return favoriteStation.filter((d) => d != undefined);
|
||||
}
|
||||
}, [nearPositionStation, favoriteStation, stationListMode, isSearchMode]);
|
||||
}, [stationSource, nearPositionStation, favoriteStation, originalStationList, getStationDataFromNameBase]);
|
||||
|
||||
// ソース種別が切り替わったら listIndex を即座にリセット(ループ防止)
|
||||
useEffect(() => {
|
||||
if (listUpStation.length == 0) {
|
||||
setListIndex(0);
|
||||
}, [stationSource.type]);
|
||||
|
||||
// listUpStation が縮小した場合、有効な範囲に1ステップでクランプ(漸減ループを防ぐ)
|
||||
useEffect(() => {
|
||||
if (listIndex < 0) return; // ソートモード中(未選択状態)は無視
|
||||
if (listUpStation.length === 0) {
|
||||
setListIndex(0);
|
||||
return;
|
||||
}
|
||||
if (listUpStation.length == 1) {
|
||||
setListIndex(0);
|
||||
return;
|
||||
}
|
||||
if (listUpStation[listIndex] == undefined) {
|
||||
const count = listIndex - 1;
|
||||
if (listIndex >= listUpStation.length) {
|
||||
setMapMode(false);
|
||||
setListIndex(count);
|
||||
setListIndex(listUpStation.length - 1);
|
||||
}
|
||||
}, [listIndex, listUpStation, isSearchMode]);
|
||||
// listIndex を依存に入れると再発火ループになるため除外
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [listUpStation]);
|
||||
useEffect(() => {
|
||||
if (originalStationList == undefined) return;
|
||||
if (listUpStation.length == 0) return;
|
||||
@@ -359,15 +367,12 @@ export const Menu: FC<props> = (props) => {
|
||||
{...{
|
||||
locationStatus,
|
||||
position,
|
||||
stationListMode,
|
||||
setStationListMode,
|
||||
stationSource,
|
||||
setStationSource,
|
||||
closeSearch,
|
||||
setSelectedCurrentStation: setListIndex,
|
||||
mapMode,
|
||||
setMapMode,
|
||||
isSearchMode,
|
||||
setisSearchMode,
|
||||
input,
|
||||
setInput,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -382,8 +387,7 @@ export const Menu: FC<props> = (props) => {
|
||||
setListIndex,
|
||||
listIndex,
|
||||
navigate,
|
||||
stationListMode,
|
||||
isSearchMode,
|
||||
stationSource,
|
||||
}}
|
||||
/>
|
||||
{listUpStation[listIndex] && (
|
||||
@@ -401,15 +405,12 @@ export const Menu: FC<props> = (props) => {
|
||||
{...{
|
||||
locationStatus,
|
||||
position,
|
||||
stationListMode,
|
||||
setStationListMode,
|
||||
stationSource,
|
||||
setStationSource,
|
||||
closeSearch,
|
||||
setSelectedCurrentStation: setListIndex,
|
||||
mapMode,
|
||||
setMapMode,
|
||||
isSearchMode,
|
||||
setisSearchMode,
|
||||
input,
|
||||
setInput,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
<manifest>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- NFC 使用権限 -->
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
<!-- NFC FeliCa (NFC-F) 対応を宣言(必須ではないが任意機能として記載) -->
|
||||
<uses-feature android:name="android.hardware.nfc" android:required="false" />
|
||||
</manifest>
|
||||
|
||||
@@ -5,25 +5,51 @@ import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import expo.modules.kotlin.Promise
|
||||
import android.nfc.NfcAdapter
|
||||
import android.nfc.Tag
|
||||
|
||||
class NfcReaderCallback(private val promise: Promise) : NfcAdapter.ReaderCallback {
|
||||
override fun onTagDiscovered(tag: Tag?) {
|
||||
val idmString = tag?.id?.joinToString("") { "%02x".format(it) }
|
||||
promise.resolve(idmString)
|
||||
}
|
||||
}
|
||||
import android.nfc.tech.NfcF
|
||||
import android.os.Bundle
|
||||
|
||||
class ExpoFelicaReaderModule : Module() {
|
||||
var nfcAdapter: NfcAdapter? = null
|
||||
private var nfcAdapter: NfcAdapter? = null
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("ExpoFelicaReader")
|
||||
|
||||
/**
|
||||
* FeliCa カードをスキャンして残高等を読み取る。
|
||||
* 戻り値: { idm: string, balance: number, systemCode: string }
|
||||
* - balance が -1 の場合は読み取り失敗(交通系以外のカードなど)
|
||||
*/
|
||||
AsyncFunction("scan") { promise: Promise ->
|
||||
nfcAdapter?.enableReaderMode(
|
||||
appContext.currentActivity,
|
||||
NfcReaderCallback(promise),
|
||||
NfcAdapter.FLAG_READER_NFC_F,
|
||||
null
|
||||
val activity = appContext.currentActivity
|
||||
if (activity == null) {
|
||||
promise.reject("NO_ACTIVITY", "アクティビティが取得できません", null)
|
||||
return@AsyncFunction
|
||||
}
|
||||
|
||||
val adapter = nfcAdapter
|
||||
if (adapter == null) {
|
||||
promise.reject("NFC_NOT_AVAILABLE", "このデバイスはNFCに対応していません", null)
|
||||
return@AsyncFunction
|
||||
}
|
||||
|
||||
adapter.enableReaderMode(
|
||||
activity,
|
||||
{ tag ->
|
||||
// ★ handleTag(NFC通信)を完了させてから disableReaderMode を呼ぶ。
|
||||
// 先に disableReaderMode を走らせると通信中に接続が切れる競合が起きる。
|
||||
// またコールバックスレッド内で直接呼ぶとデッドロックするため Thread で分離。
|
||||
try {
|
||||
handleTag(tag, promise)
|
||||
} finally {
|
||||
Thread {
|
||||
try {
|
||||
adapter.disableReaderMode(activity)
|
||||
} catch (_: Exception) {}
|
||||
}.start()
|
||||
}
|
||||
},
|
||||
NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
|
||||
Bundle()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,4 +57,164 @@ class ExpoFelicaReaderModule : Module() {
|
||||
nfcAdapter = NfcAdapter.getDefaultAdapter(appContext.reactContext)
|
||||
}
|
||||
}
|
||||
|
||||
// FeliCa タグを読み取って残高を解析する
|
||||
private fun handleTag(tag: Tag?, promise: Promise) {
|
||||
if (tag == null) {
|
||||
promise.reject("TAG_NULL", "タグが検出されませんでした", null)
|
||||
return
|
||||
}
|
||||
|
||||
val nfcF = NfcF.get(tag)
|
||||
if (nfcF == null) {
|
||||
promise.reject("NOT_FELICA", "FeliCa カードではありません", null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
nfcF.connect()
|
||||
// タイムアウトを 3000ms に設定(デフォルトが短くて失敗するケースがある)
|
||||
nfcF.timeout = 3000
|
||||
|
||||
val idm = tag.id
|
||||
val idmString = idm.joinToString("") { "%02x".format(it) }
|
||||
|
||||
// systemCode は connect() 前でも取得可能だが、接続後に読む
|
||||
val systemCodeBytes = nfcF.systemCode
|
||||
val systemCode = systemCodeBytes.joinToString("") { "%02x".format(it) }
|
||||
|
||||
// サービスコード 0x090F(交通系 IC カード残高)リトルエンディアン表現
|
||||
val balanceServiceCode = byteArrayOf(0x0F.toByte(), 0x09.toByte())
|
||||
val balanceResponse = nfcF.transceive(buildReadWithoutEncryptionCommand(idm, balanceServiceCode, listOf(0)))
|
||||
val balance = parseBalance(balanceResponse)
|
||||
|
||||
// 利用履歴読み取り(サービスコード 0x090D, 1 ブロックずつ最大 20 件)
|
||||
// 交通系 IC カードは複数ブロック一括読み取りに対応していない場合が多いため 1 件ずつ読む
|
||||
val historyServiceCode = byteArrayOf(0x0D.toByte(), 0x09.toByte())
|
||||
val history = mutableListOf<Map<String, Any>>()
|
||||
for (blockNum in 0 until 20) {
|
||||
try {
|
||||
val resp = nfcF.transceive(buildReadWithoutEncryptionCommand(idm, historyServiceCode, listOf(blockNum)))
|
||||
val blocks = parseHistoryResponse(resp)
|
||||
if (blocks.isEmpty()) break
|
||||
history.add(parseHistoryBlock(blocks[0]))
|
||||
} catch (_: Exception) {
|
||||
break // これ以上読めなければ終了
|
||||
}
|
||||
}
|
||||
|
||||
promise.resolve(
|
||||
mapOf(
|
||||
"idm" to idmString,
|
||||
"balance" to balance,
|
||||
"systemCode" to systemCode,
|
||||
"history" to history
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("READ_ERROR", e.message ?: "読み取りエラー", e)
|
||||
} finally {
|
||||
try { nfcF.close() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Without Encryption コマンドを構築する。
|
||||
* コマンド構造:
|
||||
* [Length(1), 0x06(1), IDm(8), NumServices(1), ServiceCode(2), NumBlocks(1), BlockList(2*n)]
|
||||
*/
|
||||
private fun buildReadWithoutEncryptionCommand(idm: ByteArray, serviceCode: ByteArray, blockNumbers: List<Int>): ByteArray {
|
||||
// ブロックリスト: 各ブロックを [0x80, ブロック番号] の 2 バイトで表現
|
||||
val blockList = blockNumbers.flatMap { listOf(0x80.toByte(), it.toByte()) }.toByteArray()
|
||||
val totalLen = 1 + 1 + 8 + 1 + serviceCode.size + 1 + blockList.size
|
||||
|
||||
return ByteArray(totalLen).also { cmd ->
|
||||
var i = 0
|
||||
cmd[i++] = totalLen.toByte()
|
||||
cmd[i++] = 0x06.toByte() // Read Without Encryption
|
||||
idm.forEach { cmd[i++] = it }
|
||||
cmd[i++] = 0x01.toByte() // サービス数
|
||||
serviceCode.forEach { cmd[i++] = it }
|
||||
cmd[i++] = blockNumbers.size.toByte() // ブロック数
|
||||
blockList.forEach { cmd[i++] = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Without Encryption レスポンスから残高を取り出す。
|
||||
* レスポンス構造:
|
||||
* [Length(1), 0x07(1), IDm(8), StatusFlag1(1), StatusFlag2(1), NumBlocks(1), BlockData(16*n)]
|
||||
* 交通系 IC カードの残高はブロック 0 のバイト 10(低位)-11(高位)(リトルエンディアン、単位:円)
|
||||
*/
|
||||
private fun parseBalance(response: ByteArray): Int {
|
||||
// 最低長チェック: ヘッダ(12) + NumBlocks(1) + ブロックデータ(16) = 29
|
||||
if (response.size < 29) return -1
|
||||
|
||||
val statusFlag1 = response[10].toInt() and 0xFF
|
||||
val statusFlag2 = response[11].toInt() and 0xFF
|
||||
if (statusFlag1 != 0x00 || statusFlag2 != 0x00) return -1
|
||||
|
||||
val numBlocks = response[12].toInt() and 0xFF
|
||||
if (numBlocks < 1 || response.size < 13 + 16) return -1
|
||||
|
||||
// ブロックデータ先頭 (インデックス 13)
|
||||
// 残高はブロック0のバイト10(低位)・11(高位)にリトルエンディアンで格納される
|
||||
val blockOffset = 13
|
||||
return (response[blockOffset + 11].toInt() and 0xFF shl 8) or
|
||||
(response[blockOffset + 10].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Without Encryption レスポンスから各ブロックデータ(16 バイト)のリストを取り出す。
|
||||
*/
|
||||
private fun parseHistoryResponse(response: ByteArray): List<ByteArray> {
|
||||
if (response.size < 13) return emptyList()
|
||||
val statusFlag1 = response[10].toInt() and 0xFF
|
||||
val statusFlag2 = response[11].toInt() and 0xFF
|
||||
if (statusFlag1 != 0x00 || statusFlag2 != 0x00) return emptyList()
|
||||
val numBlocks = response[12].toInt() and 0xFF
|
||||
return (0 until numBlocks).mapNotNull { i ->
|
||||
val offset = 13 + i * 16
|
||||
if (offset + 16 <= response.size) response.copyOfRange(offset, offset + 16) else null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 利用履歴ブロック(16 バイト)を Map に変換する。
|
||||
* FeliCa 交通系 IC カード利用履歴フォーマット:
|
||||
* [0] 端末種別, [1] 処理種別, [2-3] 処理番号,
|
||||
* [4-5] 日付(上位 7bit=年-2000, 次 4bit=月, 下 5bit=日),
|
||||
* [6] 入場時刻(30 分単位), [7] 出場時刻(30 分単位),
|
||||
* [8-9] 残高 LE, [10] 入場路線, [11] 入場駅, [12] 出場路線, [13] 出場駅, [14] 会社コード
|
||||
*/
|
||||
private fun parseHistoryBlock(block: ByteArray): Map<String, Any> {
|
||||
val terminalType = block[0].toInt() and 0xFF
|
||||
val processType = block[1].toInt() and 0xFF
|
||||
val processNumber = ((block[2].toInt() and 0xFF) shl 8) or (block[3].toInt() and 0xFF)
|
||||
val dateWord = ((block[4].toInt() and 0xFF) shl 8) or (block[5].toInt() and 0xFF)
|
||||
val year = 2000 + ((dateWord shr 9) and 0x7F)
|
||||
val month = (dateWord shr 5) and 0x0F
|
||||
val day = dateWord and 0x1F
|
||||
val entryMinutes = (block[6].toInt() and 0xFF) * 30
|
||||
val exitMinutes = (block[7].toInt() and 0xFF) * 30
|
||||
val balance = (block[8].toInt() and 0xFF) or ((block[9].toInt() and 0xFF) shl 8)
|
||||
return mapOf(
|
||||
"terminalType" to terminalType,
|
||||
"processType" to processType,
|
||||
"processNumber" to processNumber,
|
||||
"year" to year,
|
||||
"month" to month,
|
||||
"day" to day,
|
||||
"entryHour" to (entryMinutes / 60),
|
||||
"entryMinute" to (entryMinutes % 60),
|
||||
"exitHour" to (exitMinutes / 60),
|
||||
"exitMinute" to (exitMinutes % 60),
|
||||
"balance" to balance,
|
||||
"inLineCode" to (block[10].toInt() and 0xFF),
|
||||
"inStationCode" to (block[11].toInt() and 0xFF),
|
||||
"outLineCode" to (block[12].toInt() and 0xFF),
|
||||
"outStationCode" to (block[13].toInt() and 0xFF),
|
||||
"companyCode" to (block[14].toInt() and 0xFF)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,69 +2,194 @@ import ExpoModulesCore
|
||||
import CoreNFC
|
||||
|
||||
public class ExpoFelicaReaderModule: Module {
|
||||
var session: NfcSession?
|
||||
var semaphore: DispatchSemaphore?
|
||||
private var readerSession: FelicaReaderSession?
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoFelicaReader")
|
||||
|
||||
/**
|
||||
* FeliCa カードをスキャンして残高等を読み取る。
|
||||
* 戻り値: { idm: String, balance: Int, systemCode: String }
|
||||
* - balance が -1 の場合は読み取り失敗
|
||||
*/
|
||||
AsyncFunction("scan") { (promise: Promise) in
|
||||
session?.startSession()
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
self.semaphore?.wait()
|
||||
promise.resolve(self.session?.message)
|
||||
}
|
||||
}
|
||||
|
||||
OnCreate {
|
||||
semaphore = DispatchSemaphore(value: 0)
|
||||
session = NfcSession(semaphore: semaphore!)
|
||||
guard NFCTagReaderSession.readingAvailable else {
|
||||
promise.reject("NFC_NOT_AVAILABLE", "このデバイスは NFC に対応していません")
|
||||
return
|
||||
}
|
||||
self.readerSession = FelicaReaderSession(promise: promise)
|
||||
self.readerSession?.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NfcSession: NSObject, NFCTagReaderSessionDelegate {
|
||||
var session: NFCTagReaderSession?
|
||||
let semaphore: DispatchSemaphore
|
||||
var message: String?
|
||||
// MARK: - FeliCa NFC セッション管理
|
||||
|
||||
init (semaphore: DispatchSemaphore) {
|
||||
self.semaphore = semaphore
|
||||
class FelicaReaderSession: NSObject, NFCTagReaderSessionDelegate {
|
||||
private let promise: Promise
|
||||
private var session: NFCTagReaderSession?
|
||||
/// resolve/reject が一度だけ呼ばれるよう保護するフラグ
|
||||
private var isCompleted = false
|
||||
|
||||
init(promise: Promise) {
|
||||
self.promise = promise
|
||||
}
|
||||
|
||||
func start() {
|
||||
session = NFCTagReaderSession(pollingOption: [.iso18092], delegate: self, queue: nil)
|
||||
session?.alertMessage = "交通系 IC カード(Suica / ICOCA など)をかざしてください"
|
||||
session?.begin()
|
||||
}
|
||||
|
||||
// セッションがアクティブになったとき
|
||||
func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {}
|
||||
|
||||
// エラー / セッション終了
|
||||
func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) {
|
||||
// invalidate() 正常終了後もこのデリゲートが呼ばれるため、isCompleted で二重 reject を防ぐ
|
||||
guard !isCompleted else { return }
|
||||
guard let readerError = error as? NFCReaderError else { return }
|
||||
if readerError.code != .readerSessionInvalidationErrorFirstNDEFTagRead &&
|
||||
readerError.code != .readerSessionInvalidationErrorUserCanceled {
|
||||
isCompleted = true
|
||||
promise.reject("NFC_SESSION_ERROR", error.localizedDescription)
|
||||
}
|
||||
|
||||
func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {
|
||||
print("tagReaderSessionDidBecomeActive")
|
||||
}
|
||||
|
||||
// タグ検出
|
||||
func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
|
||||
guard let first = tags.first, case .feliCa(let felicaTag) = first else {
|
||||
session.invalidate(errorMessage: "FeliCa カードではありません")
|
||||
return
|
||||
}
|
||||
|
||||
func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
self.semaphore.signal()
|
||||
self.session = nil
|
||||
}
|
||||
|
||||
func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
|
||||
let tag = tags.first!
|
||||
session.connect(to: tag) { error in
|
||||
if nil != error {
|
||||
session.invalidate(errorMessage: "Error!")
|
||||
self.semaphore.signal()
|
||||
return
|
||||
}
|
||||
guard case .feliCa(let feliCaTag) = tag else {
|
||||
session.invalidate(errorMessage: "This is not FeliCa!")
|
||||
self.semaphore.signal()
|
||||
return
|
||||
}
|
||||
let idm = feliCaTag.currentIDm.map { String(format: "%.2hhx", $0) }.joined()
|
||||
self.message = idm
|
||||
session.alertMessage = "Success!"
|
||||
session.invalidate()
|
||||
self.semaphore.signal()
|
||||
|
||||
session.connect(to: first) { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let error = error {
|
||||
self.isCompleted = true
|
||||
session.invalidate(errorMessage: "接続に失敗しました")
|
||||
self.promise.reject("CONNECT_ERROR", error.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
let idm = felicaTag.currentIDm
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
|
||||
let systemCode = felicaTag.currentSystemCode
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
|
||||
// サービスコード 0x090F(交通系 IC カード残高)リトルエンディアン
|
||||
let balanceServiceCode = Data([0x0F, 0x09])
|
||||
|
||||
felicaTag.readWithoutEncryption(
|
||||
serviceCodeList: [balanceServiceCode],
|
||||
blockList: [Data([0x80, 0x00])]
|
||||
) { statusFlag1, statusFlag2, dataList, error in
|
||||
var balance = -1
|
||||
if error == nil, statusFlag1 == 0x00, statusFlag2 == 0x00,
|
||||
let block = dataList.first, block.count >= 12 {
|
||||
// バイト 10-11: 残高(リトルエンディアン 16 bit, 単位: 円)
|
||||
balance = Int(block[10]) | (Int(block[11]) << 8)
|
||||
}
|
||||
|
||||
// 利用履歴を 1 ブロックずつ再帰的に読み取る
|
||||
self.readHistory(felicaTag: felicaTag, blockNum: 0, accumulated: []) { history in
|
||||
self.isCompleted = true
|
||||
session.alertMessage = "読み取りが完了しました"
|
||||
session.invalidate()
|
||||
|
||||
self.promise.resolve([
|
||||
"idm": idm,
|
||||
"balance": balance,
|
||||
"systemCode": systemCode,
|
||||
"history": history
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startSession() {
|
||||
self.session = NFCTagReaderSession(pollingOption: [.iso14443, .iso15693, .iso18092], delegate: self, queue: nil)
|
||||
session?.alertMessage = "Touch your FeliCa!"
|
||||
session?.begin()
|
||||
}
|
||||
|
||||
// MARK: - 利用履歴読み取り
|
||||
|
||||
/// 1 ブロックずつ再帰的に読み取り、最大 20 件の履歴を返す。
|
||||
/// 交通系 IC カードは複数ブロック一括読み取りに対応していない場合が多いため 1 件ずつ読む。
|
||||
private func readHistory(
|
||||
felicaTag: NFCFeliCaTag,
|
||||
blockNum: Int,
|
||||
accumulated: [[String: Any]],
|
||||
completion: @escaping ([[String: Any]]) -> Void
|
||||
) {
|
||||
guard blockNum < 20 else {
|
||||
completion(accumulated)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let historyServiceCode = Data([0x0D, 0x09])
|
||||
|
||||
felicaTag.readWithoutEncryption(
|
||||
serviceCodeList: [historyServiceCode],
|
||||
blockList: [Data([0x80, UInt8(blockNum)])]
|
||||
) { statusFlag1, statusFlag2, dataList, error in
|
||||
guard error == nil, statusFlag1 == 0x00, statusFlag2 == 0x00,
|
||||
let block = dataList.first, let entry = self.parseHistoryBlock(block) else {
|
||||
// エラーまたは空ブロック → これ以上読めないので終了
|
||||
completion(accumulated)
|
||||
return
|
||||
}
|
||||
self.readHistory(
|
||||
felicaTag: felicaTag,
|
||||
blockNum: blockNum + 1,
|
||||
accumulated: accumulated + [entry],
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 利用履歴ブロック(16 バイト)を Dictionary に変換する。
|
||||
* FeliCa 交通系 IC カード利用履歴フォーマット:
|
||||
* [0] 端末種別, [1] 処理種別, [2-3] 処理番号,
|
||||
* [4-5] 日付(上位 7bit=年-2000, 次 4bit=月, 下 5bit=日),
|
||||
* [6] 入場時刻(30 分単位), [7] 出場時刻(30 分単位),
|
||||
* [8-9] 残高 LE, [10] 入場路線, [11] 入場駅, [12] 出場路線, [13] 出場駅, [14] 会社コード
|
||||
*/
|
||||
private func parseHistoryBlock(_ block: Data) -> [String: Any]? {
|
||||
guard block.count >= 16 else { return nil }
|
||||
|
||||
let terminalType = Int(block[0])
|
||||
let processType = Int(block[1])
|
||||
let processNumber = (Int(block[2]) << 8) | Int(block[3])
|
||||
|
||||
let dateWord = (Int(block[4]) << 8) | Int(block[5])
|
||||
let year = 2000 + ((dateWord >> 9) & 0x7F)
|
||||
let month = (dateWord >> 5) & 0x0F
|
||||
let day = dateWord & 0x1F
|
||||
|
||||
let entryMinutes = Int(block[6]) * 30
|
||||
let exitMinutes = Int(block[7]) * 30
|
||||
|
||||
let balance = Int(block[8]) | (Int(block[9]) << 8)
|
||||
|
||||
return [
|
||||
"terminalType" : terminalType,
|
||||
"processType" : processType,
|
||||
"processNumber" : processNumber,
|
||||
"year" : year,
|
||||
"month" : month,
|
||||
"day" : day,
|
||||
"entryHour" : entryMinutes / 60,
|
||||
"entryMinute" : entryMinutes % 60,
|
||||
"exitHour" : exitMinutes / 60,
|
||||
"exitMinute" : exitMinutes % 60,
|
||||
"balance" : balance,
|
||||
"inLineCode" : Int(block[10]),
|
||||
"inStationCode" : Int(block[11]),
|
||||
"outLineCode" : Int(block[12]),
|
||||
"outStationCode" : Int(block[13]),
|
||||
"companyCode" : Int(block[14])
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
10
modules/expo-felica-reader/package.json
Normal file
10
modules/expo-felica-reader/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "expo-felica-reader",
|
||||
"version": "0.2.0",
|
||||
"description": "Expo module for reading FeliCa (Suica/ICOCA/etc.) NFC cards",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,80 @@
|
||||
import ExpoFelicaReaderModule from "./ExpoFelicaReaderModule";
|
||||
|
||||
export async function scan(): Promise<string> {
|
||||
/**
|
||||
* 利用履歴の 1 件分
|
||||
*/
|
||||
export interface FelicaHistoryEntry {
|
||||
/** 端末種別コード */
|
||||
terminalType: number;
|
||||
/** 処理種別コード (0x01=乗車, 0x02=降車, 0x20=チャージ 等) */
|
||||
processType: number;
|
||||
/** 処理番号 */
|
||||
processNumber: number;
|
||||
/** 年 (例: 2024) */
|
||||
year: number;
|
||||
/** 月 (1-12) */
|
||||
month: number;
|
||||
/** 日 (1-31) */
|
||||
day: number;
|
||||
/** 入場時刻 - 時 */
|
||||
entryHour: number;
|
||||
/** 入場時刻 - 分 */
|
||||
entryMinute: number;
|
||||
/** 出場時刻 - 時 */
|
||||
exitHour: number;
|
||||
/** 出場時刻 - 分 */
|
||||
exitMinute: number;
|
||||
/** 取引後残高(円) */
|
||||
balance: number;
|
||||
/** 入場路線コード */
|
||||
inLineCode: number;
|
||||
/** 入場駅コード */
|
||||
inStationCode: number;
|
||||
/** 出場路線コード */
|
||||
outLineCode: number;
|
||||
/** 出場駅コード */
|
||||
outStationCode: number;
|
||||
/** 会社コード */
|
||||
companyCode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* FeliCa カードの読み取り結果
|
||||
*/
|
||||
export interface FelicaCardInfo {
|
||||
/** IDm (8バイトの製造ID / カードID) 例: "0123456789abcdef" */
|
||||
idm: string;
|
||||
/**
|
||||
* 残高(円)。
|
||||
* -1 の場合は読み取り失敗(交通系以外のカード、または残高領域のアクセス拒否)
|
||||
*/
|
||||
balance: number;
|
||||
/**
|
||||
* システムコード (例: "0003" = 交通系共通エリア, "88b4" = FeliCa Lite)
|
||||
* Android では NfcF の systemCode バイト列から取得。
|
||||
* iOS では currentSystemCode から取得。
|
||||
*/
|
||||
systemCode: string;
|
||||
/**
|
||||
* 利用履歴(最大 20 件、新しい順)。
|
||||
* 履歴サービスへのアクセスに失敗した場合は空配列。
|
||||
*/
|
||||
history: FelicaHistoryEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* FeliCa カード(Suica / ICOCA / PASMO / manaca / Toica / nimoca 等)を
|
||||
* スキャンして残高を読み取る。
|
||||
*
|
||||
* Android: NFC-F リーダーモードを有効化し、タグ検出後に
|
||||
* Read Without Encryption でサービスコード 0x008B のブロック 0 を読む。
|
||||
* iOS: CoreNFC の NFCTagReaderSession で FeliCa タグに接続し同様に読む。
|
||||
* ※ Info.plist に NFCReaderUsageDescription と com.apple.developer.nfc.readersession.formats
|
||||
* (felica) エンタイトルメントが必要。
|
||||
*
|
||||
* @returns FelicaCardInfo
|
||||
* @throws NFC 非対応デバイスやスキャンエラー時は reject される
|
||||
*/
|
||||
export async function scan(): Promise<FelicaCardInfo> {
|
||||
return await ExpoFelicaReaderModule.scan();
|
||||
}
|
||||
|
||||
10
package.json
10
package.json
@@ -2,8 +2,8 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"eject": "expo eject",
|
||||
"pushWeb": "npx expo export -p web && netlify deploy --dir dist --prod",
|
||||
"checkDiagram": "bash ./check.sh"
|
||||
@@ -24,10 +24,12 @@
|
||||
"dayjs": "^1.11.9",
|
||||
"expo": "^52.0.0",
|
||||
"expo-alternate-app-icons": "^1.3.0",
|
||||
"expo-build-properties": "~0.13.1",
|
||||
"expo-clipboard": "~7.0.1",
|
||||
"expo-constants": "~17.0.4",
|
||||
"expo-dev-client": "~5.0.9",
|
||||
"expo-device": "~7.0.2",
|
||||
"expo-felica-reader": "file:./modules/expo-felica-reader",
|
||||
"expo-font": "~13.0.3",
|
||||
"expo-haptics": "~14.0.1",
|
||||
"expo-intent-launcher": "~12.0.2",
|
||||
@@ -78,5 +80,7 @@
|
||||
"devDependencies": {
|
||||
"babel-preset-expo": "~12.0.0"
|
||||
},
|
||||
"private": true
|
||||
"private": true,
|
||||
"name": "jrshikoku",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
@@ -305,14 +305,6 @@ export const BusAndTrainDataProvider: FC<Props> = ({ children }) => {
|
||||
returnArray.push("3751D");
|
||||
break;
|
||||
}
|
||||
if (new RegExp(/^4[1-9]\d\d[DM]$/).test(trainNum)) {
|
||||
if (allTrainDiagram["5" + trainNum.substring(1)])
|
||||
returnArray.push("5" + trainNum.substring(1));
|
||||
}
|
||||
if (new RegExp(/^5[1-7]\d\d[DM]$/).test(trainNum)) {
|
||||
if (allTrainDiagram["4" + trainNum.substring(1)])
|
||||
returnArray.push("4" + trainNum.substring(1));
|
||||
}
|
||||
if (
|
||||
// 列番が4xxDまたは5xxDの場合はxxDの列番を検索
|
||||
new RegExp(/^4[1-9]\d\d[DM]$/).test(trainNum) ||
|
||||
|
||||
118
stateBox/useElesite.tsx
Normal file
118
stateBox/useElesite.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { AS } from "../storageControl";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
import { API_ENDPOINTS } from "@/constants";
|
||||
import type { ElesiteResponse, ElesiteData } from "@/types/unyohub";
|
||||
|
||||
type ElesiteHook = {
|
||||
/** えれサイト使用設定 */
|
||||
useElesite: boolean;
|
||||
/** えれサイトデータ */
|
||||
elesiteData: ElesiteResponse;
|
||||
/** 指定した列番の運用情報を文字列で取得 */
|
||||
getElesiteByTrainNumber: (trainNumber: string) => string | null;
|
||||
/** 指定した列番に紐づくエントリの配列を取得 */
|
||||
getElesiteEntriesByTrainNumber: (trainNumber: string) => ElesiteData[];
|
||||
/** えれサイト使用設定を更新 */
|
||||
setUseElesite: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const useElesite = (): ElesiteHook => {
|
||||
const [useElesite, setUseElesiteState] = useState(false);
|
||||
const [elesiteData, setElesiteData] = useState<ElesiteResponse>([]);
|
||||
|
||||
// 初期読み込み
|
||||
useEffect(() => {
|
||||
AS.getItem(STORAGE_KEYS.USE_ELESITE).then((value) => {
|
||||
setUseElesiteState(value === true || value === "true");
|
||||
});
|
||||
|
||||
AS.getItem(STORAGE_KEYS.ELESITE_DATA).then((value) => {
|
||||
if (value) {
|
||||
try {
|
||||
setElesiteData(JSON.parse(value as string));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse elesite data", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// データ更新処理
|
||||
useEffect(() => {
|
||||
if (!useElesite) return;
|
||||
|
||||
const fetchElesiteData = async () => {
|
||||
try {
|
||||
const cacheBuster = '?_=' + Date.now();
|
||||
const response = await fetch(API_ENDPOINTS.ELESITE_DATA + cacheBuster);
|
||||
const data = await response.json();
|
||||
setElesiteData(data);
|
||||
await AS.setItem(STORAGE_KEYS.ELESITE_DATA, JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch elesite data", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchElesiteData();
|
||||
|
||||
// 10分ごとにデータを更新
|
||||
const interval = setInterval(fetchElesiteData, 10 * 60 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [useElesite]);
|
||||
|
||||
// 列番から編成名を取得(formation_config.units 優先)
|
||||
const getElesiteByTrainNumber = (trainNumber: string): string | null => {
|
||||
if (!useElesite || elesiteData.length === 0) return null;
|
||||
|
||||
const results: string[] = [];
|
||||
|
||||
// 高松(left_station)側のユニットを先に表示
|
||||
// (heading_to === "left") === is_leading が true → 高松(left)端のユニット
|
||||
const sortedEntries = [...elesiteData].sort((a, b) => {
|
||||
const aNav = a.trains?.find(t => t.train_number === trainNumber)?.nav;
|
||||
const bNav = b.trains?.find(t => t.train_number === trainNumber)?.nav;
|
||||
const aIsLeft = (aNav?.heading_to === "left") === (aNav?.is_leading === true);
|
||||
const bIsLeft = (bNav?.heading_to === "left") === (bNav?.is_leading === true);
|
||||
if (aIsLeft === bIsLeft) return 0;
|
||||
return aIsLeft ? -1 : 1;
|
||||
});
|
||||
|
||||
for (const entry of sortedEntries) {
|
||||
if (!entry.trains) continue;
|
||||
const found = entry.trains.find(train => train.train_number === trainNumber);
|
||||
if (!found) continue;
|
||||
// units が1件以上ある場合のみ編成名を返す(空 units は報告なし扱い)
|
||||
const units = entry.formation_config?.units;
|
||||
const formText = units?.length
|
||||
? units.map(u => u.formation).join('+')
|
||||
: null;
|
||||
if (formText) results.push(formText);
|
||||
}
|
||||
|
||||
return results.length > 0 ? results.join(', ') : null;
|
||||
};
|
||||
|
||||
// 列番に紐づくエントリをすべて取得
|
||||
const getElesiteEntriesByTrainNumber = (trainNumber: string): ElesiteData[] => {
|
||||
if (!useElesite || elesiteData.length === 0) return [];
|
||||
return elesiteData.filter(
|
||||
(unyo) => unyo.trains?.some((t) => t.train_number === trainNumber)
|
||||
);
|
||||
};
|
||||
|
||||
// 設定を更新
|
||||
const setUseElesite = (value: boolean) => {
|
||||
setUseElesiteState(value);
|
||||
AS.setItem(STORAGE_KEYS.USE_ELESITE, value.toString());
|
||||
};
|
||||
|
||||
return {
|
||||
useElesite,
|
||||
elesiteData,
|
||||
getElesiteByTrainNumber,
|
||||
getElesiteEntriesByTrainNumber,
|
||||
setUseElesite,
|
||||
};
|
||||
};
|
||||
@@ -39,6 +39,8 @@ const initialState = {
|
||||
setTrainMenu: (e) => {},
|
||||
updatePermission: false,
|
||||
setUpdatePermission: (e) => {},
|
||||
/** 各情報ソースの利用権限 */
|
||||
dataSourcePermission: { unyohub: false } as { unyohub: boolean },
|
||||
injectJavascript: "",
|
||||
};
|
||||
|
||||
@@ -62,20 +64,23 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
const [stationMenu, setStationMenu] = useState<boolType>(undefined);
|
||||
const [LoadError, setLoadError] = useState(false);
|
||||
|
||||
//更新権限所有確認
|
||||
//更新権限所有確認・情報ソース別利用権限(将来ロールが増えたらここに足す)
|
||||
const [updatePermission, setUpdatePermission] = useState(false);
|
||||
const [dataSourcePermission, setDataSourcePermission] = useState<{ unyohub: boolean }>({ unyohub: false });
|
||||
useEffect(() => {
|
||||
if (!expoPushToken) return;
|
||||
fetch(
|
||||
"https://n8n.haruk.in/webhook/data-edit-permission?token=" + expoPushToken
|
||||
`https://jr-shikoku-backend-api-v1.haruk.in/check-permission?user_id=${expoPushToken}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (res.data == true) {
|
||||
setUpdatePermission(true);
|
||||
} else {
|
||||
setUpdatePermission(false);
|
||||
}
|
||||
});
|
||||
const role: string = res.permission ?? "";
|
||||
setUpdatePermission(role === "administrator");
|
||||
setDataSourcePermission({
|
||||
unyohub: role === "administrator" || role === "unyoHubEditor",
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [expoPushToken]);
|
||||
|
||||
//列車情報表示関連
|
||||
@@ -90,15 +95,23 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
|
||||
//GUIデザインベース
|
||||
const [uiSetting, setUiSetting] = useState("tokyo");
|
||||
|
||||
// 鉄道運用Hub使用設定
|
||||
const [useUnyohubSetting, setUseUnyohubSetting] = useState("false");
|
||||
|
||||
// えれサイト使用設定
|
||||
const [useEleSiteSetting, setUseEleSiteSetting] = useState("false");
|
||||
|
||||
//地図表示テキスト
|
||||
const injectJavascript = injectJavascriptData(
|
||||
const injectJavascript = injectJavascriptData({
|
||||
mapSwitch,
|
||||
iconSetting,
|
||||
stationMenu,
|
||||
trainMenu,
|
||||
uiSetting
|
||||
);
|
||||
uiSetting,
|
||||
useUnyohub: useUnyohubSetting,
|
||||
useElesite: useEleSiteSetting,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
//列車アイコンスイッチ
|
||||
@@ -111,6 +124,10 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
ASCore({ k: STORAGE_KEYS.TRAIN_SWITCH, s: setTrainMenu, d: "true", u: true });
|
||||
//GUIデザインベーススイッチ
|
||||
ASCore({ k: STORAGE_KEYS.UI_SETTING, s: setUiSetting, d: "tokyo", u: true });
|
||||
//鉄道運用Hubスイッチ
|
||||
ASCore({ k: STORAGE_KEYS.USE_UNYOHUB, s: setUseUnyohubSetting, d: "false", u: true });
|
||||
//えれサイトスイッチ
|
||||
ASCore({ k: STORAGE_KEYS.USE_ELESITE, s: setUseEleSiteSetting, d: "false", u: true });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -136,6 +153,7 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
setTrainMenu,
|
||||
updatePermission,
|
||||
setUpdatePermission,
|
||||
dataSourcePermission,
|
||||
injectJavascript,
|
||||
}}
|
||||
>
|
||||
|
||||
121
stateBox/useUnyohub.tsx
Normal file
121
stateBox/useUnyohub.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { AS } from "../storageControl";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
import { API_ENDPOINTS } from "@/constants";
|
||||
import type { UnyohubResponse, UnyohubData } from "@/types/unyohub";
|
||||
|
||||
type UnyohubHook = {
|
||||
/** 鉄道運用Hub使用設定 */
|
||||
useUnyohub: boolean;
|
||||
/** 鉄道運用Hubデータ */
|
||||
unyohubData: UnyohubResponse;
|
||||
/** 指定した列番の運用情報を文字列で取得(後方互換) */
|
||||
getUnyohubByTrainNumber: (trainNumber: string) => string | null;
|
||||
/** 指定した列番に紐づく UnyohubData の配列を取得 */
|
||||
getUnyohubEntriesByTrainNumber: (trainNumber: string) => UnyohubData[];
|
||||
/** 鉄道運用Hub使用設定を更新 */
|
||||
setUseUnyohub: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const useUnyohub = (): UnyohubHook => {
|
||||
const [useUnyohub, setUseUnyohubState] = useState(false);
|
||||
const [unyohubData, setUnyohubData] = useState<UnyohubResponse>([]);
|
||||
|
||||
// 初期読み込み
|
||||
useEffect(() => {
|
||||
AS.getItem(STORAGE_KEYS.USE_UNYOHUB).then((value) => {
|
||||
setUseUnyohubState(value === true || value === "true");
|
||||
});
|
||||
|
||||
AS.getItem(STORAGE_KEYS.UNYOHUB_DATA).then((value) => {
|
||||
if (value) {
|
||||
try {
|
||||
setUnyohubData(JSON.parse(value as string));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse unyohub data", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// データ更新処理
|
||||
useEffect(() => {
|
||||
if (!useUnyohub) return;
|
||||
|
||||
const fetchUnyohubData = async () => {
|
||||
try {
|
||||
// キャッシュバスティング用にタイムスタンプを追加
|
||||
const cacheBuster = '?_=' + Date.now();
|
||||
const response = await fetch(API_ENDPOINTS.UNYOHUB_DATA + cacheBuster);
|
||||
const data = await response.json();
|
||||
setUnyohubData(data);
|
||||
await AS.setItem(STORAGE_KEYS.UNYOHUB_DATA, JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch unyohub data", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUnyohubData();
|
||||
|
||||
// 10分ごとにデータを更新
|
||||
const interval = setInterval(fetchUnyohubData, 10 * 60 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [useUnyohub]);
|
||||
|
||||
// 列番から運用番号を取得(direction に基づいて正しい順序でソート)
|
||||
const getUnyohubByTrainNumber = (trainNumber: string): string | null => {
|
||||
if (!useUnyohub || unyohubData.length === 0) return null;
|
||||
|
||||
const foundUnyos: Array<{ formations: string; position_forward: number; position_rear: number; direction: string }> = [];
|
||||
|
||||
for (const unyo of unyohubData) {
|
||||
if (!unyo.trains) continue;
|
||||
const found = unyo.trains.find(train => train.train_number === trainNumber);
|
||||
if (found && unyo.formations && unyo.formations.trim() !== "") {
|
||||
foundUnyos.push({
|
||||
formations: unyo.formations,
|
||||
position_forward: found.position_forward,
|
||||
position_rear: found.position_rear,
|
||||
direction: found.direction,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (foundUnyos.length === 0) return null;
|
||||
|
||||
// outbound=pos_fwd昇順, inbound=pos_fwd降順
|
||||
const dir = foundUnyos[0].direction;
|
||||
foundUnyos.sort((a, b) => dir === "inbound"
|
||||
? b.position_forward - a.position_forward
|
||||
: a.position_forward - b.position_forward
|
||||
);
|
||||
|
||||
// 「編成番号(編成位置)」の形式で結合
|
||||
return foundUnyos
|
||||
.map(u => `${u.formations}(${u.position_forward}-${u.position_rear})`)
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
// 列番に紐づく UnyohubData エントリをすべて取得
|
||||
const getUnyohubEntriesByTrainNumber = (trainNumber: string): UnyohubData[] => {
|
||||
if (!useUnyohub || unyohubData.length === 0) return [];
|
||||
return unyohubData.filter(
|
||||
(unyo) => unyo.trains?.some((t) => t.train_number === trainNumber)
|
||||
);
|
||||
};
|
||||
|
||||
// 設定を更新
|
||||
const setUseUnyohub = (value: boolean) => {
|
||||
setUseUnyohubState(value);
|
||||
AS.setItem(STORAGE_KEYS.USE_UNYOHUB, value.toString());
|
||||
};
|
||||
|
||||
return {
|
||||
useUnyohub,
|
||||
unyohubData,
|
||||
getUnyohubByTrainNumber,
|
||||
getUnyohubEntriesByTrainNumber,
|
||||
setUseUnyohub,
|
||||
};
|
||||
};
|
||||
@@ -145,3 +145,18 @@ export type SeStringResult = [string, "normal" | "community"];
|
||||
* ナビゲーション関数の型
|
||||
*/
|
||||
export type NavigateFunction = (screen: string, params?: Record<string, any>) => void;
|
||||
|
||||
/**
|
||||
* 駅リストのソース(表示する駅の供給元を一元管理)
|
||||
* stationListMode + isSearchMode + input の 3 変数を 1 つのユニオン型に統合
|
||||
*/
|
||||
export type StationSource =
|
||||
| { type: "favorite" }
|
||||
| { type: "position" }
|
||||
| { type: "search"; query: string; lineId?: string };
|
||||
|
||||
/**
|
||||
* カルーセルの UI モード(ソート状態を一元管理)
|
||||
* isSortMode + isExitingSortMode の 2 bool を 3 値ユニオンに統合
|
||||
*/
|
||||
export type CarouselUIMode = "carousel" | "sort" | "sort-exiting";
|
||||
|
||||
83
types/unyohub.ts
Normal file
83
types/unyohub.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 鉄道運用Hub / えれサイト APIのデータ型定義
|
||||
*/
|
||||
|
||||
/** 鉄道運用Hub 列車エントリ型 */
|
||||
export type UnyohubTrain = {
|
||||
train_number: string;
|
||||
line_id: string;
|
||||
first_departure_time: string;
|
||||
final_arrival_time: string;
|
||||
starting_station: string;
|
||||
terminal_station: string;
|
||||
position_forward: number;
|
||||
position_rear: number;
|
||||
/** "inbound" | "outbound" */
|
||||
direction: string;
|
||||
};
|
||||
|
||||
/** えれサイト 列車エントリ型 */
|
||||
export type ElesiteTrain = {
|
||||
train_number: string;
|
||||
/** 行先駅名 */
|
||||
direction: string;
|
||||
nav: {
|
||||
/** "left" | "right" | "unknown" */
|
||||
heading_to: string;
|
||||
is_leading: boolean;
|
||||
};
|
||||
/** えれサイトの列車時刻表ページURL */
|
||||
timetable_url: string;
|
||||
};
|
||||
|
||||
/** 鉄道運用Hub APIデータ型 */
|
||||
export type UnyohubData = {
|
||||
operation_id?: string;
|
||||
formations: string;
|
||||
posts_count: number;
|
||||
from_beginner: boolean;
|
||||
trains: UnyohubTrain[];
|
||||
starting_location: string;
|
||||
starting_track: string;
|
||||
starting_time: string;
|
||||
terminal_location: string;
|
||||
terminal_track: string;
|
||||
ending_time: string;
|
||||
car_count: number;
|
||||
min_car_count: number;
|
||||
max_car_count: number;
|
||||
main_color: string;
|
||||
comment: string | null;
|
||||
};
|
||||
|
||||
export type UnyohubResponse = UnyohubData[];
|
||||
|
||||
/** えれサイト API形式 */
|
||||
export type ElesiteData = {
|
||||
operation_id?: string;
|
||||
formations: string;
|
||||
report_info?: {
|
||||
/** 最終投稿日時 (ISO 8601) */
|
||||
last_reported_at: string;
|
||||
/** 最終投稿時の列番 */
|
||||
reported_retsuban: string;
|
||||
/** 投稿件数 */
|
||||
total_reports: number;
|
||||
};
|
||||
formation_config: {
|
||||
/** 編成左端の駅名 */
|
||||
left_station: string;
|
||||
/** 編成右端の駅名 */
|
||||
right_station: string;
|
||||
/** ユニット情報 */
|
||||
units: Array<{ series: string; formation: string }>;
|
||||
};
|
||||
trains: ElesiteTrain[];
|
||||
posts_count: number;
|
||||
is_sashikae: boolean;
|
||||
starting_location: string;
|
||||
terminal_location: string;
|
||||
comment: string | null;
|
||||
};
|
||||
|
||||
export type ElesiteResponse = ElesiteData[];
|
||||
@@ -18,13 +18,13 @@ const SE_MAPPING: Record<string, SeStringResult> = {
|
||||
"頃編": ["頃", "community"],
|
||||
// 運休系
|
||||
"休編": ["運休", "community"], // 後方互換性のため残す
|
||||
"休発": ["出発(運休)", "normal"],
|
||||
"休着": ["到着(運休)", "normal"],
|
||||
"休発編": ["出発(運休)", "community"],
|
||||
"休着編": ["到着(運休)", "community"],
|
||||
"通休編": ["通過(運休)", "community"],
|
||||
"通発休編": ["出発(運休)", "community"],
|
||||
"通着休編": ["到着(運休)", "community"],
|
||||
"休発": ["出発", "normal"],
|
||||
"休着": ["到着", "normal"],
|
||||
"休発編": ["出発", "community"],
|
||||
"休着編": ["到着", "community"],
|
||||
"通休編": ["通過", "community"],
|
||||
"通発休編": ["出発", "community"],
|
||||
"通着休編": ["到着", "community"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
26
yarn.lock
26
yarn.lock
@@ -2424,6 +2424,16 @@ ajv@8.11.0:
|
||||
require-from-string "^2.0.2"
|
||||
uri-js "^4.2.2"
|
||||
|
||||
ajv@^8.11.0:
|
||||
version "8.18.0"
|
||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.18.0.tgz#8864186b6738d003eb3a933172bb3833e10cefbc"
|
||||
integrity sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==
|
||||
dependencies:
|
||||
fast-deep-equal "^3.1.3"
|
||||
fast-uri "^3.0.1"
|
||||
json-schema-traverse "^1.0.0"
|
||||
require-from-string "^2.0.2"
|
||||
|
||||
anser@^1.4.9:
|
||||
version "1.4.10"
|
||||
resolved "https://registry.yarnpkg.com/anser/-/anser-1.4.10.tgz#befa3eddf282684bd03b63dcda3927aef8c2e35b"
|
||||
@@ -3927,6 +3937,14 @@ expo-asset@~11.0.5:
|
||||
invariant "^2.2.4"
|
||||
md5-file "^3.2.3"
|
||||
|
||||
expo-build-properties@~0.13.1:
|
||||
version "0.13.3"
|
||||
resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-0.13.3.tgz#6b96d0486148fca6e74e62c7c502c0a9990931aa"
|
||||
integrity sha512-gw7AYP+YF50Gr912BedelRDTfR4GnUEn9p5s25g4nv0hTJGWpBZdCYR5/Oi2rmCHJXxBqhPjxzV7JRh72fntLg==
|
||||
dependencies:
|
||||
ajv "^8.11.0"
|
||||
semver "^7.6.0"
|
||||
|
||||
expo-clipboard@~7.0.1:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-7.0.1.tgz#31d61270e77a37d2a6b7ae9abf79e060497ef43b"
|
||||
@@ -3985,6 +4003,9 @@ expo-eas-client@~0.13.2:
|
||||
resolved "https://registry.yarnpkg.com/expo-eas-client/-/expo-eas-client-0.13.3.tgz#1535a99a224e360581c6253b0a1ea767e19815b8"
|
||||
integrity sha512-t+1F1tiDocSot8iSnrn/CjTUMvVvPV2DpafSVcticpbSzMGybEN7wcamO1t18fK7WxGXpZE9gxtd80qwv/LLqQ==
|
||||
|
||||
"expo-felica-reader@file:./modules/expo-felica-reader":
|
||||
version "0.2.0"
|
||||
|
||||
expo-file-system@~18.0.12:
|
||||
version "18.0.12"
|
||||
resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-18.0.12.tgz#6ceeeb0725f6c5faaf58112f18c073c2acfb3027"
|
||||
@@ -4205,6 +4226,11 @@ fast-loops@^1.1.3:
|
||||
resolved "https://registry.yarnpkg.com/fast-loops/-/fast-loops-1.1.4.tgz#61bc77d518c0af5073a638c6d9d5c7683f069ce2"
|
||||
integrity sha512-8dbd3XWoKCTms18ize6JmQF1SFnnfj5s0B7rRry22EofgMu7B6LKHVh+XfFqFGsqnbH54xgeO83PzpKI+ODhlg==
|
||||
|
||||
fast-uri@^3.0.1:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa"
|
||||
integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==
|
||||
|
||||
fastq@^1.6.0:
|
||||
version "1.19.1"
|
||||
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5"
|
||||
|
||||
Reference in New Issue
Block a user