Merge commit '3e0e4876bc64e73100b6c825d6218201577a6f9a' into develop
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
@@ -72,6 +81,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,
|
||||
@@ -164,6 +177,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 +188,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 +300,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;
|
||||
|
||||
//地図表示テキスト
|
||||
@@ -344,16 +373,46 @@ 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));
|
||||
}).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 +443,8 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
setMockTrainPositions,
|
||||
mockApiFeatureEnabled,
|
||||
setMockApiFeatureEnabled,
|
||||
positionMasters,
|
||||
lookupPosText,
|
||||
recorderState,
|
||||
recordingSnapshotCount,
|
||||
recordingList,
|
||||
|
||||
Reference in New Issue
Block a user