121 Commits

Author SHA1 Message Date
harukin-expo-dev-env
7d454cf9d4 Merge commit '09c00202247c0c97f1d8c324c2cc49214eee1393' 2026-02-07 09:04:47 +00:00
harukin-expo-dev-env
699e3039b9 Merge commit '935b63f6cee9d84530a5e7f6e8f55bcc90f8a168' 2026-02-01 13:18:49 +00:00
harukin-expo-dev-env
5af3164f21 Merge commit 'ef81c1f4cdc325f66a55b9f06212190dbec5be24' 2025-12-31 16:08:13 +00:00
harukin-expo-dev-env
cd585b8206 Merge commit 'bb1ee2666e5c91819bc3330128a9636b5e2ad753' 2025-12-31 09:52:36 +00:00
harukin-expo-dev-env
927c73567a Merge commit '0bf345ff6ac492e66da91e71db0118adb58cb28a' 2025-12-20 10:14:56 +00:00
harukin-expo-dev-env
09170c6636 Merge commit '24f32335c48eda61d312eceaf6bdd1e442c63f60' 2025-12-12 19:27:28 +00:00
harukin-expo-dev-env
8705f69725 Merge commit '2975e9094e17963b726753d954ba347226af98e2' 2025-12-05 15:25:11 +00:00
harukin-expo-dev-env
9562e04ffe Merge commit '6f74f5dfa26369dc4ce15435eccb9af51b5842e3' 2025-11-06 01:41:13 +00:00
harukin-expo-dev-env
2ffb47b60c Merge commit '09f700fb667a9dc0b05488e05c3d9cd3c31ffaae' 2025-11-06 01:33:37 +00:00
harukin-expo-dev-env
1208a78831 Merge commit 'efd9b77cadfbff4205e17388e7b2a6cad637ba9f' 2025-09-29 18:43:34 +00:00
harukin-expo-dev-env
493ef92bd6 Merge commit 'dfaf5b05b93f8fdcddb17da2a4981faffc28e0ac' 2025-09-29 06:08:16 +00:00
harukin-expo-dev-env
a51ffe4a82 Merge commit 'fa1562f870e8f5fa26db931785dd297eeb834c56' 2025-09-28 19:50:05 +00:00
harukin-expo-dev-env
0ea25f1e97 Merge commit '19f9b58497df949ff20cbad00a344118ac5740cd' 2025-09-27 17:51:11 +00:00
harukin-expo-dev-env
16bd0fe192 Merge commit '59e7ba5290bd4f175c9dafbc0dd664b75417bf2f' 2025-09-25 03:00:42 +00:00
harukin-expo-dev-env
d75a495b19 Merge commit '0d9c1cdb186a2cecc2e0aec441d9af9ab6ff56a6' 2025-09-11 05:55:26 +00:00
harukin-expo-dev-env
a346a1477c Merge commit 'bc4cb450a3dfb0005d7e815019b6317953478a9f' 2025-09-09 16:16:57 +00:00
harukin-expo-dev-env
c93ca94a61 Merge commit '9b4c0735b0475659aa7fe1f688b8daef13b714f1' 2025-08-27 01:20:09 +00:00
harukin-expo-dev-env
4480c161d1 Merge commit '087f6c882968bdef782bfe2aa0005a67fe436c7c' 2025-08-24 11:30:06 +00:00
harukin-expo-dev-env
deb9b40949 Merge commit 'ddaad38ccc2d23121f7fb6e3678417e7ac9ed593' 2025-08-24 04:49:29 +00:00
harukin-expo-dev-env
fced009607 Merge commit 'eabb2499fa43ba6a5adb3ae71e18067d86d63e35' 2025-08-19 19:19:24 +00:00
harukin-expo-dev-env
0890c1b9ff Merge commit '7bbb5b972fdb2eb429e5f71d6dd30e279b47ad10' 2025-08-13 12:50:56 +00:00
harukin-expo-dev-env
4b3816940b Merge commit 'ff4eb2c95f72581771b09ea820e9413540684e7b' 2025-08-08 10:54:00 +00:00
harukin-expo-dev-env
9ea23d6eba Merge commit '3dbaa6bfbf2bf18d01464c5e367c95593250dc85' 2025-07-27 04:12:01 +00:00
harukin-expo-dev-env
31ea303b88 Merge commit '9b266c15f80301e0b7a47cfc702c52beec36f4c4' 2025-07-27 03:54:24 +00:00
harukin-expo-dev-env
7716cb516c Merge commit 'dc7cc555c990fd4a2cd5cf47089339c887eecc75' 2025-07-18 15:42:20 +00:00
harukin-expo-dev-env
70976c0554 Merge commit '8a7285bb20c76d6e99cafd96193957829906f9ac' 2025-07-08 16:44:25 +00:00
harukin-expo-dev-env
5616c7ed96 Merge commit '8212148fb298dc678b811aadf0344731b43e8cf7' 2025-05-04 12:56:37 +00:00
harukin-expo-dev-env
13580a57d4 Merge commit 'bd2248e1a943e574ccc44892122cd9da4df14b68' 2025-04-20 17:22:59 +00:00
harukin-expo-dev-env
c9481fb0c2 Merge commit 'b64138178c4ca99bfcbb4f115403a18674ce8136' 2025-04-14 19:10:10 +00:00
harukin-expo-dev-env
5cda45740c Merge commit '67f40b55c19ff2e94b1168865486bcde7d457cab' 2025-04-10 16:38:21 +00:00
harukin-expo-dev-env
84d1305796 Merge commit 'fdefbc82bd39c8cba29911273db2f96fa39c0e3f' 2025-04-10 15:55:13 +00:00
harukin-expo-dev-env
336e3510fa Merge commit 'af8f3333cd68ea507675ad02ec59ccf2f7959e00' 2025-03-30 02:52:30 +00:00
harukin-expo-dev-env
25780e1664 Merge commit '0a364021ce2fed98257e80cc780ac4020cecb9c1' 2025-03-25 07:27:06 +00:00
harukin-expo-dev-env
e6fab84393 Merge commit 'de2b94292dc97df6e290e91976d01cea24c98ced' 2025-03-22 12:33:34 +00:00
harukin-expo-dev-env
390acdeab7 Merge commit '8c75e06ac18e33588b5bf200d7fccf05d3c34c22' 2025-03-22 11:34:25 +00:00
harukin-expo-dev-env
ff7a5624e6 Merge commit '45500e7a4ae614debd93404339958465db88f71d' 2025-03-19 15:49:36 +00:00
harukin-expo-dev-env
9b79f224d3 Merge commit '2c5023568a0442679b476bd84651eca3fcc90ee1' 2025-03-16 05:56:14 +00:00
harukin-expo-dev-env
2f76e3776f Merge commit '8a48bc48e6eb82921fcda3a84e07db2eca51e614' 2025-03-15 04:23:16 +00:00
harukin-expo-dev-env
026da47d82 Merge commit '23fb2d715a461cb0ebfca1e1944757febd315fec' 2025-03-14 18:29:20 +00:00
harukin-expo-dev-env
29be052bf6 Merge commit 'ab883827e13b5c99556857395ac950ee00c53b83' 2025-03-11 05:26:18 +00:00
harukin-expo-dev-env
2fb7d97d33 Merge commit '31618aab496a7979a39e534d023cb69103b06721' 2025-03-10 15:51:25 +00:00
harukin-expo-dev-env
76ebf8055b Merge commit '26cf84705abd4c9a69ac56fcade483a373089bf8' 2025-03-06 03:52:42 +00:00
harukin-expo-dev-env
7b572cd657 Merge commit 'fa882223d49fec390177131371b99a3101976759' 2025-03-05 11:51:51 +00:00
harukin-expo-dev-env
61fa0f8484 Merge commit 'ede2884c4a7b8a508cfd9f4f7cc87da4efe3db08' 2025-03-04 16:17:50 +00:00
harukin-expo-dev-env
935aaf2610 Merge commit '098cae50533368eec9cfdb933900c503ae46e850' 2025-03-04 15:01:08 +00:00
harukin-expo-dev-env
8ec53d6e06 Merge commit '0aaf171477659e632a23aeb6af39e83d5acf70c2' 2025-02-09 03:21:16 +00:00
harukin-expo-dev-env
e8b1a21a3b Merge commit '4a01c529b47cd85420a4c1feeac87164730dee94' 2025-01-15 12:38:58 +00:00
harukin-expo-dev-env
e6b89842a3 Merge commit '2776f17681ac2bf76a70c268c4db8921c5888a4f' 2025-01-14 07:42:06 +00:00
harukin-expo-dev-env
1fc5220405 Merge commit '58d3eae1d7797a74febc752f4185337a6eb95dad' 2025-01-09 09:56:21 +00:00
harukin-expo-dev-env
5489406578 Merge commit 'a7c4f689bc0762a63b662901f943a62827b765eb' 2024-11-22 05:40:18 +00:00
harukin-expo-dev-env
2b217e98c7 Merge commit 'd8108e2c9c60a60ba7f933af12af996b3cc0491c' 2024-11-22 05:19:00 +00:00
harukin-expo-dev-env
2c2e61a2fa Merge commit '6d3e6623a0774cb489efa3b81335db319fad2b2d' 2024-10-31 12:27:16 +00:00
harukin-expo-dev-env
c222b303df Merge commit '1346909bb74c80bedf1841aefda66a67adb98443' 2024-09-30 13:00:12 +00:00
harukin-expo-dev-env
8f8c095ecd Merge commit 'ab2a18b562c5fe18087f3140449fc7764b969df6' 2024-09-28 20:45:33 +00:00
harukin-expo-dev-env
e31e84ff34 Merge commit '9b91c4a50e1df914f66c6c5b33007caf4b0c9c32' 2024-09-20 15:18:49 +00:00
harukin-expo-dev-env
2c2b355a3e Merge commit '63209ac88878f70d0896dca429277d09ef1bf492' 2024-09-16 16:34:11 +00:00
harukin-expo-dev-env
5461087ca0 Merge commit 'c9a90809c6b178d5b85d51d47521d2541bbed7f6' 2024-09-16 16:31:31 +00:00
harukin-expo-dev-env
57c7285b6e Merge commit '628d2a42c94801ea42c939dd702ebd448176e62f' 2024-09-16 16:10:05 +00:00
harukin-expo-dev-env
5c134c95cc Merge commit '801b3dc3b0dde2f8a46962cfbca1599fb31cb6a4' 2024-09-02 02:29:04 +00:00
harukin-expo-dev-env
8781653fe8 Merge commit '9b4b4bd0d6572d0d0ffdb35ec72559bf035d07a3' 2024-06-05 10:27:22 +00:00
harukin-expo-dev-env
67ccc37c17 Merge commit '35e05e92f50ebca5d4666b9a008915b7f437f86c' 2024-06-05 09:16:05 +00:00
harukin-expo-dev-env
92caab03f5 Merge commit '52c2da333a97234bb27a25baf8af8479d53c3422' 2024-06-03 12:40:26 +00:00
harukin-expo-dev-env
dd3a57b3ae Merge commit 'b9b983a177d37db717b551f8b18d5f6c87aa0e23' 2024-06-03 12:20:23 +00:00
harukin-expo-dev-env
8df32b9c1d Merge commit '7eea78027649dec4dd7492efd8edfb0e61df5eea' 2024-04-28 10:33:20 +00:00
harukin-expo-dev-env
4b901d5015 Merge commit '5d711d37550d288142e3e768a2a0f42a7279d434' 2024-04-18 03:32:42 +00:00
harukin-expo-dev-env
fc5c62685a Merge commit '85e2ad329d89e57be4c094067de48525fe7673e3' 2024-04-18 03:21:26 +00:00
harukin-expo-dev-env
fbc98b2ff7 Merge commit '05167c810a8b727ed6527dbb07c0b9c1de53c171' 2024-04-06 17:19:41 +00:00
harukin-expo-dev-env
3502043176 Merge commit '0efab93a1451818339bfbed97545e6782eafcb9c' 2024-04-06 17:19:32 +00:00
harukin-expo-dev-env
2d0ad8d59e Merge commit 'dd7da102c43348a5420a4b616509d37c8d03a65f' 2024-03-27 15:52:25 +00:00
harukin-expo-dev-env
b5172df7a9 Merge commit '548d5d3747e6491ae421a716e85f7d60be4e41e4' 2024-03-27 15:02:35 +00:00
harukin-expo-dev-env
740d414d2d Merge commit '7e59b8c7bd7d01b35b5b0d6c5be93baed222f660' 2024-03-26 12:59:20 +00:00
harukin-expo-dev-env
8db3e6c218 Merge commit '736f9a65e92257237f347bbe2d02fe88f0e409d6' 2024-03-26 12:54:31 +00:00
harukin-expo-dev-env
fb35b01d2e Merge commit '720b627011a108979ce07a103aff2d728e68cab0' 2024-03-18 12:50:34 +00:00
harukin-expo-dev-env
5fa6b1f73e Merge commit '481ca0158d93b90af0306ed3d54786d61d6281e2' 2024-03-18 04:17:25 +00:00
harukin-expo-dev-env
c16f7401ea Merge commit '5864e821120ec726c6e96fbc8edc6a7172d5bbd7' 2024-03-18 04:04:16 +00:00
harukin-expo-dev-env
f260c5d2dd Merge commit 'a769ccb9512d8c246be82cb1f16673b13b39d4e6' 2024-03-15 18:04:11 +00:00
harukin-expo-dev-env
80e9f1a869 Merge commit 'd8ce2a7f8aac23e1001bbe16690d37c57d1c704f' 2024-03-13 13:27:05 +00:00
harukin-expo-dev-env
26096ba244 Merge commit '5806e2a259e03182d082fe454f4a1932ad9c5e53' 2024-03-13 13:10:22 +00:00
harukin-expo-dev-env
435a910ef9 Merge commit 'f2aed4b945d3e86381ebb964787fcb5aff35fdbd' 2024-03-12 16:47:26 +00:00
harukin-expo-dev-env
68b9236d65 Merge commit '7bd7d951048357c4c0b81d9048906275066e8526' 2024-03-12 16:36:37 +00:00
harukin-expo-dev-env
0148c12e08 Merge commit '75952f4edea5d59554322a5c611483a0052e4e97' 2024-03-12 14:37:09 +00:00
harukin-expo-dev-env
34dc62aee6 Merge commit 'c0cdad36837f27dee7c22930834272052d53d090' 2024-03-09 15:48:41 +00:00
harukin-OneMix4
54ae681f00 Merge commit '8a94b81052a4cf6b833bf298abcd89ed93a0ada2' 2024-01-27 01:37:29 +09:00
harukin-OneMix4
db40351fec Merge commit '431c4c9c0b426250adefbb42db7893233fabb55b' 2024-01-27 01:04:19 +09:00
harukin-OneMix4
2dd8e62f85 Merge commit '485102b5917eaa819c615dbaf47ee27f81a5751f' 2024-01-12 14:04:37 +09:00
harukin-OneMix4
0937fbb619 Merge commit '65080e68f2197f31b8a4ed4bf94ad325aa77e923' 2024-01-09 18:31:30 +09:00
harukin-OneMix4
32edab3289 Merge commit '87129c6815233ea6e42575b83a26c0a2c34a6e5c' 2024-01-09 18:21:41 +09:00
harukin-OneMix4
fa96e68770 Merge commit 'b54f2f6f7e1478cee3f1f3cacf2bc3949d45583f' 2024-01-04 02:38:22 +09:00
harukin-OneMix4
1681be4437 Merge commit '0e06b6636862fa4728d36bb9b934490087cadf02' 2024-01-04 00:47:55 +09:00
harukin-OneMix4
7f96c44e88 Merge commit '0f0d69b22d4e8b3aea9bec06855d38751881055a' 2024-01-03 01:35:33 +09:00
harukin-OneMix4
7d485c466c Merge commit '29c84bcc1ce7bd1d2512b65ef812a313507d8650' 2024-01-03 01:07:31 +09:00
harukin-OneMix4
7869cbee6d Merge commit 'a4e85ff2e6bccb35ecf04f6bdf51ac193585b765' 2023-12-29 03:50:28 +09:00
harukin-OneMix4
c2ad681891 Merge commit '57459d975bbe3a1b4e145331b57a638b2c2fead5' 2023-12-29 02:09:44 +09:00
harukin-OneMix4
163f9ead18 Merge commit 'b23b59eab349139656b2d32c4b19f744a937aa59' 2023-12-25 17:09:41 +09:00
harukin-OneMix4
611fc9953f Merge commit 'e38f550b94a6c89fd2840ff95af392e8e9f49589' 2023-12-25 15:46:35 +09:00
harukin-OneMix4
fd95e99874 Merge commit '9b70843e9c6ed45ebce609952b0202f5ebf877d7' 2023-12-25 05:47:23 +09:00
harukin-OneMix4
f5d0f993db update three month update 2023-12-13 15:56:40 +09:00
harukin-OneMix4
b7e763d265 update three month update 2023-09-17 15:44:19 +09:00
harukin-OneMix4
0afa6f402b 4.5.4.1 2023-07-16 23:00:26 +09:00
harukin-OneMix4
eacce53775 Merge commit '84d36b88cc71ed591c9a50f086ab38cd08177758' 2023-07-16 23:00:05 +09:00
harukin-OneMix4
47b1eb325e なんかマージミスった? 2023-07-16 19:40:44 +09:00
harukin-OneMix4
a145e2c24f Merge commit '711f0bbe9f00200c02fb70757d7c0ca2d0060ed4' 2023-07-16 19:36:57 +09:00
harukin-OneMix4
862742cd3e update build number 2023-06-19 14:03:37 +09:00
harukin-OneMix4
d5f2d367dd Merge commit 'a3e8e3cbbe13e4fc137937493a91708b1dff7cae' 2023-06-18 21:05:04 +09:00
harukin-OneMix4
b1f72f18b5 Merge commit '81f394d6ca2ae418c89452acabe11196e4b7e940' 2023-05-10 17:19:18 +09:00
harukin-OneMix4
d4d86fb7eb 4.5.3 2023-05-10 17:13:10 +09:00
harukin-OneMix4
056a059a68 Merge commit 'bb115afe358eb7f2fb724beb1da13b2be24780b4' 2023-05-10 17:11:56 +09:00
harukin-DeskMini
12676d59cf 4.5.2リリース(本開放/ニュース更新) 2023-03-30 03:28:34 +09:00
harukin-DeskMini
bca2b300d3 4.5.2リリース(封印開放) 2023-03-30 03:25:28 +09:00
harukin-DeskMini
406808ee58 Merge commit '47d84777e2a26235d5442a68ae3b6bcb07f580cb' 2023-03-30 03:21:25 +09:00
harukin-DeskMini
a8aadb7f08 4.5.1 2023-03-27 07:01:47 +09:00
harukin-DeskMini
b810405573 Merge commit '65dd6dd0fdd41f06cfb686d578f9d604185510d3' 2023-03-27 07:01:20 +09:00
harukin-DeskMini
6f62418426 Merge commit '9eca67901906b8852f1f1f2dfa814ed658514b4b' 2023-03-27 01:06:04 +09:00
harukin-DeskMini
88621dd1c8 Merge commit '98b22db4dbea611907c4d7a6a60b7323dbec7a76' 2023-03-27 01:02:57 +09:00
harukin-DeskMini
12d9c1f49d 封印忘れ物 2023-03-26 21:45:04 +09:00
harukin-DeskMini
30c2b8a2f9 4.5.0-リリース用一部封印 2023-03-26 21:42:49 +09:00
harukin-DeskMini
8bc7069c4e Merge commit '816d96d37be6537c9ebcf0be30c74fd154b80dc5' 2023-03-26 21:09:24 +09:00
harukin-OneMix4
737cd25539 Merge commit '64538e33f317c6fe395859673922f63c2e3b0514' 2023-03-02 17:37:40 +09:00
harukin-DeskMini
2c50e5af67 Merge commit 'e93fe7095ef717bd0ddebeb18f28038a374d83f6' 2023-02-01 17:16:10 +09:00
harukin-DeskMini
ea677b4da5 Merge commit '40fb55c8cf5462a40a499ddcc69cb5313e559f84' 2023-01-29 22:10:09 +09:00
harukin-DeskMini
4525443e39 Merge commit 'e35ab09002641b3813d2055ff8107bd8088c4629' 2023-01-29 09:11:58 +09:00
54 changed files with 1334 additions and 5038 deletions

3
.gitignore vendored
View File

@@ -52,6 +52,3 @@ Thumbs.db
*.log
*.tmp
.cache/
android/
ios/

View File

@@ -1,5 +1,5 @@
import React, { CSSProperties } from "react";
import { Alert, BackHandler, View, ViewProps } from "react-native";
import { 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,15 +33,7 @@ export default ({ route }) => {
source={{ uri }}
allowsBackForwardNavigationGestures
ref={webViewRef}
onNavigationStateChange={(navState) => {
setCanGoBack(navState.canGoBack);
if (navState.url === "https://unyohub.2pd.jp/integration/succeeded.php") {
goBack();
Alert.alert("鉄道運用HUBへの投稿完了", "運用HUBからのこのアプリへのデータ反映には暫く時間がかかりますので、しばらくお待ちください。", [
{ text: "完了" },
]);
}
}}
onNavigationStateChange={(navState) => setCanGoBack(navState.canGoBack)}
onMessage={(event) => {
const { data } = event.nativeEvent;
const { type } = JSON.parse(data);

View File

@@ -452,14 +452,6 @@
}
}
]
],
[
"expo-build-properties",
{
"android": {
"kotlinVersion": "1.9.25"
}
}
]
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -70,22 +70,22 @@ export const EachStopList: FC<props> = ({
string
]; // 阿波池田,発,6:21,1
let beforeSameStationData = null;
// 発(通常発・休発・休発編)の場合、前の着(通常着・休着・休着編)と統合する
if (se.includes("発")) {
// 運休系でない通常の発のみ、前の着を統合する
// 休編(非推奨)は発着が不明なため、次の発と統合する
if ((se.includes("発") && !se.includes("休")) || se === "休編") {
if (index > 0) {
const beforeData = array[index - 1].split(",") as [string, seTypes, string];
// 前が着(通常着でも休着でも)の場合は統合
if (beforeData[0] == station && beforeData[1].includes("着")) {
if (beforeData[0] == station) {
beforeSameStationData = beforeData;
}
}
}
let afterSameStationData = null;
// 着(通常着・休着・休着編)の場合、次の発(通常発・休発・休発編)と統合される(非表示)
if (se.includes("着")) {
// 運休系でない通常の着のみ、次の発と統合する
// 運休着(休着、休着編)は独立して表示する必要がある
if (se.includes("着") && !se.includes("休")) {
const afterData = array[index + 1]?.split(",") as [string, seTypes, string];
// 次が発(通常発でも休発でも)なら、この着を非表示にして次の発で両方表示
if (afterData && afterData[0] == station && afterData[1].includes("発")) {
if (afterData && afterData[0] == station) {
afterSameStationData = afterData;
return <></>;
}

View File

@@ -1,5 +1,11 @@
import React, { FC, useMemo, useState } from "react";
import { Text, View, TextStyle, TouchableOpacity } from "react-native";
import React, { FC, useMemo } from "react";
import {
Text,
View,
TextStyle,
TouchableOpacity,
useWindowDimensions,
} from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import { migrateTrainName } from "../../../lib/eachTrainInfoCoreLib/migrateTrainName";
import { TrainIconStatus } from "./trainIconStatus";
@@ -13,8 +19,6 @@ 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 };
@@ -48,24 +52,11 @@ 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 [
@@ -77,10 +68,7 @@ export const HeaderText: FC<Props> = ({
priority,
uwasa,
trainInfoUrl,
directions,
customTrainData,
] = useMemo(() => {
const result = customTrainDataDetector(trainNum, allCustomTrainData);
const {
type,
train_name,
@@ -90,14 +78,15 @@ export const HeaderText: FC<Props> = ({
uwasa,
train_info_url,
to_data,
directions,
} = result;
} = customTrainDataDetector(trainNum, allCustomTrainData);
const [typeString, fontAvailable, isOneMan] = getStringConfig(
type,
trainNum,
);
switch (true) {
case train_name !== "":
// 特急の場合は、列車名を取得
// 列番対称データがある場合はそれから列車番号を取得
return [
typeString,
train_name +
@@ -110,8 +99,6 @@ export const HeaderText: FC<Props> = ({
priority,
uwasa,
train_info_url,
directions,
result,
];
case trainData[trainData.length - 1] === undefined:
return [
@@ -123,10 +110,9 @@ export const HeaderText: FC<Props> = ({
priority,
uwasa,
train_info_url,
directions,
result,
];
case to_data && to_data !== "":
// 行先がある場合は、行先を取得
return [
typeString,
to_data + "行き",
@@ -136,10 +122,9 @@ export const HeaderText: FC<Props> = ({
priority,
uwasa,
train_info_url,
directions,
result,
];
default:
// 行先がある場合は、行先を取得
return [
typeString,
migrateTrainName(
@@ -151,8 +136,6 @@ export const HeaderText: FC<Props> = ({
priority,
uwasa,
train_info_url,
directions,
result,
];
}
}, [trainData]);
@@ -160,41 +143,9 @@ 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",
width: "100%",
}}
style={{ padding: 10, flexDirection: "row", alignItems: "center" }}
onTouchStart={() =>
scrollHandlers.ref.current?.scrollTo({ y: 0, animated: true })
}
@@ -204,36 +155,12 @@ 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,
@@ -255,55 +182,62 @@ export const HeaderText: FC<Props> = ({
>
<Text
style={{
...textConfig,
...(trainName.length > 10 ? { fontSize: 16 } : {}),
flexShrink: 1,
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,
}}
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>
<View style={{ flex: 1 }} />
<Text style={textConfig}>
{showHeadStation.map((d) => `${headStation[d].id} + `)}
{trainNum}
{showTailStation.map((d) => ` + ${tailStation[d].id}`)}
</Text>
<MaterialCommunityIcons
name="database"
color={hasExtraInfo ? "yellow" : "white"}
color={
priority > 200 || todayOperation?.length > 0 ? "yellow" : "white"
}
size={30}
style={{ margin: 5 }}
onPress={() => {
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");
}
const uri = `https://jr-shikoku-data-system.pages.dev/trainData/${trainNum}?userID=${expoPushToken}&from=eachTrainInfo`;
navigate("generalWebView", { uri, useExitButton: false });
SheetManager.hide("EachTrainInfo");
}}
/>
</View>

View File

@@ -17,18 +17,17 @@ 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, direction } = props;
const { data, navigate, from, todayOperation } = props;
const [anpanmanStatus, setAnpanmanStatus] = useState<apt>();
const { allCustomTrainData } = useAllTrainDiagram();
const [trainIconData, setTrainIcon] = useState<
{ vehicle_info_img: string;vehicle_info_right_img: string; vehicle_info_url: string }[]
{ vehicle_info_img: string; vehicle_info_url: string }[]
>([]);
useEffect(() => {
if (!data.trainNum) return;
@@ -80,12 +79,11 @@ 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_right_img: vehicle_info_img, vehicle_info_url }]);
setTrainIcon([{ vehicle_info_img, vehicle_info_url }]);
}
switch (data.trainNum) {
@@ -115,6 +113,30 @@ 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);
@@ -129,7 +151,7 @@ export const TrainIconStatus: FC<Props> = (props) => {
return (
<>
{trainIconData.map(
({ vehicle_info_img: trainIcon, vehicle_info_right_img: trainIconRight, vehicle_info_url: address }, index) => (
({ vehicle_info_img: trainIcon, vehicle_info_url: address }, index) => (
<TouchableOpacity
onPress={() => {
navigate("howto", {
@@ -142,7 +164,7 @@ export const TrainIconStatus: FC<Props> = (props) => {
>
{move ? (
<Image
source={{ uri: direction ? trainIcon : trainIconRight || trainIcon }}
source={{ uri: trainIcon }}
style={{
height: index > 0 ? 15 : 30,
width: index > 0 ? 12 : 24,

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,6 @@ 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);
@@ -15,6 +14,5 @@ registerSheet("TrainMenuLineSelector", TrainMenuLineSelector);
registerSheet("TrainIconUpdate", TrainIconUpdate);
registerSheet("SpecialTrainInfo", SpecialTrainInfo);
registerSheet("Social", Social);
registerSheet("TrainDataSources", TrainDataSources);
export {};

View File

@@ -92,7 +92,6 @@ 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);
@@ -158,44 +157,22 @@ export const AllTrainDiagramView: FC = () => {
)}
</View>
<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 && (
{typeString && (
<Text
style={{
fontSize: 20,
fontWeight: "bold",
color: "white",
flexShrink: 1,
}}
onTextLayout={(e) => {
if (e.nativeEvent.lines.length > 1) {
setIsWrapped(true);
}
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
fontWeight: !fontAvailable ? "bold" : undefined,
marginRight: 5,
}}
>
{typeString}
</Text>
)}
{isOneMan && <OneManText />}
{trainNameString && (
<Text style={{ fontSize: 20, fontWeight: "bold", color: "white" }}>
{trainNameString}
</Text>
)}

View File

@@ -1,5 +1,5 @@
import Sign from "@/components/駅名表/Sign";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { AS } from "@/storageControl";
import {
useWindowDimensions,
@@ -13,18 +13,6 @@ 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,
@@ -32,108 +20,23 @@ export const CarouselBox = ({
setListIndex,
listIndex,
navigate,
stationSource,
}: {
originalStationList: any;
listUpStation: any[][];
nearPositionStation: any[][];
setListIndex: (i: number) => void;
listIndex: number;
navigate: any;
stationSource: StationSource;
stationListMode,
isSearchMode
}) => {
const carouselRef = useRef<ICarouselInstance>(null);
const { width } = useWindowDimensions();
const { height, 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 });
}, [listIndex, dotButton, width]);
// ドット表示設定の読み込み
useEffect(() => {
AS.getItem("CarouselSettings/activeDotSettings").then((data) => {
setDotButton(data === "true");
carouselBadgeScrollViewRef.current.scrollTo({
x: scrollToIndex,
animated: true,
});
}, []);
}, [listIndex, dotButton, width, carouselBadgeScrollViewRef]);
const oPSign = () => {
const payload = {
currentStation: listUpStation[listIndex],
@@ -146,29 +49,33 @@ 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);
};
const RenderItem = useCallback(
({ item }) => (
useEffect(() => {
AS.getItem("CarouselSettings/activeDotSettings").then((data) => {
setDotButton(data === "true");
});
}, []);
const RenderItem = ({ item, index }) => {
return (
<View
style={{ backgroundColor: "#0000", width, flexDirection: "row" }}
style={{
backgroundColor: "#0000",
width,
flexDirection: "row",
marginLeft: 0,
marginRight: 0,
}}
key={item[0].StationNumber}
>
<View style={{ flex: 1 }} />
@@ -189,13 +96,15 @@ 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 }}>
{stationSource.type === "search"
? (stationSource.query || stationSource.lineId)
? "該当する駅が見つかりませんでした。"
: "駅名・ナンバリングを入力するか、路線を選んでください。"
: stationSource.type === "position"
{!!isSearchMode ? "路線検索モードです。入力欄に駅名やナンバリングを入力したり、上に並んでいる路線を選んでみましょう!" :stationListMode == "position"
? "現在地の近くに駅がありません。"
: "お気に入りリストがありません。お気に入りの駅を追加しよう!"}
</Text>
@@ -204,141 +113,74 @@ 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 }}>
{/* カルーセル / グリッド(同じ高さ領域を共用・クロスフェード) */}
<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>
)}
<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>
</View>
);
};

View File

@@ -9,27 +9,19 @@ 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,
stationSource,
setStationSource,
closeSearch,
stationListMode,
setStationListMode,
setSelectedCurrentStation,
mapMode,
setMapMode,
}: {
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>>;
isSearchMode,
setisSearchMode,
input,
setInput,
}) => {
const returnToDefaultMode = () => {
LayoutAnimation.configureNext({
@@ -63,15 +55,16 @@ export const CarouselTypeChanger = ({
key={"carouselTypeChanger"}
>
<SearchUnitBox
stationSource={stationSource}
setStationSource={setStationSource}
closeSearch={closeSearch}
isSearchMode={isSearchMode}
setisSearchMode={setisSearchMode}
input={input}
setInput={setInput}
/>
<TouchableOpacity
style={{
flex: 1,
backgroundColor:
stationSource.type === "position" ? "#0099CC" : "#0099CC80",
stationListMode == "position" ? "#0099CC" : "#0099CC80",
padding: 5,
alignItems: "center",
flexDirection: "row",
@@ -85,15 +78,15 @@ export const CarouselTypeChanger = ({
onPressIn={() => {
if (!position) return;
returnToDefaultMode();
setStationSource({ type: "position" });
AS.setItem(STORAGE_KEYS.STATION_LIST_MODE, "position");
setStationListMode("position");
AS.setItem("stationListMode", "position");
setSelectedCurrentStation(0);
}}
onPress={() => {
if (!position) return;
returnToDefaultMode();
setStationSource({ type: "position" });
AS.setItem(STORAGE_KEYS.STATION_LIST_MODE, "position");
setStationListMode("position");
AS.setItem("stationListMode", "position");
setSelectedCurrentStation(0);
}}
>
@@ -136,7 +129,7 @@ export const CarouselTypeChanger = ({
style={{
flex: 1,
backgroundColor:
stationSource.type === "favorite" ? "#0099CC" : "#0099CC80",
stationListMode == "favorite" ? "#0099CC" : "#0099CC80",
padding: 5,
alignItems: "center",
flexDirection: "row",
@@ -148,14 +141,16 @@ export const CarouselTypeChanger = ({
}}
onPressIn={() => {
returnToDefaultMode();
setStationSource({ type: "favorite" });
AS.setItem(STORAGE_KEYS.STATION_LIST_MODE, "favorite");
// お気に入りリスト更新
setStationListMode("favorite");
AS.setItem("stationListMode", "favorite");
setSelectedCurrentStation(0);
}}
onPress={() => {
returnToDefaultMode();
setStationSource({ type: "favorite" });
AS.setItem(STORAGE_KEYS.STATION_LIST_MODE, "favorite");
// お気に入りリスト更新
setStationListMode("favorite");
AS.setItem("stationListMode", "favorite");
setSelectedCurrentStation(0);
}}
>

View File

@@ -1,120 +0,0 @@
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>
);
});

View File

@@ -1,97 +0,0 @@
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>
);
});

View File

@@ -1,133 +0,0 @@
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 の renderItemuseCallback でメモ化) */
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,
};
}

View File

@@ -12,47 +12,38 @@ 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 = ({
stationSource,
setStationSource,
closeSearch,
}: {
stationSource: StationSource;
setStationSource: (s: StationSource) => void;
closeSearch: () => void;
isSearchMode,
setisSearchMode,
input,
setInput,
}) => {
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: isSearch ? 0 : 60,
bottom: !!isSearchMode ? 0 : 60,
right: 0,
padding: isSearch ? 5 : 10,
margin: isSearch ? 0 : 10,
padding: !!isSearchMode ? 5 : 10,
margin: !!isSearchMode ? 0 : 10,
backgroundColor: "#0099CC",
borderRadius: isSearch ? 5 : 50,
width: isSearch ? width : 50,
borderRadius: !!isSearchMode ? 5 : 50,
width: !!isSearchMode ? width : 50,
zIndex: 1000,
}}
disabled={isSearch}
disabled={!!isSearchMode}
onPress={() => {
LayoutAnimation.configureNext({
duration: 100,
update: { type: "easeInEaseOut", springDamping: 0.6 },
});
setStationSource({ type: "search", query: "", lineId: undefined });
setisSearchMode(true);
}}
>
{!isSearch && <Ionicons name="search" size={30} color="white" />}
{isSearch && (
{!isSearchMode && <Ionicons name="search" size={30} color="white" />}
{!!isSearchMode && (
<View
style={{
backgroundColor: "#0099CC",
@@ -70,7 +61,7 @@ export const SearchUnitBox = ({
duration: 100,
update: { type: "easeInEaseOut", springDamping: 0.6 },
});
closeSearch();
setisSearchMode(false);
}}
>
<Ionicons
@@ -96,15 +87,13 @@ export const SearchUnitBox = ({
<TextInput
placeholder="駅名や駅ナンバリングを入力してフィルタリングします。"
onEndEditing={() => {}}
onChange={(ret) =>
setStationSource({ type: "search", query: ret.nativeEvent.text, lineId })
}
value={query}
onChange={(ret) => setInput(ret.nativeEvent.text)}
value={input}
style={{ flex: 1 }}
/>
{query && (
{input && (
<TouchableOpacity
onPress={() => setStationSource({ type: "search", query: "", lineId })}
onPress={() => setInput("") }
style={{
padding: 3,
borderRadius: 15,
@@ -120,44 +109,48 @@ export const SearchUnitBox = ({
)}
</View>
</View>
{!query && (
{!input && (
<View style={{ flexDirection: "row", alignItems: "center" }}>
{Object.keys(lineList_LineWebID).map((d) => {
const buttonLineId = stationIDPair[lineList_LineWebID[d]];
return (
<TouchableOpacity
{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
style={{
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,
color: "white",
fontWeight: "bold",
fontSize: 20,
}}
onPress={() => {
// 同じ路線を再タップしても変化なし(元の挙動を維持)
if (lineId === buttonLineId) return;
setStationSource({ type: "search", query, lineId: buttonLineId });
}}
key={buttonLineId}
>
<Text
style={{
color: "white",
fontWeight: "bold",
fontSize: 20,
}}
>
{buttonLineId}
</Text>
</TouchableOpacity>
);
})}
{stationIDPair[lineList_LineWebID[d]]}
</Text>
</TouchableOpacity>
))}
</View>
)}
</View>

View File

@@ -1,425 +0,0 @@
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,
},
});

View File

@@ -1,214 +0,0 @@
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" },
});

View File

@@ -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 { Button, CheckBox } from "react-native-elements";
import { CheckBox } from "react-native-elements";
import { TripleSwitchArea } from "../atom/TripleSwitchArea";
import { SheetHeaderItem } from "../atom/SheetHeaderItem";
@@ -24,13 +24,12 @@ export const LayoutSettings = ({
headerSize,
setHeaderSize,
}) => {
const { goBack, navigate } = useNavigation() as any;
const { goBack } = useNavigation();
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}

View File

@@ -16,9 +16,8 @@ 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.1"; // Update this version code as needed
const versionCode = "6.2.1"; // Update this version code as needed
export const SettingTopPage = ({
testNFC,
@@ -28,11 +27,7 @@ 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={{
@@ -113,14 +108,6 @@ export const SettingTopPage = ({
navigation.navigate("setting", { screen: "LayoutSettings" })
}
/>
{canAccessDataSourceSettings && (
<SettingList
string="情報ソース設定"
onPress={() =>
navigation.navigate("setting", { screen: "DataSourceSettings" })
}
/>
)}
{Platform.OS === "android" ? (
<SettingList
string="ウィジェット設定"

View File

@@ -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,8 +24,6 @@ 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) {
@@ -53,13 +51,8 @@ export default function Setting(props) {
AS.getItem(STORAGE_KEYS.UI_SETTING).then(setUiSetting);
}, []);
const testNFC = async () => {
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 result = await ExpoFelicaReader.scan();
//alert(result);
};
const updateAndReload = () => {
Promise.all([
@@ -123,6 +116,8 @@ export default function Setting(props) {
setTrainPosition={setTrainPosition}
uiSetting={uiSetting}
setUiSetting={setUiSetting}
testNFC={testNFC}
updateAndReload={updateAndReload}
headerSize={headerSize}
setHeaderSize={setHeaderSize}
/>
@@ -172,28 +167,6 @@ 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>
);
}

View File

@@ -55,8 +55,8 @@ export const ExGridSimpleViewItem: FC<{
type: trainData?.type,
whiteMode: true,
});
// 行き先(駅名)の取得
const [destinationName] = useMemo(() => {
// 列車名、種別、フォントの取得
const [trainName] = useMemo(() => {
// to_dataが設定されていればそれを優先
if (trainData?.to_data) {
return [trainData.to_data];
@@ -75,9 +75,6 @@ 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])
@@ -149,7 +146,7 @@ export const ExGridSimpleViewItem: FC<{
// to_dataがある場合は、to_dataから駅名を抽出して色を判定
const stationNameForColor = trainData?.to_data
? trainData.to_data.replace(/行き$/, "") // 「行き」を除去
: destinationName;
: trainName;
const Stations = stationList
.map((a) => a.filter((d) => d.StationName == stationNameForColor))
@@ -164,7 +161,7 @@ export const ExGridSimpleViewItem: FC<{
);
setStationColor(stationLineColor || ["gray"]);
}
}, [stationList, destinationName, trainData]);
}, [stationList, trainName, trainData]);
// if(typeString == "回送"){
// return<></>;
// }
@@ -240,7 +237,7 @@ export const ExGridSimpleViewItem: FC<{
textDecorationLine: isCancelled ? "line-through" : "none",
}}
>
{destinationName}
{trainName}
</Text>
</View>
<View style={{ flex: 1 }} />

View File

@@ -13,7 +13,6 @@ 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";
@@ -198,19 +197,17 @@ 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} se={d.se} />
<PlatformNumber platform={d.platformNum} />
{timeDisplay ? (
<DependTime time={d.time} isDelay={isDelay} se={d.se} />
<DependTime time={d.time} isDelay={isDelay} />
) : (
<StatusAndDelay trainDelayStatus={trainDelayStatus} se={d.se} />
<StatusAndDelay trainDelayStatus={trainDelayStatus} />
)}
</TouchableOpacity>
{!!isDepartureNow && (
@@ -246,20 +243,7 @@ export const EachData: FC<Props> = (props) => {
/>
)}
{trainDescriptionSwitch && !!train.train_info && (
<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>
<Description info={train.train_info} key={d.train + "-description"} />
)}
{trainDescriptionSwitch && !!train.uwasa && (
<Description info={train.uwasa} key={d.train + "-uwasa"} />

View File

@@ -7,17 +7,13 @@ 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>
);

View File

@@ -5,33 +5,17 @@ type Props = {
lastStation: string;
ToData: string;
Station_JP: string;
se?: string;
};
export const LastStation: FC<Props> = ({ lastStation, ToData, Station_JP, se }) => {
export const LastStation: FC<Props> = ({ lastStation, ToData, Station_JP }) => {
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: isCanceled ? "#999" : isEdit ? "#ffd16fff" : "white",
color: isEdit ? "#ffd16fff" : "white",
fontWeight: "bold",
textDecorationLine: isCanceled ? "line-through" : "none",
}}
>
{string === Station_JP ? "当駅止" : string}

View File

@@ -6,13 +6,11 @@ const descriptionStyle: TextStyle = {
};
type Props = {
platform: string;
se?: string;
};
export const PlatformNumber: FC<Props> = ({ platform, se }) => {
const isCanceled = se?.includes("休");
export const PlatformNumber: FC<Props> = ({ platform }) => {
return (
<View style={{ flex: 2 }}>
<Text style={{ ...descriptionStyle, color: isCanceled ? "#999" : "white", paddingLeft: 1, textDecorationLine: isCanceled ? "line-through" : "none" }}>
<Text style={{ ...descriptionStyle, color: "white", paddingLeft: 1 }}>
{platform}
</Text>
</View>

View File

@@ -1,127 +0,0 @@
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>
);
};

View File

@@ -6,13 +6,11 @@ const descriptionStyle: TextStyle = {
};
type Props = {
trainDelayStatus: string;
se?: string;
};
export const StatusAndDelay: FC<Props> = ({ trainDelayStatus, se }) => {
const isCanceled = se?.includes("休");
export const StatusAndDelay: FC<Props> = ({ trainDelayStatus }) => {
return (
<View style={{ flex: 4 }}>
<Text style={{ ...descriptionStyle, color: isCanceled ? "#999" : "#ffd16fff", paddingLeft: 1, textDecorationLine: isCanceled ? "line-through" : "none" }}>
<Text style={{ ...descriptionStyle, color: "#ffd16fff", paddingLeft: 1 }}>
{trainDelayStatus}
</Text>
</View>

View File

@@ -9,10 +9,9 @@ type Props = {
trainID: string;
type: trainTypeID;
isThrew: boolean;
se?: string;
};
export const TrainName: FC<Props> = (props) => {
const { trainName, trainNumDistance, trainIDSwitch, trainID, type, isThrew, se } =
const { trainName, trainNumDistance, trainIDSwitch, trainID, type, isThrew } =
props;
const { name, color } = getTrainType({ type });
const TrainNumber =
@@ -24,15 +23,13 @@ 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: isCanceled ? "#999" : color,
color: color,
fontWeight: "bold",
textDecorationLine: isCanceled ? "line-through" : "none",
}}
>
{trainIDSwitch

View File

@@ -31,12 +31,6 @@ 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;
/**

View File

@@ -80,19 +80,6 @@ export const STORAGE_KEYS = {
/** 奇妙な列車通知 */
STRANGE_TRAIN: 'strangeTrain',
// 情報ソース設定系
/** 鉄道運用Hub使用設定 */
USE_UNYOHUB: 'useUnyohub',
/** 鉄道運用Hubデータ */
UNYOHUB_DATA: 'unyohubData',
/** えれサイト使用設定 */
USE_ELESITE: 'useElesite',
/** えれサイトデータ */
ELESITE_DATA: 'elesiteData',
} as const;
/**

View File

@@ -70,7 +70,6 @@ export type CustomTrainData = {
lastStation: string;
isThrough: boolean;
platformNum: string | null;
se?: string;
};
export type StationProps = {
@@ -94,7 +93,6 @@ 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;

View File

@@ -55,12 +55,6 @@ 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;
@@ -80,7 +74,6 @@ 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(",");
@@ -90,7 +83,6 @@ 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("通")) {
@@ -107,7 +99,6 @@ export const getTime: getTimeProps = (stationDiagram, station) => {
lastStation: trainData.lastStation,
isThrough: trainData.isThrough,
platformNum: trainData.platformNum,
se: trainData.se,
};
})
.filter((d) => d.time);

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { AppState, AppStateStatus, Platform } from "react-native";
type Control = {
start: () => void;
@@ -13,15 +12,10 @@ type Fn = () => void;
export const useInterval = (fn: Fn, interval: number, autostart = true) => {
const onUpdateRef = useRef<Fn>();
const [state, setState] = useState("RUNNING");
// ユーザー操作によるSTOPAppStateによる一時停止と区別する
const userStoppedRef = useRef(!autostart);
const start = () => {
userStoppedRef.current = false;
setState("RUNNING");
};
const stop = () => {
userStoppedRef.current = true;
setState("STOPPED");
};
useEffect(() => {
@@ -29,48 +23,22 @@ export const useInterval = (fn: Fn, interval: number, autostart = true) => {
}, [fn]);
useEffect(() => {
if (autostart) {
userStoppedRef.current = false;
setState("RUNNING");
} else {
userStoppedRef.current = true;
}else{
setState("STOPPED");
}
}, [autostart]);
// バックグラウンド移行時に停止、フォアグラウンド復帰時に即時実行して再開
useEffect(() => {
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;
let timerId;
if (state === "RUNNING") {
timerId = setInterval(() => {
onUpdateRef.current?.();
}, interval);
} else {
if (timerId) clearInterval(timerId);
timerId && clearInterval(timerId);
}
return () => {
if (timerId) clearInterval(timerId);
timerId && clearInterval(timerId);
};
}, [interval, state]);
return [state, { start, stop }];

File diff suppressed because it is too large Load Diff

View File

@@ -1,72 +0,0 @@
/**
* 駅情報データ (みどりの窓口・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}' },
];

View File

@@ -1,455 +0,0 @@
/**
* 列番号 → 列車画像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" },
];

View File

@@ -1,35 +0,0 @@
/**
* 列車種別ごとの表示設定
*
* - 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 },
};

139
menu.tsx
View File

@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect, useCallback, useMemo, FC } from "react";
import React, { useRef, useState, useEffect, useLayoutEffect, FC } from "react";
import { Platform, View, ScrollView, LayoutAnimation } from "react-native";
import Constants from "expo-constants";
import {
@@ -26,7 +26,6 @@ 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
@@ -44,29 +43,15 @@ export const Menu: FC<props> = (props) => {
const { navigate } = useNavigation();
const { favoriteStation } = useFavoriteStation();
const { originalStationList, getStationDataFromNameBase } = useStationList();
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 });
}, []);
const [stationListMode, setStationListMode] = useState<
"position" | "favorite"
>("position");
useEffect(() => {
AS.getItem(STORAGE_KEYS.STATION_LIST_MODE)
.then((res) => {
if (res === "position" || res === "favorite") {
setStationSource({ type: res });
}
})
.catch(() => {});
.then((res) => setStationListMode(res))
.catch(() => {
// AS.setItem(STORAGE_KEYS.STATION_LIST_MODE, "position");
});
}, []);
const mapsRef = useRef(null);
const returnToTop = (bool = true) => {
@@ -112,7 +97,7 @@ export const Menu: FC<props> = (props) => {
useEffect(() => {
if (!position) return () => {};
makeCurrentStation(position);
}, [position, stationSource.type]);
}, [position, stationListMode]);
const makeCurrentStation = (location: LocationObject) => {
if (!originalStationList) return () => {};
@@ -186,59 +171,66 @@ export const Menu: FC<props> = (props) => {
const [listIndex, setListIndex] = useState(0);
// 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 のみ指定:その路線の全駅
const [listUpStation, setListUpStation] = useState<StationProps[][]>([]);
const [isSearchMode, setisSearchMode] = useState(false);
const [input, setInput] = useState("");
useLayoutEffect(() => {
if (!!isSearchMode) {
const returnData = [];
if (!input || input == "") {
Object.keys(lineList_LineWebID).forEach((d) => {
originalStationList[d]?.forEach((D) => {
if (lineId !== stationIDPair[lineList_LineWebID[d]]) return;
if (!D.StationNumber) return;
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;
}
returnData.push([D]);
});
});
} else {
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));
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));
});
}
return returnData;
} else if (stationSource.type === "position") {
return nearPositionStation.filter((d) => d != undefined);
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);
} else {
return favoriteStation.filter((d) => d != undefined);
const returnData = favoriteStation.filter((d) => d != undefined);
if (JSON.stringify(returnData) == JSON.stringify(listUpStation)) return;
setListUpStation(returnData);
}
}, [stationSource, nearPositionStation, favoriteStation, originalStationList, getStationDataFromNameBase]);
// ソース種別が切り替わったら listIndex を即座にリセット(ループ防止)
}, [nearPositionStation, favoriteStation, stationListMode, isSearchMode]);
useEffect(() => {
setListIndex(0);
}, [stationSource.type]);
// listUpStation が縮小した場合、有効な範囲に1ステップでクランプ漸減ループを防ぐ
useEffect(() => {
if (listIndex < 0) return; // ソートモード中(未選択状態)は無視
if (listUpStation.length === 0) {
if (listUpStation.length == 0) {
setListIndex(0);
return;
}
if (listIndex >= listUpStation.length) {
setMapMode(false);
setListIndex(listUpStation.length - 1);
if (listUpStation.length == 1) {
setListIndex(0);
return;
}
// listIndex を依存に入れると再発火ループになるため除外
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listUpStation]);
if (listUpStation[listIndex] == undefined) {
const count = listIndex - 1;
setMapMode(false);
setListIndex(count);
}
}, [listIndex, listUpStation, isSearchMode]);
useEffect(() => {
if (originalStationList == undefined) return;
if (listUpStation.length == 0) return;
@@ -367,12 +359,15 @@ export const Menu: FC<props> = (props) => {
{...{
locationStatus,
position,
stationSource,
setStationSource,
closeSearch,
stationListMode,
setStationListMode,
setSelectedCurrentStation: setListIndex,
mapMode,
setMapMode,
isSearchMode,
setisSearchMode,
input,
setInput,
}}
/>
)}
@@ -387,7 +382,8 @@ export const Menu: FC<props> = (props) => {
setListIndex,
listIndex,
navigate,
stationSource,
stationListMode,
isSearchMode,
}}
/>
{listUpStation[listIndex] && (
@@ -405,12 +401,15 @@ export const Menu: FC<props> = (props) => {
{...{
locationStatus,
position,
stationSource,
setStationSource,
closeSearch,
stationListMode,
setStationListMode,
setSelectedCurrentStation: setListIndex,
mapMode,
setMapMode,
isSearchMode,
setisSearchMode,
input,
setInput,
}}
/>
)}

View File

@@ -1,6 +1,2 @@
<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>
</manifest>

View File

@@ -5,51 +5,25 @@ import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.Promise
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.NfcF
import android.os.Bundle
class NfcReaderCallback(private val promise: Promise) : NfcAdapter.ReaderCallback {
override fun onTagDiscovered(tag: Tag?) {
val idmString = tag?.id?.joinToString("") { "%02x".format(it) }
promise.resolve(idmString)
}
}
class ExpoFelicaReaderModule : Module() {
private var nfcAdapter: NfcAdapter? = null
var nfcAdapter: NfcAdapter? = null
override fun definition() = ModuleDefinition {
Name("ExpoFelicaReader")
/**
* FeliCa カードをスキャンして残高等を読み取る。
* 戻り値: { idm: string, balance: number, systemCode: string }
* - balance が -1 の場合は読み取り失敗(交通系以外のカードなど)
*/
AsyncFunction("scan") { promise: Promise ->
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 ->
// ★ handleTagNFC通信を完了させてから 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()
nfcAdapter?.enableReaderMode(
appContext.currentActivity,
NfcReaderCallback(promise),
NfcAdapter.FLAG_READER_NFC_F,
null
)
}
@@ -57,164 +31,4 @@ 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)
)
}
}

View File

@@ -2,194 +2,69 @@ import ExpoModulesCore
import CoreNFC
public class ExpoFelicaReaderModule: Module {
private var readerSession: FelicaReaderSession?
var session: NfcSession?
var semaphore: DispatchSemaphore?
public func definition() -> ModuleDefinition {
Name("ExpoFelicaReader")
/**
* FeliCa
* : { idm: String, balance: Int, systemCode: String }
* - balance -1
*/
AsyncFunction("scan") { (promise: Promise) in
guard NFCTagReaderSession.readingAvailable else {
promise.reject("NFC_NOT_AVAILABLE", "このデバイスは NFC に対応していません")
return
}
self.readerSession = FelicaReaderSession(promise: promise)
self.readerSession?.start()
session?.startSession()
DispatchQueue.global(qos: .background).async {
self.semaphore?.wait()
promise.resolve(self.session?.message)
}
}
OnCreate {
semaphore = DispatchSemaphore(value: 0)
session = NfcSession(semaphore: semaphore!)
}
}
}
// MARK: - FeliCa NFC
class NfcSession: NSObject, NFCTagReaderSessionDelegate {
var session: NFCTagReaderSession?
let semaphore: DispatchSemaphore
var message: String?
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)
init (semaphore: DispatchSemaphore) {
self.semaphore = semaphore
}
}
//
func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
guard let first = tags.first, case .feliCa(let felicaTag) = first else {
session.invalidate(errorMessage: "FeliCa カードではありません")
return
func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {
print("tagReaderSessionDidBecomeActive")
}
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)
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()
}
// 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
])
}
}
}
}
// 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
func startSession() {
self.session = NFCTagReaderSession(pollingOption: [.iso14443, .iso15693, .iso18092], delegate: self, queue: nil)
session?.alertMessage = "Touch your FeliCa!"
session?.begin()
}
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])
]
}
}
}

View File

@@ -1,10 +0,0 @@
{
"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"
}
}

View File

@@ -1,80 +1,5 @@
import ExpoFelicaReaderModule from "./ExpoFelicaReaderModule";
/**
* 利用履歴の 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> {
export async function scan(): Promise<string> {
return await ExpoFelicaReaderModule.scan();
}

View File

@@ -2,8 +2,8 @@
"main": "index.js",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"android": "expo start --android",
"ios": "expo start --ios",
"eject": "expo eject",
"pushWeb": "npx expo export -p web && netlify deploy --dir dist --prod",
"checkDiagram": "bash ./check.sh"
@@ -24,12 +24,10 @@
"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",
@@ -80,7 +78,5 @@
"devDependencies": {
"babel-preset-expo": "~12.0.0"
},
"private": true,
"name": "jrshikoku",
"version": "1.0.0"
"private": true
}

View File

@@ -305,6 +305,14 @@ 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) ||

View File

@@ -1,118 +0,0 @@
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,
};
};

View File

@@ -39,8 +39,6 @@ const initialState = {
setTrainMenu: (e) => {},
updatePermission: false,
setUpdatePermission: (e) => {},
/** 各情報ソースの利用権限 */
dataSourcePermission: { unyohub: false } as { unyohub: boolean },
injectJavascript: "",
};
@@ -64,23 +62,20 @@ 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://jr-shikoku-backend-api-v1.haruk.in/check-permission?user_id=${expoPushToken}`
"https://n8n.haruk.in/webhook/data-edit-permission?token=" + expoPushToken
)
.then((res) => res.json())
.then((res) => {
const role: string = res.permission ?? "";
setUpdatePermission(role === "administrator");
setDataSourcePermission({
unyohub: role === "administrator" || role === "unyoHubEditor",
});
})
.catch(() => {});
if (res.data == true) {
setUpdatePermission(true);
} else {
setUpdatePermission(false);
}
});
}, [expoPushToken]);
//列車情報表示関連
@@ -95,23 +90,15 @@ 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,
useUnyohub: useUnyohubSetting,
useElesite: useEleSiteSetting,
});
uiSetting
);
useEffect(() => {
//列車アイコンスイッチ
@@ -124,10 +111,6 @@ 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 (
@@ -153,7 +136,6 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
setTrainMenu,
updatePermission,
setUpdatePermission,
dataSourcePermission,
injectJavascript,
}}
>

View File

@@ -1,121 +0,0 @@
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,
};
};

View File

@@ -145,18 +145,3 @@ 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";

View File

@@ -1,83 +0,0 @@
/**
* 鉄道運用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[];

View File

@@ -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"],
};
/**

View File

@@ -2424,16 +2424,6 @@ 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"
@@ -3937,14 +3927,6 @@ 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"
@@ -4003,9 +3985,6 @@ 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"
@@ -4226,11 +4205,6 @@ 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"