Compare commits

..

1 Commits

Author SHA1 Message Date
harukin-expo-dev-env c1b4adc310 Add Windows port documentation for JR四国アプリ using React Native for Windows 2026-05-02 11:47:31 +00:00
24 changed files with 320 additions and 1155 deletions
-18
View File
@@ -1,7 +1,5 @@
import React from "react";
import { Alert, ActivityIndicator, AppState, AppStateStatus, BackHandler, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import * as FileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing";
import { WebView } from "react-native-webview";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { BigButton } from "./components/atom/BigButton";
@@ -131,7 +129,6 @@ export default ({ route }) => {
contentMode="mobile"
allowsBackForwardNavigationGestures
ref={webViewRef}
injectedJavaScriptBeforeContentLoaded={`true;`}
onLoadStart={() => {
isLoadingRef.current = true;
setHasError(false);
@@ -220,21 +217,6 @@ export default ({ route }) => {
}
return;
}
if (type === "printHtml") {
(async () => {
try {
const path = FileSystem.cacheDirectory + "diagram.html";
await FileSystem.writeAsStringAsync(path, parsed.html, { encoding: FileSystem.EncodingType.UTF8 });
const ok = await Sharing.isAvailableAsync();
if (ok) {
await Sharing.shareAsync(path, { mimeType: "text/html", dialogTitle: "ダイヤグラムを共有" });
}
} catch (e) {
Alert.alert("エラー", "PDF出力の準備に失敗しました。");
}
})();
return;
}
if (type === "back") return webViewRef.current?.goBack();
if (type === "windowClose") return goBack();
}}
+160
View File
@@ -0,0 +1,160 @@
# JR四国アプリ Windows版 (React Native for Windows) 引き継ぎ資料
## プロジェクト概要
JR四国の列車走行位置・運行情報アプリ。Expo Managed Workflow (React Native) で構築。
Android/iOS向けに運用中。このタスクは **Windows PC版の追加** を目的とする。
- **リポジトリ**: `/home/ubuntu/jrshikoku` (Ubuntu開発サーバー) → Windows環境に clone/コピーして作業
- **ブランチ**: `experiment/pc-version`mainブランチ: `master`
- **React Native**: 0.83.2 / **Expo**: ^55.0.8
---
## これまでの経緯
### 試みたこと: Electron + Expo webビルド
`npx expo export -p web` でWebビルドし、ElectronでラップしてCORSをメインプロセスで回避する方法を試みた。
**結果**: 動作はしたが根本的な問題が多く断念。
| 問題 | 原因 |
|------|------|
| `injectJavaScript` が動かない | Electron `<webview>` はクロスオリジンにJS注入できない |
| 列車描画が壊れる | `window.ReactNativeWebView` が未定義でスクリプトがクラッシュ |
| 操作感が悪い | WebViewがiframe/Electron webviewタグになり、ネイティブのスクロール感が失われる |
| `usage.htm`(利用規約)が毎回出る | `injectJavascript` が動かないため `goBack()` 処理が機能しない |
**結論**: `react-native-webview` をネイティブとして動かせないWeb系アプローチには限界がある。
---
## 推奨アプローチ: React Native for Windows (RNW)
### なぜRNWが良いか
- `react-native-webview`**WebView2 (Chromium)** として動く → `injectJavaScript` がモバイルと同じ挙動
- ネイティブアプリなので CORS 制限がない
- スクロール・タッチ感がネイティブ
- このアプリの主要機能(走行位置WebView、API通信)がそのまま動く可能性が高い
### バージョン対応
RN 0.83.2 に対応する RNW は **0.83.x**
```
yarn add react-native-windows@0.83.x
```
---
## セットアップ手順 (Windows)
### 1. 前提環境のインストール
[Microsoft公式ドキュメント](https://microsoft.github.io/react-native-windows/docs/rnw-dependencies) に従う。
必要なもの:
- **Visual Studio 2022** (Community可)
- ワークロード: 「C++ によるデスクトップ開発」「ユニバーサル Windows プラットフォーム開発」
- 個別コンポーネント: 「Windows 11 SDK (10.0.22621.0 以上)」「C++ CMake ツール」
- **Node.js** LTS
- **Git**
```powershell
# winget で一括インストール (任意)
winget install Microsoft.VisualStudio.2022.Community
winget install OpenJS.NodeJS.LTS
winget install Git.Git
```
### 2. リポジトリをWindowsにクローン
```powershell
git clone <リポジトリURL>
cd jrshikoku
git checkout experiment/pc-version
yarn install
```
### 3. RNW の初期化
```powershell
yarn add react-native-windows@0.83.x
npx react-native-windows-init --overwrite
```
`windows/` ディレクトリが生成される。
### 4. ビルド & 実行
```powershell
npx react-native run-windows
```
---
## 予想される問題と対処
### Expoモジュールの非対応
Expo Managed Workflow のモジュールはネイティブコードを自動生成するが、RNW は公式サポート外。
以下のモジュールはWindowsで動かない可能性が高い(クラッシュしたらスタブに差し替える):
| モジュール | 状況 | 対処 |
|-----------|------|------|
| `expo-notifications` | Windowsプッシュ通知は別実装が必要 | Platform.OS === 'windows' でスキップ |
| `expo-felica-reader` | ハードウェア依存、Windows非対応 | スタブに差し替え |
| `expo-live-activity` | iOS専用 | すでにPlatform.OS === 'web' ガード済み |
| `expo-linear-gradient` | RNW向けは要確認 | react-native-linear-gradient に差し替えも |
| `react-native-maps` | Windows未対応 | metro.config.js でスタブを当てる |
### Platform.OS の値
RNW では `Platform.OS === 'windows'``'web'` でも `'ios'` でもない)。
既存の `Platform.OS === 'android'` ガードがある箇所は意図通り動く(Windowsはスキップされる)。
### `react-native-webview` のCORS
ネイティブアプリなので基本的にCORSはない。ただし JR四国の `X-Frame-Options`
iframeではなく WebView2 で直接開くため無関係。
---
## アプリ構成の重要ポイント
### 走行位置タブ (`components/Apps/WebView.tsx`)
最重要コンポーネント。`react-native-webview` の WebView に `train.jr-shikoku.co.jp/sp.html` を読み込み、大量の JavaScript (`injectJavascript`) を注入して列車データを描画している。
- `injectJavascript`: `lib/webViewInjectjavascript.ts` で生成。ユーザー設定・ダークモード等を考慮した巨大スクリプト
- `injectJavascriptBeforeContentLoaded`: モックAPI使用時のみ、XHRインターセプター
- `window.ReactNativeWebView.postMessage` でアプリ側に列車クリック等のイベントを送る
### 状態管理
Context API (stateBox/) で全状態を管理。Redux等は使用していない。
### API
バックエンド: `jr-shikoku-backend-api-v1.haruk.in`(外部サーバー)
列車走行位置: JR四国公式 `train.jr-shikoku.co.jp`WebView内で直接アクセス)
---
## Claudeへの指示テンプレート
```
このプロジェクトはExpo React NativeのJR四国アプリ(RN 0.83.2)。
experiment/pc-versionブランチで作業中。
React Native for Windows (RNW 0.83.x) を使ってWindows PC版を作りたい。
まず package.json と windows/ ディレクトリの存在を確認し、
RNW の初期化が完了しているか確認してから作業を開始してほしい。
主要な懸念点:
1. expo-notifications など Expo 系モジュールが Windows ビルドを壊す可能性
2. react-native-maps は Windows 非対応(metro.config.js でスタブを当てる予定)
3. Platform.OS === 'windows' の分岐が必要になる箇所を確認すること
```
-14
View File
@@ -18,18 +18,4 @@ export default [
lng: 133.816444,
isSpot: true,
},
{
Station_JP: ".小歩危展望台",
Station_EN: "Koboke Observatory",
MyStation: "0",
StationNumber: null,
DispNum: "3",
StationTimeTable: "",
StationMap: "https://maps.app.goo.gl/WBMN5R2tk2tusavk7",
JrHpUrl: "https://miyoshi-tourism.jp/spot/5438/",
jslodApi: "spot",
lat: 33.9372609,
lng: 133.753258,
isSpot: true,
},
];
@@ -122,16 +122,6 @@ export const EachStopList: FC<props> = ({
const StationNumbers = Stations.filter((d) => d.StationNumber != null).map(
(d) => d.StationNumber as string
);
const trimmedPlatformNum = platformNum?.trim();
const hasPlatformNum = !!trimmedPlatformNum;
const isPlatformNumNumeric = hasPlatformNum && /^\d+$/.test(trimmedPlatformNum);
const platformDisplay = hasPlatformNum
? isPlatformNumNumeric
? parseInt(trimmedPlatformNum, 10) === 0
? "⓪"
: String.fromCharCode(0x2460 + parseInt(trimmedPlatformNum, 10) - 1)
: `(${trimmedPlatformNum})`
: "";
// SE文字列を表示用に変換
const [seString, seType] = parseSeString(se);
@@ -265,18 +255,10 @@ export const EachStopList: FC<props> = ({
textAlignVertical: "center",
}}
>
{station}
{hasPlatformNum && (
<Text
style={{
fontSize: fontScale(isPlatformNumNumeric ? 20 : 14),
color: colors.text,
fontStyle: isCommunity ? "italic" : "normal",
}}
>
{platformDisplay}
</Text>
)}
{station}{platformNum &&
(parseInt(platformNum) === 0
? "⓪"
: String.fromCharCode(0x2460 + parseInt(platformNum) - 1))}
</Text>
<View style={{ flex: 1 }} />
</View>
@@ -1,4 +1,4 @@
import React, { FC, useState, useEffect, useMemo, useRef } from "react";
import React, { FC, useState, useEffect, useRef } from "react";
import {
View,
Text,
@@ -21,10 +21,6 @@ import type { UnyohubData, ElesiteData } from "@/types/unyohub";
import { useUnyohub } from "@/stateBox/useUnyohub";
import { useElesite } from "@/stateBox/useElesite";
import { useThemeColors } from "@/lib/theme";
import {
buildElesiteLineGroups,
type ElesiteLineGroup,
} from "@/lib/elesiteTrainOrder";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
@@ -233,80 +229,6 @@ const ActiveFormationChipsCycler: FC<{ items: string[]; color: string }> = ({ it
);
};
const ElesiteLineGroupCycler: FC<{
groups: ElesiteLineGroup[];
color: string;
onActiveGroupChange?: (group: ElesiteLineGroup | null) => void;
}> = ({ groups, color, onActiveGroupChange }) => {
const { colors } = useThemeColors();
const [index, setIndex] = useState(0);
const opacity = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (groups.length === 0) {
onActiveGroupChange?.(null);
return;
}
if (index >= groups.length) {
setIndex(0);
return;
}
onActiveGroupChange?.(groups[index]);
}, [groups, index, onActiveGroupChange]);
useEffect(() => {
if (groups.length <= 1) return;
const timer = setInterval(() => {
Animated.timing(opacity, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}).start(() => {
setIndex((prev) => (prev + 1) % groups.length);
Animated.timing(opacity, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}).start();
});
}, 3000);
return () => clearInterval(timer);
}, [groups.length]);
const group = groups[index];
if (!group) return null;
return (
<Animated.View style={{ opacity }}>
{group.formationText ? (
<View style={styles.elesiteFormationWrap}>
<FormationChips text={group.formationText} color={color} />
</View>
) : (
<Text style={[styles.subText, { color: colors.textSecondary }]}>
</Text>
)}
{(group.leftStation || group.rightStation || group.lineLabel) && (
<RefDirectionBanner
rows={[
{
leftLabel: group.leftStation ?? undefined,
lineLabel: group.lineLabel ?? undefined,
rightLabel: group.rightStation ?? undefined,
},
]}
color={color}
/>
)}
</Animated.View>
);
};
export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
payload,
}) => {
@@ -415,34 +337,8 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
const opCount = todayOperation.length;
const unyoCount = unyohubEntries.length;
const elesiteCount = elesiteEntries.length;
const elesiteLineGroups = useMemo(
() => buildElesiteLineGroups(elesiteEntries, trainNum),
[elesiteEntries, trainNum],
);
const elesiteDisplayGroups = useMemo(
() =>
elesiteLineGroups.some((group) => group.hasFormations)
? elesiteLineGroups.filter((group) => group.hasFormations)
: elesiteLineGroups,
[elesiteLineGroups],
);
const [activeElesiteGroupKey, setActiveElesiteGroupKey] = useState<string | null>(null);
const hasTrainInfo = priority > 200;
useEffect(() => {
if (elesiteDisplayGroups.length === 0) {
setActiveElesiteGroupKey(null);
return;
}
if (
!activeElesiteGroupKey ||
!elesiteDisplayGroups.some((group) => group.key === activeElesiteGroupKey)
) {
setActiveElesiteGroupKey(elesiteDisplayGroups[0].key);
}
}, [activeElesiteGroupKey, elesiteDisplayGroups]);
// 運用情報: train_ids / related_train_ids の位置番号でソートして unit_ids を収集
// "4565M,2" のようなカンマ区切り位置番号を解析
// 上り(resolvedDirection=true)=ASC, 下り(false)=DESC
@@ -632,25 +528,46 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
</View>
);
const elesiteHasNonEmptyFormations = elesiteDisplayGroups.some(
(group) => group.hasFormations,
// えれサイト最終投稿時刻(last_reported_at が最新のエントリ)
const elesiteLastReportedAt =
elesiteEntries
.map((e) => e.report_info?.last_reported_at)
.filter((d): d is string => !!d)
.sort()
.at(-1) ?? null;
// えれサイト: units が1件以上あるエントリのみ「データあり」と判定
const elesiteHasNonEmptyFormations = elesiteEntries.some(
(e) => (e.formation_config?.units?.length ?? 0) > 0,
);
const activeElesiteGroup =
elesiteDisplayGroups.find((group) => group.key === activeElesiteGroupKey) ??
elesiteDisplayGroups[0] ??
null;
const activeElesiteGroupIndex = activeElesiteGroup
? elesiteDisplayGroups.findIndex((group) => group.key === activeElesiteGroup.key)
: 0;
const elesiteBadgeCount = elesiteHasNonEmptyFormations
? elesiteDisplayGroups.length
: null;
const activeElesiteGroupLabel =
activeElesiteGroup?.lineLabel ||
[activeElesiteGroup?.leftStation, activeElesiteGroup?.rightStation]
.filter(Boolean)
.join("〜") ||
null;
const elesiteNonEmptyFormationEntries = elesiteEntries
.filter((e) => (e.formation_config?.units?.length ?? 0) > 0)
.sort((a, b) => {
// high松(left_station)側のユニットを先に表示
// (heading_to === "left") === is_leading が true → high松(left)端のユニット
const aNav = a.trains?.find((t) => t.train_number.trim() === trainNum)?.nav;
const bNav = b.trains?.find((t) => t.train_number.trim() === trainNum)?.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;
});
// えれサイト: 編成名テキスト(formation_config.units 優先)
const elesiteFormationNames =
elesiteNonEmptyFormationEntries
.slice(0, 4)
.map((e) => {
const units = e.formation_config?.units;
return units?.length
? units.map((u) => u.formation).join("+")
: e.formations;
})
.join("・") +
(elesiteNonEmptyFormationEntries.length > 4
? `${elesiteNonEmptyFormationEntries.length - 4}`
: "");
// 列車情報 subテキスト
const trainInfoSub = customTrainData?.vehicle_formation
@@ -671,15 +588,23 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
const elesiteFormationDetail = (
<View style={styles.operationDetailBlock}>
{elesiteDisplayGroups.length > 0 && (
<ElesiteLineGroupCycler
groups={elesiteDisplayGroups}
color="#44bb44"
onActiveGroupChange={(group) =>
setActiveElesiteGroupKey(group?.key ?? null)
}
/>
{elesiteHasNonEmptyFormations && (
<Text style={styles.unitIdText}>{elesiteFormationNames}</Text>
)}
{elesiteCount > 0
? (() => {
const fc = (elesiteNonEmptyFormationEntries[0] ?? elesiteEntries[0])
?.formation_config;
return fc?.left_station && fc?.right_station ? (
<RefDirectionBanner
rows={[
{ leftLabel: fc.left_station, rightLabel: fc.right_station },
]}
color="#44bb44"
/>
) : null;
})()
: undefined}
</View>
);
return (
@@ -786,20 +711,23 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
elesiteCount === 0
? "この列車の運用データはありません"
: !elesiteHasNonEmptyFormations
? activeElesiteGroupLabel || "本日の運用報告なし"
: elesiteDisplayGroups.length > 1
? activeElesiteGroupLabel || ""
: activeElesiteGroup?.lastReportedAt
? `最終投稿: ${formatHHMM(activeElesiteGroup.lastReportedAt)}`
: activeElesiteGroupLabel || ""
? "本日の運用報告なし"
: elesiteLastReportedAt
? `最終投稿: ${formatHHMM(elesiteLastReportedAt)}`
: ""
}
badge={elesiteBadgeCount}
badge={elesiteHasNonEmptyFormations ? elesiteCount : null}
badgeColor="#44bb44"
detail={elesiteFormationDetail}
disabled={elesiteCount === 0}
onPress={() => {
const matchedEntry =
elesiteNonEmptyFormationEntries[0] ?? elesiteEntries[0];
const matchedTrain = matchedEntry?.trains?.find(
(t) => t.train_number.trim() === trainNum,
);
const url =
activeElesiteGroup?.timetableUrl || "https://www.elesite-next.com/";
matchedTrain?.timetable_url || "https://www.elesite-next.com/";
SheetManager.hide("TrainDataSources");
Linking.openURL(url);
}}
@@ -1481,11 +1409,6 @@ const styles = StyleSheet.create({
subText: {
fontSize: 12,
},
routePagerText: {
fontSize: 11,
fontWeight: "600",
marginBottom: 4,
},
subNodeWrap: {
marginTop: 2,
},
@@ -1521,9 +1444,6 @@ const styles = StyleSheet.create({
color: "#0077aa",
letterSpacing: 0.5,
},
elesiteFormationWrap: {
marginBottom: 4,
},
footer: {
height: 20,
},
+13 -52
View File
@@ -1,4 +1,4 @@
import React, { FC, useRef, useCallback, useState } from "react";
import React, { FC, useRef, useCallback } from "react";
import {
View,
Text,
@@ -33,69 +33,32 @@ export const PlaybackTimeline: FC = () => {
} = useTrainMenu();
// ─── すべてのフックは早期returnより前に呼ぶ ───
const trackViewRef = useRef<View>(null);
const trackWidthRef = useRef(1);
const trackPageXRef = useRef(0); // スクリーン絶対座標でのトラック左端
const [isScrubbing, setIsScrubbing] = useState(false);
const [scrubIndex, setScrubIndex] = useState<number | null>(null);
const scrubIndexRef = useRef<number | null>(null);
// PanResponder のハンドラ内で最新値を参照するために ref を使う
const seekRef = useRef(seekToSnapshot);
seekRef.current = seekToSnapshot;
const totalRef = useRef(0);
// gestureState.moveX(画面絶対座標)からインデックスを計算
// locationX はドラッグ中に子 View をまたぐと基準がズレて端に飛ぶため使わない
const indexFromPageX = useCallback((pageX: number) => {
if (totalRef.current <= 1) return 0;
const relX = pageX - trackPageXRef.current;
const width = Math.max(trackWidthRef.current, 1);
const clampedX = Math.max(0, Math.min(relX, width));
const idx = Math.round((clampedX / width) * (totalRef.current - 1));
return Math.max(0, Math.min(idx, totalRef.current - 1));
}, []);
const endScrubbing = useCallback((index: number) => {
seekRef.current(index);
setIsScrubbing(false);
setScrubIndex(null);
scrubIndexRef.current = null;
}, []);
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (evt, gestureState) => {
const idx = indexFromPageX(gestureState.x0);
pausePlayback();
setIsScrubbing(true);
setScrubIndex(idx);
scrubIndexRef.current = idx;
onPanResponderGrant: (evt) => {
const x = evt.nativeEvent.locationX;
const idx = Math.round((x / trackWidthRef.current) * (totalRef.current - 1));
seekRef.current(Math.max(0, Math.min(idx, totalRef.current - 1)));
},
onPanResponderMove: (evt, gestureState) => {
const idx = indexFromPageX(gestureState.moveX);
setScrubIndex(idx);
scrubIndexRef.current = idx;
},
onPanResponderRelease: (evt, gestureState) => {
const idx = scrubIndexRef.current ?? indexFromPageX(gestureState.moveX);
endScrubbing(idx);
},
onPanResponderTerminate: (evt, gestureState) => {
const idx = scrubIndexRef.current ?? indexFromPageX(gestureState.moveX);
endScrubbing(idx);
onPanResponderMove: (evt) => {
const x = evt.nativeEvent.locationX;
const idx = Math.round((x / trackWidthRef.current) * (totalRef.current - 1));
seekRef.current(Math.max(0, Math.min(idx, totalRef.current - 1)));
},
})
).current;
const onTrackLayout = useCallback((e: LayoutChangeEvent) => {
trackWidthRef.current = e.nativeEvent.layout.width;
// レイアウト確定後に絶対座標を取得
trackViewRef.current?.measure((_x, _y, _w, _h, pageX) => {
trackPageXRef.current = pageX;
});
}, []);
const btnSize = moderateScale(36);
@@ -107,8 +70,7 @@ export const PlaybackTimeline: FC = () => {
const total = activeRecording.snapshots.length;
totalRef.current = total; // PanResponder が参照する最新値を更新
const displayIndex = isScrubbing && scrubIndex !== null ? scrubIndex : playbackIndex;
const snap = activeRecording.snapshots[displayIndex];
const snap = activeRecording.snapshots[playbackIndex];
// スナップショット時刻 = 録画開始時刻 + elapsed
const snapTime = dayjs(activeRecording.recordedAt).add(snap.t, "ms");
@@ -121,7 +83,7 @@ export const PlaybackTimeline: FC = () => {
? `${Math.floor(totalSec / 60)}:${String(totalSec % 60).padStart(2, "0")}`
: `${totalSec}s`;
const progress = total > 1 ? displayIndex / (total - 1) : 0;
const progress = total > 1 ? playbackIndex / (total - 1) : 0;
return (
<View
@@ -206,7 +168,7 @@ export const PlaybackTimeline: FC = () => {
{timeLabel}
</Text>
<Text style={{ fontSize: moderateScale(10), color: colors.textSecondary, fontVariant: ["tabular-nums"] }}>
{displayIndex + 1}/{total} {totalLabel}
{playbackIndex + 1}/{total} {totalLabel}
</Text>
</View>
@@ -222,8 +184,7 @@ export const PlaybackTimeline: FC = () => {
{/* 下段: スクラバートラック */}
<View
ref={trackViewRef}
style={{ marginTop: 6, paddingVertical: 8, marginVertical: -8 }}
style={{ marginTop: 6 }}
onLayout={onTrackLayout}
{...panResponder.panHandlers}
>
+2 -3
View File
@@ -22,11 +22,10 @@ type ReloadButton = {
}
export const ReloadButton:FC<ReloadButton> = ({ onPress, right }) => {
const { fixed } = useThemeColors();
const { mapSwitch, LoadError = false, mockApiFeatureEnabled } = useTrainMenu();
const { mapSwitch, LoadError = false } = useTrainMenu();
const { top } = useSafeAreaInsets();
const { moderateScale } = useResponsive();
const buttonSize = moderateScale(50);
const buttonColor = LoadError ? "red" : mockApiFeatureEnabled ? "#7c3aed" : fixed.primary;
const styles: stylesType = {
touch: {
position: "absolute",
@@ -34,7 +33,7 @@ export const ReloadButton:FC<ReloadButton> = ({ onPress, right }) => {
right: 10 + right,
width: buttonSize,
height: buttonSize,
backgroundColor: buttonColor,
backgroundColor: LoadError ? "red" : fixed.primary,
borderColor: fixed.textOnPrimary,
borderStyle: "solid",
borderWidth: 1,
-18
View File
@@ -268,24 +268,6 @@ export const FixedContentBottom = (props) => {
</Text>
<MaterialCommunityIcons name="chart-gantt" color="white" size={moderateScale(40)} />
</TextBox>
<TextBox
backgroundColor="#7F8C8D"
flex={1}
onPressButton={() => {
const uri = `https://shikoku-railinfo.haruk.in/timetable/?userID=${expoPushToken}&from=eachTrainInfo`;
props.navigate("generalWebView", { uri, useExitButton: false });
SheetManager.hide("EachTrainInfo");
}}
>
<Text style={{ color: "white", fontWeight: "bold", fontSize: fontScale(20) }}>
</Text>
<MaterialCommunityIcons
name="file-document-outline"
color="white"
size={moderateScale(40)}
/>
</TextBox>
</View>
<Text style={{ fontWeight: "bold", fontSize: fontScale(20), color: colors.text }}></Text>
<TextBox
+24 -94
View File
@@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect } from "react";
import {
Alert,
View,
Text,
ScrollView,
@@ -12,7 +11,6 @@ import {
import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import Swipeable from "react-native-gesture-handler/Swipeable";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants";
@@ -304,7 +302,6 @@ export const DataSourceSettings = () => {
useState<JrDataSystemUiVariant>(
getJrDataSystemUiVariant(DEFAULT_JR_DATA_SYSTEM_ENV),
);
const recordingSwipeRefs = useRef<Record<string, { close: () => void } | null>>({});
const applyJrDataSystemEnv = (env: JrDataSystemEnvironmentKey) => {
setJrDataSystemEnv(env);
@@ -361,28 +358,6 @@ export const DataSourceSettings = () => {
applyJrDataSystemEnv(env);
};
const closeRecordingSwipe = (id: string) => {
recordingSwipeRefs.current[id]?.close();
};
const confirmDeleteRecording = (id: string, label: string) => {
closeRecordingSwipe(id);
Alert.alert(
"録画を削除",
`${label} の録画を削除しますか?`,
[
{ text: "キャンセル", style: "cancel" },
{
text: "削除",
style: "destructive",
onPress: () => {
void deleteRecording(id);
},
},
],
);
};
return (
<View style={[styles.container, { backgroundColor: fixed.primary }]}>
<SheetHeaderItem
@@ -578,16 +553,13 @@ export const DataSourceSettings = () => {
month: 'numeric', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
const recordingRow = (
<TouchableOpacity
onPress={() => startPlayback(rec.id)}
disabled={isPlaying}
activeOpacity={0.72}
return (
<View
key={rec.id}
style={{
flexDirection: 'row', alignItems: 'center',
backgroundColor: colors.backgroundSecondary,
borderRadius: 8, padding: 12, gap: 10,
opacity: isPlaying ? 0.6 : 1,
borderRadius: 8, padding: 10, gap: 8,
}}
>
<View style={{ flex: 1 }}>
@@ -598,71 +570,29 @@ export const DataSourceSettings = () => {
{rec.snapshotCount} / {durationLabel}
</Text>
</View>
<View style={{ alignItems: 'flex-end', gap: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<MaterialCommunityIcons
name={isPlaying ? 'pause-circle-outline' : 'play-circle-outline'}
size={18}
color={isPlaying ? colors.textTertiary : '#43a047'}
/>
<Text
style={{
color: isPlaying ? colors.textTertiary : colors.textPrimary,
fontSize: 12,
fontWeight: '600',
}}
>
{isPlaying ? '再生中は操作不可' : 'タップで再生'}
</Text>
</View>
{!isPlaying && (
<Text style={{ color: colors.textTertiary, fontSize: 10 }}>
</Text>
)}
</View>
<MaterialCommunityIcons
name="chevron-right"
size={18}
color={colors.iconSecondary}
/>
</TouchableOpacity>
);
if (isPlaying) {
return <View key={rec.id}>{recordingRow}</View>;
}
return (
<Swipeable
key={rec.id}
ref={(instance) => {
recordingSwipeRefs.current[rec.id] = instance;
}}
friction={2}
overshootRight={false}
rightThreshold={48}
renderRightActions={() => (
<View
{!isPlaying && (
<TouchableOpacity
onPress={() => startPlayback(rec.id)}
style={{
width: 96,
borderRadius: 8,
backgroundColor: '#e53935',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 6,
backgroundColor: '#43a047', borderRadius: 6,
paddingHorizontal: 10, paddingVertical: 6,
}}
>
<MaterialCommunityIcons name="trash-can-outline" size={18} color="#fff" />
<Text style={{ color: '#fff', fontSize: 11, fontWeight: 'bold', marginTop: 4 }}>
</Text>
</View>
<Text style={{ color: '#fff', fontWeight: 'bold', fontSize: 12 }}></Text>
</TouchableOpacity>
)}
onSwipeableOpen={() => confirmDeleteRecording(rec.id, dateLabel)}
>
{recordingRow}
</Swipeable>
{!isPlaying && (
<TouchableOpacity
onPress={() => deleteRecording(rec.id)}
style={{
borderColor: '#e53935', borderWidth: 1, borderRadius: 6,
paddingHorizontal: 10, paddingVertical: 6,
}}
>
<Text style={{ color: '#e53935', fontSize: 12 }}></Text>
</TouchableOpacity>
)}
</View>
);
})}
</View>
+1 -1
View File
@@ -17,7 +17,7 @@ import { useNotification } from "../../stateBox/useNotifications";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors, type ColorThemePref } from "@/lib/theme/useThemeColors";
const versionCode = "7.0.6"; // Update this version code as needed
const versionCode = "7.0.3"; // Update this version code as needed
export const SettingTopPage = ({
testNFC,
-3
View File
@@ -7,9 +7,6 @@ const BASE_URL = 'https://jr-shikoku-api-data-storage.haruk.in';
export const API_ENDPOINTS = {
/** 本日のダイアグラムデータ */
DIAGRAM_TODAY: `${BASE_URL}/tmp/diagram-today.json`,
/** 本日のダイアグラムデータ(experimental環境用) */
DIAGRAM_TODAY_BETA: `${BASE_URL}/tmp/diagram-today-beta.json`,
/** カスタム列車データ */
CUSTOM_TRAIN_DATA: 'https://haruk.in/api/jr/getTrain.php',
File diff suppressed because one or more lines are too long
-277
View File
@@ -1,277 +0,0 @@
import dosan from "@/assets/originData/dosan";
import dosan2 from "@/assets/originData/dosan2";
import koutoku from "@/assets/originData/koutoku";
import naruto from "@/assets/originData/naruto";
import seto from "@/assets/originData/seto";
import tokushima from "@/assets/originData/tokushima";
import trainList from "@/assets/originData/trainList";
import uwajima from "@/assets/originData/uwajima";
import uwajima2 from "@/assets/originData/uwajima2";
import yosan from "@/assets/originData/yosan";
import type { ElesiteData } from "@/types/unyohub";
type HeadingDirection = "left" | "right";
export type ElesiteLineGroup = {
key: string;
lineCode: string | null;
lineLabel: string | null;
leftStation: string | null;
rightStation: string | null;
timetableUrl: string | null;
lastReportedAt: string | null;
entries: ElesiteData[];
formations: string[];
formationText: string | null;
hasFormations: boolean;
};
// マリンライナー(3xxxM)用: JR西日本区間の駅は originData にないのでここで定義
const MARINE_STATION_SEQUENCE = [
"岡山", "大元", "備前西市", "妹尾", "早島", "茶屋町", "植松", "木見", "上の町",
"児島", "坂出", "鴨川", "国分", "端岡", "鬼無", "高松",
];
const STATION_SEQUENCES: string[][] = [
...[yosan, uwajima, uwajima2, dosan, dosan2, koutoku, tokushima, naruto, seto].map(
(stations) => stations.map((station) => station.Station_JP),
),
MARINE_STATION_SEQUENCE,
];
const uniqueNonEmpty = (values: Array<string | null | undefined>): string[] => {
const seen = new Set<string>();
return values.filter((value): value is string => {
const normalized = value?.trim();
if (!normalized || seen.has(normalized)) return false;
seen.add(normalized);
return true;
});
};
const getElesiteQueryParam = (url: string, key: string): string | null => {
if (!url) return null;
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = url.match(new RegExp(`[?&]${escapedKey}=([^&#]+)`));
if (!match) return null;
try {
return decodeURIComponent(match[1].replace(/\+/g, " "));
} catch {
return match[1];
}
};
const getElesiteMatchedTrain = (
entry: ElesiteData,
trainNumber: string,
) =>
entry.trains?.find(
(train) => train.train_number.trim() === trainNumber.trim(),
);
const getElesiteLineMeta = (
entry: ElesiteData,
trainNumber: string,
): Omit<ElesiteLineGroup, "entries" | "formations" | "formationText" | "hasFormations" | "lastReportedAt"> => {
const matchedTrain = getElesiteMatchedTrain(entry, trainNumber);
const timetableUrl = matchedTrain?.timetable_url?.trim() || null;
const lineCode = timetableUrl
? getElesiteQueryParam(timetableUrl, "rosen_code")
: null;
const lineLabel = timetableUrl
? getElesiteQueryParam(timetableUrl, "rosen_name")
: null;
const leftStation = entry.formation_config?.left_station?.trim() || null;
const rightStation = entry.formation_config?.right_station?.trim() || null;
const fallbackKey = [lineLabel, leftStation, rightStation]
.filter(Boolean)
.join("::") || "unknown";
return {
key: lineCode || fallbackKey,
lineCode,
lineLabel,
leftStation,
rightStation,
timetableUrl,
};
};
const getElesiteEntryFormations = (entry: ElesiteData): string[] =>
uniqueNonEmpty(entry.formation_config?.units?.map((unit) => unit.formation) ?? []);
const getRouteEndpoints = (
trainNumber: string,
): { firstStation: string; lastStation: string } | null => {
const diagram = trainList[trainNumber.trim()];
if (!diagram) return null;
const stations = diagram
.split("#")
.map((stop) => stop.split(",")[0]?.trim())
.filter((station): station is string => !!station);
if (stations.length < 2) return null;
return {
firstStation: stations[0],
lastStation: stations[stations.length - 1],
};
};
const getMatchingStationSequence = (
leftStation: string,
rightStation: string,
firstStation: string,
lastStation: string,
): string[] | null =>
STATION_SEQUENCES.find(
(sequence) =>
sequence.includes(leftStation) &&
sequence.includes(rightStation) &&
sequence.includes(firstStation) &&
sequence.includes(lastStation),
) ??
STATION_SEQUENCES.find(
(sequence) =>
sequence.includes(leftStation) && sequence.includes(rightStation),
) ??
null;
export const inferElesiteHeadingDirection = (
entry: ElesiteData,
trainNumber: string,
): HeadingDirection | null => {
const matchedTrain = entry.trains?.find(
(train) => train.train_number.trim() === trainNumber.trim(),
);
const headingTo = matchedTrain?.nav?.heading_to;
if (headingTo === "left" || headingTo === "right") {
return headingTo;
}
const route = getRouteEndpoints(trainNumber);
if (!route) return null;
const leftStation = entry.formation_config?.left_station;
const rightStation = entry.formation_config?.right_station;
if (!leftStation || !rightStation) return null;
const sequence = getMatchingStationSequence(
leftStation,
rightStation,
route.firstStation,
route.lastStation,
);
if (!sequence) return null;
const firstIndex = sequence.indexOf(route.firstStation);
const lastIndex = sequence.indexOf(route.lastStation);
const leftIndex = sequence.indexOf(leftStation);
const rightIndex = sequence.indexOf(rightStation);
if ([firstIndex, lastIndex, leftIndex, rightIndex].some((index) => index < 0)) {
return null;
}
const trainDelta = lastIndex - firstIndex;
const formationDelta = rightIndex - leftIndex;
if (trainDelta === 0 || formationDelta === 0) return null;
return Math.sign(trainDelta) === Math.sign(formationDelta)
? "right"
: "left";
};
export const getElesiteLeftSideRank = (
entry: ElesiteData,
trainNumber: string,
): number => {
const matchedTrain = entry.trains?.find(
(train) => train.train_number.trim() === trainNumber.trim(),
);
if (!matchedTrain?.nav) return 2;
const headingDirection = inferElesiteHeadingDirection(entry, trainNumber);
if (!headingDirection) {
const isLeftSide =
(matchedTrain.nav.heading_to === "left") ===
(matchedTrain.nav.is_leading === true);
return isLeftSide ? 0 : 1;
}
const isLeftSide =
(headingDirection === "left") === (matchedTrain.nav.is_leading === true);
return isLeftSide ? 0 : 1;
};
export const sortElesiteEntriesByTrainNumber = (
entries: ElesiteData[],
trainNumber: string,
): ElesiteData[] =>
[...entries].sort(
(a, b) =>
getElesiteLeftSideRank(a, trainNumber) -
getElesiteLeftSideRank(b, trainNumber),
);
export const buildElesiteLineGroups = (
entries: ElesiteData[],
trainNumber: string,
): ElesiteLineGroup[] => {
const groups = new Map<string, ElesiteData[]>();
const lineOrder: string[] = [];
for (const entry of entries) {
const key = getElesiteLineMeta(entry, trainNumber).key;
if (!groups.has(key)) {
groups.set(key, []);
lineOrder.push(key);
}
groups.get(key)?.push(entry);
}
return lineOrder.map((key) => {
const groupedEntries = groups.get(key) ?? [];
const sortedEntries = sortElesiteEntriesByTrainNumber(groupedEntries, trainNumber);
const lineMetas = sortedEntries.map((entry) => getElesiteLineMeta(entry, trainNumber));
const formations = uniqueNonEmpty(
sortedEntries.flatMap((entry) => getElesiteEntryFormations(entry)),
);
return {
key,
lineCode: lineMetas.map((meta) => meta.lineCode).find(Boolean) ?? null,
lineLabel: lineMetas.map((meta) => meta.lineLabel).find(Boolean) ?? null,
leftStation: lineMetas.map((meta) => meta.leftStation).find(Boolean) ?? null,
rightStation: lineMetas.map((meta) => meta.rightStation).find(Boolean) ?? null,
timetableUrl:
lineMetas.map((meta) => meta.timetableUrl).find(Boolean) ?? null,
lastReportedAt:
sortedEntries
.map((entry) => entry.report_info?.last_reported_at)
.filter((value): value is string => !!value)
.sort()
.at(-1) ?? null,
entries: sortedEntries,
formations,
formationText: formations.length > 0 ? formations.join("+") : null,
hasFormations: formations.length > 0,
};
});
};
export const getElesiteSummaryByTrainNumber = (
entries: ElesiteData[],
trainNumber: string,
): string | null => {
const lineGroups = buildElesiteLineGroups(entries, trainNumber).filter(
(group) => group.hasFormations,
);
return lineGroups[0]?.formationText ?? null;
};
-7
View File
@@ -90,13 +90,6 @@ export const getBackendApiBaseUrl = (environment: unknown): string => {
return JR_DATA_SYSTEM_ENVS[envKey].backendApiBaseUrl;
};
export const getDiagramTodayUrl = (environment: unknown): string => {
const envKey = normalizeJrDataSystemEnvironment(environment);
return JR_DATA_SYSTEM_ENVS[envKey].track === "experimental"
? "https://jr-shikoku-api-data-storage.haruk.in/tmp/diagram-today-beta.json"
: "https://jr-shikoku-api-data-storage.haruk.in/tmp/diagram-today.json";
};
export const rewriteBackendApiUrl = (url: string, environment: unknown): string => {
if (typeof url !== "string" || url.length === 0) return url;
const target = getBackendApiBaseUrl(environment);
-1
View File
@@ -14,7 +14,6 @@
*/
export { generateXhrInterceptorJs, MockApiConfig, TrainEntry } from './webviewXhrInterceptor';
export { PositionMaster, PositionLookup, fetchPositionMasters, fetchMockTrainPositions, buildPosLookup, lookupPos } from './positionMasters';
// Pre-captured sample train position data from the official site
import trainJson from './mockData/train.json';
-101
View File
@@ -1,101 +0,0 @@
/**
* Position Masters JR Shikoku mock API
*
* Fetches the position-master table from the mock API server and provides
* a lookup helper to convert (PosNum, Line) Pos text.
*
* Used by:
* - useTrainMenu: fetches on mock-enable, stores in context
* - useCurrentTrain: fills Pos when mapping mock TrainEntry trainDataType
* - webviewXhrInterceptor: bakes lookup into injected JS so WebView can
* also resolve Pos text client-side
*/
const POSITION_MASTERS_URL =
'https://jr-shikoku-backend-mock-api-v1.haruk.in/position-masters';
const MOCK_TRAIN_POSITIONS_URL =
'https://jr-shikoku-backend-mock-api-v1.haruk.in/train-positions/current';
export interface PositionMaster {
pos_num: number;
/** "yosan" | "koutoku" | "tokushima" | "dosan" | "uwajima" | "kubokawa" */
line: string;
/** 表示テキスト e.g. "高松", "高松~栗林" */
pos_text: string;
pos_type: 'station' | 'between' | 'approaching' | 'yard';
display_order: number;
}
/** key: `${pos_num}:${line}` → pos_text */
export type PositionLookup = Map<string, string>;
/** Module-level cache (lives for the app session, not persisted). */
let _cache: PositionMaster[] | null = null;
/**
* Fetch position masters from the remote API.
* Results are cached in memory for the session.
*/
export const fetchPositionMasters = async (): Promise<PositionMaster[]> => {
if (_cache) return _cache;
const res = await fetch(POSITION_MASTERS_URL);
if (!res.ok) throw new Error(`position-masters fetch failed: ${res.status}`);
const data: PositionMaster[] = await res.json();
_cache = data;
return data;
};
/** Clear the in-memory cache (useful for testing / forced refresh). */
export const clearPositionMastersCache = () => {
_cache = null;
};
/**
* Build a fast Map from the masters array.
* When multiple records share the same (pos_num, line) pair (different
* display_order), the one with the lower display_order takes priority.
*/
export const buildPosLookup = (masters: PositionMaster[]): PositionLookup => {
const sorted = [...masters].sort((a, b) => a.display_order - b.display_order);
const map = new Map<string, string>();
for (const m of sorted) {
const key = `${m.pos_num}:${m.line}`;
if (!map.has(key)) {
map.set(key, m.pos_text);
}
}
return map;
};
/**
* Look up the Pos text for a given (PosNum, Line) pair.
* Returns `undefined` when no match is found.
*/
export const lookupPos = (
posNum: number,
line: string,
lookup: PositionLookup,
): string | undefined => lookup.get(`${posNum}:${line}`);
/**
* Serialize the lookup as a plain JS object literal suitable for embedding
* into an injected JavaScript string.
*/
export const serializePosLookupForJs = (lookup: PositionLookup): string => {
const entries = Array.from(lookup.entries())
.map(([k, v]) => `${JSON.stringify(k)}:${JSON.stringify(v)}`)
.join(',');
return `{${entries}}`;
};
/**
* Fetch the latest train positions from the mock API server.
* Returns an array of TrainEntry objects (GetDateTime sentinel included).
* Throws on network error or non-OK response.
*/
export const fetchMockTrainPositions = async (): Promise<any[]> => {
const res = await fetch(MOCK_TRAIN_POSITIONS_URL);
if (!res.ok) throw new Error(`mock train-positions fetch failed: ${res.status}`);
return res.json();
};
+4 -30
View File
@@ -1,5 +1,3 @@
import { PositionMaster, buildPosLookup, serializePosLookupForJs } from './positionMasters';
/**
* WebView XHR Interceptor for JR Shikoku official train position site
*
@@ -55,13 +53,6 @@ export interface MockApiConfig {
*/
trainPositions: TrainEntry[];
/**
* Position master data fetched from the mock API server.
* When provided, the interceptor script will use it to fill Pos text from PosNum
* for any train entry whose Pos field is absent.
*/
positionMasters?: PositionMaster[];
/**
* When true, all /g? static API calls are also intercepted and served from
* the supplied staticData map. When false (default), only the train position
@@ -95,11 +86,6 @@ export const generateXhrInterceptorJs = (config: MockApiConfig): string => {
.join(",\n ")
: "";
// Bake position-masters lookup into the script so Pos can be resolved client-side
const posLookupJs = config.positionMasters && config.positionMasters.length > 0
? serializePosLookupForJs(buildPosLookup(config.positionMasters))
: '{}';
return `
(function() {
// Double-injection guard: IJBCL and injectedJavaScript may both run this code.
@@ -114,20 +100,6 @@ export const generateXhrInterceptorJs = (config: MockApiConfig): string => {
var _STATIC_MAP = {
${staticEntries}
};
// Position masters lookup: key = "posNum:line", value = Pos text
var _POS_LOOKUP = ${posLookupJs};
// Enrich a _MOCK_TRAIN array by filling Pos from _POS_LOOKUP when absent
function _enrichTrain(entries) {
return entries.map(function(entry) {
if (!entry.TrainNum) return entry; // GetDateTime sentinel
if (entry.Pos) return entry; // already has text
var key = entry.PosNum + ':' + entry.Line;
var text = _POS_LOOKUP[key];
if (!text) return entry;
return Object.assign({}, entry, { Pos: text });
});
}
// ── Prototype-patching approach ────────────────────────────────────────────
// Instead of replacing window.XMLHttpRequest with a wrapper class,
@@ -142,7 +114,10 @@ export const generateXhrInterceptorJs = (config: MockApiConfig): string => {
_proto.open = function(method, url) {
var qs = (url || '').replace(/^[^?]+\\?/, '');
if (qs === 'arg1=train&arg2=train') {
this.__jrsMockBody = JSON.stringify(_enrichTrain(_MOCK_TRAIN));
this.__jrsMockBody = JSON.stringify(_MOCK_TRAIN);
// Still call origOpen so the XHR enters OPENED state.
// This allows the page to call setRequestHeader() without errors.
// The real request is suppressed in send() by not calling _origSend.
_origOpen.apply(this, arguments);
return;
}
@@ -212,7 +187,6 @@ export const generateXhrInterceptorJs = (config: MockApiConfig): string => {
// Called from React Native via injectJavaScript when the playback frame changes.
window.__jrsMockUpdateTrain = function(newData) {
_MOCK_TRAIN = newData;
// Re-enrich on next open() call (data is enriched lazily in _proto.open)
};
window.__jrsMockActive = true;
+10 -93
View File
@@ -3,15 +3,6 @@ import { TRAIN_TYPE_CONFIG } from './webview/trainTypeConfig';
import { TRAIN_ICON_MAP, TRAIN_ICON_REGEX } from './webview/trainIconMap';
import { STATION_DATA } from './webview/stationData';
import { generateXhrInterceptorJs, MockApiConfig } from './mockApi/webviewXhrInterceptor';
import dosan from '@/assets/originData/dosan';
import dosan2 from '@/assets/originData/dosan2';
import koutoku from '@/assets/originData/koutoku';
import naruto from '@/assets/originData/naruto';
import seto from '@/assets/originData/seto';
import tokushima from '@/assets/originData/tokushima';
import uwajima from '@/assets/originData/uwajima';
import uwajima2 from '@/assets/originData/uwajima2';
import yosan from '@/assets/originData/yosan';
export interface InjectJavascriptOptions {
/** 地図スイッチ ("true" | "false") */
@@ -41,11 +32,6 @@ export interface InjectJavascriptOptions {
* experimental https://jr-shikoku-backend-api-v1-beta.haruk.in を渡す。
*/
backendApiBaseUrl?: string;
/**
* JSONのURLURL
* experimental diagram-today-beta.json URLを渡す
*/
diagramTodayUrl?: string;
}
const PRODUCTION_BACKEND_BASE = 'https://jr-shikoku-backend-api-v1.haruk.in';
@@ -60,19 +46,8 @@ export const injectJavascriptData = ({
useElesite,
isDark,
backendApiBaseUrl,
diagramTodayUrl,
mockApiConfig,
}: InjectJavascriptOptions): string => {
const MARINE_STATION_SEQUENCE = [
"岡山", "大元", "備前西市", "妹尾", "早島", "茶屋町", "植松", "木見", "上の町",
"児島", "坂出", "鴨川", "国分", "端岡", "鬼無", "高松",
];
const elesiteStationSequences: string[][] = [
...[yosan, uwajima, uwajima2, dosan, dosan2, koutoku, tokushima, naruto, seto].map(
(stations) => stations.map((station) => station.Station_JP),
),
MARINE_STATION_SEQUENCE,
];
// 一番上のメニュー非表示 地図スイッチによって切り替え
const topMenu =
@@ -502,71 +477,17 @@ export const injectJavascriptData = ({
return null;
}
const results = [];
const elesiteStationSequences = ${JSON.stringify(elesiteStationSequences)};
const getElesiteRouteEndpoints = (targetTrainNumber) => {
const diagram = trainDiagramData2[targetTrainNumber] || trainTimeInfo[targetTrainNumber];
if (!diagram) return null;
const stations = diagram
.split('#')
.map((stop) => stop.split(',')[0] && stop.split(',')[0].trim())
.filter(Boolean);
if (stations.length < 2) return null;
return {
firstStation: stations[0],
lastStation: stations[stations.length - 1],
};
};
const getElesiteHeadingDirection = (entry, targetTrainNumber) => {
const matchedTrain = entry.trains && entry.trains.find((train) => train.train_number === targetTrainNumber);
const nav = matchedTrain && matchedTrain.nav;
if (!nav) return null;
if (nav.heading_to === 'left' || nav.heading_to === 'right') {
return nav.heading_to;
}
const route = getElesiteRouteEndpoints(targetTrainNumber);
const formationConfig = entry.formation_config;
if (!route || !formationConfig || !formationConfig.left_station || !formationConfig.right_station) {
return null;
}
const sequence = elesiteStationSequences.find((stations) =>
stations.includes(formationConfig.left_station) &&
stations.includes(formationConfig.right_station) &&
stations.includes(route.firstStation) &&
stations.includes(route.lastStation)
) || elesiteStationSequences.find((stations) =>
stations.includes(formationConfig.left_station) &&
stations.includes(formationConfig.right_station)
);
if (!sequence) return null;
const firstIndex = sequence.indexOf(route.firstStation);
const lastIndex = sequence.indexOf(route.lastStation);
const leftIndex = sequence.indexOf(formationConfig.left_station);
const rightIndex = sequence.indexOf(formationConfig.right_station);
if ([firstIndex, lastIndex, leftIndex, rightIndex].some((index) => index < 0)) {
return null;
}
const trainDelta = lastIndex - firstIndex;
const formationDelta = rightIndex - leftIndex;
if (trainDelta === 0 || formationDelta === 0) {
return null;
}
return Math.sign(trainDelta) === Math.sign(formationDelta) ? 'right' : 'left';
};
const getElesiteSortRank = (entry, targetTrainNumber) => {
const matchedTrain = entry.trains && entry.trains.find((train) => train.train_number === targetTrainNumber);
const nav = matchedTrain && matchedTrain.nav;
if (!nav) return 2;
const headingDirection = getElesiteHeadingDirection(entry, targetTrainNumber);
if (!headingDirection) {
const isLeftSide = (nav.heading_to === 'left') === (nav.is_leading === true);
return isLeftSide ? 0 : 1;
}
const isLeftSide = (headingDirection === 'left') === (nav.is_leading === true);
return isLeftSide ? 0 : 1;
};
// 高松(left_station)側のユニットを先に表示
// (heading_to === "left") === is_leading が true → 高松(left)端のユニット
const sortedElesiteData = elesiteData.slice().sort((a, b) => {
return getElesiteSortRank(a, trainNumber) - getElesiteSortRank(b, trainNumber);
const aT = a.trains && a.trains.find(t => t.train_number === trainNumber);
const bT = b.trains && b.trains.find(t => t.train_number === trainNumber);
const aNav = aT && aT.nav;
const bNav = bT && bT.nav;
const aIsLeft = (aNav && aNav.heading_to === 'left') === (aNav && aNav.is_leading === true);
const bIsLeft = (bNav && bNav.heading_to === 'left') === (bNav && bNav.is_leading === true);
if (aIsLeft === bIsLeft) return 0;
return aIsLeft ? -1 : 1;
});
for (const entry of sortedElesiteData) {
if (!entry.trains) continue;
@@ -683,6 +604,7 @@ export const injectJavascriptData = ({
}
if(new RegExp(/^4[1-9]\\d\\d[DM]$/).test() || new RegExp(/^5[1-7]\\d\\d[DM]$/).test() || new RegExp(/^3[2-9]\\d\\d[DM]$/).test(TrainNumber) ){
flag=true;
isWanman = true;
}
if(new RegExp(/^49[0-4]\\dD$/).test() || new RegExp(/^9[0-4]\\dD$/).test()){
@@ -1514,11 +1436,6 @@ setStationMenuDialog.observe(document.querySelector('#disp'), {
result = result.split(PRODUCTION_BACKEND_BASE).join(backendApiBaseUrl);
}
const PRODUCTION_DIAGRAM_TODAY = API_ENDPOINTS.DIAGRAM_TODAY;
if (diagramTodayUrl && diagramTodayUrl !== PRODUCTION_DIAGRAM_TODAY) {
result = result.split(PRODUCTION_DIAGRAM_TODAY).join(diagramTodayUrl);
}
// Prepend XHR interceptor as a fallback for platforms where
// injectedJavaScriptBeforeContentLoaded doesn't run reliably.
// The __jrsMockActive guard in the interceptor prevents double-patching
-1
View File
@@ -1,7 +1,6 @@
{
"main": "index.js",
"scripts": {
"compile-web-script": "npx tsx scripts/compile-web-script.ts",
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
-144
View File
@@ -1,144 +0,0 @@
#!/usr/bin/env tsx
/**
* compile-web-script.ts
*
* Usage:
* yarn compile-web-script
* yarn compile-web-script --no-obf # minifyのみ
* yarn compile-web-script --no-userscript # .js userscript不要
*
* Options ():
* UI=tokyo|default (default: tokyo)
* DARK=true|false (default: false)
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { injectJavascriptData } from '../lib/webViewInjectjavascript';
// ---- オプション解析 ----
const args = process.argv.slice(2);
const noObf = args.includes('--no-obf');
const noUserscript = args.includes('--no-userscript');
const options = {
mapSwitch: 'false',
iconSetting: 'true',
stationMenu: 'false',
trainMenu: 'false',
uiSetting: (process.env.UI ?? 'tokyo') as string,
useUnyohub: 'false',
useElesite: 'false',
isDark: (process.env.DARK ?? 'false') === 'true',
backendApiBaseUrl:'https://jr-shikoku-backend-api-v1.haruk.in',
mockApiConfig: null,
} as const;
// ---- 出力ディレクトリ ----
const outDir = path.resolve(__dirname, '../docs/generated');
fs.mkdirSync(outDir, { recursive: true });
// const baseJs = path.join(outDir, 'webInjectJavascript.parsed.current.js'); // 中間: ベタJS
// const minJs = path.join(outDir, 'webInjectJavascript.parsed.current.min.js'); // 中間: minify済み
// const obfJs = path.join(outDir, 'webInjectJavascript.parsed.current.obf.js'); // 中間: 難読化済みJS
// const baseUser = path.join(outDir, 'webInjectJavascript.parsed.current.user.js'); // 中間: ベタuserscript
const obfUser = path.join(outDir, 'webInjectJavascript.parsed.current.obf.user.js');
// 中間ファイルを出力したい場合は上の const を有効化して各ステップの writeFileSync コメントを外す
const baseJs = '/tmp/webInjectJavascript.base.js';
const minJs = '/tmp/webInjectJavascript.min.js';
const obfJs = '/tmp/webInjectJavascript.obf.js';
// ---- userscript ヘッダ ----
const userscriptHeader = `\
// ==UserScript==
// @name JR Shikoku WebInject
// @namespace jrshikoku
// @version 1.0.0
// @description Generated inject script for train.jr-shikoku.co.jp
// @match https://train.jr-shikoku.co.jp/*
// @run-at document-end
// @grant none
// ==/UserScript==
(function(){
`;
const userscriptFooter = `\n})();\n`;
function wrapUserscript(src: string): string {
return userscriptHeader + src + userscriptFooter;
}
// ---- Step 1: ベタJS生成 ----
console.log('[1/3] Generating base JS...');
const shim = `\
/*
Generated from lib/webViewInjectjavascript.ts
Options: ${JSON.stringify(options)}
Date: ${new Date().toISOString()}
*/
if (!window.ReactNativeWebView) {
window.ReactNativeWebView = {
postMessage: (msg) => console.log('[ReactNativeWebView.postMessage]', msg),
};
}
`;
const raw = injectJavascriptData(options);
const baseContent = `${shim}\n${raw}`;
fs.writeFileSync(baseJs, baseContent, 'utf8');
// fs.writeFileSync(baseJs, baseContent, 'utf8'); // 中間ファイルとして保存する場合
// if (!noUserscript) {
// fs.writeFileSync(baseUser, wrapUserscript(baseContent), 'utf8'); // ベタuserscriptを保存する場合
// }
console.log(` -> base JS generated (${Math.round(Buffer.byteLength(baseContent) / 1024)}KB)`);
// ---- Step 2: minify (terser) ----
console.log('[2/3] Minifying with terser...');
const terserCmd = [
'npx', 'terser', JSON.stringify(baseJs),
'--compress', 'passes=3,drop_console=false,pure_getters=true',
'--mangle', 'toplevel=true',
'--output', JSON.stringify(minJs),
].join(' ');
execSync(terserCmd, { stdio: 'inherit' });
// fs.copyFileSync(minJs, path.join(outDir, 'webInjectJavascript.parsed.current.min.js')); // 中間ファイルとして保存する場合
console.log(` -> minified (${Math.round(fs.statSync(minJs).size / 1024)}KB)`);
if (noObf) {
console.log('[3/3] Skipped obfuscation (--no-obf)');
console.log('Done.');
process.exit(0);
}
// ---- Step 3: 難読化 (javascript-obfuscator) ----
console.log('[3/3] Obfuscating with javascript-obfuscator...');
const obfCmd = [
'npx', 'javascript-obfuscator', JSON.stringify(minJs),
'--output', JSON.stringify(obfJs),
'--compact', 'true',
'--control-flow-flattening', 'false',
'--dead-code-injection', 'false',
'--string-array', 'true',
'--string-array-encoding', 'base64',
'--string-array-threshold', '0.75',
'--string-array-rotate', 'true',
'--string-array-shuffle', 'true',
'--string-array-calls-transform', 'true',
'--string-array-index-shift', 'true',
'--string-array-wrappers-count', '2',
'--string-array-wrappers-type', 'function',
'--string-array-wrappers-chained-calls', 'true',
'--split-strings', 'true',
'--split-strings-chunk-length', '8',
'--identifier-names-generator', 'hexadecimal',
'--rename-globals', 'false',
'--self-defending', 'false',
].join(' ');
execSync(obfCmd, { stdio: 'inherit' });
// fs.copyFileSync(obfJs, path.join(outDir, 'webInjectJavascript.parsed.current.obf.js')); // 難読化JSを保存する場合
const obfContent = fs.readFileSync(obfJs, 'utf8');
fs.writeFileSync(obfUser, wrapUserscript(obfContent), 'utf8');
console.log('\nDone. Generated:');
console.log(` ${path.relative(process.cwd(), obfUser)} (${Math.round(fs.statSync(obfUser).size / 1024)}KB)`);
+2 -9
View File
@@ -12,7 +12,6 @@ import React, {
import { API_ENDPOINTS, STORAGE_KEYS } from "@/constants";
import {
getBackendApiBaseUrl,
getDiagramTodayUrl,
BACKEND_API_BASE_URLS,
} from "@/lib/jrDataSystemEnvironment";
const initialState = {
@@ -54,23 +53,17 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
const [backendApiBaseUrl, setBackendApiBaseUrl] = React.useState(
BACKEND_API_BASE_URLS.production,
);
const [diagramTodayUrl, setDiagramTodayUrl] = React.useState(
"https://jr-shikoku-api-data-storage.haruk.in/tmp/diagram-today.json",
);
React.useEffect(() => {
AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV)
.then((value) => {
setBackendApiBaseUrl(getBackendApiBaseUrl(value));
setDiagramTodayUrl(getDiagramTodayUrl(value));
})
.then((value) => setBackendApiBaseUrl(getBackendApiBaseUrl(value)))
.catch(() => {});
}, []);
const getTrainDiagram = () => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
fetch(diagramTodayUrl, { signal: controller.signal })
fetch("https://jr-shikoku-api-data-storage.haruk.in/tmp/diagram-today.json", { signal: controller.signal })
.then((res) => res.json())
.then((res) => {
const data = {};
+5 -33
View File
@@ -16,7 +16,6 @@ import { checkDuplicateTrainData } from "@/lib/checkDuplicateTrainData";
import { getStationID } from "@/lib/eachTrainInfoCoreLib/getStationData";
import { trainDataType } from "@/lib/trainPositionTextArray";
import { MOCK_TRAIN_POSITIONS } from "@/lib/mockApi";
import { fetchMockTrainPositions } from "@/lib/mockApi/positionMasters";
import WebView from "react-native-webview";
import { StationProps } from "@/lib/CommonTypes";
type loading = "loading" | "success" | "error";
@@ -78,7 +77,7 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
const [currentTrainLoading, setCurrentTrainLoading] =
useState<loading>("loading");
const { mockApiFeatureEnabled, mockTrainPositions, addTrainSnapshot, lookupPosText, recorderState } = useTrainMenu();
const { mockApiFeatureEnabled, mockTrainPositions, addTrainSnapshot } = useTrainMenu();
const { getInjectJavascriptAddress, stationList, originalStationList } =
useStationList();
@@ -262,8 +261,8 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
}
};
const getCurrentTrain = () => {
// 再生中: 録画データをそのまま使う(ポーリング不要)
if (recorderState === 'playing') {
// モックAPI機能が有効な場合はモックデータを使用
if (mockApiFeatureEnabled) {
const source = mockTrainPositions ?? MOCK_TRAIN_POSITIONS;
const mapped = source
.filter((x): x is import("@/lib/mockApi/webviewXhrInterceptor").TrainEntry =>
@@ -272,8 +271,8 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
.map((x) => ({
Index: x.Index,
num: x.TrainNum,
delay: x.delay as "入線" | number,
Pos: x.Pos || lookupPosText(x.PosNum, x.Line) || '',
delay: x.delay,
Pos: x.Pos,
PosNum: x.PosNum,
Direction: x.Direction,
Type: x.Type,
@@ -283,33 +282,6 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
setCurrentTrainLoading("success");
return;
}
// モックON(通常): モックAPIサーバーをポーリング
if (mockApiFeatureEnabled) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
fetchMockTrainPositions()
.then((data) => {
const entries = data.filter((x: any) => "TrainNum" in x);
const mapped = entries.map((x: any) => ({
Index: x.Index as number,
num: x.TrainNum as string,
delay: x.delay as "入線" | number,
Pos: (x.Pos || lookupPosText(x.PosNum, x.Line) || '') as string,
PosNum: x.PosNum as number,
Direction: x.Direction as number,
Type: x.Type as string,
Line: x.Line as string,
}));
setCurrentTrain(mapped);
setCurrentTrainLoading("success");
})
.catch(() => {
setCurrentTrainLoading("error");
})
.finally(() => clearTimeout(timeoutId));
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
fetch("https://n8n.haruk.in/webhook/c501550c-7d1b-4e50-927b-4429fe18931a", { signal: controller.signal })
+25 -5
View File
@@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
import { AS } from "../storageControl";
import { STORAGE_KEYS } from "@/constants";
import { API_ENDPOINTS } from "@/constants";
import { getElesiteSummaryByTrainNumber } from "@/lib/elesiteTrainOrder";
import type { ElesiteResponse, ElesiteData } from "@/types/unyohub";
type ElesiteHook = {
@@ -67,11 +66,32 @@ export const useElesite = (): ElesiteHook => {
const getElesiteByTrainNumber = (trainNumber: string): string | null => {
if (!useElesite || elesiteData.length === 0) return null;
const matchedEntries = elesiteData.filter((entry) =>
entry.trains?.some((train) => train.train_number.trim() === trainNumber),
);
const results: string[] = [];
return getElesiteSummaryByTrainNumber(matchedEntries, trainNumber);
// 高松(left_station)側のユニットを先に表示
// (heading_to === "left") === is_leading が true → 高松(left)端のユニット
const sortedEntries = [...elesiteData].sort((a, b) => {
const aNav = a.trains?.find(t => t.train_number.trim() === trainNumber)?.nav;
const bNav = b.trains?.find(t => t.train_number.trim() === 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.trim() === 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;
};
// 列番に紐づくエントリをすべて取得
+3 -70
View File
@@ -4,7 +4,6 @@ import React, {
useState,
useEffect,
useRef,
useCallback,
FC,
} from "react";
@@ -15,14 +14,6 @@ import { getStationList2 } from "../lib/getStationList";
import { injectJavascriptData, generateBeforeContentLoadedScript } from "../lib/webViewInjectjavascript";
import { MockApiConfig, TrainEntry } from "../lib/mockApi/webviewXhrInterceptor";
import { MOCK_TRAIN_POSITIONS } from "../lib/mockApi";
import {
PositionMaster,
PositionLookup,
fetchPositionMasters,
fetchMockTrainPositions,
buildPosLookup,
lookupPos,
} from "../lib/mockApi/positionMasters";
import {
TrainRecording,
RecordingMeta,
@@ -39,7 +30,6 @@ import { useThemeColors } from "@/lib/theme";
import { STORAGE_KEYS } from "@/constants/storage";
import {
getBackendApiBaseUrl,
getDiagramTodayUrl,
BACKEND_API_BASE_URLS,
} from "@/lib/jrDataSystemEnvironment";
@@ -82,10 +72,6 @@ const initialState = {
/** モックAPI検証機能が設定で有効化されているか(admin専用) */
mockApiFeatureEnabled: false,
setMockApiFeatureEnabled: (e: boolean) => {},
/** 位置マスターデータ(mock API server から取得) */
positionMasters: [] as PositionMaster[],
/** PosNum + Line → Pos テキスト変換 */
lookupPosText: (_posNum: number, _line: string): string | undefined => undefined,
// --- 録画・再生 ---
recorderState: 'idle' as 'idle' | 'recording' | 'playing',
recordingSnapshotCount: 0,
@@ -133,9 +119,6 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
const [backendApiBaseUrl, setBackendApiBaseUrl] = useState(
BACKEND_API_BASE_URLS.production,
);
const [diagramTodayUrl, setDiagramTodayUrl] = useState(
"https://jr-shikoku-api-data-storage.haruk.in/tmp/diagram-today.json",
);
//更新権限所有確認・情報ソース別利用権限(将来ロールが増えたらここに足す)
const [updatePermission, setUpdatePermission] = useState(false);
@@ -181,9 +164,6 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
// モックAPI設定
const [mockTrainPositions, setMockTrainPositions] = useState<TrainEntry[] | null>(null);
// 位置マスター(mock API server から取得・キャッシュ)
const [positionMasters, setPositionMasters] = useState<PositionMaster[]>([]);
const [posLookup, setPosLookup] = useState<PositionLookup>(new Map());
// admin専用: モックAPI検証機能の有効化(永続化)
const [mockApiFeatureEnabled, setMockApiFeatureEnabledState] = useState(false);
@@ -192,15 +172,6 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
AS.setItem(STORAGE_KEYS.MOCK_API_FEATURE_ENABLED, value.toString());
// 機能をオフにしたらデータをリセット
if (!value) setMockTrainPositions(MOCK_TRAIN_POSITIONS);
// 機能をオンにしたら位置マスターを取得(未取得の場合)
if (value && positionMasters.length === 0) {
fetchPositionMasters()
.then((masters) => {
setPositionMasters(masters);
setPosLookup(buildPosLookup(masters));
})
.catch(() => {});
}
};
// --- 録画・再生 ---
@@ -304,13 +275,9 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
setRecordingList(list);
};
/** PosNum + Line → Pos テキスト(位置マスターから) */
const lookupPosText = (posNum: number, line: string): string | undefined =>
lookupPos(posNum, line, posLookup);
const mockApiConfig: MockApiConfig | null =
mockApiFeatureEnabled && mockTrainPositions
? { trainPositions: mockTrainPositions, positionMasters }
? { trainPositions: mockTrainPositions }
: null;
//地図表示テキスト
@@ -324,7 +291,6 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
useElesite: useEleSiteSetting,
isDark: useThemeColors().isDark,
backendApiBaseUrl,
diagramTodayUrl,
mockApiConfig,
});
@@ -378,47 +344,16 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
});
//モックAPI検証機能スイッチ(admin専用・再起動不要)
AS.getItem(STORAGE_KEYS.MOCK_API_FEATURE_ENABLED).then((value) => {
const enabled = value === "true" || value === true;
setMockApiFeatureEnabledState(enabled);
// 起動時に既に有効なら位置マスターを取得
if (enabled) {
fetchPositionMasters()
.then((masters) => {
setPositionMasters(masters);
setPosLookup(buildPosLookup(masters));
})
.catch(() => {});
}
setMockApiFeatureEnabledState(value === "true" || value === true);
}).catch(() => {});
// バックエンドAPIベースURL(環境設定から読み込み)
AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV).then((value) => {
setBackendApiBaseUrl(getBackendApiBaseUrl(value));
setDiagramTodayUrl(getDiagramTodayUrl(value));
}).catch(() => {});
// 静的サンプルデータで初期化(モックAPIが応答するまでのフォールバック)
// サンプルデータで初期化
setMockTrainPositions(MOCK_TRAIN_POSITIONS);
}, []);
// モックAPIポーリング: モックON かつ 録画/再生中でない場合に15秒ごとに更新
const fetchAndSetMockPositions = useCallback(() => {
if (!mockApiFeatureEnabled || recorderState !== 'idle') return;
fetchMockTrainPositions()
.then((data) => {
const entries = data.filter((x: any) => "TrainNum" in x) as TrainEntry[];
setMockTrainPositions(entries);
})
.catch(() => {});
}, [mockApiFeatureEnabled, recorderState]);
useEffect(() => {
if (!mockApiFeatureEnabled || recorderState !== 'idle') return;
// 即時取得
fetchAndSetMockPositions();
// 15秒ごとにポーリング
const timer = setInterval(fetchAndSetMockPositions, 15000);
return () => clearInterval(timer);
}, [mockApiFeatureEnabled, recorderState]);
return (
<TrainMenuContext.Provider
value={{
@@ -449,8 +384,6 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
setMockTrainPositions,
mockApiFeatureEnabled,
setMockApiFeatureEnabled,
positionMasters,
lookupPosText,
recorderState,
recordingSnapshotCount,
recordingList,