Compare commits
18 Commits
experiment
...
feature/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ba9a750ea | ||
|
|
95f498e894 | ||
|
|
23ef404f7b | ||
|
|
709cbda835 | ||
|
|
f2bb72e40d | ||
|
|
6650a8c951 | ||
|
|
d76daf8791 | ||
|
|
8d7005170c | ||
|
|
26aa71866a | ||
|
|
2274934731 | ||
|
|
6c674a3f38 | ||
|
|
d72acabe9d | ||
|
|
3e0e4876bc | ||
|
|
3ab0b332ec | ||
|
|
5e6ff42fe6 | ||
|
|
4916edd498 | ||
|
|
0c582765db | ||
|
|
ff6a8c9518 |
@@ -1,5 +1,7 @@
|
||||
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";
|
||||
@@ -129,6 +131,7 @@ export default ({ route }) => {
|
||||
contentMode="mobile"
|
||||
allowsBackForwardNavigationGestures
|
||||
ref={webViewRef}
|
||||
injectedJavaScriptBeforeContentLoaded={`true;`}
|
||||
onLoadStart={() => {
|
||||
isLoadingRef.current = true;
|
||||
setHasError(false);
|
||||
@@ -217,6 +220,21 @@ 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
WINDOWS_PORT.md
160
WINDOWS_PORT.md
@@ -1,160 +0,0 @@
|
||||
# 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' の分岐が必要になる箇所を確認すること
|
||||
```
|
||||
@@ -18,4 +18,18 @@ 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,6 +122,16 @@ 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);
|
||||
|
||||
@@ -255,10 +265,18 @@ export const EachStopList: FC<props> = ({
|
||||
textAlignVertical: "center",
|
||||
}}
|
||||
>
|
||||
{station}{platformNum &&
|
||||
(parseInt(platformNum) === 0
|
||||
? "⓪"
|
||||
: String.fromCharCode(0x2460 + parseInt(platformNum) - 1))}
|
||||
{station}
|
||||
{hasPlatformNum && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: fontScale(isPlatformNumNumeric ? 20 : 14),
|
||||
color: colors.text,
|
||||
fontStyle: isCommunity ? "italic" : "normal",
|
||||
}}
|
||||
>
|
||||
{platformDisplay}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
</View>
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { UnyohubData, ElesiteData } from "@/types/unyohub";
|
||||
import { useUnyohub } from "@/stateBox/useUnyohub";
|
||||
import { useElesite } from "@/stateBox/useElesite";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { sortElesiteEntriesByTrainNumber } from "@/lib/elesiteTrainOrder";
|
||||
import ViewShot from "react-native-view-shot";
|
||||
import * as Sharing from "expo-sharing";
|
||||
|
||||
@@ -540,20 +541,10 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
const elesiteHasNonEmptyFormations = elesiteEntries.some(
|
||||
(e) => (e.formation_config?.units?.length ?? 0) > 0,
|
||||
);
|
||||
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;
|
||||
});
|
||||
const elesiteNonEmptyFormationEntries = sortElesiteEntriesByTrainNumber(
|
||||
elesiteEntries.filter((e) => (e.formation_config?.units?.length ?? 0) > 0),
|
||||
trainNum,
|
||||
);
|
||||
// えれサイト: 編成名テキスト(formation_config.units 優先)
|
||||
const elesiteFormationNames =
|
||||
elesiteNonEmptyFormationEntries
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { FC, useRef, useCallback } from "react";
|
||||
import React, { FC, useRef, useCallback, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -33,32 +33,69 @@ 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) => {
|
||||
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)));
|
||||
onPanResponderGrant: (evt, gestureState) => {
|
||||
const idx = indexFromPageX(gestureState.x0);
|
||||
pausePlayback();
|
||||
setIsScrubbing(true);
|
||||
setScrubIndex(idx);
|
||||
scrubIndexRef.current = 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)));
|
||||
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);
|
||||
},
|
||||
})
|
||||
).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);
|
||||
@@ -70,7 +107,8 @@ export const PlaybackTimeline: FC = () => {
|
||||
const total = activeRecording.snapshots.length;
|
||||
totalRef.current = total; // PanResponder が参照する最新値を更新
|
||||
|
||||
const snap = activeRecording.snapshots[playbackIndex];
|
||||
const displayIndex = isScrubbing && scrubIndex !== null ? scrubIndex : playbackIndex;
|
||||
const snap = activeRecording.snapshots[displayIndex];
|
||||
|
||||
// スナップショット時刻 = 録画開始時刻 + elapsed
|
||||
const snapTime = dayjs(activeRecording.recordedAt).add(snap.t, "ms");
|
||||
@@ -83,7 +121,7 @@ export const PlaybackTimeline: FC = () => {
|
||||
? `${Math.floor(totalSec / 60)}:${String(totalSec % 60).padStart(2, "0")}`
|
||||
: `${totalSec}s`;
|
||||
|
||||
const progress = total > 1 ? playbackIndex / (total - 1) : 0;
|
||||
const progress = total > 1 ? displayIndex / (total - 1) : 0;
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -168,7 +206,7 @@ export const PlaybackTimeline: FC = () => {
|
||||
{timeLabel}
|
||||
</Text>
|
||||
<Text style={{ fontSize: moderateScale(10), color: colors.textSecondary, fontVariant: ["tabular-nums"] }}>
|
||||
{playbackIndex + 1}/{total} {totalLabel}
|
||||
{displayIndex + 1}/{total} {totalLabel}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -184,7 +222,8 @@ export const PlaybackTimeline: FC = () => {
|
||||
|
||||
{/* 下段: スクラバートラック */}
|
||||
<View
|
||||
style={{ marginTop: 6 }}
|
||||
ref={trackViewRef}
|
||||
style={{ marginTop: 6, paddingVertical: 8, marginVertical: -8 }}
|
||||
onLayout={onTrackLayout}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
|
||||
@@ -22,10 +22,11 @@ type ReloadButton = {
|
||||
}
|
||||
export const ReloadButton:FC<ReloadButton> = ({ onPress, right }) => {
|
||||
const { fixed } = useThemeColors();
|
||||
const { mapSwitch, LoadError = false } = useTrainMenu();
|
||||
const { mapSwitch, LoadError = false, mockApiFeatureEnabled } = 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",
|
||||
@@ -33,7 +34,7 @@ export const ReloadButton:FC<ReloadButton> = ({ onPress, right }) => {
|
||||
right: 10 + right,
|
||||
width: buttonSize,
|
||||
height: buttonSize,
|
||||
backgroundColor: LoadError ? "red" : fixed.primary,
|
||||
backgroundColor: buttonColor,
|
||||
borderColor: fixed.textOnPrimary,
|
||||
borderStyle: "solid",
|
||||
borderWidth: 1,
|
||||
|
||||
@@ -268,6 +268,24 @@ 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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Alert,
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
@@ -11,6 +12,7 @@ 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";
|
||||
@@ -302,6 +304,7 @@ 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);
|
||||
@@ -358,6 +361,28 @@ 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
|
||||
@@ -553,13 +578,16 @@ export const DataSourceSettings = () => {
|
||||
month: 'numeric', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
return (
|
||||
<View
|
||||
key={rec.id}
|
||||
const recordingRow = (
|
||||
<TouchableOpacity
|
||||
onPress={() => startPlayback(rec.id)}
|
||||
disabled={isPlaying}
|
||||
activeOpacity={0.72}
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderRadius: 8, padding: 10, gap: 8,
|
||||
borderRadius: 8, padding: 12, gap: 10,
|
||||
opacity: isPlaying ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
@@ -570,29 +598,71 @@ export const DataSourceSettings = () => {
|
||||
{rec.snapshotCount} コマ / {durationLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{!isPlaying && (
|
||||
<TouchableOpacity
|
||||
onPress={() => startPlayback(rec.id)}
|
||||
<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
|
||||
style={{
|
||||
backgroundColor: '#43a047', borderRadius: 6,
|
||||
paddingHorizontal: 10, paddingVertical: 6,
|
||||
width: 96,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#e53935',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 6,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: 'bold', fontSize: 12 }}>▶</Text>
|
||||
</TouchableOpacity>
|
||||
<MaterialCommunityIcons name="trash-can-outline" size={18} color="#fff" />
|
||||
<Text style={{ color: '#fff', fontSize: 11, fontWeight: 'bold', marginTop: 4 }}>
|
||||
削除
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{!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>
|
||||
onSwipeableOpen={() => confirmDeleteRecording(rec.id, dateLabel)}
|
||||
>
|
||||
{recordingRow}
|
||||
</Swipeable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
@@ -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.3"; // Update this version code as needed
|
||||
const versionCode = "7.0.5"; // Update this version code as needed
|
||||
|
||||
export const SettingTopPage = ({
|
||||
testNFC,
|
||||
|
||||
@@ -7,6 +7,9 @@ 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
142
lib/elesiteTrainOrder.ts
Normal file
142
lib/elesiteTrainOrder.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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";
|
||||
|
||||
// マリンライナー(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 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),
|
||||
);
|
||||
@@ -90,6 +90,13 @@ 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);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
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
lib/mockApi/positionMasters.ts
Normal file
101
lib/mockApi/positionMasters.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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();
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { PositionMaster, buildPosLookup, serializePosLookupForJs } from './positionMasters';
|
||||
|
||||
/**
|
||||
* WebView XHR Interceptor for JR Shikoku official train position site
|
||||
*
|
||||
@@ -53,6 +55,13 @@ 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
|
||||
@@ -86,6 +95,11 @@ 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.
|
||||
@@ -100,6 +114,20 @@ 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,
|
||||
@@ -114,10 +142,7 @@ 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(_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.
|
||||
this.__jrsMockBody = JSON.stringify(_enrichTrain(_MOCK_TRAIN));
|
||||
_origOpen.apply(this, arguments);
|
||||
return;
|
||||
}
|
||||
@@ -187,6 +212,7 @@ 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;
|
||||
|
||||
@@ -3,6 +3,15 @@ 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") */
|
||||
@@ -32,6 +41,11 @@ export interface InjectJavascriptOptions {
|
||||
* experimental 環境では https://jr-shikoku-backend-api-v1-beta.haruk.in を渡す。
|
||||
*/
|
||||
backendApiBaseUrl?: string;
|
||||
/**
|
||||
* ダイアグラムJSONのURL。省略時は本番URL。
|
||||
* experimental 環境では diagram-today-beta.json のURLを渡す。
|
||||
*/
|
||||
diagramTodayUrl?: string;
|
||||
}
|
||||
|
||||
const PRODUCTION_BACKEND_BASE = 'https://jr-shikoku-backend-api-v1.haruk.in';
|
||||
@@ -46,8 +60,19 @@ 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 =
|
||||
@@ -477,17 +502,71 @@ 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) => {
|
||||
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;
|
||||
return getElesiteSortRank(a, trainNumber) - getElesiteSortRank(b, trainNumber);
|
||||
});
|
||||
for (const entry of sortedElesiteData) {
|
||||
if (!entry.trains) continue;
|
||||
@@ -604,7 +683,6 @@ 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(列番データ)){
|
||||
@@ -1436,6 +1514,11 @@ 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,6 +1,7 @@
|
||||
{
|
||||
"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
scripts/compile-web-script.ts
Normal file
144
scripts/compile-web-script.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/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)`);
|
||||
@@ -12,6 +12,7 @@ import React, {
|
||||
import { API_ENDPOINTS, STORAGE_KEYS } from "@/constants";
|
||||
import {
|
||||
getBackendApiBaseUrl,
|
||||
getDiagramTodayUrl,
|
||||
BACKEND_API_BASE_URLS,
|
||||
} from "@/lib/jrDataSystemEnvironment";
|
||||
const initialState = {
|
||||
@@ -53,17 +54,23 @@ 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)))
|
||||
.then((value) => {
|
||||
setBackendApiBaseUrl(getBackendApiBaseUrl(value));
|
||||
setDiagramTodayUrl(getDiagramTodayUrl(value));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const getTrainDiagram = () => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||||
fetch("https://jr-shikoku-api-data-storage.haruk.in/tmp/diagram-today.json", { signal: controller.signal })
|
||||
fetch(diagramTodayUrl, { signal: controller.signal })
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
const data = {};
|
||||
|
||||
@@ -16,6 +16,7 @@ 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";
|
||||
@@ -77,7 +78,7 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
|
||||
const [currentTrainLoading, setCurrentTrainLoading] =
|
||||
useState<loading>("loading");
|
||||
|
||||
const { mockApiFeatureEnabled, mockTrainPositions, addTrainSnapshot } = useTrainMenu();
|
||||
const { mockApiFeatureEnabled, mockTrainPositions, addTrainSnapshot, lookupPosText, recorderState } = useTrainMenu();
|
||||
|
||||
const { getInjectJavascriptAddress, stationList, originalStationList } =
|
||||
useStationList();
|
||||
@@ -261,8 +262,8 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
|
||||
}
|
||||
};
|
||||
const getCurrentTrain = () => {
|
||||
// モックAPI機能が有効な場合はモックデータを使用
|
||||
if (mockApiFeatureEnabled) {
|
||||
// 再生中: 録画データをそのまま使う(ポーリング不要)
|
||||
if (recorderState === 'playing') {
|
||||
const source = mockTrainPositions ?? MOCK_TRAIN_POSITIONS;
|
||||
const mapped = source
|
||||
.filter((x): x is import("@/lib/mockApi/webviewXhrInterceptor").TrainEntry =>
|
||||
@@ -271,8 +272,8 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
|
||||
.map((x) => ({
|
||||
Index: x.Index,
|
||||
num: x.TrainNum,
|
||||
delay: x.delay,
|
||||
Pos: x.Pos,
|
||||
delay: x.delay as "入線" | number,
|
||||
Pos: x.Pos || lookupPosText(x.PosNum, x.Line) || '',
|
||||
PosNum: x.PosNum,
|
||||
Direction: x.Direction,
|
||||
Type: x.Type,
|
||||
@@ -282,6 +283,33 @@ 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 })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
||||
import { AS } from "../storageControl";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
import { API_ENDPOINTS } from "@/constants";
|
||||
import { sortElesiteEntriesByTrainNumber } from "@/lib/elesiteTrainOrder";
|
||||
import type { ElesiteResponse, ElesiteData } from "@/types/unyohub";
|
||||
|
||||
type ElesiteHook = {
|
||||
@@ -70,14 +71,7 @@ export const useElesite = (): ElesiteHook => {
|
||||
|
||||
// 高松(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;
|
||||
});
|
||||
const sortedEntries = sortElesiteEntriesByTrainNumber(elesiteData, trainNumber);
|
||||
|
||||
for (const entry of sortedEntries) {
|
||||
if (!entry.trains) continue;
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
FC,
|
||||
} from "react";
|
||||
|
||||
@@ -14,6 +15,14 @@ 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,
|
||||
@@ -30,6 +39,7 @@ import { useThemeColors } from "@/lib/theme";
|
||||
import { STORAGE_KEYS } from "@/constants/storage";
|
||||
import {
|
||||
getBackendApiBaseUrl,
|
||||
getDiagramTodayUrl,
|
||||
BACKEND_API_BASE_URLS,
|
||||
} from "@/lib/jrDataSystemEnvironment";
|
||||
|
||||
@@ -72,6 +82,10 @@ 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,
|
||||
@@ -119,6 +133,9 @@ 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);
|
||||
@@ -164,6 +181,9 @@ 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);
|
||||
|
||||
@@ -172,6 +192,15 @@ 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(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// --- 録画・再生 ---
|
||||
@@ -275,9 +304,13 @@ 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 }
|
||||
? { trainPositions: mockTrainPositions, positionMasters }
|
||||
: null;
|
||||
|
||||
//地図表示テキスト
|
||||
@@ -291,6 +324,7 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
useElesite: useEleSiteSetting,
|
||||
isDark: useThemeColors().isDark,
|
||||
backendApiBaseUrl,
|
||||
diagramTodayUrl,
|
||||
mockApiConfig,
|
||||
});
|
||||
|
||||
@@ -344,16 +378,47 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
});
|
||||
//モックAPI検証機能スイッチ(admin専用・再起動不要)
|
||||
AS.getItem(STORAGE_KEYS.MOCK_API_FEATURE_ENABLED).then((value) => {
|
||||
setMockApiFeatureEnabledState(value === "true" || value === true);
|
||||
const enabled = value === "true" || value === true;
|
||||
setMockApiFeatureEnabledState(enabled);
|
||||
// 起動時に既に有効なら位置マスターを取得
|
||||
if (enabled) {
|
||||
fetchPositionMasters()
|
||||
.then((masters) => {
|
||||
setPositionMasters(masters);
|
||||
setPosLookup(buildPosLookup(masters));
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}).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={{
|
||||
@@ -384,6 +449,8 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
setMockTrainPositions,
|
||||
mockApiFeatureEnabled,
|
||||
setMockApiFeatureEnabled,
|
||||
positionMasters,
|
||||
lookupPosText,
|
||||
recorderState,
|
||||
recordingSnapshotCount,
|
||||
recordingList,
|
||||
|
||||
Reference in New Issue
Block a user