Compare commits
243
Commits
+2
-1
@@ -59,4 +59,5 @@ ios/
|
||||
!modules/**/ios/
|
||||
*.ipa
|
||||
*.apk
|
||||
*.aab
|
||||
*.aab
|
||||
.env.local
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"Sentry": {
|
||||
"url": "https://mcp.sentry.dev/mcp/xprocess-m5/jr-shikoku-unofficial-apps"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useRealWindowDimensions,
|
||||
} from "./utils/dexDimensionOverride";
|
||||
import { Linking, Platform, UIManager, View } from "react-native";
|
||||
import { enableFreeze, enableScreens } from "react-native-screens";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import "./utils/disableFontScaling"; // グローバルなフォントスケーリング無効化
|
||||
import { AppContainer } from "./Apps";
|
||||
@@ -25,22 +26,89 @@ import { buildProvidersTree } from "./lib/providerTreeProvider";
|
||||
import { StationListProvider } from "./stateBox/useStationList";
|
||||
import { NotificationProvider } from "./stateBox/useNotifications";
|
||||
import { UserPositionProvider } from "./stateBox/useUserPosition";
|
||||
import { rootNavigationRef, stackAwareNavigate } from "./lib/rootNavigation";
|
||||
import {
|
||||
markStartupExplicitTarget,
|
||||
resolveStartupNavigationSource,
|
||||
rootNavigationRef,
|
||||
stackAwareNavigate,
|
||||
} from "./lib/rootNavigation";
|
||||
import { AppThemeProvider } from "./lib/theme";
|
||||
import StatusbarDetect from "./StatusbarDetect";
|
||||
import * as Sentry from '@sentry/react-native';
|
||||
import {
|
||||
startAppLifecycleCrashSentinel,
|
||||
stopAppLifecycleCrashSentinel,
|
||||
} from "./lib/observability/appLifecycleCrashSentinel";
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://1090312e4cf501f5a455d523eff2d538@o4511646874664960.ingest.us.sentry.io/4511646880432128',
|
||||
|
||||
// Adds more context data to events (IP address, cookies, user, etc.)
|
||||
// For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/
|
||||
sendDefaultPii: true,
|
||||
|
||||
// Enable Logs
|
||||
enableLogs: true,
|
||||
|
||||
// Configure Session Replay
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1,
|
||||
integrations: [Sentry.mobileReplayIntegration(), Sentry.feedbackIntegration()],
|
||||
|
||||
tracesSampleRate: __DEV__ ? 1.0 : 0.05,
|
||||
|
||||
beforeSend(event) {
|
||||
const dataFetch = event.contexts?.data_fetch as any;
|
||||
if (dataFetch?.responseHead && typeof dataFetch.responseHead === "string") {
|
||||
dataFetch.responseHead = dataFetch.responseHead.slice(0, 300);
|
||||
}
|
||||
return event;
|
||||
},
|
||||
|
||||
// uncomment the line below to enable Spotlight (https://spotlightjs.com)
|
||||
// spotlight: __DEV__,
|
||||
});
|
||||
|
||||
LogBox.ignoreLogs([
|
||||
"ViewPropTypes will be removed",
|
||||
"ColorPropType will be removed",
|
||||
]);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
enableFreeze(false);
|
||||
enableScreens(false);
|
||||
}
|
||||
|
||||
if (Platform.OS === "android") {
|
||||
if (UIManager.setLayoutAnimationEnabledExperimental) {
|
||||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
export default Sentry.wrap(function App() {
|
||||
useEffect(() => {
|
||||
const nativeScreensMode =
|
||||
Platform.OS === "ios" ? "screens-disabled" : "default";
|
||||
Sentry.setTag("native_screens_mode", nativeScreensMode);
|
||||
Sentry.setContext("runtime_navigation_flags", {
|
||||
platform: Platform.OS,
|
||||
nativeScreensMode,
|
||||
});
|
||||
Sentry.addBreadcrumb({
|
||||
category: "runtime.flags",
|
||||
level: "info",
|
||||
message: "runtime navigation flags applied",
|
||||
data: {
|
||||
platform: Platform.OS,
|
||||
nativeScreensMode,
|
||||
},
|
||||
});
|
||||
void startAppLifecycleCrashSentinel({ nativeScreensMode });
|
||||
return () => {
|
||||
stopAppLifecycleCrashSentinel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
UpdateAsync();
|
||||
}, []);
|
||||
@@ -78,46 +146,69 @@ export default function App() {
|
||||
|
||||
const routeFromUrl = (url: string, retryCount = 0) => {
|
||||
const normalized = (url || "").toLowerCase();
|
||||
if (!normalized) return;
|
||||
if (!normalized) return false;
|
||||
|
||||
if (
|
||||
normalized.includes("felicahistorypage") ||
|
||||
normalized.includes("open/felica")
|
||||
) {
|
||||
markStartupExplicitTarget();
|
||||
navigateWhenReady(() => openFelicaPage(), url, retryCount);
|
||||
} else if (normalized.includes("open/traininfo")) {
|
||||
return true;
|
||||
}
|
||||
if (normalized.includes("open/traininfo")) {
|
||||
markStartupExplicitTarget();
|
||||
navigateWhenReady(() => {
|
||||
stackAwareNavigate("topMenu", { screen: "menu" });
|
||||
setTimeout(() => {
|
||||
SheetManager.show("JRSTraInfo");
|
||||
}, 450);
|
||||
}, url, retryCount);
|
||||
} else if (normalized.includes("open/operation")) {
|
||||
return true;
|
||||
}
|
||||
if (normalized.includes("open/operation")) {
|
||||
markStartupExplicitTarget();
|
||||
navigateWhenReady(() => {
|
||||
stackAwareNavigate("information");
|
||||
}, url, retryCount);
|
||||
} else if (normalized.includes("open/settings")) {
|
||||
return true;
|
||||
}
|
||||
if (normalized.includes("open/settings")) {
|
||||
markStartupExplicitTarget();
|
||||
navigateWhenReady(() => {
|
||||
stackAwareNavigate("topMenu", {
|
||||
screen: "setting",
|
||||
});
|
||||
}, url, retryCount);
|
||||
} else if (normalized.includes("open/topmenu")) {
|
||||
return true;
|
||||
}
|
||||
if (normalized.includes("open/topmenu")) {
|
||||
markStartupExplicitTarget();
|
||||
navigateWhenReady(() => {
|
||||
stackAwareNavigate("topMenu", {
|
||||
screen: "menu",
|
||||
});
|
||||
}, url, retryCount);
|
||||
} else if (normalized.includes("positions/apps")) {
|
||||
return true;
|
||||
}
|
||||
if (normalized.includes("positions/apps")) {
|
||||
markStartupExplicitTarget();
|
||||
navigateWhenReady(() => {
|
||||
stackAwareNavigate("positions");
|
||||
}, url, retryCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
Linking.getInitialURL().then((url) => {
|
||||
if (url) routeFromUrl(url);
|
||||
});
|
||||
Linking.getInitialURL()
|
||||
.then((url) => {
|
||||
if (url) routeFromUrl(url);
|
||||
})
|
||||
.finally(() => {
|
||||
resolveStartupNavigationSource();
|
||||
});
|
||||
|
||||
const sub = Linking.addEventListener("url", ({ url }) => {
|
||||
routeFromUrl(url);
|
||||
@@ -135,10 +226,10 @@ export default function App() {
|
||||
StationListProvider,
|
||||
FavoriteStationProvider,
|
||||
TrainDelayDataProvider,
|
||||
TrainMenuProvider, // CurrentTrainProvider より先に置くことで useTrainMenu が使える
|
||||
CurrentTrainProvider,
|
||||
AreaInfoProvider,
|
||||
BusAndTrainDataProvider,
|
||||
TrainMenuProvider,
|
||||
SheetProvider,
|
||||
]);
|
||||
return (
|
||||
@@ -157,7 +248,7 @@ export default function App() {
|
||||
</DeviceOrientationChangeProvider>
|
||||
</AppThemeProvider>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 低密度ディスプレイ(DeX等)で全体を transform scale で拡大。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { NavigationContainer, DarkTheme, DefaultTheme } from "@react-navigation/native";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar } from "react-native";
|
||||
import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar, useWindowDimensions, InteractionManager } from "react-native";
|
||||
import { useNavigationState } from "@react-navigation/native";
|
||||
import { useFonts } from "expo-font";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
@@ -14,9 +14,15 @@ import { useTrainMenu } from "./stateBox/useTrainMenu";
|
||||
import lineColorList from "./assets/originData/lineColorList";
|
||||
import { stationIDPair } from "./lib/getStationList";
|
||||
import "./components/ActionSheetComponents/sheets";
|
||||
import { rootNavigationRef } from "./lib/rootNavigation";
|
||||
import { lastObservedRootRouteRef, positionsLifecycleRef, rootNavigationRef } from "./lib/rootNavigation";
|
||||
import { fixedColors } from "./lib/theme/colors";
|
||||
import { useThemeColors } from "./lib/theme";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import WebView from "react-native-webview";
|
||||
import {
|
||||
recordAppLifecycleRootNavigation,
|
||||
setAppLifecycleWebViewActive,
|
||||
} from "./lib/observability/appLifecycleCrashSentinel";
|
||||
|
||||
type RootTabParamList = {
|
||||
positions: undefined;
|
||||
@@ -35,10 +41,218 @@ type TabProps = {
|
||||
|
||||
const Tab = createBottomTabNavigator<RootTabParamList>();
|
||||
|
||||
const HiddenStartupPreloadWebViews = ({
|
||||
shouldPreloadPositions,
|
||||
shouldPreloadInformation,
|
||||
}: {
|
||||
shouldPreloadPositions: boolean;
|
||||
shouldPreloadInformation: boolean;
|
||||
}) => {
|
||||
const [positionsLoaded, setPositionsLoaded] = React.useState(false);
|
||||
const [informationLoaded, setInformationLoaded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
Sentry.setContext("startup_hidden_preload", {
|
||||
shouldPreloadPositions,
|
||||
shouldPreloadInformation,
|
||||
positionsLoaded,
|
||||
informationLoaded,
|
||||
});
|
||||
}, [informationLoaded, positionsLoaded, shouldPreloadInformation, shouldPreloadPositions]);
|
||||
|
||||
const hiddenStyle = React.useMemo(() => ({
|
||||
position: "absolute" as const,
|
||||
width: 1,
|
||||
height: 1,
|
||||
opacity: 0,
|
||||
left: -1000,
|
||||
top: -1000,
|
||||
pointerEvents: "none" as const,
|
||||
}), []);
|
||||
|
||||
const showPositions = shouldPreloadPositions && !positionsLoaded;
|
||||
const showInformation = shouldPreloadInformation && !informationLoaded;
|
||||
|
||||
if (!showPositions && !showInformation) return null;
|
||||
|
||||
return (
|
||||
<View pointerEvents="none" style={hiddenStyle}>
|
||||
{showPositions ? (
|
||||
<WebView
|
||||
source={{ uri: "https://train.jr-shikoku.co.jp/" }}
|
||||
originWhitelist={["https://train.jr-shikoku.co.jp"]}
|
||||
javaScriptEnabled
|
||||
setSupportMultipleWindows={false}
|
||||
onLoadStart={() => {
|
||||
setAppLifecycleWebViewActive("startup_hidden_positions", true);
|
||||
Sentry.addBreadcrumb({
|
||||
category: "startup.preload",
|
||||
level: "info",
|
||||
message: "positions hidden preload loadStart",
|
||||
});
|
||||
}}
|
||||
onLoadEnd={() => {
|
||||
setAppLifecycleWebViewActive("startup_hidden_positions", false);
|
||||
Sentry.addBreadcrumb({
|
||||
category: "startup.preload",
|
||||
level: "info",
|
||||
message: "positions hidden preload loadEnd",
|
||||
});
|
||||
setPositionsLoaded(true);
|
||||
}}
|
||||
onError={() => {
|
||||
setAppLifecycleWebViewActive("startup_hidden_positions", false);
|
||||
Sentry.addBreadcrumb({
|
||||
category: "startup.preload",
|
||||
level: "error",
|
||||
message: "positions hidden preload error",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{showInformation ? (
|
||||
<WebView
|
||||
source={{ uri: "https://www.jr-shikoku.co.jp/info/" }}
|
||||
originWhitelist={["https://www.jr-shikoku.co.jp"]}
|
||||
javaScriptEnabled
|
||||
setSupportMultipleWindows={false}
|
||||
onLoadStart={() => {
|
||||
setAppLifecycleWebViewActive("startup_hidden_operation", true);
|
||||
Sentry.addBreadcrumb({
|
||||
category: "startup.preload",
|
||||
level: "info",
|
||||
message: "operation hidden preload loadStart",
|
||||
});
|
||||
}}
|
||||
onLoadEnd={() => {
|
||||
setAppLifecycleWebViewActive("startup_hidden_operation", false);
|
||||
Sentry.addBreadcrumb({
|
||||
category: "startup.preload",
|
||||
level: "info",
|
||||
message: "operation hidden preload loadEnd",
|
||||
});
|
||||
setInformationLoaded(true);
|
||||
}}
|
||||
onError={() => {
|
||||
setAppLifecycleWebViewActive("startup_hidden_operation", false);
|
||||
Sentry.addBreadcrumb({
|
||||
category: "startup.preload",
|
||||
level: "error",
|
||||
message: "operation hidden preload error",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const PositionsRootGateContext = React.createContext({
|
||||
shouldActivate: false,
|
||||
isRootFocused: false,
|
||||
});
|
||||
|
||||
const InformationRootGateContext = React.createContext({
|
||||
shouldActivate: false,
|
||||
isRootFocused: false,
|
||||
});
|
||||
|
||||
const DeferredPositionsRoot = ({ shouldActivate, isRootFocused }: { shouldActivate: boolean; isRootFocused: boolean }) => {
|
||||
const [hasActivated, setHasActivated] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
Sentry.setContext("positions_root_gate", {
|
||||
focused: isRootFocused,
|
||||
activated: hasActivated,
|
||||
shouldActivate,
|
||||
});
|
||||
}, [hasActivated, isRootFocused, shouldActivate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldActivate || hasActivated) return;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.root",
|
||||
level: "info",
|
||||
message: "positions root activation",
|
||||
data: {
|
||||
reason: "first_root_visit",
|
||||
},
|
||||
});
|
||||
setHasActivated(true);
|
||||
}, [hasActivated, shouldActivate]);
|
||||
|
||||
if (!hasActivated) {
|
||||
return <View style={{ flex: 1 }} />;
|
||||
}
|
||||
|
||||
return <Top />;
|
||||
};
|
||||
|
||||
const PositionsTabScreen = React.memo(() => {
|
||||
const { shouldActivate, isRootFocused } = React.useContext(PositionsRootGateContext);
|
||||
return (
|
||||
<DeferredPositionsRoot
|
||||
shouldActivate={shouldActivate}
|
||||
isRootFocused={isRootFocused}
|
||||
/>
|
||||
);
|
||||
});
|
||||
PositionsTabScreen.displayName = "PositionsTabScreen";
|
||||
|
||||
|
||||
const DeferredInformationRoot = ({ shouldActivate, isRootFocused }: { shouldActivate: boolean; isRootFocused: boolean }) => {
|
||||
const [hasActivated, setHasActivated] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
Sentry.setContext("operation_root_gate", {
|
||||
focused: isRootFocused,
|
||||
activated: hasActivated,
|
||||
shouldActivate,
|
||||
});
|
||||
}, [hasActivated, isRootFocused, shouldActivate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldActivate || hasActivated) return;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "operation.root",
|
||||
level: "info",
|
||||
message: "operation root activation",
|
||||
data: {
|
||||
reason: isRootFocused ? "first_focus" : "background_prewarm",
|
||||
},
|
||||
});
|
||||
setHasActivated(true);
|
||||
}, [hasActivated, isRootFocused, shouldActivate]);
|
||||
|
||||
if (!hasActivated) {
|
||||
return <View style={{ flex: 1 }} />;
|
||||
}
|
||||
|
||||
return <TNDView />;
|
||||
};
|
||||
|
||||
const InformationTabScreen = React.memo(() => {
|
||||
const { shouldActivate, isRootFocused } = React.useContext(InformationRootGateContext);
|
||||
return (
|
||||
<DeferredInformationRoot
|
||||
shouldActivate={shouldActivate}
|
||||
isRootFocused={isRootFocused}
|
||||
/>
|
||||
);
|
||||
});
|
||||
InformationTabScreen.displayName = "InformationTabScreen";
|
||||
|
||||
export function AppContainer() {
|
||||
const { areaInfo, areaIconBadgeText, isInfo } = useAreaInfo();
|
||||
const { selectedLine } = useTrainMenu();
|
||||
const { width, height } = useWindowDimensions();
|
||||
const [isExtraWindowOpen, setIsExtraWindowOpen] = React.useState(false);
|
||||
const [hasVisitedPositions, setHasVisitedPositions] = React.useState(false);
|
||||
const [hasVisitedInformation, setHasVisitedInformation] = React.useState(false);
|
||||
const [currentRootRoute, setCurrentRootRoute] = React.useState<keyof RootTabParamList | null>(null);
|
||||
const [navigationReady, setNavigationReady] = React.useState(false);
|
||||
const startupPrewarmAttemptedRef = React.useRef(false);
|
||||
const informationStartupPrewarmAttemptedRef = React.useRef(false);
|
||||
|
||||
// フェードアニメーション用 (0=通常, 1=追加ウィンドウ青)
|
||||
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
||||
@@ -62,6 +276,7 @@ export function AppContainer() {
|
||||
return `#${[r, g, b].map((v) => Math.min(255, v).toString(16).padStart(2, "0")).join("")}`;
|
||||
};
|
||||
const { isDark } = useThemeColors();
|
||||
const lastRootNavStateRef = React.useRef("");
|
||||
const lineColorDark = lineColor ? darkenHex(lineColor, 0.78) : null;
|
||||
const linking = {
|
||||
prefixes: ["jrshikoku://"],
|
||||
@@ -99,16 +314,150 @@ export function AppContainer() {
|
||||
tabBarLabel: label,
|
||||
headerShown: false,
|
||||
gestureEnabled: true,
|
||||
unmountOnBlur: false,
|
||||
tabBarIcon: initIcon(icon as any, iconFamily, tabBarBadge, isInfo),
|
||||
|
||||
},
|
||||
});
|
||||
const applyRootNavigationState = React.useCallback((state: any, source: "ready" | "change") => {
|
||||
const activeRoute = state?.routes?.[state?.index ?? 0];
|
||||
const hasExtra = (activeRoute?.state?.index ?? 0) > 0;
|
||||
const nestedState = activeRoute?.state;
|
||||
const nestedRoute = nestedState?.routes?.[nestedState.index ?? 0]?.name ?? null;
|
||||
const signature = JSON.stringify({
|
||||
rootIndex: state?.index ?? 0,
|
||||
rootRoute: activeRoute?.name ?? null,
|
||||
nestedIndex: nestedState?.index ?? 0,
|
||||
nestedRoute,
|
||||
hasExtra,
|
||||
});
|
||||
if (lastRootNavStateRef.current !== signature) {
|
||||
lastRootNavStateRef.current = signature;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "nav.root",
|
||||
level: "info",
|
||||
message: source === "ready" ? "root state initialized" : "root state change",
|
||||
data: {
|
||||
rootIndex: state?.index ?? 0,
|
||||
rootRoute: activeRoute?.name ?? null,
|
||||
nestedIndex: nestedState?.index ?? 0,
|
||||
nestedRoute,
|
||||
hasExtra,
|
||||
source,
|
||||
},
|
||||
});
|
||||
}
|
||||
const rootRouteName = (activeRoute?.name ?? null) as keyof RootTabParamList | null;
|
||||
if (rootRouteName) {
|
||||
lastObservedRootRouteRef.current = rootRouteName;
|
||||
}
|
||||
recordAppLifecycleRootNavigation({
|
||||
rootIndex: state?.index ?? 0,
|
||||
rootRoute: rootRouteName,
|
||||
nestedIndex: nestedState?.index ?? 0,
|
||||
nestedRoute,
|
||||
source,
|
||||
});
|
||||
Sentry.setTag("root_tab", rootRouteName ?? "unknown");
|
||||
Sentry.setContext("root_navigation", {
|
||||
rootIndex: state?.index ?? 0,
|
||||
rootRoute: rootRouteName,
|
||||
nestedIndex: nestedState?.index ?? 0,
|
||||
nestedRoute,
|
||||
hasExtra,
|
||||
source,
|
||||
operationOrientation: width > height ? "landscape" : "portrait",
|
||||
hasVisitedPositions,
|
||||
hasVisitedInformation,
|
||||
});
|
||||
setCurrentRootRoute(rootRouteName);
|
||||
setIsExtraWindowOpen(hasExtra);
|
||||
}, [hasVisitedInformation, hasVisitedPositions, width, height]);
|
||||
|
||||
const [fontLoaded, error] = useFonts({
|
||||
"JR-Nishi": require("./assets/fonts/jr-nishi.otf"),
|
||||
Zou: require("./assets/fonts/DelaGothicOne-Regular.ttf"),
|
||||
"JNR-font": require("./assets/fonts/JNRfont_pict.ttf"),
|
||||
"DiaPro": require("./assets/fonts/DiaPro-Regular.otf"),
|
||||
});
|
||||
React.useEffect(() => {
|
||||
if (!navigationReady || !fontLoaded) return;
|
||||
if (startupPrewarmAttemptedRef.current) return;
|
||||
if (hasVisitedPositions) {
|
||||
startupPrewarmAttemptedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (currentRootRoute === null || currentRootRoute === "positions") return;
|
||||
|
||||
startupPrewarmAttemptedRef.current = true;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.root",
|
||||
level: "info",
|
||||
message: "positions startup prewarm scheduled",
|
||||
data: {
|
||||
currentRootRoute,
|
||||
},
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (cancelled || hasVisitedPositions) return;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.root",
|
||||
level: "info",
|
||||
message: "positions startup prewarm activated",
|
||||
data: {
|
||||
currentRootRoute,
|
||||
},
|
||||
});
|
||||
setHasVisitedPositions(true);
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [currentRootRoute, fontLoaded, hasVisitedPositions, navigationReady]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!navigationReady || !fontLoaded) return;
|
||||
if (informationStartupPrewarmAttemptedRef.current) return;
|
||||
if (hasVisitedInformation) {
|
||||
informationStartupPrewarmAttemptedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (currentRootRoute === null || currentRootRoute === "information") return;
|
||||
|
||||
informationStartupPrewarmAttemptedRef.current = true;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "operation.root",
|
||||
level: "info",
|
||||
message: "operation startup prewarm scheduled",
|
||||
data: {
|
||||
currentRootRoute,
|
||||
},
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (cancelled || hasVisitedInformation) return;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "operation.root",
|
||||
level: "info",
|
||||
message: "operation startup prewarm activated",
|
||||
data: {
|
||||
currentRootRoute,
|
||||
},
|
||||
});
|
||||
setHasVisitedInformation(true);
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [currentRootRoute, fontLoaded, hasVisitedInformation, navigationReady]);
|
||||
|
||||
if (!fontLoaded) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
@@ -121,14 +470,109 @@ export function AppContainer() {
|
||||
ref={rootNavigationRef}
|
||||
linking={linking}
|
||||
theme={isDark ? DarkTheme : DefaultTheme}
|
||||
onReady={() => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "nav.root",
|
||||
level: "info",
|
||||
message: "navigation ready",
|
||||
});
|
||||
applyRootNavigationState(rootNavigationRef.getRootState?.() ?? rootNavigationRef.getState?.(), "ready");
|
||||
setNavigationReady(true);
|
||||
}}
|
||||
onStateChange={(state) => {
|
||||
const activeRoute = state?.routes?.[state?.index ?? 0];
|
||||
const hasExtra = (activeRoute?.state?.index ?? 0) > 0;
|
||||
setIsExtraWindowOpen(hasExtra);
|
||||
applyRootNavigationState(state, "change");
|
||||
}}
|
||||
>
|
||||
<PositionsRootGateContext.Provider
|
||||
value={{
|
||||
shouldActivate: hasVisitedPositions,
|
||||
isRootFocused: currentRootRoute === "positions",
|
||||
}}
|
||||
>
|
||||
<InformationRootGateContext.Provider
|
||||
value={{
|
||||
shouldActivate: hasVisitedInformation,
|
||||
isRootFocused: currentRootRoute === "information",
|
||||
}}
|
||||
>
|
||||
<Tab.Navigator
|
||||
id="rootTabs"
|
||||
detachInactiveScreens={false}
|
||||
initialRouteName="topMenu"
|
||||
screenListeners={({ route }) => ({
|
||||
tabPress: (event) => {
|
||||
const rootState = rootNavigationRef.getState();
|
||||
const activeRootRouteName =
|
||||
(rootState?.routes?.[rootState.index ?? 0]?.name as keyof RootTabParamList | undefined) ??
|
||||
currentRootRoute ??
|
||||
null;
|
||||
const leavingUnstablePositions =
|
||||
route.name !== "positions" &&
|
||||
positionsLifecycleRef.current.isUnstable;
|
||||
const blockingUnstableExit =
|
||||
route.name !== "positions" &&
|
||||
positionsLifecycleRef.current.blockTabExit;
|
||||
|
||||
Sentry.addBreadcrumb({
|
||||
category: "nav.tab",
|
||||
level: "info",
|
||||
message: blockingUnstableExit
|
||||
? "root tabPress blocked while positions unstable"
|
||||
: leavingUnstablePositions
|
||||
? "root tabPress intercepted for unstable positions"
|
||||
: "root tabPress",
|
||||
data: {
|
||||
route: route.name,
|
||||
currentRootRoute,
|
||||
activeRootRouteName,
|
||||
unstablePositions: positionsLifecycleRef.current.isUnstable,
|
||||
blockTabExit: positionsLifecycleRef.current.blockTabExit,
|
||||
},
|
||||
});
|
||||
|
||||
if (blockingUnstableExit) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (leavingUnstablePositions) {
|
||||
event.preventDefault();
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.root",
|
||||
level: "info",
|
||||
message: "positions root exit blocked while session unstable",
|
||||
data: { route: route.name },
|
||||
});
|
||||
}
|
||||
},
|
||||
focus: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "nav.tab",
|
||||
level: "info",
|
||||
message: "root tab focus",
|
||||
data: { route: route.name },
|
||||
});
|
||||
},
|
||||
blur: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "nav.tab",
|
||||
level: "info",
|
||||
message: "root tab blur",
|
||||
data: { route: route.name },
|
||||
});
|
||||
},
|
||||
state: (event) => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "nav.tab",
|
||||
level: "info",
|
||||
message: "root tab state",
|
||||
data: {
|
||||
route: route.name,
|
||||
stateType: event.type,
|
||||
},
|
||||
});
|
||||
},
|
||||
})}
|
||||
screenOptions={({ route }) => {
|
||||
const showGradient = route.name === "positions" && !!lineColor && !!lineColorDark;
|
||||
const defaultBg = isDark ? "#1c1c1e" : "white";
|
||||
@@ -136,6 +580,7 @@ export function AppContainer() {
|
||||
const defaultInactive = isDark ? "#8e8e93" : "#8e8e93";
|
||||
return {
|
||||
lazy: false,
|
||||
freezeOnBlur: false,
|
||||
sceneContainerStyle: { backgroundColor: defaultBg },
|
||||
tabBarActiveTintColor: (showGradient || isExtraWindowOpen) ? "white" : defaultActive,
|
||||
tabBarInactiveTintColor: (showGradient || isExtraWindowOpen) ? "rgba(255,255,255,0.75)" : defaultInactive,
|
||||
@@ -169,7 +614,32 @@ export function AppContainer() {
|
||||
>
|
||||
<Tab.Screen
|
||||
{...getTabProps("positions", "走行位置", "bar-chart", "AntDesign")}
|
||||
component={Top}
|
||||
component={PositionsTabScreen}
|
||||
listeners={{
|
||||
tabPress: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.root",
|
||||
level: "info",
|
||||
message: "positions root tabPress activation",
|
||||
});
|
||||
setHasVisitedPositions(true);
|
||||
},
|
||||
focus: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.root",
|
||||
level: "info",
|
||||
message: "positions root focus activation",
|
||||
});
|
||||
setHasVisitedPositions(true);
|
||||
},
|
||||
blur: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.root",
|
||||
level: "info",
|
||||
message: "positions root blur preserved",
|
||||
});
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
{...getTabProps("topMenu", "トップメニュー", "radio", "Ionicons")}
|
||||
@@ -185,9 +655,40 @@ export function AppContainer() {
|
||||
areaInfo ? areaIconBadgeText : undefined,
|
||||
isInfo
|
||||
)}
|
||||
children={TNDView}
|
||||
component={InformationTabScreen}
|
||||
listeners={{
|
||||
tabPress: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "operation.root",
|
||||
level: "info",
|
||||
message: "operation root tabPress activation",
|
||||
});
|
||||
setHasVisitedInformation(true);
|
||||
},
|
||||
focus: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "operation.root",
|
||||
level: "info",
|
||||
message: "operation root focus activation",
|
||||
});
|
||||
setHasVisitedInformation(true);
|
||||
},
|
||||
blur: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "operation.root",
|
||||
level: "info",
|
||||
message: "operation root blur preserved",
|
||||
});
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</InformationRootGateContext.Provider>
|
||||
</PositionsRootGateContext.Provider>
|
||||
<HiddenStartupPreloadWebViews
|
||||
shouldPreloadPositions={navigationReady && fontLoaded && currentRootRoute === "topMenu" && !hasVisitedPositions}
|
||||
shouldPreloadInformation={navigationReady && fontLoaded && currentRootRoute === "topMenu" && !hasVisitedInformation}
|
||||
/>
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
+428
-30
@@ -1,32 +1,161 @@
|
||||
import React from "react";
|
||||
import { Alert, BackHandler, View } from "react-native";
|
||||
import { Alert, ActivityIndicator, 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";
|
||||
import { useFocusEffect, useNavigation } from "@react-navigation/native";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { AS } from "./storageControl";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import {
|
||||
DEFAULT_JR_DATA_SYSTEM_ENV,
|
||||
normalizeJrDataSystemEnvironment,
|
||||
rewriteJrDataSystemUrl,
|
||||
} from "@/lib/jrDataSystemEnvironment";
|
||||
|
||||
const RECORDING_DOWNLOAD_BRIDGE_SCRIPT = `
|
||||
(() => {
|
||||
if (window.__JRS_RECORDING_DOWNLOAD_BRIDGE__) return true;
|
||||
window.__JRS_RECORDING_DOWNLOAD_BRIDGE__ = true;
|
||||
|
||||
const blobUrls = new Map();
|
||||
const post = (payload) => {
|
||||
window.ReactNativeWebView?.postMessage(JSON.stringify(payload));
|
||||
};
|
||||
const isLikelyRecordingDownload = (url, anchor) => {
|
||||
if (!url) return false;
|
||||
const lowerUrl = String(url).toLowerCase();
|
||||
const downloadName = anchor?.getAttribute?.('download') || '';
|
||||
const lowerName = String(downloadName).toLowerCase();
|
||||
return Boolean(downloadName)
|
||||
|| lowerUrl.includes('recording')
|
||||
|| lowerUrl.includes('recordings')
|
||||
|| lowerUrl.includes('download')
|
||||
|| lowerUrl.includes('export')
|
||||
|| lowerUrl.includes('json')
|
||||
|| lowerName.includes('recording')
|
||||
|| lowerName.endsWith('.json');
|
||||
};
|
||||
const readUrlAsText = async (url) => {
|
||||
if (blobUrls.has(url)) {
|
||||
return blobUrls.get(url).text();
|
||||
}
|
||||
const response = await fetch(url, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
return response.text();
|
||||
};
|
||||
const importFromUrl = async (url) => {
|
||||
try {
|
||||
const text = await readUrlAsText(url);
|
||||
post({ type: 'importRecordingDownload', text, sourceUrl: url });
|
||||
} catch (error) {
|
||||
post({
|
||||
type: 'importRecordingDownloadError',
|
||||
message: error?.message || String(error),
|
||||
sourceUrl: url,
|
||||
});
|
||||
}
|
||||
};
|
||||
const findAnchor = (target) => {
|
||||
let node = target;
|
||||
while (node && node !== document) {
|
||||
if (node.tagName === 'A' && node.href) return node;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (window.URL?.createObjectURL) {
|
||||
const originalCreateObjectURL = window.URL.createObjectURL.bind(window.URL);
|
||||
window.URL.createObjectURL = (object) => {
|
||||
const url = originalCreateObjectURL(object);
|
||||
if (object instanceof Blob) blobUrls.set(url, object);
|
||||
return url;
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const anchor = findAnchor(event.target);
|
||||
if (!anchor || !isLikelyRecordingDownload(anchor.href, anchor)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
importFromUrl(anchor.href);
|
||||
}, true);
|
||||
|
||||
if (window.HTMLAnchorElement?.prototype?.click) {
|
||||
const originalClick = window.HTMLAnchorElement.prototype.click;
|
||||
window.HTMLAnchorElement.prototype.click = function patchedClick() {
|
||||
if (isLikelyRecordingDownload(this.href, this)) {
|
||||
importFromUrl(this.href);
|
||||
return;
|
||||
}
|
||||
return originalClick.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
})();
|
||||
true;
|
||||
`;
|
||||
|
||||
const isLikelyRecordingDownloadUrl = (url: string) => {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
return lowerUrl.includes('recording')
|
||||
|| lowerUrl.includes('recordings')
|
||||
|| lowerUrl.includes('download')
|
||||
|| lowerUrl.includes('export')
|
||||
|| lowerUrl.includes('json')
|
||||
|| lowerUrl.endsWith('.json');
|
||||
};
|
||||
|
||||
export default ({ route }) => {
|
||||
if (!route.params) {
|
||||
return null;
|
||||
}
|
||||
const { uri, useExitButton = true } = route.params;
|
||||
const {
|
||||
uri,
|
||||
useExitButton = true,
|
||||
importRecordingDownloads = false,
|
||||
} = route.params;
|
||||
const { goBack } = useNavigation();
|
||||
const { fixed } = useThemeColors();
|
||||
const { importRecordingsFromText } = useTrainMenu();
|
||||
const webViewRef = React.useRef<WebView>(null);
|
||||
const [canGoBack, setCanGoBack] = React.useState(false);
|
||||
const nativeCanGoBackRef = React.useRef(false);
|
||||
const historyStackRef = React.useRef<string[]>([]);
|
||||
const [selectedEnvironment, setSelectedEnvironment] = React.useState(
|
||||
DEFAULT_JR_DATA_SYSTEM_ENV,
|
||||
);
|
||||
const [resolvedUri, setResolvedUri] = React.useState("");
|
||||
const [isEnvironmentReady, setIsEnvironmentReady] = React.useState(false);
|
||||
const hasAlerted = React.useRef(false);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [hasError, setHasError] = React.useState(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState("");
|
||||
// WebViewをforce remountするためのkey
|
||||
const [webViewKey, setWebViewKey] = React.useState(0);
|
||||
// RN-side watchdog: WebViewプロセスの死活確認用
|
||||
const lastPongAt = React.useRef<number>(Date.now());
|
||||
const isLoadingRef = React.useRef(true);
|
||||
const hasErrorRef = React.useRef(false);
|
||||
// コンテンツ消失検知用: bodyLen の最大値と連続白画面カウント
|
||||
const maxBodyLenRef = React.useRef(0);
|
||||
const blankCountRef = React.useRef(0);
|
||||
|
||||
const remount = React.useCallback(() => {
|
||||
lastPongAt.current = Date.now(); // remount直後に誤検知しないようリセット
|
||||
maxBodyLenRef.current = 0;
|
||||
blankCountRef.current = 0;
|
||||
setHasError(false);
|
||||
hasErrorRef.current = false;
|
||||
setIsLoading(true);
|
||||
isLoadingRef.current = true;
|
||||
setWebViewKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -34,12 +163,15 @@ export default ({ route }) => {
|
||||
const applyEnvironment = (value: unknown) => {
|
||||
if (!isMounted) return;
|
||||
const nextEnvironment = normalizeJrDataSystemEnvironment(value);
|
||||
const rawUri = typeof uri === "string" ? uri : "";
|
||||
historyStackRef.current = [];
|
||||
nativeCanGoBackRef.current = false;
|
||||
setCanGoBack(false);
|
||||
setSelectedEnvironment(nextEnvironment);
|
||||
setResolvedUri(
|
||||
rewriteJrDataSystemUrl(
|
||||
typeof uri === "string" ? uri : "",
|
||||
nextEnvironment,
|
||||
),
|
||||
importRecordingDownloads
|
||||
? rawUri
|
||||
: rewriteJrDataSystemUrl(rawUri, nextEnvironment),
|
||||
);
|
||||
setIsEnvironmentReady(true);
|
||||
};
|
||||
@@ -51,14 +183,95 @@ export default ({ route }) => {
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [uri]);
|
||||
}, [uri, importRecordingDownloads]);
|
||||
|
||||
const handleReload = () => {
|
||||
lastPongAt.current = Date.now();
|
||||
setHasError(false);
|
||||
hasErrorRef.current = false;
|
||||
setIsLoading(true);
|
||||
isLoadingRef.current = true;
|
||||
setWebViewKey((k) => k + 1);
|
||||
};
|
||||
|
||||
const syncCanGoBack = React.useCallback((nativeCanGoBack: boolean) => {
|
||||
nativeCanGoBackRef.current = nativeCanGoBack;
|
||||
setCanGoBack(nativeCanGoBack || historyStackRef.current.length > 1);
|
||||
}, []);
|
||||
|
||||
const pushHistoryEntry = React.useCallback((url: string) => {
|
||||
if (!url) return;
|
||||
const stack = historyStackRef.current;
|
||||
if (stack[stack.length - 1] === url) return;
|
||||
stack.push(url);
|
||||
if (stack.length > 30) {
|
||||
stack.splice(0, stack.length - 30);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePseudoBack = React.useCallback(() => {
|
||||
const stack = historyStackRef.current;
|
||||
if (stack.length <= 1) return false;
|
||||
stack.pop();
|
||||
const previousUrl = stack[stack.length - 1];
|
||||
if (!previousUrl) return false;
|
||||
setResolvedUri(previousUrl);
|
||||
syncCanGoBack(false);
|
||||
remount();
|
||||
return true;
|
||||
}, [remount, syncCanGoBack]);
|
||||
|
||||
const handleWebViewBack = React.useCallback(() => {
|
||||
if (nativeCanGoBackRef.current) {
|
||||
webViewRef.current?.goBack();
|
||||
return true;
|
||||
}
|
||||
return handlePseudoBack();
|
||||
}, [handlePseudoBack]);
|
||||
|
||||
const handleImportRecordingText = React.useCallback(
|
||||
async (content: string) => {
|
||||
try {
|
||||
const result = await importRecordingsFromText(content);
|
||||
Alert.alert(
|
||||
"録画データを読み込みました",
|
||||
result.overwrittenCount > 0
|
||||
? `${result.importedCount}件を読み込みました。${result.overwrittenCount}件は同じIDのため上書きしました。`
|
||||
: `${result.importedCount}件を読み込みました。`,
|
||||
);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
"録画データを読み込めませんでした",
|
||||
(error as Error).message || "ファイル内容を確認してください。",
|
||||
);
|
||||
}
|
||||
},
|
||||
[importRecordingsFromText],
|
||||
);
|
||||
|
||||
const handleImportRecordingUrl = React.useCallback(
|
||||
async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
await handleImportRecordingText(await response.text());
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
"録画データを取得できませんでした",
|
||||
(error as Error).message || "通信状態を確認してください。",
|
||||
);
|
||||
}
|
||||
},
|
||||
[handleImportRecordingText],
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
const onHardwareBack = () => {
|
||||
if (canGoBack) {
|
||||
webViewRef.current?.goBack();
|
||||
return true;
|
||||
if (handleWebViewBack()) return true;
|
||||
}
|
||||
goBack();
|
||||
return true;
|
||||
@@ -66,52 +279,237 @@ export default ({ route }) => {
|
||||
|
||||
const subscription = BackHandler.addEventListener("hardwareBackPress", onHardwareBack);
|
||||
return () => subscription.remove();
|
||||
}, [canGoBack, goBack])
|
||||
}, [canGoBack, goBack, handleWebViewBack])
|
||||
);
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
|
||||
{isEnvironmentReady && (
|
||||
<WebView
|
||||
key={webViewKey}
|
||||
source={{ uri: resolvedUri }}
|
||||
contentMode="mobile"
|
||||
allowsBackForwardNavigationGestures
|
||||
setSupportMultipleWindows={false}
|
||||
ref={webViewRef}
|
||||
injectedJavaScriptBeforeContentLoaded={
|
||||
importRecordingDownloads ? RECORDING_DOWNLOAD_BRIDGE_SCRIPT : `true;`
|
||||
}
|
||||
onLoadStart={() => {
|
||||
isLoadingRef.current = true;
|
||||
setHasError(false);
|
||||
hasErrorRef.current = false;
|
||||
maxBodyLenRef.current = 0;
|
||||
blankCountRef.current = 0;
|
||||
}}
|
||||
onLoadEnd={() => {
|
||||
setIsLoading(false);
|
||||
isLoadingRef.current = false;
|
||||
lastPongAt.current = Date.now();
|
||||
}}
|
||||
onError={(syntheticEvent) => {
|
||||
const { nativeEvent } = syntheticEvent;
|
||||
setIsLoading(false);
|
||||
isLoadingRef.current = false;
|
||||
setHasError(true);
|
||||
hasErrorRef.current = true;
|
||||
setErrorMessage(nativeEvent.description || "ページを読み込めませんでした");
|
||||
}}
|
||||
onHttpError={(syntheticEvent) => {
|
||||
const { nativeEvent } = syntheticEvent;
|
||||
if (nativeEvent.statusCode >= 500) {
|
||||
setIsLoading(false);
|
||||
isLoadingRef.current = false;
|
||||
setHasError(true);
|
||||
hasErrorRef.current = true;
|
||||
setErrorMessage(`サーバーエラー (${nativeEvent.statusCode})`);
|
||||
}
|
||||
}}
|
||||
onRenderProcessGone={() => {
|
||||
// クラッシュ・メモリ回収どちらも自動remount
|
||||
remount();
|
||||
}}
|
||||
// iOS: コンテンツプロセスがメモリ圧迫で終了した場合
|
||||
onContentProcessDidTerminate={() => remount()}
|
||||
onShouldStartLoadWithRequest={(request) => {
|
||||
if (request.isTopFrame === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const rewrittenUrl = rewriteJrDataSystemUrl(
|
||||
request.url,
|
||||
selectedEnvironment,
|
||||
);
|
||||
if (rewrittenUrl !== request.url) {
|
||||
setResolvedUri(rewrittenUrl);
|
||||
if (
|
||||
importRecordingDownloads &&
|
||||
isLikelyRecordingDownloadUrl(request.url) &&
|
||||
request.url !== resolvedUri
|
||||
) {
|
||||
void handleImportRecordingUrl(request.url);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!importRecordingDownloads) {
|
||||
const rewrittenUrl = rewriteJrDataSystemUrl(
|
||||
request.url,
|
||||
selectedEnvironment,
|
||||
);
|
||||
if (rewrittenUrl !== request.url) {
|
||||
setResolvedUri(rewrittenUrl);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
onNavigationStateChange={(navState) => {
|
||||
setCanGoBack(navState.canGoBack);
|
||||
if (navState.url === "https://unyohub.2pd.jp/integration/succeeded.php") {
|
||||
webViewRef.current?.goBack();
|
||||
if (!hasAlerted.current) {
|
||||
hasAlerted.current = true;
|
||||
Alert.alert("鉄道運用HUBへの投稿完了", "運用HUBからのこのアプリへのデータ反映には暫く時間がかかりますので、しばらくお待ちください。", [
|
||||
{ text: "完了" },
|
||||
]);
|
||||
onFileDownload={(event) => {
|
||||
if (!importRecordingDownloads) return;
|
||||
const downloadUrl = event.nativeEvent.downloadUrl;
|
||||
if (downloadUrl) {
|
||||
void handleImportRecordingUrl(downloadUrl);
|
||||
}
|
||||
}
|
||||
}}
|
||||
}}
|
||||
onNavigationStateChange={(navState) => {
|
||||
if (navState.url) {
|
||||
const nextUrl = importRecordingDownloads
|
||||
? navState.url
|
||||
: rewriteJrDataSystemUrl(navState.url, selectedEnvironment);
|
||||
setResolvedUri((current) => (current === nextUrl ? current : nextUrl));
|
||||
if (nextUrl !== "https://unyohub.2pd.jp/integration/succeeded.php") {
|
||||
pushHistoryEntry(nextUrl);
|
||||
}
|
||||
}
|
||||
syncCanGoBack(navState.canGoBack);
|
||||
// SPA内遷移中は白画面誤検知を防ぐためblankCountをリセット
|
||||
if (navState.loading) blankCountRef.current = 0;
|
||||
if (navState.url === "https://unyohub.2pd.jp/integration/succeeded.php") {
|
||||
webViewRef.current?.goBack();
|
||||
if (!hasAlerted.current) {
|
||||
hasAlerted.current = true;
|
||||
Alert.alert("鉄道運用HUBへの投稿完了", "運用HUBからのこのアプリへのデータ反映には暫く時間がかかりますので、しばらくお待ちください。", [
|
||||
{ text: "完了" },
|
||||
]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMessage={(event) => {
|
||||
const { data } = event.nativeEvent;
|
||||
const { type } = JSON.parse(data);
|
||||
if (type === "back") return webViewRef.current?.goBack();
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { type } = parsed;
|
||||
if (type === "importRecordingDownload") {
|
||||
void handleImportRecordingText(String(parsed.text ?? ""));
|
||||
return;
|
||||
}
|
||||
if (type === "importRecordingDownloadError") {
|
||||
Alert.alert(
|
||||
"録画データを取得できませんでした",
|
||||
parsed.message || "ダウンロード内容を読み取れませんでした。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (type === "pong") {
|
||||
lastPongAt.current = Date.now();
|
||||
const bodyLen: number = parsed.bodyLen ?? 0;
|
||||
// innerTextベース: 最大値を更新
|
||||
if (bodyLen > maxBodyLenRef.current) maxBodyLenRef.current = bodyLen;
|
||||
// 一度200文字超の表示テキストがあった後に20文字未満になったら白画面と判定
|
||||
// SPA遷移中の一時的な空白を避けるため3回連続(15秒)で発火
|
||||
if (maxBodyLenRef.current > 200 && bodyLen < 20) {
|
||||
blankCountRef.current += 1;
|
||||
if (blankCountRef.current >= 3) {
|
||||
blankCountRef.current = 0;
|
||||
maxBodyLenRef.current = 0;
|
||||
remount();
|
||||
}
|
||||
} else {
|
||||
blankCountRef.current = 0;
|
||||
}
|
||||
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 handleWebViewBack();
|
||||
if (type === "windowClose") return goBack();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{useExitButton && <BigButton onPress={goBack} string="閉じる" />}
|
||||
{isLoading && !hasError && (
|
||||
<View style={wvStyles.loadingOverlay} pointerEvents="none">
|
||||
<ActivityIndicator size="large" color="#fff" />
|
||||
</View>
|
||||
)}
|
||||
{hasError && (
|
||||
<View style={wvStyles.errorOverlay}>
|
||||
<MaterialCommunityIcons name="wifi-off" size={48} color="#ccc" />
|
||||
<Text style={wvStyles.errorText}>{errorMessage}</Text>
|
||||
<TouchableOpacity style={wvStyles.reloadButton} onPress={handleReload}>
|
||||
<MaterialCommunityIcons name="reload" size={18} color="#fff" />
|
||||
<Text style={wvStyles.reloadButtonText}>再読み込み</Text>
|
||||
</TouchableOpacity>
|
||||
{useExitButton && (
|
||||
<TouchableOpacity style={wvStyles.backButton} onPress={goBack}>
|
||||
<Text style={wvStyles.backButtonText}>閉じる</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{useExitButton && !hasError && <BigButton onPress={goBack} string="閉じる" />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const wvStyles = StyleSheet.create({
|
||||
loadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(0,0,0,0.25)",
|
||||
},
|
||||
errorOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#1a1a2e",
|
||||
gap: 16,
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
errorText: {
|
||||
color: "#aaa",
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
},
|
||||
reloadButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
backgroundColor: "#0099CC",
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
reloadButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
backButton: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
backButtonText: {
|
||||
color: "#888",
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
||||
+101
-9
@@ -18,11 +18,16 @@ import Setting from "@/components/Settings/settings";
|
||||
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
|
||||
import { optionData } from "@/lib/stackOption";
|
||||
import { AllTrainDiagramView } from "@/components/AllTrainDiagramView";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useNavigation, useIsFocused } from "@react-navigation/native";
|
||||
import {
|
||||
startupExplicitTargetRef,
|
||||
waitForStartupNavigationResolution,
|
||||
} from "@/lib/rootNavigation";
|
||||
import { news } from "@/config/newsUpdate";
|
||||
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
|
||||
import GeneralWebView from "@/GeneralWebView";
|
||||
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
const Stack = createStackNavigator();
|
||||
|
||||
export function MenuPage() {
|
||||
@@ -32,17 +37,54 @@ export function MenuPage() {
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
const navigation = useNavigation<any>();
|
||||
const { addListener } = navigation;
|
||||
const isFocused = useIsFocused();
|
||||
const isDark = useColorScheme() === "dark";
|
||||
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
|
||||
|
||||
useEffect(() => {
|
||||
AS.getItem(STORAGE_KEYS.START_PAGE)
|
||||
.then((res) => {
|
||||
if (res == "true") navigation.navigate("positions");
|
||||
})
|
||||
.catch((e) => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "topMenu.screen",
|
||||
level: "info",
|
||||
message: "top menu mounted",
|
||||
});
|
||||
return () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "topMenu.screen",
|
||||
level: "info",
|
||||
message: "top menu unmounted",
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "topMenu.screen",
|
||||
level: "info",
|
||||
message: isFocused ? "top menu focused" : "top menu blurred",
|
||||
data: {
|
||||
width,
|
||||
height,
|
||||
},
|
||||
});
|
||||
}, [height, isFocused, width]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await waitForStartupNavigationResolution();
|
||||
if (cancelled) return;
|
||||
|
||||
const res = await AS.getItem(STORAGE_KEYS.START_PAGE);
|
||||
if (res !== "true" || startupExplicitTargetRef.current) return;
|
||||
|
||||
navigation.navigate("positions");
|
||||
} catch (e) {
|
||||
//6.0以降false
|
||||
AS.setItem(STORAGE_KEYS.START_PAGE, "false");
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
//ニュース表示
|
||||
AS.getItem(STORAGE_KEYS.NEWS_STATUS)
|
||||
@@ -55,10 +97,22 @@ export function MenuPage() {
|
||||
if (isSetIcon == "true") SheetManager.show("TrainIconUpdate");
|
||||
})
|
||||
.catch((error) => logger.error("Error fetching icon setting:", error));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scrollRef = useRef(null);
|
||||
const [mapMode, setMapMode] = useState(false);
|
||||
useEffect(() => {
|
||||
Sentry.setContext("top_menu_screen", {
|
||||
focused: isFocused,
|
||||
width,
|
||||
height,
|
||||
mapMode,
|
||||
});
|
||||
}, [height, isFocused, mapMode, width]);
|
||||
const [mapHeight, setMapHeight] = useState(0);
|
||||
const mapHeightRef = useRef(0);
|
||||
const favoriteStationRef = useRef(favoriteStation);
|
||||
@@ -85,6 +139,16 @@ export function MenuPage() {
|
||||
}, [height, tabBarHeight, width]);
|
||||
useEffect(() => {
|
||||
const unsubscribe = addListener("tabPress", (e: any) => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "topMenu.screen",
|
||||
level: "info",
|
||||
message: "top menu tabPress handler",
|
||||
data: {
|
||||
focused: navigation.isFocused(),
|
||||
stackIndex: stackNavRef.current?.getState()?.index ?? null,
|
||||
mapMode,
|
||||
},
|
||||
});
|
||||
if (navigation.isFocused() && stackNavRef.current) {
|
||||
if (stackNavRef.current.getState()?.index > 0) {
|
||||
e.preventDefault();
|
||||
@@ -119,9 +183,37 @@ export function MenuPage() {
|
||||
<Stack.Navigator
|
||||
id={null}
|
||||
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
|
||||
screenListeners={({ navigation: stackNav }) => {
|
||||
screenListeners={({ route, navigation: stackNav }) => {
|
||||
stackNavRef.current = stackNav;
|
||||
return {};
|
||||
return {
|
||||
focus: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "topMenu.screen",
|
||||
level: "info",
|
||||
message: "top menu stack focus",
|
||||
data: { route: route.name },
|
||||
});
|
||||
},
|
||||
blur: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "topMenu.screen",
|
||||
level: "info",
|
||||
message: "top menu stack blur",
|
||||
data: { route: route.name },
|
||||
});
|
||||
},
|
||||
state: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "topMenu.screen",
|
||||
level: "info",
|
||||
message: "top menu stack state",
|
||||
data: {
|
||||
route: route.name,
|
||||
stackIndex: stackNav.getState()?.index ?? null,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createStackNavigator } from "@react-navigation/stack";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useIsFocused, useNavigation } from "@react-navigation/native";
|
||||
import { useColorScheme } from "react-native";
|
||||
import Apps from "./components/Apps";
|
||||
import TrainBase from "./components/trainbaseview";
|
||||
@@ -13,16 +13,20 @@ import { useCurrentTrain } from "./stateBox/useCurrentTrain";
|
||||
import { useTrainMenu } from "./stateBox/useTrainMenu";
|
||||
import { AS } from "./storageControl";
|
||||
import { news } from "./config/newsUpdate";
|
||||
import { Linking, Platform } from "react-native";
|
||||
import { InteractionManager, Linking, Platform, View } from "react-native";
|
||||
import GeneralWebView from "./GeneralWebView";
|
||||
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
|
||||
import { positionsStackNavRef } from "./lib/rootNavigation";
|
||||
import { positionsLifecycleRef, positionsStackNavRef } from "./lib/rootNavigation";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
const Stack = createStackNavigator();
|
||||
export const Top = () => {
|
||||
const { webview } = useCurrentTrain();
|
||||
const { navigate, addListener, isFocused } = useNavigation<any>();
|
||||
const navigation = useNavigation<any>();
|
||||
const { navigate, addListener } = navigation;
|
||||
const isTabFocused = useIsFocused();
|
||||
const isDark = useColorScheme() === "dark";
|
||||
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
|
||||
const [hasActivatedStack, setHasActivatedStack] = useState(false);
|
||||
|
||||
//地図用
|
||||
const { mapSwitch } = useTrainMenu();
|
||||
@@ -31,24 +35,136 @@ export const Top = () => {
|
||||
mapSwitchRef.current = mapSwitch;
|
||||
}, [mapSwitch]);
|
||||
|
||||
const goToFavoriteList = () =>
|
||||
const goToFavoriteList = (event?: any) => {
|
||||
if (!hasActivatedStack) {
|
||||
event?.preventDefault?.();
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.stack",
|
||||
level: "info",
|
||||
message: "positions tabLongPress ignored before stack activation",
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate("positions", { screen: "favoriteList" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = addListener("tabLongPress", goToFavoriteList);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.stack",
|
||||
level: "info",
|
||||
message: "positions stack mounted",
|
||||
});
|
||||
return () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.stack",
|
||||
level: "info",
|
||||
message: "positions stack unmounted",
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasActivatedStack) return;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.stack",
|
||||
level: "info",
|
||||
message: "positions stack activation scheduled",
|
||||
data: {
|
||||
reason: isTabFocused ? "first_focus" : "background_prewarm",
|
||||
},
|
||||
});
|
||||
let cancelled = false;
|
||||
const activate = () => {
|
||||
if (cancelled) return;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.stack",
|
||||
level: "info",
|
||||
message: "positions stack activation committed",
|
||||
data: {
|
||||
reason: isTabFocused ? "first_focus" : "background_prewarm",
|
||||
},
|
||||
});
|
||||
setHasActivatedStack(true);
|
||||
};
|
||||
|
||||
if (isTabFocused) {
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
setTimeout(activate, 0);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
task.cancel?.();
|
||||
};
|
||||
}
|
||||
|
||||
const timer = setTimeout(activate, 300);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [hasActivatedStack, isTabFocused]);
|
||||
|
||||
useEffect(() => {
|
||||
Sentry.setContext("positions_stack", {
|
||||
focused: isTabFocused,
|
||||
activated: hasActivatedStack,
|
||||
mapSwitch,
|
||||
});
|
||||
}, [hasActivatedStack, isTabFocused, mapSwitch]);
|
||||
|
||||
const stackNavRef = positionsStackNavRef;
|
||||
|
||||
useEffect(() => {
|
||||
positionsLifecycleRef.current = {
|
||||
...positionsLifecycleRef.current,
|
||||
deactivateStack: null,
|
||||
};
|
||||
return () => {
|
||||
positionsLifecycleRef.current = {
|
||||
...positionsLifecycleRef.current,
|
||||
deactivateStack: null,
|
||||
};
|
||||
};
|
||||
}, []);
|
||||
|
||||
const goToTrainMenu = useCallback((e: any) => {
|
||||
const stackNav = stackNavRef.current;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.stack",
|
||||
level: "info",
|
||||
message: "positions tabPress handler",
|
||||
data: {
|
||||
platform: Platform.OS,
|
||||
focused: isTabFocused,
|
||||
mapSwitch: mapSwitchRef.current,
|
||||
stackIndex: stackNav?.getState()?.index ?? null,
|
||||
},
|
||||
});
|
||||
if (Platform.OS === "web") {
|
||||
Linking.openURL("https://train.jr-shikoku.co.jp/");
|
||||
setTimeout(() => navigate("topMenu", { screen: "menu" }), 100);
|
||||
return;
|
||||
}
|
||||
if (!isFocused()) return;
|
||||
const stackNav = stackNavRef.current;
|
||||
if (!hasActivatedStack || !stackNav) {
|
||||
e?.preventDefault?.();
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.stack",
|
||||
level: "info",
|
||||
message: "positions tabPress ignored before stack activation",
|
||||
data: {
|
||||
focused: isTabFocused,
|
||||
activated: hasActivatedStack,
|
||||
hasStackNav: !!stackNav,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isTabFocused) return;
|
||||
if (stackNav && stackNav.getState()?.index > 0) {
|
||||
e.preventDefault();
|
||||
stackNav.goBack();
|
||||
@@ -58,20 +174,74 @@ export const Top = () => {
|
||||
navigate("positions", { screen: "trainMenu" });
|
||||
else webview.current?.injectJavaScript(`AccordionClassEvent()`);
|
||||
return;
|
||||
}, [isFocused, navigate, webview]);
|
||||
}, [hasActivatedStack, isTabFocused, navigate, webview]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = addListener("tabPress", goToTrainMenu);
|
||||
return unsubscribe;
|
||||
}, [addListener, goToTrainMenu]);
|
||||
|
||||
if (!hasActivatedStack) {
|
||||
return <View style={{ flex: 1, backgroundColor: bgColor }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack.Navigator
|
||||
id={null}
|
||||
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
|
||||
screenListeners={({ navigation: stackNav }) => {
|
||||
screenListeners={({ route, navigation: stackNav }) => {
|
||||
stackNavRef.current = stackNav;
|
||||
return {};
|
||||
return {
|
||||
focus: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.stack",
|
||||
level: "info",
|
||||
message: "positions stack focus",
|
||||
data: { route: route.name },
|
||||
});
|
||||
},
|
||||
blur: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.stack",
|
||||
level: "info",
|
||||
message: "positions stack blur",
|
||||
data: { route: route.name },
|
||||
});
|
||||
},
|
||||
transitionStart: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.stack",
|
||||
level: "info",
|
||||
message: "positions stack transitionStart",
|
||||
data: {
|
||||
route: route.name,
|
||||
stackIndex: stackNav.getState()?.index ?? null,
|
||||
},
|
||||
});
|
||||
},
|
||||
transitionEnd: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.stack",
|
||||
level: "info",
|
||||
message: "positions stack transitionEnd",
|
||||
data: {
|
||||
route: route.name,
|
||||
stackIndex: stackNav.getState()?.index ?? null,
|
||||
},
|
||||
});
|
||||
},
|
||||
state: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.stack",
|
||||
level: "info",
|
||||
message: "positions stack state",
|
||||
data: {
|
||||
route: route.name,
|
||||
stackIndex: stackNav.getState()?.index ?? null,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"android",
|
||||
"web"
|
||||
],
|
||||
"version": "7.0.0",
|
||||
"version": "7.1.0",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icons/s8600.png",
|
||||
@@ -24,7 +24,7 @@
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"buildNumber": "63",
|
||||
"buildNumber": "66",
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "jrshikokuinfo.xprocess.hrkn",
|
||||
"appleTeamId": "54CRDT797G",
|
||||
@@ -33,6 +33,8 @@
|
||||
},
|
||||
"infoPlist": {
|
||||
"NFCReaderUsageDescription": "To read FeliCa card",
|
||||
"NSPhotoLibraryUsageDescription": "運行情報画像を共有または保存するために写真ライブラリへのアクセスを使用します。",
|
||||
"NSPhotoLibraryAddUsageDescription": "運行情報画像を写真ライブラリに保存するために使用します。",
|
||||
"com.apple.developer.nfc.readersession.felica.systemcodes": [
|
||||
"0003",
|
||||
"FE00"
|
||||
@@ -55,7 +57,7 @@
|
||||
},
|
||||
"android": {
|
||||
"package": "jrshikokuinfo.xprocess.hrkn",
|
||||
"versionCode": 30,
|
||||
"versionCode": 32,
|
||||
"intentFilters": [
|
||||
{
|
||||
"action": "VIEW",
|
||||
@@ -970,13 +972,29 @@
|
||||
"expo-web-browser",
|
||||
"expo-asset",
|
||||
"expo-sharing",
|
||||
[
|
||||
"react-native-share",
|
||||
{
|
||||
"ios": [],
|
||||
"android": [],
|
||||
"enableBase64ShareAndroid": false
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-maps",
|
||||
{
|
||||
"iosGoogleMapsApiKey": "AIzaSyAVGDTjBkR_0wkQiNkoo5WDLhqXCjrjk8Y",
|
||||
"androidGoogleMapsApiKey": "AIzaSyAmFb-Yj033bXZWlSzNrfq_0jc1PgRrWcE"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@sentry/react-native/expo",
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"project": "jr-shikoku-unofficial-apps",
|
||||
"organization": "xprocess-m5"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useUnyohub } from "@/stateBox/useUnyohub";
|
||||
import { useElesite } from "@/stateBox/useElesite";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { useResponsive } from "@/lib/responsive";
|
||||
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
|
||||
type Props = {
|
||||
data: { trainNum: string; limited: string };
|
||||
@@ -47,11 +48,11 @@ export const HeaderText: FC<Props> = ({
|
||||
from,
|
||||
scrollRef,
|
||||
}) => {
|
||||
const { limited, trainNum } = data;
|
||||
const { trainNum } = data;
|
||||
|
||||
const { fixed } = useThemeColors();
|
||||
const { fontScale } = useResponsive();
|
||||
const { updatePermission } = useTrainMenu();
|
||||
const { iconSetting } = useTrainMenu();
|
||||
const { allCustomTrainData, getTodayOperationByTrainId } =
|
||||
useAllTrainDiagram();
|
||||
const { expoPushToken } = useNotification();
|
||||
@@ -63,15 +64,14 @@ export const HeaderText: FC<Props> = ({
|
||||
} = useUnyohub();
|
||||
const { getElesiteEntriesByTrainNumber, useElesite: elesiteEnabled } =
|
||||
useElesite();
|
||||
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
|
||||
|
||||
// 追加ソースのON/OFFをここで管理(将来ソースが増えたらここに足す)
|
||||
const additionalSources = {
|
||||
unyohub: unyohubEnabled,
|
||||
elesite: elesiteEnabled,
|
||||
};
|
||||
const hasAdditionalSources = Object.values(additionalSources).some(Boolean);
|
||||
|
||||
// 列車名、種別、フォントの取得
|
||||
const [
|
||||
typeName,
|
||||
trainName,
|
||||
@@ -133,7 +133,7 @@ export const HeaderText: FC<Props> = ({
|
||||
case to_data && to_data !== "":
|
||||
return [
|
||||
typeString,
|
||||
to_data + "行き",
|
||||
`${to_data}行き`,
|
||||
fontAvailable,
|
||||
isOneMan,
|
||||
infogram,
|
||||
@@ -147,7 +147,7 @@ export const HeaderText: FC<Props> = ({
|
||||
return [
|
||||
typeString,
|
||||
migrateTrainName(
|
||||
trainData[trainData.length - 1].split(",")[0] + "行き",
|
||||
`${trainData[trainData.length - 1].split(",")[0]}行き`,
|
||||
),
|
||||
fontAvailable,
|
||||
isOneMan,
|
||||
@@ -161,7 +161,7 @@ export const HeaderText: FC<Props> = ({
|
||||
}
|
||||
}, [trainData, trainNum, allCustomTrainData]);
|
||||
|
||||
const allTodayOperation = getTodayOperationByTrainId(trainNum);
|
||||
const allTodayOperation = getTodayOperationByTrainId(trainNum) ?? [];
|
||||
const todayOperation = allTodayOperation.filter((d) => d.state !== 100);
|
||||
|
||||
let iconTrainDirection =
|
||||
@@ -198,7 +198,6 @@ export const HeaderText: FC<Props> = ({
|
||||
: getUnyohubEntriesByTrainNumber(unyohubTrainNumForSourceScreen);
|
||||
const elesiteEntries = getElesiteEntriesByTrainNumber(trainNum);
|
||||
|
||||
// 車番(formations) がある場合のみ「運用Hub情報あり」と判定
|
||||
const hasUnyohubFormation = unyohubEntries.some(
|
||||
(e) => !!e.formations && e.formations.trim() !== "",
|
||||
);
|
||||
@@ -208,12 +207,21 @@ export const HeaderText: FC<Props> = ({
|
||||
|
||||
const hasExtraInfo =
|
||||
priority > 200 ||
|
||||
todayOperation?.length > 0 ||
|
||||
todayOperation.length > 0 ||
|
||||
hasUnyohubFormation ||
|
||||
hasElesiteFormation;
|
||||
|
||||
const [isWrapped, setIsWrapped] = useState(false);
|
||||
|
||||
const openTrainInfoUrl = () => {
|
||||
if (!trainInfoUrl) return;
|
||||
const uri = trainInfoUrl.includes("pdf")
|
||||
? getPDFViewURL(trainInfoUrl)
|
||||
: trainInfoUrl;
|
||||
navigate("generalWebView", { uri, useExitButton: true });
|
||||
SheetManager.hide("EachTrainInfo");
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -232,6 +240,7 @@ export const HeaderText: FC<Props> = ({
|
||||
from={from}
|
||||
todayOperation={todayOperation}
|
||||
direction={iconTrainDirection}
|
||||
iconDisplayMode={iconDisplayMode}
|
||||
/>
|
||||
|
||||
<View
|
||||
@@ -270,26 +279,21 @@ export const HeaderText: FC<Props> = ({
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
onPress={() => {
|
||||
if (!trainInfoUrl) return;
|
||||
const uri = trainInfoUrl.includes("pdf")
|
||||
? getPDFViewURL(trainInfoUrl)
|
||||
: trainInfoUrl;
|
||||
navigate("generalWebView", { uri, useExitButton: true });
|
||||
SheetManager.hide("EachTrainInfo");
|
||||
}}
|
||||
onPress={openTrainInfoUrl}
|
||||
disabled={!trainInfoUrl}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
...textConfig,
|
||||
color: fixed.textOnPrimary,
|
||||
...(trainName.length > 10 ? { fontSize: fontScale(16) } : { fontSize: fontScale(17) }),
|
||||
...(trainName.length > 10
|
||||
? { fontSize: fontScale(16) }
|
||||
: { fontSize: fontScale(17) }),
|
||||
flexShrink: 1,
|
||||
}}
|
||||
onTextLayout={(e) => {
|
||||
if (e.nativeEvent.lines.length > 1) setIsWrapped(true);
|
||||
}}
|
||||
if (e.nativeEvent.lines.length > 1) setIsWrapped(true);
|
||||
}}
|
||||
>
|
||||
{trainName}
|
||||
</Text>
|
||||
@@ -328,7 +332,6 @@ export const HeaderText: FC<Props> = ({
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 追加ソースが全てオフ → 元の挙動(直接 DB ページを開く)
|
||||
const uri = `https://jr-shikoku-data-system.pages.dev/trainData/${trainNum}?userID=${expoPushToken}&from=eachTrainInfo`;
|
||||
navigate("generalWebView", { uri, useExitButton: false });
|
||||
SheetManager.hide("EachTrainInfo");
|
||||
|
||||
@@ -3,13 +3,14 @@ import { Text } from "react-native";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
type props = {
|
||||
infogram: string;
|
||||
fontSize?: number;
|
||||
}
|
||||
export const InfogramText: FC<props> = ({infogram}) => {
|
||||
export const InfogramText: FC<props> = ({infogram, fontSize = 20}) => {
|
||||
const { fixed } = useThemeColors();
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontSize,
|
||||
color: fixed.textOnPrimary,
|
||||
fontFamily: "JNR-font",
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { ComponentProps, FC } from "react";
|
||||
import { Image, TouchableOpacity, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { TrainIconEntry } from "@/lib/trainIconEntries";
|
||||
|
||||
type StackVariant = "header" | "list";
|
||||
type StatusIcon = ComponentProps<typeof Ionicons>;
|
||||
|
||||
type Props = {
|
||||
entries: TrainIconEntry[];
|
||||
direction?: boolean;
|
||||
hidden?: boolean;
|
||||
onPressEntry?: (entry: TrainIconEntry, index: number) => void;
|
||||
statusIcon?: StatusIcon;
|
||||
variant?: StackVariant;
|
||||
};
|
||||
|
||||
const iconSize = {
|
||||
header: {
|
||||
width: 24,
|
||||
height: 30,
|
||||
stackedWidth: 12,
|
||||
stackedHeight: 15,
|
||||
marginRight: 5,
|
||||
stackedMarginLeft: -10,
|
||||
stackedMarginTop: 10,
|
||||
statusSize: 24,
|
||||
},
|
||||
list: {
|
||||
width: 20,
|
||||
height: 22,
|
||||
stackedWidth: 10,
|
||||
stackedHeight: 12,
|
||||
marginRight: 2,
|
||||
stackedMarginLeft: -8,
|
||||
stackedMarginTop: 8,
|
||||
statusSize: 18,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const TrainIconStack: FC<Props> = ({
|
||||
entries,
|
||||
direction,
|
||||
hidden = false,
|
||||
onPressEntry,
|
||||
statusIcon,
|
||||
variant = "header",
|
||||
}) => {
|
||||
const size = iconSize[variant];
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: "row", alignItems: "flex-start" }}>
|
||||
{entries.map((entry, index) => {
|
||||
const trainIcon = direction
|
||||
? entry.vehicle_info_img
|
||||
: entry.vehicle_info_right_img || entry.vehicle_info_img;
|
||||
if (!trainIcon) return null;
|
||||
|
||||
const content = (
|
||||
<View>
|
||||
<View style={{ opacity: hidden ? 0 : 1 }}>
|
||||
<Image
|
||||
source={{ uri: trainIcon }}
|
||||
style={{
|
||||
height: index > 0 ? size.stackedHeight : size.height,
|
||||
width: index > 0 ? size.stackedWidth : size.width,
|
||||
marginRight: size.marginRight,
|
||||
marginLeft: index > 0 ? size.stackedMarginLeft : 0,
|
||||
marginTop: index > 0 ? size.stackedMarginTop : 0,
|
||||
}}
|
||||
resizeMethod="resize"
|
||||
/>
|
||||
</View>
|
||||
{statusIcon && hidden && (
|
||||
<View style={{ position: "absolute", top: 0, left: 0 }}>
|
||||
<Ionicons {...statusIcon} size={size.statusSize} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!onPressEntry) {
|
||||
return <View key={`${trainIcon}-${index}`}>{content}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`${trainIcon}-${index}`}
|
||||
onPress={() => onPressEntry(entry, index)}
|
||||
disabled={!entry.vehicle_info_url}
|
||||
>
|
||||
{content}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { ComponentProps, FC, useEffect, useState } from "react";
|
||||
import { View, Image, TouchableOpacity } from "react-native";
|
||||
import React, { ComponentProps, FC, useEffect, useMemo, useState } from "react";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
@@ -8,6 +7,9 @@ import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
|
||||
import { useInterval } from "@/lib/useInterval";
|
||||
import type { NavigateFunction } from "@/types";
|
||||
import { OperationLogs } from "@/lib/CommonTypes";
|
||||
import type { IconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
|
||||
import { TrainIconStack } from "./TrainIconStack";
|
||||
|
||||
type GlyphNames = ComponentProps<typeof Ionicons>["name"];
|
||||
|
||||
@@ -17,94 +19,54 @@ type Props = {
|
||||
from: string;
|
||||
todayOperation: OperationLogs[];
|
||||
direction?: boolean;
|
||||
iconDisplayMode: IconDisplayMode;
|
||||
};
|
||||
type apt = {
|
||||
name: GlyphNames;
|
||||
color: string;
|
||||
};
|
||||
export const TrainIconStatus: FC<Props> = (props) => {
|
||||
const { data, navigate, from, todayOperation, direction } = props;
|
||||
const {
|
||||
data,
|
||||
navigate,
|
||||
from,
|
||||
todayOperation,
|
||||
direction,
|
||||
iconDisplayMode,
|
||||
} = props;
|
||||
const [anpanmanStatus, setAnpanmanStatus] = useState<apt>();
|
||||
const { allCustomTrainData } = useAllTrainDiagram();
|
||||
const [trainIconData, setTrainIcon] = useState<
|
||||
{ vehicle_info_img: string;vehicle_info_right_img: string; vehicle_info_url: string }[]
|
||||
>([]);
|
||||
const customTrainData = useMemo(
|
||||
() => customTrainDataDetector(data.trainNum, allCustomTrainData),
|
||||
[data.trainNum, allCustomTrainData],
|
||||
);
|
||||
const trainIconData = useMemo(
|
||||
() =>
|
||||
data.trainNum
|
||||
? resolveTrainIconEntries({
|
||||
trainNum: data.trainNum,
|
||||
customTrainData,
|
||||
todayOperation,
|
||||
iconDisplayMode,
|
||||
})
|
||||
: [],
|
||||
[data.trainNum, customTrainData, todayOperation, iconDisplayMode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data.trainNum) return;
|
||||
const { train_info_img: vehicle_info_img, vehicle_info_url } =
|
||||
customTrainDataDetector(data.trainNum, allCustomTrainData);
|
||||
if (todayOperation.length !== 0) {
|
||||
const returnData =
|
||||
todayOperation
|
||||
.sort((a, b) => {
|
||||
// trainIdからカンマ以降の数字を抽出する関数
|
||||
const extractOrderNumber = (trainId: string): number => {
|
||||
const parts = trainId.split(',');
|
||||
if (parts.length > 1) {
|
||||
const num = parseInt(parts[1].trim(), 10);
|
||||
return isNaN(num) ? Infinity : num;
|
||||
}
|
||||
return Infinity; // カンマなし = 末尾に移動
|
||||
};
|
||||
|
||||
// data.trainNumと一致するtrainIdを探す関数
|
||||
const findMatchingTrainId = (operation: OperationLogs): string | null => {
|
||||
const allTrainIds = [
|
||||
...(operation.train_ids || []),
|
||||
...(operation.related_train_ids || []),
|
||||
];
|
||||
|
||||
// data.trainNumの接頭辞と一致するものを探す
|
||||
for (const trainId of allTrainIds) {
|
||||
const prefix = trainId.split(',')[0]; // カンマ前の部分
|
||||
if (prefix === data.trainNum) {
|
||||
return trainId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const aTrainId = findMatchingTrainId(a);
|
||||
const bTrainId = findMatchingTrainId(b);
|
||||
|
||||
// マッチしたものがない場合は元の順序を保持
|
||||
if (!aTrainId || !bTrainId) {
|
||||
return aTrainId ? -1 : bTrainId ? 1 : 0;
|
||||
}
|
||||
|
||||
const aOrder = extractOrderNumber(aTrainId);
|
||||
const bOrder = extractOrderNumber(bTrainId);
|
||||
|
||||
return aOrder - bOrder;
|
||||
})
|
||||
.map((op) => ({
|
||||
vehicle_info_img: op.vehicle_img || vehicle_info_img,
|
||||
vehicle_info_right_img: op.vehicle_img_right || op.vehicle_img || vehicle_info_img,
|
||||
vehicle_info_url: op.vehicle_info_url,
|
||||
})) || [];
|
||||
setTrainIcon(returnData);
|
||||
} else if (vehicle_info_img) {
|
||||
setTrainIcon([{ vehicle_info_img, vehicle_info_right_img: vehicle_info_img, vehicle_info_url }]);
|
||||
}
|
||||
|
||||
// アンパンマンステータスAPIのエンドポイント判定
|
||||
let anpanmanApiPath: string | null = null;
|
||||
switch (data.trainNum) {
|
||||
// 予讃線 → yosan-anpanman
|
||||
// しおかぜ 8000 アンパン
|
||||
case "10M":
|
||||
case "22M":
|
||||
case "9M":
|
||||
case "21M":
|
||||
// いしづち 8000 アンパン
|
||||
case "1010M":
|
||||
case "1022M":
|
||||
case "1009M":
|
||||
case "1021M":
|
||||
// いしづち 三桁 アンパン
|
||||
case "1041M":
|
||||
case "1044M":
|
||||
// 宇和海 2000 アンパン
|
||||
case "1058D":
|
||||
case "1066D":
|
||||
case "1074D":
|
||||
@@ -113,8 +75,6 @@ export const TrainIconStatus: FC<Props> = (props) => {
|
||||
case "1067D":
|
||||
anpanmanApiPath = "yosan-anpanman";
|
||||
break;
|
||||
// 土讃線 → dosan-anpanman
|
||||
// 南風 2700 アンパン
|
||||
case "32D":
|
||||
case "36D":
|
||||
case "44D":
|
||||
@@ -132,12 +92,12 @@ export const TrainIconStatus: FC<Props> = (props) => {
|
||||
fetch(
|
||||
`https://n8n.haruk.in/webhook/${anpanmanApiPath}?trainNum=${
|
||||
data.trainNum
|
||||
}&month=${dayjs().format("M")}&day=${dayjs().format("D")}`,{ cache: "no-store" }
|
||||
}&month=${dayjs().format("M")}&day=${dayjs().format("D")}`,
|
||||
{ cache: "no-store" },
|
||||
)
|
||||
.then((d) => d.json())
|
||||
.then((d) => {
|
||||
if (d.trainStatus == "〇" || d.trainStatus == "○") {
|
||||
//setAnpanmanStatus({name:"checkmark-circle-outline",color:"blue"});
|
||||
} else if (d.trainStatus == "▲") {
|
||||
setAnpanmanStatus({ name: "warning-outline", color: "yellow" });
|
||||
} else if (d.trainStatus == "×") {
|
||||
@@ -146,11 +106,8 @@ export const TrainIconStatus: FC<Props> = (props) => {
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [data.trainNum, allCustomTrainData, todayOperation]);
|
||||
}, [data.trainNum, allCustomTrainData, todayOperation, iconDisplayMode]);
|
||||
|
||||
// JSスレッドでの点滅(useInterval + useState)
|
||||
// reanimated の withRepeat はUIスレッドで毎フレーム更新し続けるため
|
||||
// ActionSheetのスプリングアニメーションと競合する
|
||||
const [showIcon, setShowIcon] = useState(false);
|
||||
useInterval(() => {
|
||||
if (anpanmanStatus) {
|
||||
@@ -159,47 +116,18 @@ export const TrainIconStatus: FC<Props> = (props) => {
|
||||
}, 1000, !!anpanmanStatus);
|
||||
|
||||
return (
|
||||
<>
|
||||
{trainIconData.map(
|
||||
({ vehicle_info_img: trainIcon, vehicle_info_right_img: trainIconRight, vehicle_info_url: address }, index) => (
|
||||
<TouchableOpacity
|
||||
key={`${trainIcon}-${index}`}
|
||||
onPress={() => {
|
||||
navigate("howto", {
|
||||
info: address,
|
||||
goTo: from == "LED" ? "menu" : from,
|
||||
});
|
||||
SheetManager.hide("EachTrainInfo");
|
||||
}}
|
||||
disabled={!address}
|
||||
>
|
||||
<View>
|
||||
<View style={{ opacity: anpanmanStatus && showIcon ? 0 : 1 }}>
|
||||
<Image
|
||||
source={{ uri: direction ? trainIcon : trainIconRight || trainIcon }}
|
||||
style={{
|
||||
height: index > 0 ? 15 : 30,
|
||||
width: index > 0 ? 12 : 24,
|
||||
marginRight: 5,
|
||||
marginLeft: index > 0 ? -10 : 0,
|
||||
marginTop: index > 0 ? 10 : 0,
|
||||
}}
|
||||
resizeMethod="resize"
|
||||
/>
|
||||
</View>
|
||||
{anpanmanStatus && showIcon && (
|
||||
<View style={{ position: "absolute", top: 0, left: 0 }}>
|
||||
<Ionicons
|
||||
{...anpanmanStatus}
|
||||
size={24}
|
||||
style={{ marginRight: 5 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
<TrainIconStack
|
||||
entries={trainIconData}
|
||||
direction={direction}
|
||||
hidden={!!anpanmanStatus && showIcon}
|
||||
statusIcon={anpanmanStatus}
|
||||
onPressEntry={(entry) => {
|
||||
navigate("howto", {
|
||||
info: entry.vehicle_info_url,
|
||||
goTo: from == "LED" ? "menu" : from,
|
||||
});
|
||||
SheetManager.hide("EachTrainInfo");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -48,7 +48,9 @@ export const StationDeteilView = (props) => {
|
||||
|
||||
const [usePDFView, setUsePDFView] = useState(undefined);
|
||||
useEffect(() => {
|
||||
AS.getItem(STORAGE_KEYS.USE_PDF_VIEW).then(setUsePDFView);
|
||||
AS.getItem(STORAGE_KEYS.USE_PDF_VIEW)
|
||||
.then(setUsePDFView)
|
||||
.catch(() => setUsePDFView("false"));
|
||||
}, []);
|
||||
const info =
|
||||
currentStation &&
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { FC, useState, useEffect, useRef } from "react";
|
||||
import React, { FC, useState, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -21,6 +21,10 @@ 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";
|
||||
|
||||
@@ -83,6 +87,226 @@ const formatDateHHMM = (datetime: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* FormationChips: "+"区切りの編成名をチップ形式で表示 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const FormationChips: FC<{ text: string; color: string }> = ({ text, color }) => {
|
||||
const parts = text.split("+").map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
return (
|
||||
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 2 }}>
|
||||
{parts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && (
|
||||
<Text style={{ fontSize: 11, color, fontWeight: "bold" }}>+</Text>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: color + "66",
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 1,
|
||||
backgroundColor: color + "12",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", color }} numberOfLines={1}>
|
||||
{part}
|
||||
</Text>
|
||||
</View>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* FadingSubCycler */
|
||||
/* ------------------------------------------------------------------ */
|
||||
type FadingSubItem = { label: string; datetime: string | null };
|
||||
|
||||
const FadingSubCycler: FC<{ items: FadingSubItem[]; color: string }> = ({ items, color }) => {
|
||||
const { colors } = useThemeColors();
|
||||
const [index, setIndex] = useState(0);
|
||||
const opacity = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length <= 1) return;
|
||||
const cycle = () => {
|
||||
Animated.timing(opacity, { toValue: 0, duration: 300, useNativeDriver: true }).start(() => {
|
||||
setIndex((i) => (i + 1) % items.length);
|
||||
Animated.timing(opacity, { toValue: 1, duration: 300, useNativeDriver: true }).start();
|
||||
});
|
||||
};
|
||||
const id = setInterval(cycle, 3000);
|
||||
return () => clearInterval(id);
|
||||
}, [items.length]);
|
||||
|
||||
const item = items[index];
|
||||
return (
|
||||
<Animated.View style={{ opacity, flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||
{item.datetime ? (
|
||||
<Text style={{ fontSize: 11, color: colors.textSecondary }} numberOfLines={1}>
|
||||
{`最終投稿: ${formatDateHHMM(item.datetime)}`}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={{ fontSize: 11, color: colors.textSecondary }} numberOfLines={1}>
|
||||
{`運用情報 ${index + 1}/${items.length}`}
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ActiveFormationChipsCycler: 全チップ常時表示、アクティブのみ枠アニメ */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const ActiveFormationChipsCycler: FC<{ items: string[]; color: string }> = ({ items, color }) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const borderAnim = useRef(new Animated.Value(items.length <= 1 ? 1.5 : 0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length <= 1) return;
|
||||
Animated.timing(borderAnim, { toValue: 1.5, duration: 200, useNativeDriver: false }).start();
|
||||
const id = setInterval(() => {
|
||||
Animated.timing(borderAnim, { toValue: 0, duration: 200, useNativeDriver: false }).start(() => {
|
||||
setActiveIndex((i) => (i + 1) % items.length);
|
||||
Animated.timing(borderAnim, { toValue: 1.5, duration: 200, useNativeDriver: false }).start();
|
||||
});
|
||||
}, 3000);
|
||||
return () => clearInterval(id);
|
||||
}, [items.length]);
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 4 }}>
|
||||
{items.map((text, i) => {
|
||||
const isActive = i === activeIndex;
|
||||
const parts = text.split("+").map((s) => s.trim()).filter(Boolean);
|
||||
const inner = (
|
||||
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 2 }}>
|
||||
{parts.map((part, j) => (
|
||||
<React.Fragment key={j}>
|
||||
{j > 0 && (
|
||||
<Text style={{ fontSize: 11, fontWeight: "bold", color: isActive ? color : color + "55" }}>+</Text>
|
||||
)}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: isActive ? "700" : "500",
|
||||
color: isActive ? color : color + "55",
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{part}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && (
|
||||
<Text style={{ fontSize: 11, color: color + "55" }}>・</Text>
|
||||
)}
|
||||
{isActive ? (
|
||||
<Animated.View
|
||||
style={{
|
||||
borderWidth: borderAnim,
|
||||
borderColor: color,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
backgroundColor: color + "12",
|
||||
}}
|
||||
>
|
||||
{inner}
|
||||
</Animated.View>
|
||||
) : (
|
||||
inner
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
}) => {
|
||||
@@ -128,6 +352,12 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
destinationStation,
|
||||
} = payload;
|
||||
|
||||
// __メモ書き サフィックスを除去して列番部分だけを返す
|
||||
const stripMemoSuffix = (value: string | null | undefined): string => {
|
||||
if (!value) return "";
|
||||
return value.split("__")[0].trim();
|
||||
};
|
||||
|
||||
const isFreightRetsuban = trainNum.includes("レ");
|
||||
const hubTrainNum = (unyohubTrainNumProp || trainNum).replace(/レ/g, "");
|
||||
const freightUnyohubCandidates = (() => {
|
||||
@@ -145,11 +375,23 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
})();
|
||||
const matchesHubTrainNum = (candidate?: string | null): boolean => {
|
||||
if (!candidate) return false;
|
||||
if (candidate === hubTrainNum) return true;
|
||||
const normalized = stripMemoSuffix(candidate).replace(/レ/g, "");
|
||||
if (normalized === hubTrainNum) return true;
|
||||
if (!isFreightRetsuban) return false;
|
||||
return freightUnyohubCandidates.has(candidate);
|
||||
return freightUnyohubCandidates.has(normalized);
|
||||
};
|
||||
|
||||
// APIデータ内の元の列番(__メモ付き)を取得して運用Hub連携 URL に使う
|
||||
const originalHubTrainNum = (() => {
|
||||
for (const entry of unyohubEntries) {
|
||||
const match = entry.trains?.find(
|
||||
(t) => stripMemoSuffix(t.train_number).replace(/レ/g, "") === hubTrainNum,
|
||||
);
|
||||
if (match?.train_number) return match.train_number;
|
||||
}
|
||||
return hubTrainNum;
|
||||
})();
|
||||
|
||||
// 進行方向の確定:
|
||||
// 1. payload.direction が明示されていればそれを使う
|
||||
// 2. customTrainData.directions が設定されていればそれを使う
|
||||
@@ -173,8 +415,34 @@ 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
|
||||
@@ -262,6 +530,12 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
|
||||
// フェードサイクル用アイテム(編成あるエントリのみ)
|
||||
const unyohubSubItems: FadingSubItem[] = nonEmptyFormationEntries.map((e) => ({
|
||||
label: e.formations?.trim() || e.operation_id || "",
|
||||
datetime: e.last_posted_datetime ?? null,
|
||||
}));
|
||||
|
||||
// 投稿日時が今日でない場合はカードを薄く表示("YYYY-MM-DD HH:MM:SS" 形式)
|
||||
const todayDateStr = new Date().toLocaleDateString("sv"); // "YYYY-MM-DD"
|
||||
const isUnyohubStale =
|
||||
@@ -293,28 +567,32 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
)?.direction;
|
||||
const hubSortDescending = matchedDirection === "inbound";
|
||||
|
||||
const formationNames =
|
||||
[...nonEmptyFormationEntries]
|
||||
.sort((a, b) => {
|
||||
const posA =
|
||||
a.trains?.find((t) => matchesHubTrainNum(t.train_number))
|
||||
?.position_forward ?? 0;
|
||||
const posB =
|
||||
b.trains?.find((t) => matchesHubTrainNum(t.train_number))
|
||||
?.position_forward ?? 0;
|
||||
return hubSortDescending ? posB - posA : posA - posB;
|
||||
})
|
||||
.slice(0, 4)
|
||||
.map((e) => e.formations)
|
||||
.join("・") +
|
||||
(nonEmptyFormationEntries.length > 4
|
||||
? ` 他${nonEmptyFormationEntries.length - 4}件`
|
||||
: "");
|
||||
const sortedFormationDisplay = [...nonEmptyFormationEntries]
|
||||
.sort((a, b) => {
|
||||
const posA =
|
||||
a.trains?.find((t) => matchesHubTrainNum(t.train_number))
|
||||
?.position_forward ?? 0;
|
||||
const posB =
|
||||
b.trains?.find((t) => matchesHubTrainNum(t.train_number))
|
||||
?.position_forward ?? 0;
|
||||
return hubSortDescending ? posB - posA : posA - posB;
|
||||
})
|
||||
.slice(0, 4);
|
||||
|
||||
const formationDetail = (
|
||||
<View style={styles.operationDetailBlock}>
|
||||
{hasNonEmptyFormations && (
|
||||
<Text style={[styles.unitIdText, { color: colors.textAccent, opacity: isUnyohubStale ? 0.4 : 1 }]}>{formationNames}</Text>
|
||||
<View style={{ opacity: isUnyohubStale ? 0.4 : 1 }}>
|
||||
<ActiveFormationChipsCycler
|
||||
items={sortedFormationDisplay.map((e) => e.formations || "")}
|
||||
color={colors.textAccent}
|
||||
/>
|
||||
{nonEmptyFormationEntries.length > 4 && (
|
||||
<Text style={{ fontSize: 11, color: colors.textTertiary, marginTop: 2 }}>
|
||||
他{nonEmptyFormationEntries.length - 4}件
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{unyohubGroupNames !== "" && (
|
||||
<Text style={[styles.subText, { color: colors.textSecondary }]}>{unyohubGroupNames}</Text>
|
||||
@@ -354,46 +632,25 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
</View>
|
||||
);
|
||||
|
||||
// えれサイト最終投稿時刻(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 elesiteHasNonEmptyFormations = elesiteDisplayGroups.some(
|
||||
(group) => group.hasFormations,
|
||||
);
|
||||
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 === trainNum)?.nav;
|
||||
const bNav = b.trains?.find((t) => t.train_number === 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}件`
|
||||
: "");
|
||||
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;
|
||||
|
||||
// 列車情報 subテキスト
|
||||
const trainInfoSub = customTrainData?.vehicle_formation
|
||||
@@ -414,23 +671,15 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
|
||||
const elesiteFormationDetail = (
|
||||
<View style={styles.operationDetailBlock}>
|
||||
{elesiteHasNonEmptyFormations && (
|
||||
<Text style={styles.unitIdText}>{elesiteFormationNames}</Text>
|
||||
{elesiteDisplayGroups.length > 0 && (
|
||||
<ElesiteLineGroupCycler
|
||||
groups={elesiteDisplayGroups}
|
||||
color="#44bb44"
|
||||
onActiveGroupChange={(group) =>
|
||||
setActiveElesiteGroupKey(group?.key ?? null)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{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 (
|
||||
@@ -503,9 +752,11 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
label="外部コミュニティデータ"
|
||||
sub={
|
||||
hasNonEmptyFormations
|
||||
? unyohubLastPostedDatetime
|
||||
? `最終投稿: ${formatDateHHMM(unyohubLastPostedDatetime)}`
|
||||
: ""
|
||||
? unyohubSubItems.length > 1
|
||||
? <FadingSubCycler items={unyohubSubItems} color={colors.textSecondary} />
|
||||
: unyohubLastPostedDatetime
|
||||
? `最終投稿: ${formatDateHHMM(unyohubLastPostedDatetime)}`
|
||||
: ""
|
||||
: unyoCount > 0
|
||||
? "数日の運用報告なし"
|
||||
: "この列車の運用データはありません"
|
||||
@@ -516,7 +767,8 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
disabled={unyoCount === 0}
|
||||
onPress={() =>
|
||||
openWebView(
|
||||
`https://jr-shikoku-data-system.pages.dev/unyohub-connection-train-data/${hubTrainNum}`,
|
||||
`https://jr-shikoku-data-system.pages.dev/unyohub-connection-train-data/${originalHubTrainNum}`,
|
||||
|
||||
true,
|
||||
)
|
||||
}
|
||||
@@ -534,23 +786,20 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
elesiteCount === 0
|
||||
? "この列車の運用データはありません"
|
||||
: !elesiteHasNonEmptyFormations
|
||||
? "本日の運用報告なし"
|
||||
: elesiteLastReportedAt
|
||||
? `最終投稿: ${formatHHMM(elesiteLastReportedAt)}`
|
||||
: ""
|
||||
? activeElesiteGroupLabel || "本日の運用報告なし"
|
||||
: elesiteDisplayGroups.length > 1
|
||||
? activeElesiteGroupLabel || ""
|
||||
: activeElesiteGroup?.lastReportedAt
|
||||
? `最終投稿: ${formatHHMM(activeElesiteGroup.lastReportedAt)}`
|
||||
: activeElesiteGroupLabel || ""
|
||||
}
|
||||
badge={elesiteHasNonEmptyFormations ? elesiteCount : null}
|
||||
badge={elesiteBadgeCount}
|
||||
badgeColor="#44bb44"
|
||||
detail={elesiteFormationDetail}
|
||||
disabled={elesiteCount === 0}
|
||||
onPress={() => {
|
||||
const matchedEntry =
|
||||
elesiteNonEmptyFormationEntries[0] ?? elesiteEntries[0];
|
||||
const matchedTrain = matchedEntry?.trains?.find(
|
||||
(t) => t.train_number === trainNum,
|
||||
);
|
||||
const url =
|
||||
matchedTrain?.timetable_url || "https://www.elesite-next.com/";
|
||||
activeElesiteGroup?.timetableUrl || "https://www.elesite-next.com/";
|
||||
SheetManager.hide("TrainDataSources");
|
||||
Linking.openURL(url);
|
||||
}}
|
||||
@@ -1011,7 +1260,7 @@ type SourceCardProps = {
|
||||
color: string;
|
||||
title: string;
|
||||
label: string;
|
||||
sub?: string;
|
||||
sub?: string | React.ReactNode;
|
||||
badge: number | string | null;
|
||||
badgeColor: string;
|
||||
disabled?: boolean;
|
||||
@@ -1063,9 +1312,13 @@ const SourceCard: FC<SourceCardProps> = ({
|
||||
<Text style={[styles.labelText, { color: colors.textQuaternary }]}>{label}</Text>
|
||||
</View>
|
||||
{sub && (
|
||||
<Text style={[styles.subText, { color: colors.textSecondary }]} numberOfLines={1}>
|
||||
{sub}
|
||||
</Text>
|
||||
typeof sub === "string" ? (
|
||||
<Text style={[styles.subText, { color: colors.textSecondary }]} numberOfLines={1}>
|
||||
{sub}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.subNodeWrap}>{sub}</View>
|
||||
)
|
||||
)}
|
||||
{detail && <View style={styles.detailWrap}>{detail}</View>}
|
||||
</View>
|
||||
@@ -1228,6 +1481,14 @@ const styles = StyleSheet.create({
|
||||
subText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
routePagerText: {
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
},
|
||||
subNodeWrap: {
|
||||
marginTop: 2,
|
||||
},
|
||||
subTextDisabled: {
|
||||
color: "#bbb",
|
||||
},
|
||||
@@ -1260,6 +1521,9 @@ const styles = StyleSheet.create({
|
||||
color: "#0077aa",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
elesiteFormationWrap: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
footer: {
|
||||
height: 20,
|
||||
},
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
TextInput,
|
||||
ScrollView,
|
||||
Linking,
|
||||
Image,
|
||||
} from "react-native";
|
||||
import { useAllTrainDiagram } from "../stateBox/useAllTrainDiagram";
|
||||
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
|
||||
@@ -24,10 +23,17 @@ import { Switch } from "@rneui/themed";
|
||||
import { migrateTrainName } from "@/lib/eachTrainInfoCoreLib/migrateTrainName";
|
||||
import { OneManText } from "./ActionSheetComponents/EachTrainInfoCore/HeaderTextParts/OneManText";
|
||||
import { getStringConfig } from "@/lib/getStringConfig";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
import { InfogramText } from "./ActionSheetComponents/EachTrainInfoCore/HeaderTextParts/InfogramText";
|
||||
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
|
||||
import { TrainIconStack } from "./ActionSheetComponents/EachTrainInfoCore/TrainIconStack";
|
||||
|
||||
export const AllTrainDiagramView: FC = () => {
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const { goBack, navigate } = useNavigation<any>();
|
||||
const { iconSetting } = useTrainMenu();
|
||||
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
const {
|
||||
keyList,
|
||||
@@ -80,9 +86,27 @@ export const AllTrainDiagramView: FC = () => {
|
||||
openTrainInfo: (d: string) => void;
|
||||
};
|
||||
const Item: FC<ItemProps> = ({ id, openTrainInfo }) => {
|
||||
const { train_info_img, train_name, type, train_num_distance, to_data } =
|
||||
customTrainDataDetector(id, allCustomTrainData);
|
||||
const todayOperation = getTodayOperationByTrainId(id).filter(d=> d.state !== 100);
|
||||
const customTrainData = customTrainDataDetector(id, allCustomTrainData);
|
||||
const {
|
||||
train_name,
|
||||
type,
|
||||
train_num_distance,
|
||||
to_data,
|
||||
infogram,
|
||||
directions,
|
||||
} = customTrainData;
|
||||
const todayOperation = (getTodayOperationByTrainId(id) ?? []).filter(d=> d.state !== 100);
|
||||
const trainIconData = resolveTrainIconEntries({
|
||||
trainNum: id,
|
||||
customTrainData,
|
||||
todayOperation,
|
||||
iconDisplayMode,
|
||||
});
|
||||
let iconTrainDirection =
|
||||
parseInt(id.replace(/[^\d]/g, "")) % 2 == 0 ? true : false;
|
||||
if (directions != undefined) {
|
||||
iconTrainDirection = directions ? true : false;
|
||||
}
|
||||
const [isWrapped, setIsWrapped] = useState(false);
|
||||
|
||||
const [typeString, fontAvailable, isOneMan] = getStringConfig(type, id);
|
||||
@@ -124,29 +148,11 @@ export const AllTrainDiagramView: FC = () => {
|
||||
onPress={() => openTrainInfo(id)}
|
||||
>
|
||||
<View style={{ marginHorizontal: 5, flexDirection: "row" }}>
|
||||
{todayOperation.length > 0
|
||||
? todayOperation.map((operation, index) => (
|
||||
<Image
|
||||
key={index}
|
||||
source={{ uri: operation.vehicle_img || train_info_img }}
|
||||
style={{
|
||||
width: 20,
|
||||
height: 22,
|
||||
marginHorizontal: 2,
|
||||
display: index == 0 ? "flex" : "none", //暫定対応:複数アイコンがある場合は最初のアイコンのみ表示
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: train_info_img && (
|
||||
<Image
|
||||
source={{ uri: train_info_img }}
|
||||
style={{
|
||||
width: 20,
|
||||
height: 22,
|
||||
marginHorizontal: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TrainIconStack
|
||||
entries={trainIconData}
|
||||
direction={iconTrainDirection}
|
||||
variant="list"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
@@ -173,22 +179,33 @@ export const AllTrainDiagramView: FC = () => {
|
||||
)}
|
||||
{isOneMan && <OneManText />}
|
||||
</View>
|
||||
{trainNameString && (
|
||||
<Text
|
||||
{(trainNameString || infogram) && (
|
||||
<View
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
color: fixed.textOnPrimary,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexShrink: 1,
|
||||
}}
|
||||
onTextLayout={(e) => {
|
||||
if (e.nativeEvent.lines.length > 1) {
|
||||
setIsWrapped(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trainNameString}
|
||||
</Text>
|
||||
{trainNameString && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
color: fixed.textOnPrimary,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
onTextLayout={(e) => {
|
||||
if (e.nativeEvent.lines.length > 1) {
|
||||
setIsWrapped(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trainNameString}
|
||||
</Text>
|
||||
)}
|
||||
{infogram ? <InfogramText infogram={infogram} fontSize={18} /> : null}
|
||||
</View>
|
||||
)}
|
||||
<View style={{ flex: 1 }} />
|
||||
<Text style={{ fontSize: 20, fontWeight: "bold", color: fixed.textOnPrimary }}>
|
||||
|
||||
+277
-32
@@ -1,10 +1,12 @@
|
||||
import React from "react";
|
||||
import { InteractionManager } from "react-native";
|
||||
import {
|
||||
View,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
StatusBar,
|
||||
useColorScheme,
|
||||
Button,
|
||||
} from "react-native";
|
||||
import * as Updates from "expo-updates";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -23,7 +25,11 @@ import { NewMenu } from "./Apps/NewMenu";
|
||||
import { MapsButton } from "./Apps/MapsButton";
|
||||
import { ReloadButton } from "./Apps/ReloadButton";
|
||||
import { useStationList } from "../stateBox/useStationList";
|
||||
import { positionsLifecycleRef } from "../lib/rootNavigation";
|
||||
import { FixedPositionBox } from "./Apps/FixedPositionBox";
|
||||
import { PlaybackTimeline } from "./Apps/PlaybackTimeline";
|
||||
import { RecordingStatusBar } from "./Apps/RecordingStatusBar";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
|
||||
export default function Apps() {
|
||||
const { webview, fixedPosition, setFixedPosition } = useCurrentTrain();
|
||||
@@ -31,11 +37,15 @@ export default function Apps() {
|
||||
const { navigate } = useNavigation<any>();
|
||||
const { isLandscape } = useDeviceOrientationChange();
|
||||
const { top } = useSafeAreaInsets();
|
||||
const handleLayout = () => {};
|
||||
const { originalStationList } = useStationList();
|
||||
const { mapSwitch, trainInfo, setTrainInfo, selectedLine } = useTrainMenu();
|
||||
const isDark = useColorScheme() === "dark";
|
||||
const isFocused = useIsFocused();
|
||||
const [hasActivatedScreen, setHasActivatedScreen] = React.useState(false);
|
||||
const [hasActivatedWebView, setHasActivatedWebView] = React.useState(false);
|
||||
const [hasStableWebViewSession, setHasStableWebViewSession] = React.useState(false);
|
||||
const lastFocusSignatureRef = React.useRef("");
|
||||
const lastLayoutSignatureRef = React.useRef("");
|
||||
|
||||
const lineColor = selectedLine && stationIDPair[selectedLine]
|
||||
? lineColorList[stationIDPair[selectedLine]]
|
||||
@@ -82,20 +92,219 @@ export default function Apps() {
|
||||
}
|
||||
};
|
||||
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
|
||||
|
||||
const resetTransientPositionsSession = React.useCallback(() => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: hasStableWebViewSession
|
||||
? "positions stable session preserved before leave"
|
||||
: "positions transient session reset before leave",
|
||||
data: {
|
||||
stableWebViewSession: hasStableWebViewSession,
|
||||
},
|
||||
});
|
||||
positionsLifecycleRef.current = {
|
||||
...positionsLifecycleRef.current,
|
||||
isUnstable: false,
|
||||
blockTabExit: false,
|
||||
};
|
||||
if (hasStableWebViewSession) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
webview.current?.stopLoading?.();
|
||||
} catch {}
|
||||
setHasActivatedWebView(false);
|
||||
setHasActivatedScreen(false);
|
||||
setHasStableWebViewSession(false);
|
||||
}, [hasStableWebViewSession, webview]);
|
||||
|
||||
React.useEffect(() => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions screen mounted",
|
||||
});
|
||||
return () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions screen unmounted",
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isFocused) {
|
||||
if (!hasActivatedWebView && !hasStableWebViewSession) {
|
||||
// allow one hidden startup prewarm so the positions session can begin before first tab focus
|
||||
} else {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions webview preserved on blur",
|
||||
data: {
|
||||
stableWebViewSession: hasStableWebViewSession,
|
||||
activatedWebView: hasActivatedWebView,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (hasActivatedScreen && hasActivatedWebView) return;
|
||||
const activationReason = isFocused
|
||||
? (hasActivatedScreen ? "focus" : "first_focus")
|
||||
: "background_prewarm";
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions screen activation scheduled",
|
||||
data: {
|
||||
reason: activationReason,
|
||||
},
|
||||
});
|
||||
let cancelled = false;
|
||||
const activate = () => {
|
||||
if (cancelled) {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions screen activation cancelled before webview mount",
|
||||
data: {
|
||||
reason: activationReason,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions screen activation committed",
|
||||
data: {
|
||||
reason: activationReason,
|
||||
},
|
||||
});
|
||||
setHasActivatedScreen(true);
|
||||
setHasActivatedWebView(true);
|
||||
};
|
||||
|
||||
if (isFocused) {
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
setTimeout(activate, 250);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
task.cancel?.();
|
||||
};
|
||||
}
|
||||
|
||||
const timer = setTimeout(activate, 350);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [hasActivatedScreen, hasActivatedWebView, hasStableWebViewSession, isFocused]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const shouldGuardTabExit = isFocused && hasActivatedWebView && !hasStableWebViewSession;
|
||||
positionsLifecycleRef.current = {
|
||||
isUnstable: shouldGuardTabExit,
|
||||
blockTabExit: shouldGuardTabExit,
|
||||
resetBeforeLeave: resetTransientPositionsSession,
|
||||
deactivateStack: positionsLifecycleRef.current.deactivateStack,
|
||||
};
|
||||
return () => {
|
||||
positionsLifecycleRef.current = {
|
||||
isUnstable: false,
|
||||
blockTabExit: false,
|
||||
resetBeforeLeave: null,
|
||||
deactivateStack: positionsLifecycleRef.current.deactivateStack,
|
||||
};
|
||||
};
|
||||
}, [hasActivatedWebView, hasStableWebViewSession, isFocused, resetTransientPositionsSession]);
|
||||
|
||||
React.useEffect(() => {
|
||||
Sentry.setContext("positions_screen", {
|
||||
focused: isFocused,
|
||||
activatedScreen: hasActivatedScreen,
|
||||
activatedWebView: hasActivatedWebView,
|
||||
stableWebViewSession: hasStableWebViewSession,
|
||||
mapSwitch,
|
||||
selectedLine: selectedLine ?? null,
|
||||
trainNum: trainInfo?.trainNum ?? null,
|
||||
landscape: isLandscape,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}, [hasActivatedScreen, hasActivatedWebView, hasStableWebViewSession, height, isFocused, isLandscape, mapSwitch, selectedLine, trainInfo?.trainNum, width]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const signature = JSON.stringify({
|
||||
focused: isFocused,
|
||||
mapSwitch,
|
||||
selectedLine: selectedLine ?? null,
|
||||
trainNum: trainInfo?.trainNum ?? null,
|
||||
landscape: isLandscape,
|
||||
});
|
||||
if (lastFocusSignatureRef.current === signature) return;
|
||||
lastFocusSignatureRef.current = signature;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: isFocused ? "positions focused" : "positions blurred",
|
||||
data: {
|
||||
mapSwitch,
|
||||
selectedLine: selectedLine ?? null,
|
||||
trainNum: trainInfo?.trainNum ?? null,
|
||||
landscape: isLandscape,
|
||||
},
|
||||
});
|
||||
}, [hasActivatedWebView, isFocused, isLandscape, mapSwitch, selectedLine, trainInfo?.trainNum]);
|
||||
|
||||
const handleLayout = React.useCallback((event) => {
|
||||
const layout = event?.nativeEvent?.layout;
|
||||
if (!layout) return;
|
||||
const signature = [Math.round(layout.width), Math.round(layout.height), isLandscape ? "landscape" : "portrait"].join(":");
|
||||
if (lastLayoutSignatureRef.current === signature) return;
|
||||
lastLayoutSignatureRef.current = signature;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions layout",
|
||||
data: {
|
||||
width: Math.round(layout.width),
|
||||
height: Math.round(layout.height),
|
||||
landscape: isLandscape,
|
||||
},
|
||||
});
|
||||
}, [isLandscape]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: bgColor }}>
|
||||
{isFocused && mapSwitch === "true" && lineColor && lineColorDark && (
|
||||
<LinearGradient
|
||||
colors={[lineColorDark, lineColor]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={{ position: "absolute", top: 0, left: 0, right: 0, height: top }}
|
||||
/>
|
||||
)}
|
||||
{isFocused && mapSwitch !== "true" && (
|
||||
<View style={{ position: "absolute", top: 0, left: 0, right: 0, height: top, backgroundColor: "#0099CC" }} />
|
||||
)}
|
||||
{isFocused && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{ position: "absolute", top: 0, left: 0, right: 0, height: top }}
|
||||
>
|
||||
{mapSwitch === "true" && lineColor && lineColorDark ? (
|
||||
<LinearGradient
|
||||
colors={[lineColorDark, lineColor]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#0099CC",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{isFocused && hasActivatedScreen && (
|
||||
<StatusBar barStyle="light-content" />
|
||||
)}
|
||||
<View
|
||||
@@ -106,28 +315,64 @@ export default function Apps() {
|
||||
}}
|
||||
onLayout={handleLayout}
|
||||
>
|
||||
<AppsWebView
|
||||
{...{
|
||||
openStationACFromEachTrainInfo,
|
||||
}}
|
||||
/>
|
||||
{hasActivatedScreen ? (
|
||||
<>
|
||||
{hasActivatedWebView ? (
|
||||
<AppsWebView
|
||||
openStationACFromEachTrainInfo={openStationACFromEachTrainInfo}
|
||||
onInitialLoadReady={() => {
|
||||
if (hasStableWebViewSession) return;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions webview became stable",
|
||||
});
|
||||
positionsLifecycleRef.current = {
|
||||
...positionsLifecycleRef.current,
|
||||
isUnstable: false,
|
||||
blockTabExit: false,
|
||||
};
|
||||
setHasStableWebViewSession(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View style={{ flex: 1 }} />
|
||||
)}
|
||||
|
||||
<MapsButton
|
||||
onPress={() => {
|
||||
navigate("trainMenu", { webview });
|
||||
}}
|
||||
/>
|
||||
{isFocused && (
|
||||
<MapsButton
|
||||
onPress={() => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "maps button press",
|
||||
data: {
|
||||
mapSwitch,
|
||||
selectedLine: selectedLine ?? null,
|
||||
hasActivatedWebView,
|
||||
},
|
||||
});
|
||||
navigate("trainMenu", { webview });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fixedPosition.type && <FixedPositionBox />}
|
||||
|
||||
{mapSwitch == "true" ? (
|
||||
<ReloadButton
|
||||
onPress={() => Updates.reloadAsync()}
|
||||
right={isLandscape && trainInfo.trainNum ? (width / 100) * 40 : 0}
|
||||
/>
|
||||
) : (
|
||||
<NewMenu />
|
||||
)}
|
||||
{isFocused && fixedPosition.type && <FixedPositionBox />}
|
||||
{isFocused && <PlaybackTimeline />}
|
||||
{isFocused && <RecordingStatusBar />}
|
||||
{isFocused &&
|
||||
(mapSwitch == "true" ? (
|
||||
<ReloadButton
|
||||
onPress={() => Updates.reloadAsync()}
|
||||
right={isLandscape && trainInfo.trainNum ? (width / 100) * 40 : 0}
|
||||
/>
|
||||
) : (
|
||||
<NewMenu />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<View style={{ flex: 1 }} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,8 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
import { resolveTrainDataIcon } from "@/lib/trainDataIcon";
|
||||
import {
|
||||
startTrainFollowActivity,
|
||||
updateTrainFollowActivity,
|
||||
@@ -48,8 +50,9 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
setFixedPositionSize,
|
||||
} = useCurrentTrain();
|
||||
|
||||
const { mapSwitch } = useTrainMenu();
|
||||
const { mapSwitch, iconSetting } = useTrainMenu();
|
||||
const { allCustomTrainData, allTrainDiagram } = useAllTrainDiagram();
|
||||
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
|
||||
|
||||
const [liveNotifyId, setLiveNotifyId] = useState<string | null>(null);
|
||||
const liveNotifyIdRef = useRef<string | null>(null);
|
||||
@@ -59,11 +62,12 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
const [customData, setCustomData] = useState<CustomTrainData>(
|
||||
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
|
||||
);
|
||||
const customTrainIcon = resolveTrainDataIcon(customData, iconDisplayMode);
|
||||
useEffect(() => {
|
||||
setCustomData(
|
||||
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
|
||||
);
|
||||
}, [currentTrain, trainID]);
|
||||
}, [currentTrain, trainID, allCustomTrainData]);
|
||||
useEffect(() => {
|
||||
const stationData = getCurrentStationData(trainID);
|
||||
if (stationData) {
|
||||
@@ -555,7 +559,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: customData.train_info_img || "" }}
|
||||
source={{ uri: customTrainIcon }}
|
||||
width={fixedPositionSize === 226 ? 23 : 14}
|
||||
height={fixedPositionSize === 226 ? 26 : 17}
|
||||
style={{ margin: 5 }}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import React, { FC, useRef, useCallback, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
PanResponder,
|
||||
LayoutChangeEvent,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import dayjs from "dayjs";
|
||||
import { useTrainMenu } from "../../stateBox/useTrainMenu";
|
||||
import { useThemeColors } from "../../lib/theme";
|
||||
import { useResponsive } from "../../lib/responsive";
|
||||
|
||||
/**
|
||||
* 再生中に走行位置WebViewの上部に表示するタイムラインコントローラー。
|
||||
* FixedPositionBox と同じ absolute 配置で zIndex を上に設定する。
|
||||
*/
|
||||
export const PlaybackTimeline: FC = () => {
|
||||
const { top } = useSafeAreaInsets();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const { moderateScale } = useResponsive();
|
||||
const {
|
||||
recorderState,
|
||||
activeRecording,
|
||||
playbackIndex,
|
||||
playbackPaused,
|
||||
stopPlayback,
|
||||
pausePlayback,
|
||||
resumePlayback,
|
||||
seekToSnapshot,
|
||||
} = 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;
|
||||
},
|
||||
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);
|
||||
const iconSize = moderateScale(20);
|
||||
|
||||
// ─── 早期return ───
|
||||
if (recorderState !== "playing" || !activeRecording) return null;
|
||||
|
||||
const total = activeRecording.snapshots.length;
|
||||
totalRef.current = total; // PanResponder が参照する最新値を更新
|
||||
|
||||
const displayIndex = isScrubbing && scrubIndex !== null ? scrubIndex : playbackIndex;
|
||||
const snap = activeRecording.snapshots[displayIndex];
|
||||
|
||||
// スナップショット時刻 = 録画開始時刻 + elapsed
|
||||
const snapTime = dayjs(activeRecording.recordedAt).add(snap.t, "ms");
|
||||
const timeLabel = snapTime.format("HH:mm:ss");
|
||||
|
||||
// 録画の総時間をフォーマット
|
||||
const totalSec = Math.round(activeRecording.durationMs / 1000);
|
||||
const totalLabel =
|
||||
totalSec >= 60
|
||||
? `${Math.floor(totalSec / 60)}:${String(totalSec % 60).padStart(2, "0")}`
|
||||
: `${totalSec}s`;
|
||||
|
||||
const progress = total > 1 ? displayIndex / (total - 1) : 0;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 2000,
|
||||
backgroundColor: colors.surface + "f2", // 少し透過
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSecondary,
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 6,
|
||||
paddingBottom: 8,
|
||||
}}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
{/* 上段: ボタン + 時刻 + コマ数 */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{/* 先頭コマへ */}
|
||||
<TouchableOpacity
|
||||
onPress={() => seekToSnapshot(0)}
|
||||
style={[styles.btn(btnSize, colors.borderSecondary)]}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
||||
>
|
||||
<Ionicons name="play-skip-back" size={iconSize} color={colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 前のコマ */}
|
||||
<TouchableOpacity
|
||||
onPress={() => seekToSnapshot(playbackIndex - 1)}
|
||||
style={[styles.btn(btnSize, colors.borderSecondary)]}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
||||
>
|
||||
<Ionicons name="play-back" size={iconSize} color={colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 再生 / 一時停止 */}
|
||||
<TouchableOpacity
|
||||
onPress={playbackPaused ? resumePlayback : pausePlayback}
|
||||
style={[styles.btn(btnSize, fixed.primary)]}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={playbackPaused ? "play" : "pause"}
|
||||
size={iconSize}
|
||||
color={fixed.textOnPrimary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 次のコマ */}
|
||||
<TouchableOpacity
|
||||
onPress={() => seekToSnapshot(playbackIndex + 1)}
|
||||
style={[styles.btn(btnSize, colors.borderSecondary)]}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
||||
>
|
||||
<Ionicons name="play-forward" size={iconSize} color={colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 末尾コマへ */}
|
||||
<TouchableOpacity
|
||||
onPress={() => seekToSnapshot(total - 1)}
|
||||
style={[styles.btn(btnSize, colors.borderSecondary)]}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
||||
>
|
||||
<Ionicons name="play-skip-forward" size={iconSize} color={colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* スペーサー */}
|
||||
<View style={{ flex: 1 }} />
|
||||
|
||||
{/* 時刻表示 */}
|
||||
<View style={{ alignItems: "flex-end" }}>
|
||||
<Text style={{ fontSize: moderateScale(15), fontWeight: "bold", color: colors.textPrimary, fontVariant: ["tabular-nums"] }}>
|
||||
{timeLabel}
|
||||
</Text>
|
||||
<Text style={{ fontSize: moderateScale(10), color: colors.textSecondary, fontVariant: ["tabular-nums"] }}>
|
||||
{displayIndex + 1}/{total} {totalLabel}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 停止ボタン */}
|
||||
<TouchableOpacity
|
||||
onPress={stopPlayback}
|
||||
style={[styles.btn(btnSize, "#e53935"), { marginLeft: 6 }]}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
||||
>
|
||||
<Ionicons name="stop" size={iconSize} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 下段: スクラバートラック */}
|
||||
<View
|
||||
ref={trackViewRef}
|
||||
style={{ marginTop: 6, paddingVertical: 8, marginVertical: -8 }}
|
||||
onLayout={onTrackLayout}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
{/* トラック背景 */}
|
||||
<View
|
||||
style={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: colors.borderSecondary,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* 進捗バー */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: `${progress * 100}%`,
|
||||
backgroundColor: fixed.primary,
|
||||
borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{/* ドラッグハンドル */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -4,
|
||||
left: `${progress * 100}%`,
|
||||
marginLeft: -8,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 7,
|
||||
backgroundColor: fixed.primary,
|
||||
borderWidth: 2,
|
||||
borderColor: fixed.textOnPrimary,
|
||||
elevation: 2,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ヘルパー: ボタンスタイル生成
|
||||
const styles = {
|
||||
btn: (size: number, bg: string) => ({
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: bg,
|
||||
alignItems: "center" as const,
|
||||
justifyContent: "center" as const,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { FC, useEffect, useRef, useState } from "react";
|
||||
import { View, Text, AppState, InteractionManager } from "react-native";
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTrainMenu } from "../../stateBox/useTrainMenu";
|
||||
import { useThemeColors } from "../../lib/theme";
|
||||
import { useResponsive } from "../../lib/responsive";
|
||||
|
||||
const KEEP_AWAKE_TAG = "recording-status-bar";
|
||||
|
||||
const isActivityUnavailableError = (error: unknown) =>
|
||||
String(error).includes("The current activity is no longer available");
|
||||
|
||||
/**
|
||||
* 録画中に走行位置画面上部に表示するステータスバー。
|
||||
* PlaybackTimeline と同じ absolute 配置。
|
||||
* 録画中はスリープを抑制する。
|
||||
*/
|
||||
export const RecordingStatusBar: FC = () => {
|
||||
const { top } = useSafeAreaInsets();
|
||||
const { colors } = useThemeColors();
|
||||
const { moderateScale } = useResponsive();
|
||||
const { recorderState, recordingSnapshotCount } = useTrainMenu();
|
||||
|
||||
// 経過時間(秒)
|
||||
const [elapsedSec, setElapsedSec] = useState(0);
|
||||
const startTimeRef = useRef<number>(Date.now());
|
||||
|
||||
// 録画開始時にタイマーをリセットして1秒ごとに更新
|
||||
useEffect(() => {
|
||||
if (recorderState !== "recording") {
|
||||
setElapsedSec(0);
|
||||
return;
|
||||
}
|
||||
startTimeRef.current = Date.now();
|
||||
setElapsedSec(0);
|
||||
const timer = setInterval(() => {
|
||||
setElapsedSec(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [recorderState]);
|
||||
|
||||
// 録画中はスリープ抑制
|
||||
useEffect(() => {
|
||||
if (recorderState !== "recording") return;
|
||||
if (__DEV__) return;
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const activate = async () => {
|
||||
if (!mounted || AppState.currentState !== "active") return;
|
||||
try {
|
||||
await activateKeepAwakeAsync(KEEP_AWAKE_TAG);
|
||||
} catch (error) {
|
||||
if (!isActivityUnavailableError(error)) {
|
||||
console.warn("RecordingStatusBar: failed to activate keep awake", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const interactionHandle = InteractionManager.runAfterInteractions(() => {
|
||||
void activate();
|
||||
});
|
||||
|
||||
const subscription = AppState.addEventListener("change", (state) => {
|
||||
if (state === "active") {
|
||||
void activate();
|
||||
return;
|
||||
}
|
||||
deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => {});
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
interactionHandle.cancel();
|
||||
subscription.remove();
|
||||
deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => {});
|
||||
};
|
||||
}, [recorderState]);
|
||||
|
||||
if (recorderState !== "recording") return null;
|
||||
|
||||
const minutes = Math.floor(elapsedSec / 60);
|
||||
const seconds = elapsedSec % 60;
|
||||
const timeLabel = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 2000,
|
||||
backgroundColor: "rgba(229, 57, 53, 0.92)",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 7,
|
||||
gap: 10,
|
||||
}}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{/* 点滅 REC ドット */}
|
||||
<BlinkDot />
|
||||
|
||||
<Text
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontWeight: "bold",
|
||||
fontSize: moderateScale(13),
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
REC
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontSize: moderateScale(15),
|
||||
fontWeight: "bold",
|
||||
fontVariant: ["tabular-nums"],
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
{timeLabel}
|
||||
</Text>
|
||||
|
||||
<View style={{ flex: 1 }} />
|
||||
|
||||
<Text
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
fontSize: moderateScale(11),
|
||||
}}
|
||||
>
|
||||
{recordingSnapshotCount} コマ
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/** 1秒ごとに点滅する録画インジケータードット */
|
||||
const BlinkDot: FC = () => {
|
||||
const [visible, setVisible] = useState(true);
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setVisible((v) => !v), 700);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: visible ? "#fff" : "transparent",
|
||||
borderWidth: 1.5,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
+226
-15
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { WebView } from "react-native-webview";
|
||||
|
||||
@@ -15,13 +15,19 @@ import { useCurrentTrain } from "../../stateBox/useCurrentTrain";
|
||||
import { useDeviceOrientationChange } from "../../stateBox/useDeviceOrientationChange";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useNavigation, useIsFocused } from "@react-navigation/native";
|
||||
import { useTrainMenu } from "../../stateBox/useTrainMenu";
|
||||
import { useStationList } from "../../stateBox/useStationList";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
|
||||
import { useWebViewRemount } from "@/lib/useWebViewRemount";
|
||||
import { generateMockUpdateScript } from "../../lib/mockApi/webviewXhrInterceptor";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import { setAppLifecycleWebViewActive } from "@/lib/observability/appLifecycleCrashSentinel";
|
||||
export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady }) => {
|
||||
const initialPositionsUrl = "https://train.jr-shikoku.co.jp/sp.html";
|
||||
const { webview, currentTrain } = useCurrentTrain();
|
||||
const { navigate } = useNavigation<any>();
|
||||
const isFocused = useIsFocused();
|
||||
const { favoriteStation } = useFavoriteStation();
|
||||
const { isLandscape } = useDeviceOrientationChange();
|
||||
const { isDark } = useThemeColors();
|
||||
@@ -34,14 +40,128 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
|
||||
setLoadError,
|
||||
setTrainInfo,
|
||||
injectJavascript,
|
||||
injectJavascriptBeforeContentLoaded,
|
||||
mockApiFeatureEnabled,
|
||||
mockTrainPositions,
|
||||
} = useTrainMenu();
|
||||
var urlcache = "";
|
||||
let once = false;
|
||||
const addWebViewBreadcrumb = (
|
||||
message: string,
|
||||
data?: Record<string, string | number | boolean | null | undefined>
|
||||
) => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.webview",
|
||||
level: "info",
|
||||
message,
|
||||
data,
|
||||
});
|
||||
};
|
||||
const { remountKey, processHandlers, pingHandlers, webViewRef } = useWebViewRemount({
|
||||
pingEnabled: Platform.OS === "ios",
|
||||
backgroundThresholdMs: null,
|
||||
isFocused,
|
||||
pauseWatchdogWhenUnfocused: Platform.OS === "ios",
|
||||
ignoreProcessTerminationWhenUnfocused: Platform.OS === "ios",
|
||||
onRemount: (reason, data) => {
|
||||
addWebViewBreadcrumb("webview remount requested", {
|
||||
reason,
|
||||
...(data ?? {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
const lastRemountKeyRef = useRef<typeof remountKey | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setAppLifecycleWebViewActive("positions_main", true);
|
||||
return () => setAppLifecycleWebViewActive("positions_main", false);
|
||||
}, []);
|
||||
|
||||
// コマが変化したとき(再生・シーク)に WebView 内の _MOCK_TRAIN を差し替えて再描画
|
||||
const mountedRef = useRef(false);
|
||||
const urlCacheRef = useRef("");
|
||||
const initialInjectDoneRef = useRef(false);
|
||||
const pendingInitialInjectRef = useRef<string | null>(null);
|
||||
const loadEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const focusedRef = useRef(isFocused);
|
||||
const initialLoadReadyNotifiedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
focusedRef.current = isFocused;
|
||||
addWebViewBreadcrumb(isFocused ? "webview focused" : "webview blurred", {
|
||||
landscape: isLandscape,
|
||||
mockApi: mockApiFeatureEnabled,
|
||||
});
|
||||
if (!isFocused) return;
|
||||
if (!pendingInitialInjectRef.current) return;
|
||||
addWebViewBreadcrumb("webview pending initial inject resumed on focus");
|
||||
webview?.current?.injectJavaScript(pendingInitialInjectRef.current);
|
||||
pendingInitialInjectRef.current = null;
|
||||
initialInjectDoneRef.current = true;
|
||||
}, [isFocused, isLandscape, mockApiFeatureEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastRemountKeyRef.current !== remountKey) {
|
||||
addWebViewBreadcrumb("webview remount key", { remountKey });
|
||||
lastRemountKeyRef.current = remountKey;
|
||||
initialInjectDoneRef.current = false;
|
||||
pendingInitialInjectRef.current = null;
|
||||
initialLoadReadyNotifiedRef.current = false;
|
||||
if (loadEndTimeoutRef.current) {
|
||||
clearTimeout(loadEndTimeoutRef.current);
|
||||
loadEndTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [remountKey]);
|
||||
|
||||
useEffect(() => {
|
||||
Sentry.setContext("positions_webview", {
|
||||
focused: isFocused,
|
||||
landscape: isLandscape,
|
||||
mockApi: mockApiFeatureEnabled,
|
||||
remountKey,
|
||||
currentUrl: urlCacheRef.current || null,
|
||||
});
|
||||
}, [isFocused, isLandscape, mockApiFeatureEnabled, remountKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
addWebViewBreadcrumb("webview cleanup start", {
|
||||
focused: focusedRef.current,
|
||||
currentUrl: urlCacheRef.current || null,
|
||||
});
|
||||
if (loadEndTimeoutRef.current) {
|
||||
clearTimeout(loadEndTimeoutRef.current);
|
||||
loadEndTimeoutRef.current = null;
|
||||
}
|
||||
addWebViewBreadcrumb("webview unmounted");
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
// 初回マウント時はスキップ(beforeContentLoaded で既に正しいデータが入っている)
|
||||
if (!mountedRef.current) {
|
||||
mountedRef.current = true;
|
||||
addWebViewBreadcrumb("mock positions effect skipped initial mount", {
|
||||
mockApi: mockApiFeatureEnabled,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!mockApiFeatureEnabled || !mockTrainPositions) return;
|
||||
addWebViewBreadcrumb("mock positions injected", {
|
||||
positionCount: mockTrainPositions.length,
|
||||
});
|
||||
const script = generateMockUpdateScript(mockTrainPositions);
|
||||
webview?.current?.injectJavaScript(script);
|
||||
}, [mockApiFeatureEnabled, mockTrainPositions, webview]);
|
||||
|
||||
const attachWebViewRefs = useCallback((instance) => {
|
||||
webview.current = instance;
|
||||
webViewRef.current = instance;
|
||||
}, [webViewRef, webview]);
|
||||
|
||||
const onNavigationStateChange = ({ url }) => {
|
||||
if (url == urlcache) return;
|
||||
if (url == urlCacheRef.current) return;
|
||||
//URL二重判定回避
|
||||
urlcache = url;
|
||||
urlCacheRef.current = url;
|
||||
addWebViewBreadcrumb("webview navigation", { url });
|
||||
switch (true) {
|
||||
case url.includes("https://train.jr-shikoku.co.jp/usage.htm"):
|
||||
if (Platform.OS === "android") navigate("howto", { info: url });
|
||||
@@ -55,7 +175,20 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
|
||||
break;
|
||||
}
|
||||
};
|
||||
const getRestorableUrl = () => {
|
||||
const cachedUrl = urlCacheRef.current;
|
||||
if (!cachedUrl) return initialPositionsUrl;
|
||||
if (cachedUrl.includes("https://train.jr-shikoku.co.jp/usage.htm")) {
|
||||
return initialPositionsUrl;
|
||||
}
|
||||
if (cachedUrl.includes("https://train.jr-shikoku.co.jp/train.html")) {
|
||||
return initialPositionsUrl;
|
||||
}
|
||||
return cachedUrl;
|
||||
};
|
||||
|
||||
const onMessage = (event) => {
|
||||
pingHandlers.onMessage?.(event);
|
||||
const { data } = event.nativeEvent;
|
||||
/**
|
||||
* {type,trainNum,limited}
|
||||
@@ -63,14 +196,27 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
|
||||
* {type,event,id,name,pdf,map,url,chk}
|
||||
*/
|
||||
if (data.includes("train.html")) {
|
||||
addWebViewBreadcrumb("webview train link", { data });
|
||||
navigate("trainbase", { info: data, from: "Train" });
|
||||
return;
|
||||
}
|
||||
if (!originalStationList) {
|
||||
addWebViewBreadcrumb("webview message blocked waiting station data");
|
||||
alert("駅名標データを取得中...");
|
||||
return;
|
||||
}
|
||||
const dataSet = JSON.parse(data);
|
||||
let dataSet;
|
||||
try {
|
||||
dataSet = JSON.parse(data);
|
||||
} catch (error) {
|
||||
addWebViewBreadcrumb("webview message parse failed", {
|
||||
dataPreview: data.slice(0, 120),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
addWebViewBreadcrumb("webview message", {
|
||||
type: dataSet.type ?? "unknown",
|
||||
});
|
||||
switch (dataSet.type) {
|
||||
case "LoadError": {
|
||||
setLoadError(true);
|
||||
@@ -134,8 +280,30 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadStart = () => {
|
||||
pingHandlers.onLoadStart?.();
|
||||
addWebViewBreadcrumb("webview loadStart", {
|
||||
focused: focusedRef.current,
|
||||
currentUrl: urlCacheRef.current || null,
|
||||
});
|
||||
};
|
||||
|
||||
const onLoadEnd = () => {
|
||||
if (once) return () => {};
|
||||
pingHandlers.onLoadEnd?.();
|
||||
addWebViewBreadcrumb("webview loadEnd", {
|
||||
focused: focusedRef.current,
|
||||
hasStationData: !!stationData,
|
||||
hasOriginalStationList: !!originalStationList,
|
||||
favoriteCount: favoriteStation.length,
|
||||
});
|
||||
if (!initialLoadReadyNotifiedRef.current) {
|
||||
initialLoadReadyNotifiedRef.current = true;
|
||||
addWebViewBreadcrumb("webview initial load ready", {
|
||||
focused: focusedRef.current,
|
||||
});
|
||||
onInitialLoadReady?.();
|
||||
}
|
||||
if (initialInjectDoneRef.current) return () => {};
|
||||
if (!stationData) return () => {};
|
||||
if (!originalStationList) return () => {};
|
||||
if (favoriteStation.length < 1) return () => {};
|
||||
@@ -143,17 +311,45 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
|
||||
favoriteStation[0][0].StationNumber
|
||||
);
|
||||
if (!string) return () => {};
|
||||
setTimeout(() => {
|
||||
if (loadEndTimeoutRef.current) {
|
||||
clearTimeout(loadEndTimeoutRef.current);
|
||||
}
|
||||
addWebViewBreadcrumb("webview initial inject scheduled", {
|
||||
focused: focusedRef.current,
|
||||
});
|
||||
loadEndTimeoutRef.current = setTimeout(() => {
|
||||
addWebViewBreadcrumb(
|
||||
focusedRef.current
|
||||
? "webview initial inject run"
|
||||
: "webview initial inject run while blurred",
|
||||
{
|
||||
focused: focusedRef.current,
|
||||
}
|
||||
);
|
||||
webview?.current?.injectJavaScript(string);
|
||||
}, 500);
|
||||
once = true;
|
||||
pendingInitialInjectRef.current = null;
|
||||
initialInjectDoneRef.current = true;
|
||||
loadEndTimeoutRef.current = null;
|
||||
}, focusedRef.current ? 500 : 700);
|
||||
};
|
||||
|
||||
const handleRenderProcessGone = (event) => {
|
||||
addWebViewBreadcrumb("webview render process gone", {
|
||||
didCrash: event.nativeEvent.didCrash,
|
||||
});
|
||||
processHandlers.onRenderProcessGone?.(event);
|
||||
};
|
||||
|
||||
const handleContentProcessDidTerminate = () => {
|
||||
addWebViewBreadcrumb("webview content process terminated");
|
||||
processHandlers.onContentProcessDidTerminate?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<WebView
|
||||
key={isDark ? 'dark' : 'light'}
|
||||
ref={webview}
|
||||
source={{ uri: "https://train.jr-shikoku.co.jp/sp.html" }}
|
||||
key={`positions-webview-${remountKey}`}
|
||||
ref={attachWebViewRefs}
|
||||
source={{ uri: getRestorableUrl() }}
|
||||
originWhitelist={[
|
||||
"https://train.jr-shikoku.co.jp",
|
||||
"https://train.jr-shikoku.co.jp/sp.html",
|
||||
@@ -164,7 +360,22 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
|
||||
setSupportMultipleWindows
|
||||
contentMode="mobile"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
onLoadStart={onLoadStart}
|
||||
onError={(event) => {
|
||||
addWebViewBreadcrumb("webview error", {
|
||||
description: event.nativeEvent.description || null,
|
||||
code: event.nativeEvent.code ?? null,
|
||||
});
|
||||
}}
|
||||
onHttpError={(event) => {
|
||||
addWebViewBreadcrumb("webview http error", {
|
||||
statusCode: event.nativeEvent.statusCode ?? null,
|
||||
});
|
||||
}}
|
||||
onRenderProcessGone={handleRenderProcessGone}
|
||||
onContentProcessDidTerminate={handleContentProcessDidTerminate}
|
||||
{...{ onMessage, onNavigationStateChange, onLoadEnd }}
|
||||
injectedJavaScriptBeforeContentLoaded={injectJavascriptBeforeContentLoaded}
|
||||
injectedJavaScript={injectJavascript}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
ScrollView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import Carousel, { ICarouselInstance } from "react-native-reanimated-carousel";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
@@ -27,6 +28,10 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSortMode } from "./useSortMode";
|
||||
import { StationSource } from "@/types";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
|
||||
const isMissingStorageKeyError = (error: unknown) =>
|
||||
String(error).includes("Not Found!");
|
||||
|
||||
export const CarouselBox = ({
|
||||
originalStationList,
|
||||
@@ -45,6 +50,9 @@ export const CarouselBox = ({
|
||||
navigate: any;
|
||||
stationSource: StationSource;
|
||||
}) => {
|
||||
const isAndroid = Platform.OS === "android";
|
||||
const sortControlsEntering = isAndroid ? undefined : FadeIn.duration(200);
|
||||
const sortControlsExiting = isAndroid ? undefined : FadeOut.duration(150);
|
||||
const carouselRef = useRef<ICarouselInstance>(null);
|
||||
const { width } = useWindowDimensions();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
@@ -109,6 +117,15 @@ export const CarouselBox = ({
|
||||
// コンテナ高さ(カルーセル ↔ グリッドで可変)
|
||||
const containerHeight = useSharedValue(carouselHeight);
|
||||
const containerHeightStyle = useAnimatedStyle(() => ({ height: containerHeight.value }));
|
||||
const androidContainerStyle = isAndroid ? { height: isSortMode ? gridHeight : carouselHeight } : null;
|
||||
const carouselModeProps = {
|
||||
mode: "parallax" as const,
|
||||
modeConfig: {
|
||||
parallaxScrollingScale: 1,
|
||||
parallaxScrollingOffset: 100,
|
||||
parallaxAdjacentItemScale: 0.8,
|
||||
},
|
||||
};
|
||||
|
||||
// ドットエリアのフェード
|
||||
const dotsOpacity = useSharedValue(1);
|
||||
@@ -122,6 +139,11 @@ export const CarouselBox = ({
|
||||
const gridAnimStyle = useAnimatedStyle(() => ({ opacity: gridOpacity.value }));
|
||||
|
||||
useEffect(() => {
|
||||
if (isAndroid) {
|
||||
setIsGridMounted(isSortMode);
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = 250;
|
||||
if (isSortMode) {
|
||||
setIsGridMounted(true); // フェードイン前にマウント
|
||||
@@ -137,7 +159,7 @@ export const CarouselBox = ({
|
||||
if (finished) runOnJS(setIsGridMounted)(false); // フェードアウト完了後にアンマウント
|
||||
});
|
||||
}
|
||||
}, [isSortMode, gridHeight, carouselHeight]);
|
||||
}, [isAndroid, isSortMode, gridHeight, carouselHeight]);
|
||||
|
||||
// ソートモード終了直後フラグ(次の listIndex 変更でアニメーションをスキップ)
|
||||
const justExitedSortRef = useRef(false);
|
||||
@@ -150,11 +172,10 @@ export const CarouselBox = ({
|
||||
|
||||
// バッジからのインデックス変更をカルーセルに反映
|
||||
useEffect(() => {
|
||||
if (listIndex >= 0 && carouselRef.current) {
|
||||
const animated = !justExitedSortRef.current;
|
||||
justExitedSortRef.current = false;
|
||||
carouselRef.current.scrollTo({ index: listIndex, animated });
|
||||
}
|
||||
if (listIndex < 0) return;
|
||||
const animated = !justExitedSortRef.current;
|
||||
justExitedSortRef.current = false;
|
||||
carouselRef.current?.scrollTo({ index: listIndex, animated });
|
||||
}, [listIndex]);
|
||||
|
||||
// ドットのスクロール追従
|
||||
@@ -167,9 +188,39 @@ export const CarouselBox = ({
|
||||
|
||||
// ドット表示設定の読み込み
|
||||
useEffect(() => {
|
||||
AS.getItem("CarouselSettings/activeDotSettings").then((data) => {
|
||||
setDotButton(data === "true");
|
||||
Sentry.addBreadcrumb({
|
||||
category: "menu.carousel",
|
||||
level: "info",
|
||||
message: "carousel activeDotSettings read scheduled",
|
||||
});
|
||||
AS.getItem("CarouselSettings/activeDotSettings")
|
||||
.then((data) => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "menu.carousel",
|
||||
level: "info",
|
||||
message: "carousel activeDotSettings read success",
|
||||
data: {
|
||||
rawValue: data == null ? "<null>" : String(data),
|
||||
resolved: data === "true",
|
||||
},
|
||||
});
|
||||
setDotButton(data === "true");
|
||||
})
|
||||
.catch((error) => {
|
||||
setDotButton(false);
|
||||
if (isMissingStorageKeyError(error)) {
|
||||
return;
|
||||
}
|
||||
Sentry.addBreadcrumb({
|
||||
category: "menu.carousel",
|
||||
level: "error",
|
||||
message: "carousel activeDotSettings read failed",
|
||||
data: {
|
||||
errorName: error instanceof Error ? error.name : typeof error,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const oPSign = () => {
|
||||
@@ -199,7 +250,38 @@ export const CarouselBox = ({
|
||||
duration: 600,
|
||||
update: { type: "spring", springDamping: 0.5 },
|
||||
});
|
||||
AS.setItem("CarouselSettings/activeDotSettings", !dotButton ? "true" : "false");
|
||||
const nextValue = !dotButton ? "true" : "false";
|
||||
Sentry.addBreadcrumb({
|
||||
category: "menu.carousel",
|
||||
level: "info",
|
||||
message: "carousel activeDotSettings write scheduled",
|
||||
data: {
|
||||
nextValue,
|
||||
},
|
||||
});
|
||||
void AS.setItem("CarouselSettings/activeDotSettings", nextValue)
|
||||
.then(() => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "menu.carousel",
|
||||
level: "info",
|
||||
message: "carousel activeDotSettings write success",
|
||||
data: {
|
||||
nextValue,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "menu.carousel",
|
||||
level: "error",
|
||||
message: "carousel activeDotSettings write failed",
|
||||
data: {
|
||||
nextValue,
|
||||
errorName: error instanceof Error ? error.name : typeof error,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
});
|
||||
setDotButton(!dotButton);
|
||||
};
|
||||
|
||||
@@ -255,10 +337,10 @@ export const CarouselBox = ({
|
||||
return (
|
||||
<View style={{ flex: 1, paddingTop: 10 }}>
|
||||
{/* カルーセル / グリッド(同じ高さ領域を共用・クロスフェード) */}
|
||||
<Animated.View style={[{ overflow: "visible" }, containerHeightStyle]}>
|
||||
<Animated.View style={[{ overflow: "visible" }, isAndroid ? androidContainerStyle : containerHeightStyle]}>
|
||||
{/* カルーセル */}
|
||||
<Animated.View
|
||||
style={[{ position: "absolute", width }, carouselAnimStyle]}
|
||||
style={[{ position: "absolute", width }, isAndroid ? { opacity: isSortMode ? 0 : 1 } : carouselAnimStyle]}
|
||||
pointerEvents={isSortMode ? "none" : "auto"}
|
||||
>
|
||||
<Carousel
|
||||
@@ -270,13 +352,7 @@ export const CarouselBox = ({
|
||||
loop={false}
|
||||
width={width}
|
||||
style={{ width, alignContent: "center" }}
|
||||
mode="parallax"
|
||||
modeConfig={{
|
||||
parallaxScrollingScale: 1,
|
||||
parallaxScrollingOffset: 100,
|
||||
parallaxAdjacentItemScale: 0.8,
|
||||
}}
|
||||
scrollAnimationDuration={600}
|
||||
scrollAnimationDuration={isAndroid ? 450 : 600}
|
||||
onSnapToItem={setListIndex}
|
||||
renderItem={RenderItem}
|
||||
overscrollEnabled={false}
|
||||
@@ -285,6 +361,8 @@ export const CarouselBox = ({
|
||||
? 0
|
||||
: lastValidListIndexRef.current
|
||||
}
|
||||
enabled={!isSortMode}
|
||||
{...carouselModeProps}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
@@ -293,7 +371,7 @@ export const CarouselBox = ({
|
||||
<Animated.View
|
||||
style={[
|
||||
{ position: "absolute", width, height: gridHeight, paddingHorizontal: gridPad, overflow: "visible" },
|
||||
gridAnimStyle,
|
||||
isAndroid ? { opacity: 1 } : gridAnimStyle,
|
||||
]}
|
||||
>
|
||||
<Sortable.Grid
|
||||
@@ -311,7 +389,7 @@ export const CarouselBox = ({
|
||||
</Animated.View>
|
||||
|
||||
{/* ドットエリア:ソートモード時はフェードアウト */}
|
||||
<Animated.View style={dotsAnimStyle} pointerEvents={isSortMode ? "none" : "auto"}>
|
||||
<Animated.View style={isAndroid ? { opacity: isSortMode ? 0 : 1 } : dotsAnimStyle} pointerEvents={isSortMode ? "none" : "auto"}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
@@ -355,8 +433,8 @@ export const CarouselBox = ({
|
||||
{/* 並び替えコントロール:ソートモード時に最下部からスライドイン */}
|
||||
{isSortMode && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(150)}
|
||||
entering={sortControlsEntering}
|
||||
exiting={sortControlsExiting}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -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,13 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { View, Text, ScrollView, StyleSheet, Image, TouchableOpacity, Linking } from "react-native";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
Linking,
|
||||
} from "react-native";
|
||||
import { Switch } from "@rneui/themed";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
@@ -10,9 +18,14 @@ import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import {
|
||||
DEFAULT_JR_DATA_SYSTEM_ENV,
|
||||
getJrDataSystemTrack,
|
||||
getJrDataSystemUiVariant,
|
||||
JR_DATA_SYSTEM_ENV_OPTIONS,
|
||||
JrDataSystemTrack,
|
||||
JrDataSystemUiVariant,
|
||||
JrDataSystemEnvironmentKey,
|
||||
normalizeJrDataSystemEnvironment,
|
||||
resolveJrDataSystemEnvironment,
|
||||
} from "@/lib/jrDataSystemEnvironment";
|
||||
|
||||
const HUB_LOGO_PNG = require("@/assets/relationLogo/unyohub_logo.webp");
|
||||
@@ -64,7 +77,16 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
|
||||
const { colors } = useThemeColors();
|
||||
|
||||
return (
|
||||
<View style={[styles.accordionCard, { backgroundColor: colors.surface, borderColor: colors.borderSecondary }, enabled && styles.accordionCardEnabled]}>
|
||||
<View
|
||||
style={[
|
||||
styles.accordionCard,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.borderSecondary,
|
||||
},
|
||||
enabled && styles.accordionCardEnabled,
|
||||
]}
|
||||
>
|
||||
{/* ── ヘッダー行(常時表示) ── */}
|
||||
<View style={styles.accordionHeader}>
|
||||
{/* 左:ロゴ */}
|
||||
@@ -72,8 +94,14 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
|
||||
|
||||
{/* 中央:タイトル+タグライン */}
|
||||
<View style={styles.accordionTitles}>
|
||||
<Text style={[styles.accordionTitle, { color: colors.textPrimary }]}>{title}</Text>
|
||||
<Text style={[styles.accordionTagline, { color: colors.textTertiary }]}>{tagline}</Text>
|
||||
<Text style={[styles.accordionTitle, { color: colors.textPrimary }]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.accordionTagline, { color: colors.textTertiary }]}
|
||||
>
|
||||
{tagline}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 右:スイッチ */}
|
||||
@@ -87,19 +115,36 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
|
||||
|
||||
{/* スイッチ状態テキスト */}
|
||||
<View style={styles.accordionStatusRow}>
|
||||
<View style={[styles.statusDot, { backgroundColor: enabled ? accentColor : colors.textDisabled }]} />
|
||||
<Text style={[styles.statusText, { color: enabled ? accentColor : colors.textQuaternary }]}>
|
||||
{enabled ? "有効 — 編成データを取得します" : "無効 — データを取得しません"}
|
||||
<View
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{ backgroundColor: enabled ? accentColor : colors.textDisabled },
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusText,
|
||||
{ color: enabled ? accentColor : colors.textQuaternary },
|
||||
]}
|
||||
>
|
||||
{enabled
|
||||
? "有効 — 編成データを取得します"
|
||||
: "無効 — データを取得しません"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* ── 展開トリガー ── */}
|
||||
<TouchableOpacity
|
||||
style={[styles.accordionToggleRow, { borderTopColor: colors.borderCard }]}
|
||||
style={[
|
||||
styles.accordionToggleRow,
|
||||
{ borderTopColor: colors.borderCard },
|
||||
]}
|
||||
onPress={() => setExpanded((v) => !v)}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<Text style={[styles.accordionToggleLabel, { color: colors.textSecondary }]}>
|
||||
<Text
|
||||
style={[styles.accordionToggleLabel, { color: colors.textSecondary }]}
|
||||
>
|
||||
{expanded ? "詳細を閉じる" : (detailLabel ?? `${title} について`)}
|
||||
</Text>
|
||||
<MaterialCommunityIcons
|
||||
@@ -111,31 +156,69 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
|
||||
|
||||
{/* ── 展開コンテンツ ── */}
|
||||
{expanded && (
|
||||
<View style={[styles.accordionBody, { borderTopColor: colors.borderCard, backgroundColor: colors.backgroundTertiary }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.accordionBody,
|
||||
{
|
||||
borderTopColor: colors.borderCard,
|
||||
backgroundColor: colors.backgroundTertiary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* 説明文 */}
|
||||
<Text style={[styles.bodyDesc, { color: colors.textSecondary }]}>{description}</Text>
|
||||
<Text style={[styles.bodyDesc, { color: colors.textSecondary }]}>
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
{/* 機能リスト */}
|
||||
<View style={[styles.bodyFeatures, { borderTopColor: colors.borderSecondary }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.bodyFeatures,
|
||||
{ borderTopColor: colors.borderSecondary },
|
||||
]}
|
||||
>
|
||||
{features.map((f) => (
|
||||
<View key={f.icon} style={styles.featureRow}>
|
||||
<View style={styles.featureIcon}>
|
||||
<MaterialCommunityIcons name={f.icon as any} size={14} color={colors.iconSecondary} />
|
||||
<MaterialCommunityIcons
|
||||
name={f.icon as any}
|
||||
size={14}
|
||||
color={colors.iconSecondary}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.featureLabel, { color: colors.textPrimary }]}>{f.label}</Text>
|
||||
<Text style={[styles.featureText, { color: colors.textSecondary }]}>{f.text}</Text>
|
||||
<Text
|
||||
style={[styles.featureLabel, { color: colors.textPrimary }]}
|
||||
>
|
||||
{f.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.featureText, { color: colors.textSecondary }]}
|
||||
>
|
||||
{f.text}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* リンク */}
|
||||
<TouchableOpacity
|
||||
style={[styles.bodyLink, { borderTopColor: colors.borderSecondary }]}
|
||||
style={[
|
||||
styles.bodyLink,
|
||||
{ borderTopColor: colors.borderSecondary },
|
||||
]}
|
||||
onPress={() => Linking.openURL(linkUrl)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="open-in-new" size={13} color={colors.iconSecondary} />
|
||||
<Text style={[styles.bodyLinkText, { color: colors.textSecondary }]}>{linkLabel}</Text>
|
||||
<MaterialCommunityIcons
|
||||
name="open-in-new"
|
||||
size={13}
|
||||
color={colors.iconSecondary}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.bodyLinkText, { color: colors.textSecondary }]}
|
||||
>
|
||||
{linkLabel}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
@@ -147,17 +230,45 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
|
||||
/* 定数 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const UNYOHUB_FEATURES: Feature[] = [
|
||||
{ icon: "calendar-today", label: "運用データ", text: "当日・過去数日から投稿があった運用の継続予測運用情報を表示" },
|
||||
{ icon: "map-outline", label: "対象エリア", text: "JR四国全線" },
|
||||
{ icon: "train", label: "対象運用", text: "JR四国管内営業列車及び貨物列車,定期回送列車に対応、臨時列車/突発運用は非対応" },
|
||||
{ icon: "pencil", label: "入力方式", text: "アプリ内連携システムにて当日の運用の投稿が可能" },
|
||||
{
|
||||
icon: "calendar-today",
|
||||
label: "運用データ",
|
||||
text: "当日・過去数日から投稿があった運用の継続予測運用情報を表示",
|
||||
},
|
||||
{ icon: "map-outline", label: "対象エリア", text: "JR四国全線" },
|
||||
{
|
||||
icon: "train",
|
||||
label: "対象運用",
|
||||
text: "JR四国管内営業列車及び貨物列車,定期回送列車に対応、臨時列車/突発運用は非対応",
|
||||
},
|
||||
{
|
||||
icon: "pencil",
|
||||
label: "入力方式",
|
||||
text: "アプリ内連携システムにて当日の運用の投稿が可能",
|
||||
},
|
||||
];
|
||||
|
||||
const ELESITE_FEATURES: Feature[] = [
|
||||
{ icon: "calendar-today", label: "運用データ", text: "当日報告のあった運用情報のみ表示" },
|
||||
{ icon: "map-outline", label: "対象エリア", text: "予讃線/瀬戸大橋線(直通している特急などの列番は含みます)" },
|
||||
{ icon: "train", label: "対象運用", text: "JR四国管内営業列車対応、臨時列車/突発運用は非対応" },
|
||||
{ icon: "pencil", label: "入力方式", text: "アプリ外リンク連携にて当日の運用の投稿が可能" },
|
||||
{
|
||||
icon: "calendar-today",
|
||||
label: "運用データ",
|
||||
text: "当日報告のあった運用情報のみ表示",
|
||||
},
|
||||
{
|
||||
icon: "map-outline",
|
||||
label: "対象エリア",
|
||||
text: "予讃線/瀬戸大橋線(直通している特急などの列番は含みます)",
|
||||
},
|
||||
{
|
||||
icon: "train",
|
||||
label: "対象運用",
|
||||
text: "JR四国管内営業列車対応、臨時列車/突発運用は非対応",
|
||||
},
|
||||
{
|
||||
icon: "pencil",
|
||||
label: "入力方式",
|
||||
text: "アプリ外リンク連携にて当日の運用の投稿が可能",
|
||||
},
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -172,6 +283,19 @@ export const DataSourceSettings = () => {
|
||||
const [useElesite, setUseElesite] = useState(false);
|
||||
const [jrDataSystemEnv, setJrDataSystemEnv] =
|
||||
useState<JrDataSystemEnvironmentKey>(DEFAULT_JR_DATA_SYSTEM_ENV);
|
||||
const [jrDataSystemTrack, setJrDataSystemTrack] = useState<JrDataSystemTrack>(
|
||||
getJrDataSystemTrack(DEFAULT_JR_DATA_SYSTEM_ENV),
|
||||
);
|
||||
const [jrDataSystemUiVariant, setJrDataSystemUiVariant] =
|
||||
useState<JrDataSystemUiVariant>(
|
||||
getJrDataSystemUiVariant(DEFAULT_JR_DATA_SYSTEM_ENV),
|
||||
);
|
||||
const applyJrDataSystemEnv = (env: JrDataSystemEnvironmentKey) => {
|
||||
setJrDataSystemEnv(env);
|
||||
setJrDataSystemTrack(getJrDataSystemTrack(env));
|
||||
setJrDataSystemUiVariant(getJrDataSystemUiVariant(env));
|
||||
AS.setItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV, env);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
AS.getItem(STORAGE_KEYS.USE_UNYOHUB).then((value) => {
|
||||
@@ -182,10 +306,17 @@ export const DataSourceSettings = () => {
|
||||
});
|
||||
AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV)
|
||||
.then((value) => {
|
||||
setJrDataSystemEnv(normalizeJrDataSystemEnvironment(value));
|
||||
const env = normalizeJrDataSystemEnvironment(value);
|
||||
setJrDataSystemEnv(env);
|
||||
setJrDataSystemTrack(getJrDataSystemTrack(env));
|
||||
setJrDataSystemUiVariant(getJrDataSystemUiVariant(env));
|
||||
})
|
||||
.catch(() => {
|
||||
setJrDataSystemEnv(DEFAULT_JR_DATA_SYSTEM_ENV);
|
||||
setJrDataSystemTrack(getJrDataSystemTrack(DEFAULT_JR_DATA_SYSTEM_ENV));
|
||||
setJrDataSystemUiVariant(
|
||||
getJrDataSystemUiVariant(DEFAULT_JR_DATA_SYSTEM_ENV),
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -199,9 +330,19 @@ export const DataSourceSettings = () => {
|
||||
AS.setItem(STORAGE_KEYS.USE_ELESITE, value.toString());
|
||||
};
|
||||
|
||||
const handleSelectJrDataSystemEnv = (value: JrDataSystemEnvironmentKey) => {
|
||||
setJrDataSystemEnv(value);
|
||||
AS.setItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV, value);
|
||||
const handleSelectJrDataSystemTrack = (value: JrDataSystemTrack) => {
|
||||
setJrDataSystemTrack(value);
|
||||
const normalizedVariant =
|
||||
value === "experimental" ? "release" : jrDataSystemUiVariant;
|
||||
setJrDataSystemUiVariant(normalizedVariant);
|
||||
const env = resolveJrDataSystemEnvironment(value, normalizedVariant);
|
||||
applyJrDataSystemEnv(env);
|
||||
};
|
||||
|
||||
const handleSelectJrDataSystemUiVariant = (value: JrDataSystemUiVariant) => {
|
||||
setJrDataSystemUiVariant(value);
|
||||
const env = resolveJrDataSystemEnvironment(jrDataSystemTrack, value);
|
||||
applyJrDataSystemEnv(env);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -214,56 +355,107 @@ export const DataSourceSettings = () => {
|
||||
position: "left",
|
||||
}}
|
||||
/>
|
||||
<ScrollView style={[styles.content, { backgroundColor: colors.backgroundSecondary }]} contentContainerStyle={styles.contentInner}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.textTertiary }]}>外部データソース</Text>
|
||||
<ScrollView
|
||||
style={[
|
||||
styles.content,
|
||||
{ backgroundColor: colors.backgroundSecondary },
|
||||
]}
|
||||
contentContainerStyle={styles.contentInner}
|
||||
>
|
||||
<Text style={[styles.sectionTitle, { color: colors.textTertiary }]}>
|
||||
外部データソース
|
||||
</Text>
|
||||
|
||||
<DataSourceAccordionCard
|
||||
logo={HUB_LOGO_PNG}
|
||||
accentColor="#0099CC"
|
||||
title="鉄道運用Hub"
|
||||
tagline="コミュニティによる列車運用情報サービス"
|
||||
enabled={useUnyohub}
|
||||
onToggle={handleToggleUnyohub}
|
||||
description={
|
||||
"鉄道運用Hubはオープンソースのユーザー投稿型鉄道運用情報データベースアプリケーションです。JR 四国をはじめ全国多数の路線系統に対応しています。\n\nデータがある列車では地図上にアイコンでマークが表示され、列車情報画面の編成表示も更新されます。"
|
||||
}
|
||||
features={UNYOHUB_FEATURES}
|
||||
linkLabel="unyohub.2pd.jp を開く(JR四国)"
|
||||
linkUrl="https://unyohub.2pd.jp/railroad_shikoku/"
|
||||
/>
|
||||
<DataSourceAccordionCard
|
||||
logo={HUB_LOGO_PNG}
|
||||
accentColor="#0099CC"
|
||||
title="鉄道運用Hub"
|
||||
tagline="コミュニティによる列車運用情報サービス"
|
||||
enabled={useUnyohub}
|
||||
onToggle={handleToggleUnyohub}
|
||||
description={
|
||||
"鉄道運用Hubはオープンソースのユーザー投稿型鉄道運用情報データベースアプリケーションです。JR 四国をはじめ全国多数の路線系統に対応しています。\n\nデータがある列車では地図上にアイコンでマークが表示され、列車情報画面の編成表示も更新されます。"
|
||||
}
|
||||
features={UNYOHUB_FEATURES}
|
||||
linkLabel="unyohub.2pd.jp を開く(JR四国)"
|
||||
linkUrl="https://unyohub.2pd.jp/railroad_shikoku/"
|
||||
/>
|
||||
|
||||
<DataSourceAccordionCard
|
||||
logo={ELESITE_LOGO_PNG}
|
||||
accentColor="#44bb44"
|
||||
title="えれサイト"
|
||||
tagline="コミュニティによる列車運用情報サービス"
|
||||
enabled={useElesite}
|
||||
onToggle={handleToggleElesite}
|
||||
description={
|
||||
"えれサイトは、鉄道の運用情報を利用者同士で共有するサービスです。皆様からの投稿をもとに、列車のリアルタイムな動きを反映しています。JR四国の特急・普通列車をはじめ、現在は全国の路線に対応しています。\n\nデータがある列車では地図上にアイコンでマークが表示され、列車情報画面の編成表示も更新されます。"
|
||||
}
|
||||
features={ELESITE_FEATURES}
|
||||
linkLabel="elesite-next.com を開く"
|
||||
linkUrl="https://www.elesite-next.com/"
|
||||
/>
|
||||
<DataSourceAccordionCard
|
||||
logo={ELESITE_LOGO_PNG}
|
||||
accentColor="#44bb44"
|
||||
title="えれサイト"
|
||||
tagline="コミュニティによる列車運用情報サービス"
|
||||
enabled={useElesite}
|
||||
onToggle={handleToggleElesite}
|
||||
description={
|
||||
"えれサイトは、鉄道の運用情報を利用者同士で共有するサービスです。皆様からの投稿をもとに、列車のリアルタイムな動きを反映しています。JR四国の特急・普通列車をはじめ、現在は全国の路線に対応しています。\n\nデータがある列車では地図上にアイコンでマークが表示され、列車情報画面の編成表示も更新されます。"
|
||||
}
|
||||
features={ELESITE_FEATURES}
|
||||
linkLabel="elesite-next.com を開く"
|
||||
linkUrl="https://www.elesite-next.com/"
|
||||
/>
|
||||
|
||||
<View style={[styles.infoSection, { backgroundColor: colors.backgroundTertiary }]}>
|
||||
<Text style={[styles.infoText, { color: colors.textCaution }]}>
|
||||
外部のコミュニティデータソースとの連携を管理します。
|
||||
{"\n\n"}
|
||||
データの正確性は保証されません。また、これらの連携情報を利用する時点でそれぞれのサイトの利用規約に同意したものとします。{"\n\n"}外部ソースはJR四国非公式アプリが管理していないデータであるため、お問い合わせは各サービスの窓口までお願いいたします。
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.infoSection,
|
||||
{ backgroundColor: colors.backgroundTertiary },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.infoText, { color: colors.textCaution }]}>
|
||||
外部のコミュニティデータソースとの連携を管理します。
|
||||
{"\n\n"}
|
||||
データの正確性は保証されません。また、これらの連携情報を利用する時点でそれぞれのサイトの利用規約に同意したものとします。
|
||||
{"\n\n"}
|
||||
外部ソースはJR四国非公式アプリが管理していないデータであるため、お問い合わせは各サービスの窓口までお願いいたします。
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{showDebugSelector && (
|
||||
<View style={[styles.debugSection, { backgroundColor: colors.surface, borderColor: colors.borderSecondary }]}>
|
||||
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>デバッグ: 投稿システム接続先</Text>
|
||||
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>
|
||||
列車情報・編成投稿画面を、本番 / ChatGPT案 / Claude案で切り替えます。
|
||||
{showDebugSelector && (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
styles.debugSection,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.borderSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>
|
||||
投稿システム接続先
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.debugDescription,
|
||||
{ color: colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
本番運用と実験場、および本番のリリース版/ベータ版を切り替えます。
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
styles.debugCurrentText,
|
||||
{ color: colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
系統
|
||||
</Text>
|
||||
<View style={styles.debugOptionRow}>
|
||||
{JR_DATA_SYSTEM_ENV_OPTIONS.map((option) => {
|
||||
const selected = jrDataSystemEnv === option.key;
|
||||
{[
|
||||
{
|
||||
key: "production" as const,
|
||||
label: "本番",
|
||||
caption: "一般公開向け",
|
||||
},
|
||||
{
|
||||
key: "experimental" as const,
|
||||
label: "実験",
|
||||
caption: "毎日リセット",
|
||||
},
|
||||
].map((option) => {
|
||||
const selected = jrDataSystemTrack === option.key;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.key}
|
||||
@@ -278,13 +470,17 @@ export const DataSourceSettings = () => {
|
||||
: colors.borderSecondary,
|
||||
},
|
||||
]}
|
||||
onPress={() => handleSelectJrDataSystemEnv(option.key)}
|
||||
onPress={() => handleSelectJrDataSystemTrack(option.key)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.debugOptionTitle,
|
||||
{ color: selected ? fixed.textOnPrimary : colors.textPrimary },
|
||||
{
|
||||
color: selected
|
||||
? fixed.textOnPrimary
|
||||
: colors.textPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
@@ -292,7 +488,11 @@ export const DataSourceSettings = () => {
|
||||
<Text
|
||||
style={[
|
||||
styles.debugOptionCaption,
|
||||
{ color: selected ? fixed.textOnPrimary : colors.textTertiary },
|
||||
{
|
||||
color: selected
|
||||
? fixed.textOnPrimary
|
||||
: colors.textTertiary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{option.caption}
|
||||
@@ -301,10 +501,96 @@ export const DataSourceSettings = () => {
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Text style={[styles.debugCurrentText, { color: colors.textTertiary }]}>現在の接続先: {JR_DATA_SYSTEM_ENV_OPTIONS.find((option) => option.key === jrDataSystemEnv)?.baseUrl}</Text>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
styles.debugCurrentText,
|
||||
{ color: colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
UIバージョン(本番のみ)
|
||||
</Text>
|
||||
<View style={styles.debugOptionRow}>
|
||||
{[
|
||||
{
|
||||
key: "release" as const,
|
||||
label: "リリース",
|
||||
caption: "安定版",
|
||||
},
|
||||
{
|
||||
key: "beta" as const,
|
||||
label: "ベータ",
|
||||
caption: "夜間ビルド",
|
||||
},
|
||||
].map((option) => {
|
||||
const selected = jrDataSystemUiVariant === option.key;
|
||||
const disabled = jrDataSystemTrack === "experimental";
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.key}
|
||||
style={[
|
||||
styles.debugOptionButton,
|
||||
{
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
backgroundColor: selected
|
||||
? fixed.primary
|
||||
: colors.backgroundTertiary,
|
||||
borderColor: selected
|
||||
? fixed.primary
|
||||
: colors.borderSecondary,
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
if (disabled) return;
|
||||
handleSelectJrDataSystemUiVariant(option.key);
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.debugOptionTitle,
|
||||
{
|
||||
color: selected
|
||||
? fixed.textOnPrimary
|
||||
: colors.textPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.debugOptionCaption,
|
||||
{
|
||||
color: selected
|
||||
? fixed.textOnPrimary
|
||||
: colors.textTertiary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{option.caption}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.debugCurrentText,
|
||||
{ color: colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
現在の接続先:{" "}
|
||||
{
|
||||
JR_DATA_SYSTEM_ENV_OPTIONS.find(
|
||||
(option) => option.key === jrDataSystemEnv,
|
||||
)?.baseUrl
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,23 +24,49 @@ export const LayoutSettings = ({
|
||||
setTrainPosition,
|
||||
headerSize,
|
||||
setHeaderSize,
|
||||
allowHubIcon = false,
|
||||
}) => {
|
||||
const { goBack } = useNavigation() as any;
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const visibleIconSetting =
|
||||
allowHubIcon || iconSetting !== "hub" ? iconSetting : "original";
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
|
||||
<SheetHeaderItem title="レイアウト設定" LeftItem={{ title: "< 設定", onPress: goBack }} />
|
||||
<ScrollView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<SwitchArea
|
||||
str="列車アイコン表示"
|
||||
bool={iconSetting}
|
||||
setBool={setIconSetting}
|
||||
falseImage={require("../../assets/configuration/icon_default.jpg")}
|
||||
trueImage={require("../../assets/configuration/icon_original.jpg")}
|
||||
falseText={"本家\n(文字アイコン)"}
|
||||
trueText={"オリジナル\n(車種アイコン)"}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#00000010",
|
||||
borderRadius: 10,
|
||||
margin: 5,
|
||||
}}
|
||||
>
|
||||
<TripleSwitchArea
|
||||
str="列車アイコン表示"
|
||||
bool={visibleIconSetting}
|
||||
setBool={setIconSetting}
|
||||
firstItem={{
|
||||
firstImage: require("../../assets/configuration/icon_default.jpg"),
|
||||
firstText: "本家\n(文字アイコン)",
|
||||
firstValue: "default",
|
||||
}}
|
||||
secondItem={{
|
||||
secondImage: require("../../assets/configuration/icon_original.jpg"),
|
||||
secondText: "オリジナル\n(車種アイコン)",
|
||||
secondValue: "original",
|
||||
}}
|
||||
thirdItem={
|
||||
allowHubIcon
|
||||
? {
|
||||
thirdImage: require("../../assets/relationLogo/unyohub_logo.webp"),
|
||||
thirdText: "鉄道運用Hub\n(Hubアイコン)",
|
||||
thirdValue: "hub",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<SwitchArea
|
||||
str="列車表示"
|
||||
bool={uiSetting}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { Switch } from "@rneui/themed";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
|
||||
type OperationInfoSettingsProps = {
|
||||
operationLandscapeEnabled: boolean;
|
||||
setOperationLandscapeEnabled: (value: boolean) => void;
|
||||
operationCaptureEnabled: boolean;
|
||||
setOperationCaptureEnabled: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const OperationInfoSettings = ({
|
||||
operationLandscapeEnabled,
|
||||
setOperationLandscapeEnabled,
|
||||
operationCaptureEnabled,
|
||||
setOperationCaptureEnabled,
|
||||
}: OperationInfoSettingsProps) => {
|
||||
const { goBack } = useNavigation();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
|
||||
<SheetHeaderItem
|
||||
title="運行情報設定(β)"
|
||||
LeftItem={{ title: "< 設定", onPress: goBack }}
|
||||
/>
|
||||
<ScrollView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<SettingRow
|
||||
title="横倒し表示機能"
|
||||
description="端末を横向きにしたとき、運行情報ページを見やすく再構成した専用表示を有効にします。"
|
||||
value={operationLandscapeEnabled}
|
||||
onValueChange={setOperationLandscapeEnabled}
|
||||
/>
|
||||
<SettingRow
|
||||
title="スクリーンショット切り出し機能"
|
||||
description="運行情報ページ内に、項目単位・全体単位の切り出しボタンを表示します。"
|
||||
value={operationCaptureEnabled}
|
||||
onValueChange={setOperationCaptureEnabled}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
type SettingRowProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const SettingRow = ({ title, description, value, onValueChange }: SettingRowProps) => {
|
||||
const { colors, fixed } = useThemeColors();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSecondary ?? "#ccc",
|
||||
backgroundColor: colors.surface,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 16, fontWeight: "600", color: colors.text }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={{ marginTop: 4, fontSize: 13, lineHeight: 19, color: colors.textSecondary }}>
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
color={fixed.primary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,528 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
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 { useThemeColors } from "@/lib/theme";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import { useNotification } from "@/stateBox/useNotifications";
|
||||
|
||||
export const ResearchToolsSettings = () => {
|
||||
const navigation = useNavigation<any>();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const { expoPushToken } = useNotification();
|
||||
const {
|
||||
updatePermission,
|
||||
mockApiFeatureEnabled,
|
||||
setMockApiFeatureEnabled,
|
||||
recorderState,
|
||||
recordingSnapshotCount,
|
||||
recordingList,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
startPlayback,
|
||||
stopPlayback,
|
||||
deleteRecording,
|
||||
exportRecordingFile,
|
||||
exportAllRecordingsFile,
|
||||
importRecordingFile,
|
||||
} = useTrainMenu();
|
||||
const showResearchTools = __DEV__ || updatePermission;
|
||||
const recordingSwipeRefs = useRef<Record<string, { close: () => void } | null>>({});
|
||||
const [recordingFileStatus, setRecordingFileStatus] = useState<{
|
||||
type: "info" | "success" | "error";
|
||||
text: string;
|
||||
}>({ type: "info", text: "録画JSONの書き出しと読み込みができます。" });
|
||||
|
||||
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);
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const setFileStatus = (type: "info" | "success" | "error", text: string) => {
|
||||
setRecordingFileStatus({ type, text });
|
||||
};
|
||||
|
||||
const handleExportRecordingFile = async (id: string, label: string) => {
|
||||
try {
|
||||
await exportRecordingFile(id);
|
||||
setFileStatus("success", `${label} の録画JSONを書き出しました。`);
|
||||
} catch (error) {
|
||||
setFileStatus(
|
||||
"error",
|
||||
`録画JSONを書き出せませんでした: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportAllRecordingsFile = async () => {
|
||||
try {
|
||||
await exportAllRecordingsFile();
|
||||
setFileStatus("success", `${recordingList.length}件の録画JSONを書き出しました。`);
|
||||
} catch (error) {
|
||||
setFileStatus(
|
||||
"error",
|
||||
`録画JSONを書き出せませんでした: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportRecordingFile = async () => {
|
||||
try {
|
||||
const result = await importRecordingFile();
|
||||
if (!result) {
|
||||
setFileStatus("info", "録画JSONの読み込みをキャンセルしました。");
|
||||
return;
|
||||
}
|
||||
setFileStatus(
|
||||
"success",
|
||||
result.overwrittenCount > 0
|
||||
? `${result.importedCount}件を読み込みました。${result.overwrittenCount}件は同じIDのため上書きしました。`
|
||||
: `${result.importedCount}件を読み込みました。`,
|
||||
);
|
||||
} catch (error) {
|
||||
setFileStatus(
|
||||
"error",
|
||||
`録画JSONを読み込めませんでした: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openRecordingDatabase = () => {
|
||||
const uri = `https://experimental.shikoku-railinfo.haruk.in/position-board?from=eachTrainInfo&userID=${encodeURIComponent(expoPushToken || "")}`;
|
||||
const params = {
|
||||
uri,
|
||||
importRecordingDownloads: true,
|
||||
useExitButton: false,
|
||||
};
|
||||
const parentNavigation = navigation.getParent?.();
|
||||
if (parentNavigation) {
|
||||
parentNavigation.navigate("generalWebView", params);
|
||||
return;
|
||||
}
|
||||
navigation.navigate("generalWebView", params);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: fixed.primary }]}>
|
||||
<SheetHeaderItem
|
||||
title="調査ツール"
|
||||
LeftItem={{
|
||||
title: "< 戻る",
|
||||
onPress: () => navigation.goBack(),
|
||||
position: "left",
|
||||
}}
|
||||
/>
|
||||
<ScrollView
|
||||
style={[styles.content, { backgroundColor: colors.backgroundSecondary }]}
|
||||
contentContainerStyle={styles.contentInner}
|
||||
>
|
||||
{!showResearchTools ? (
|
||||
<View
|
||||
style={[
|
||||
styles.debugSection,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.borderSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>利用できる調査ツールはありません</Text>
|
||||
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>このページは開発・管理向けの調査機能を配置しています。</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
styles.debugSection,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.borderSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>デバッグ: モックAPI検証</Text>
|
||||
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>公式サイトの代わりにサンプルデータを流し込みます。</Text>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={[styles.debugCurrentText, { color: colors.textPrimary, fontSize: 14 }]}>モックAPI検証機能</Text>
|
||||
<Switch
|
||||
value={mockApiFeatureEnabled}
|
||||
onValueChange={setMockApiFeatureEnabled}
|
||||
color={fixed.primary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.debugSection,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.borderSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>デバッグ: 走行位置録画</Text>
|
||||
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>ライブデータを録画してモックとして再生します。録画中はモックOFFになります。</Text>
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor:
|
||||
recorderState === "recording"
|
||||
? "#e53935"
|
||||
: recorderState === "playing"
|
||||
? "#43a047"
|
||||
: colors.borderSecondary,
|
||||
}}
|
||||
/>
|
||||
<Text style={[styles.debugCurrentText, { color: colors.textSecondary, fontSize: 13 }]}>
|
||||
{recorderState === "recording"
|
||||
? `録画中… ${recordingSnapshotCount} スナップショット`
|
||||
: recorderState === "playing"
|
||||
? "再生中"
|
||||
: `${recordingList.length} 件の録画`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonRow}>
|
||||
{recorderState === "idle" && (
|
||||
<TouchableOpacity onPress={startRecording} style={styles.recordButton}>
|
||||
<Text style={styles.primaryButtonText}>● 録画開始</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{recorderState === "recording" && (
|
||||
<TouchableOpacity onPress={stopRecording} style={[styles.neutralButton, { backgroundColor: colors.borderSecondary }]}>
|
||||
<Text style={[styles.neutralButtonText, { color: colors.textPrimary }]}>■ 録画停止</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{recorderState === "playing" && (
|
||||
<TouchableOpacity onPress={stopPlayback} style={[styles.neutralButton, { backgroundColor: colors.borderSecondary }]}>
|
||||
<Text style={[styles.neutralButtonText, { color: colors.textPrimary }]}>■ 再生停止</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.fileBox, { backgroundColor: colors.backgroundTertiary }]}>
|
||||
<Text style={{ color: colors.textPrimary, fontSize: 13, fontWeight: "600" }}>録画JSONファイル</Text>
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
void handleImportRecordingFile();
|
||||
}}
|
||||
disabled={recorderState === "recording"}
|
||||
style={{
|
||||
backgroundColor: recorderState === "recording" ? colors.borderSecondary : fixed.primary,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
opacity: recorderState === "recording" ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: fixed.textOnPrimary, fontWeight: "bold", fontSize: 12 }}>JSONを読み込む</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
void handleExportAllRecordingsFile();
|
||||
}}
|
||||
disabled={recordingList.length === 0 || recorderState === "recording"}
|
||||
style={{
|
||||
backgroundColor:
|
||||
recordingList.length === 0 || recorderState === "recording"
|
||||
? colors.borderSecondary
|
||||
: colors.surface,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSecondary,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
opacity: recordingList.length === 0 || recorderState === "recording" ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: colors.textPrimary, fontWeight: "bold", fontSize: 12 }}>全件を書き出す</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
backgroundColor:
|
||||
recordingFileStatus.type === "success"
|
||||
? "#43a04722"
|
||||
: recordingFileStatus.type === "error"
|
||||
? "#e5393522"
|
||||
: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor:
|
||||
recordingFileStatus.type === "success"
|
||||
? "#43a04755"
|
||||
: recordingFileStatus.type === "error"
|
||||
? "#e5393555"
|
||||
: colors.borderSecondary,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: colors.textSecondary, fontSize: 12, lineHeight: 18 }}>{recordingFileStatus.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{recordingList.length > 0 && recorderState !== "recording" && (
|
||||
<View style={styles.recordingList}>
|
||||
{recordingList.map((rec) => {
|
||||
const isPlaying = recorderState === "playing";
|
||||
const durationSec = Math.round(rec.durationMs / 1000);
|
||||
const durationLabel = durationSec >= 60
|
||||
? `${Math.floor(durationSec / 60)}分${durationSec % 60}秒`
|
||||
: `${durationSec}秒`;
|
||||
const dateLabel = new Date(rec.recordedAt).toLocaleString("ja-JP", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const recordingRow = (
|
||||
<TouchableOpacity
|
||||
onPress={() => startPlayback(rec.id)}
|
||||
onLongPress={() => {
|
||||
void handleExportRecordingFile(rec.id, dateLabel);
|
||||
}}
|
||||
disabled={isPlaying}
|
||||
activeOpacity={0.72}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
gap: 10,
|
||||
opacity: isPlaying ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: colors.textPrimary, fontSize: 13, fontWeight: "bold" }}>{dateLabel}</Text>
|
||||
<Text style={{ color: colors.textSecondary, fontSize: 11 }}>{rec.snapshotCount} コマ / {durationLabel}</Text>
|
||||
</View>
|
||||
<View style={{ alignItems: "flex-end", gap: 4 }}>
|
||||
<View style={styles.playbackHint}>
|
||||
<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={styles.deleteAction}>
|
||||
<MaterialCommunityIcons name="trash-can-outline" size={18} color="#fff" />
|
||||
<Text style={styles.deleteActionText}>削除</Text>
|
||||
</View>
|
||||
)}
|
||||
onSwipeableOpen={() => confirmDeleteRecording(rec.id, dateLabel)}
|
||||
>
|
||||
{recordingRow}
|
||||
</Swipeable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={openRecordingDatabase}
|
||||
activeOpacity={0.78}
|
||||
style={[
|
||||
styles.databaseButton,
|
||||
{
|
||||
backgroundColor: fixed.primary,
|
||||
borderColor: fixed.primary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="database-search-outline"
|
||||
size={18}
|
||||
color={fixed.textOnPrimary}
|
||||
/>
|
||||
<Text style={[styles.databaseButtonText, { color: fixed.textOnPrimary }]}>
|
||||
全録データベースを参照
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0099CC",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f8f8fc",
|
||||
},
|
||||
contentInner: {
|
||||
paddingHorizontal: 14,
|
||||
paddingBottom: 40,
|
||||
paddingTop: 20,
|
||||
gap: 12,
|
||||
},
|
||||
debugSection: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
padding: 14,
|
||||
gap: 10,
|
||||
},
|
||||
debugTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
debugDescription: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
debugCurrentText: {
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
},
|
||||
switchRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 8,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
gap: 8,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
marginTop: 10,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
recordButton: {
|
||||
backgroundColor: "#e53935",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
neutralButton: {
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: "#fff",
|
||||
fontWeight: "bold",
|
||||
fontSize: 13,
|
||||
},
|
||||
neutralButtonText: {
|
||||
fontWeight: "bold",
|
||||
fontSize: 13,
|
||||
},
|
||||
fileBox: {
|
||||
marginTop: 4,
|
||||
gap: 8,
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
},
|
||||
recordingList: {
|
||||
marginTop: 10,
|
||||
gap: 6,
|
||||
},
|
||||
playbackHint: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
},
|
||||
deleteAction: {
|
||||
width: 96,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#e53935",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: 6,
|
||||
},
|
||||
deleteActionText: {
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
fontWeight: "bold",
|
||||
marginTop: 4,
|
||||
},
|
||||
databaseButton: {
|
||||
minHeight: 46,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
databaseButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
});
|
||||
@@ -16,8 +16,9 @@ import { SwitchArea } from "../atom/SwitchArea";
|
||||
import { useNotification } from "../../stateBox/useNotifications";
|
||||
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
|
||||
import { useThemeColors, type ColorThemePref } from "@/lib/theme/useThemeColors";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
|
||||
const versionCode = "7.0.2"; // Update this version code as needed
|
||||
const versionCode = "7.1";
|
||||
|
||||
export const SettingTopPage = ({
|
||||
testNFC,
|
||||
@@ -29,6 +30,8 @@ export const SettingTopPage = ({
|
||||
const { expoPushToken } = useNotification();
|
||||
const { colors, fixed, colorTheme, setColorTheme } = useThemeColors();
|
||||
const navigation = useNavigation<any>();
|
||||
const { updatePermission } = useTrainMenu();
|
||||
const showResearchTools = __DEV__ || updatePermission;
|
||||
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
|
||||
@@ -118,6 +121,12 @@ export const SettingTopPage = ({
|
||||
navigation.navigate("setting", { screen: "SoundSettings" })
|
||||
}
|
||||
/>
|
||||
<SettingList
|
||||
string="運行情報設定(β)"
|
||||
onPress={() =>
|
||||
navigation.navigate("setting", { screen: "OperationInfoSettings" })
|
||||
}
|
||||
/>
|
||||
|
||||
<SectionHeader title="通知・データ" />
|
||||
<SettingList
|
||||
@@ -135,6 +144,14 @@ export const SettingTopPage = ({
|
||||
navigation.navigate("setting", { screen: "DataSourceSettings" })
|
||||
}
|
||||
/>
|
||||
{showResearchTools && (
|
||||
<SettingList
|
||||
string="調査ツール"
|
||||
onPress={() =>
|
||||
navigation.navigate("setting", { screen: "ResearchToolsSettings" })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SectionHeader title="その他" />
|
||||
<SettingList
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
ToastAndroid,
|
||||
} from "react-native";
|
||||
import { createStackNavigator } from "@react-navigation/stack";
|
||||
import { TransitionPresets } from "@react-navigation/stack";
|
||||
import { pushTransitionOptions } from "@/lib/stackOption";
|
||||
import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
|
||||
import * as Updates from "expo-updates";
|
||||
import { AS } from "../../storageControl";
|
||||
@@ -22,15 +22,36 @@ import { FavoriteSettings } from "./FavoriteSettings";
|
||||
import { NotificationSettings } from "./NotificationSettings";
|
||||
import { LauncherIconSettings } from "./LauncherIconSettings";
|
||||
import { DataSourceSettings } from "./DataSourceSettings";
|
||||
import { ResearchToolsSettings } from "./ResearchToolsSettings";
|
||||
import { FelicaHistoryPage } from "./FelicaHistoryPage";
|
||||
import { SoundSettings } from "./SoundSettings";
|
||||
import { OperationInfoSettings } from "./OperationInfoSettings";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import {
|
||||
normalizeIconDisplayMode,
|
||||
type IconDisplayMode,
|
||||
} from "@/lib/iconDisplayMode";
|
||||
|
||||
const readStoredValue = async <T,>(
|
||||
key: string,
|
||||
fallback: T,
|
||||
mapValue?: (value: any) => T,
|
||||
) => {
|
||||
try {
|
||||
const value = await AS.getItem(key);
|
||||
return mapValue ? mapValue(value) : (value as T);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const Stack = createStackNavigator();
|
||||
export default function Setting(props) {
|
||||
const {
|
||||
navigation: { navigate },
|
||||
} = props;
|
||||
const [iconSetting, setIconSetting] = useState(false);
|
||||
const { updatePermission } = useTrainMenu();
|
||||
const [iconSetting, setIconSetting] = useState<IconDisplayMode>("original");
|
||||
const [mapSwitch, setMapSwitch] = useState(false);
|
||||
const [stationMenu, setStationMenu] = useState(false);
|
||||
const [usePDFView, setUsePDFView] = useState(false);
|
||||
@@ -39,16 +60,54 @@ export default function Setting(props) {
|
||||
const [headerSize, setHeaderSize] = useState("default");
|
||||
const [startPage, setStartPage] = useState(false);
|
||||
const [uiSetting, setUiSetting] = useState("tokyo");
|
||||
const [operationLandscapeEnabled, setOperationLandscapeEnabled] = useState(false);
|
||||
const [operationCaptureEnabled, setOperationCaptureEnabled] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
AS.getItem(STORAGE_KEYS.ICON_SWITCH).then(setIconSetting);
|
||||
AS.getItem(STORAGE_KEYS.MAP_SWITCH).then(setMapSwitch);
|
||||
AS.getItem(STORAGE_KEYS.STATION_SWITCH).then(setStationMenu);
|
||||
AS.getItem(STORAGE_KEYS.USE_PDF_VIEW).then(setUsePDFView);
|
||||
AS.getItem(STORAGE_KEYS.TRAIN_SWITCH).then(setTrainMenu);
|
||||
AS.getItem(STORAGE_KEYS.TRAIN_POSITION_SWITCH).then(setTrainPosition);
|
||||
AS.getItem(STORAGE_KEYS.HEADER_SIZE).then(setHeaderSize);
|
||||
AS.getItem(STORAGE_KEYS.START_PAGE).then(setStartPage);
|
||||
AS.getItem(STORAGE_KEYS.UI_SETTING).then(setUiSetting);
|
||||
void Promise.all([
|
||||
readStoredValue(STORAGE_KEYS.ICON_SWITCH, "original", normalizeIconDisplayMode),
|
||||
readStoredValue(STORAGE_KEYS.MAP_SWITCH, false, (value) => value === true || value === "true"),
|
||||
readStoredValue(STORAGE_KEYS.STATION_SWITCH, false, (value) => value === true || value === "true"),
|
||||
readStoredValue(STORAGE_KEYS.USE_PDF_VIEW, false, (value) => value === true || value === "true"),
|
||||
readStoredValue(STORAGE_KEYS.TRAIN_SWITCH, false, (value) => value === true || value === "true"),
|
||||
readStoredValue(STORAGE_KEYS.TRAIN_POSITION_SWITCH, false, (value) => value === true || value === "true"),
|
||||
readStoredValue(STORAGE_KEYS.HEADER_SIZE, "default"),
|
||||
readStoredValue(STORAGE_KEYS.START_PAGE, false, (value) => value === true || value === "true"),
|
||||
readStoredValue(STORAGE_KEYS.UI_SETTING, "tokyo"),
|
||||
readStoredValue(
|
||||
STORAGE_KEYS.OPERATION_INFO_LANDSCAPE_ENABLED,
|
||||
false,
|
||||
(value) => value === true || value === "true",
|
||||
),
|
||||
readStoredValue(
|
||||
STORAGE_KEYS.OPERATION_INFO_CAPTURE_ENABLED,
|
||||
false,
|
||||
(value) => value === true || value === "true",
|
||||
),
|
||||
]).then(([
|
||||
nextIconSetting,
|
||||
nextMapSwitch,
|
||||
nextStationMenu,
|
||||
nextUsePdfView,
|
||||
nextTrainMenu,
|
||||
nextTrainPosition,
|
||||
nextHeaderSize,
|
||||
nextStartPage,
|
||||
nextUiSetting,
|
||||
nextOperationLandscapeEnabled,
|
||||
nextOperationCaptureEnabled,
|
||||
]) => {
|
||||
setIconSetting(nextIconSetting);
|
||||
setMapSwitch(nextMapSwitch);
|
||||
setStationMenu(nextStationMenu);
|
||||
setUsePDFView(nextUsePdfView);
|
||||
setTrainMenu(nextTrainMenu);
|
||||
setTrainPosition(nextTrainPosition);
|
||||
setHeaderSize(nextHeaderSize);
|
||||
setStartPage(nextStartPage);
|
||||
setUiSetting(nextUiSetting);
|
||||
setOperationLandscapeEnabled(nextOperationLandscapeEnabled);
|
||||
setOperationCaptureEnabled(nextOperationCaptureEnabled);
|
||||
});
|
||||
}, []);
|
||||
const testNFC = async () => {
|
||||
console.log("Testing NFC...");
|
||||
@@ -73,8 +132,10 @@ export default function Setting(props) {
|
||||
}
|
||||
};
|
||||
const updateAndReload = () => {
|
||||
const iconSettingToSave =
|
||||
updatePermission || iconSetting !== "hub" ? iconSetting : "original";
|
||||
Promise.all([
|
||||
AS.setItem(STORAGE_KEYS.ICON_SWITCH, iconSetting.toString()),
|
||||
AS.setItem(STORAGE_KEYS.ICON_SWITCH, iconSettingToSave.toString()),
|
||||
AS.setItem(STORAGE_KEYS.MAP_SWITCH, mapSwitch.toString()),
|
||||
AS.setItem(STORAGE_KEYS.STATION_SWITCH, stationMenu.toString()),
|
||||
AS.setItem(STORAGE_KEYS.USE_PDF_VIEW, usePDFView.toString()),
|
||||
@@ -83,6 +144,8 @@ export default function Setting(props) {
|
||||
AS.setItem(STORAGE_KEYS.HEADER_SIZE, headerSize),
|
||||
AS.setItem(STORAGE_KEYS.START_PAGE, startPage.toString()),
|
||||
AS.setItem(STORAGE_KEYS.UI_SETTING, uiSetting),
|
||||
AS.setItem(STORAGE_KEYS.OPERATION_INFO_LANDSCAPE_ENABLED, operationLandscapeEnabled.toString()),
|
||||
AS.setItem(STORAGE_KEYS.OPERATION_INFO_CAPTURE_ENABLED, operationCaptureEnabled.toString()),
|
||||
]).then(() => Updates.reloadAsync());
|
||||
};
|
||||
return (
|
||||
@@ -91,7 +154,7 @@ export default function Setting(props) {
|
||||
name="settingTopPage"
|
||||
options={{
|
||||
gestureEnabled: false,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
@@ -111,7 +174,7 @@ export default function Setting(props) {
|
||||
name="LayoutSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
@@ -136,6 +199,7 @@ export default function Setting(props) {
|
||||
setUiSetting={setUiSetting}
|
||||
headerSize={headerSize}
|
||||
setHeaderSize={setHeaderSize}
|
||||
allowHubIcon={updatePermission}
|
||||
/>
|
||||
)}
|
||||
</Stack.Screen>
|
||||
@@ -143,7 +207,7 @@ export default function Setting(props) {
|
||||
name="NotificationSettings"
|
||||
options={{
|
||||
//gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
@@ -154,7 +218,7 @@ export default function Setting(props) {
|
||||
name="LauncherIconSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
@@ -165,7 +229,7 @@ export default function Setting(props) {
|
||||
name="FavoriteSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
@@ -176,18 +240,29 @@ export default function Setting(props) {
|
||||
name="DataSourceSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
}}
|
||||
component={DataSourceSettings}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ResearchToolsSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
}}
|
||||
component={ResearchToolsSettings}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="FelicaHistoryPage"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
@@ -198,13 +273,33 @@ export default function Setting(props) {
|
||||
name="SoundSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
}}
|
||||
component={SoundSettings}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="OperationInfoSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<OperationInfoSettings
|
||||
{...props}
|
||||
operationLandscapeEnabled={operationLandscapeEnabled}
|
||||
setOperationLandscapeEnabled={setOperationLandscapeEnabled}
|
||||
operationCaptureEnabled={operationCaptureEnabled}
|
||||
setOperationCaptureEnabled={setOperationCaptureEnabled}
|
||||
/>
|
||||
)}
|
||||
</Stack.Screen>
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type hoge = {
|
||||
name: string;
|
||||
timeType: string;
|
||||
time: string;
|
||||
platformNum: string | null;
|
||||
}[];
|
||||
export const ExGridSimpleView: FC<{
|
||||
data: hoge;
|
||||
@@ -63,6 +64,7 @@ export const ExGridSimpleView: FC<{
|
||||
timeType: string;
|
||||
time: string;
|
||||
isOperating: boolean;
|
||||
platformNum: string | null;
|
||||
}[];
|
||||
} = {
|
||||
"4": [], "5": [], "6": [], "7": [], "8": [], "9": [],
|
||||
|
||||
@@ -29,6 +29,7 @@ export const ExGridSimpleViewItem: FC<{
|
||||
timeType: string;
|
||||
time: string;
|
||||
isOperating: boolean;
|
||||
platformNum: string | null;
|
||||
};
|
||||
index: number;
|
||||
array: {
|
||||
@@ -233,6 +234,20 @@ export const ExGridSimpleViewItem: FC<{
|
||||
{d.timeType}
|
||||
</Text>
|
||||
)}
|
||||
{showLastStop && d.platformNum && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 10,
|
||||
position: "absolute",
|
||||
top: 36,
|
||||
left: 28,
|
||||
fontWeight: "bold",
|
||||
color: isCancelled ? "gray" : colors.text,
|
||||
}}
|
||||
>
|
||||
{d.platformNum}番
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
|
||||
@@ -11,6 +11,7 @@ type hoge = {
|
||||
name: string;
|
||||
timeType: string;
|
||||
time: string;
|
||||
platformNum: string | null;
|
||||
};
|
||||
export const ListView: FC<{
|
||||
data: hoge[];
|
||||
|
||||
@@ -29,6 +29,7 @@ export const ListViewItem: FC<{
|
||||
name: string;
|
||||
timeType: string;
|
||||
time: string;
|
||||
platformNum: string | null;
|
||||
};
|
||||
showVehicle?: boolean;
|
||||
showAppSource?: boolean;
|
||||
@@ -385,6 +386,20 @@ export const ListViewItem: FC<{
|
||||
>
|
||||
{trainName}
|
||||
</Text>
|
||||
{d.platformNum ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: isCancelled ? "gray" : colors.text,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 1,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 11, color: colors.diagramBackground, fontWeight: "bold" }}>
|
||||
{d.platformNum}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -91,6 +91,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
|
||||
name: string;
|
||||
timeType: string;
|
||||
time: string;
|
||||
platformNum: string | null;
|
||||
}[];
|
||||
const [showTypeFiltering, setShowTypeFiltering] = useState(false);
|
||||
const [showLastStop, setShowLastStop] = useState(false);
|
||||
@@ -160,7 +161,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
|
||||
return station === stationName;
|
||||
})
|
||||
.forEach((x) => {
|
||||
const [name, timeType, time] = x.split(",");
|
||||
const [name, timeType, time, platformNum] = x.split(",");
|
||||
if (!name || !timeType || !time) return;
|
||||
|
||||
const { type } = customTrainDataDetector(d, allCustomTrainData);
|
||||
@@ -170,6 +171,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
|
||||
name,
|
||||
timeType,
|
||||
time,
|
||||
platformNum: platformNum ?? null,
|
||||
};
|
||||
// //条件によってフィルタリング
|
||||
if (!threw && timeType && timeType.includes("通")) return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { FC } from "react";
|
||||
import { Image, StyleSheet, View } from "react-native";
|
||||
import { Image, StyleSheet, View, Platform } from "react-native";
|
||||
import { Marker } from "react-native-maps";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useStationList } from "@/stateBox/useStationList";
|
||||
|
||||
@@ -21,7 +21,8 @@ export const SimpleSwitch = ({
|
||||
<View style={{ flex: 1 }}>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
backgroundColor: bool == value.toString() ? color : null,
|
||||
backgroundColor:
|
||||
bool === value || String(bool) === String(value) ? color : null,
|
||||
padding: 5,
|
||||
borderRadius: 5,
|
||||
margin: 10,
|
||||
@@ -32,7 +33,7 @@ export const SimpleSwitch = ({
|
||||
}}
|
||||
onPress={() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
setBool(value.toString());
|
||||
setBool(value);
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
|
||||
@@ -66,7 +66,7 @@ export const SwitchArea = ({
|
||||
subText={trueText}
|
||||
/>
|
||||
</View>
|
||||
{bool == "true" && children}
|
||||
{(bool === true || String(bool) === "true") && children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,11 +5,13 @@ export const TripleSwitchArea = ({
|
||||
str,
|
||||
bool,
|
||||
setBool,
|
||||
firstItem: { firstImage, firstText, firstValue },
|
||||
secondItem: { secondImage, secondText, secondValue },
|
||||
thirdItem: { thirdImage, thirdText, thirdValue },
|
||||
firstItem,
|
||||
secondItem,
|
||||
thirdItem,
|
||||
}) => {
|
||||
const { colors } = useThemeColors();
|
||||
const { firstImage, firstText, firstValue } = firstItem;
|
||||
const { secondImage, secondText, secondValue } = secondItem;
|
||||
return (
|
||||
<View style={{ flexDirection: "column", padding: 10 }}>
|
||||
<Text
|
||||
@@ -41,14 +43,16 @@ export const TripleSwitchArea = ({
|
||||
image={secondImage}
|
||||
subText={secondText}
|
||||
/>
|
||||
<SimpleSwitch
|
||||
bool={bool}
|
||||
setBool={setBool}
|
||||
color="red"
|
||||
value={thirdValue}
|
||||
image={thirdImage}
|
||||
subText={thirdText}
|
||||
/>
|
||||
{thirdItem ? (
|
||||
<SimpleSwitch
|
||||
bool={bool}
|
||||
setBool={setBool}
|
||||
color="red"
|
||||
value={thirdItem.thirdValue}
|
||||
image={thirdItem.thirdImage}
|
||||
subText={thirdItem.thirdText}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef, useState, useEffect, useLayoutEffect } from "react";
|
||||
import { View, Text, TouchableOpacity, Linking } from "react-native";
|
||||
import MapView from "react-native-maps";
|
||||
import { View, Text, TouchableOpacity, Linking, Platform } from "react-native";
|
||||
import MapView, { Marker } from "react-native-maps";
|
||||
import { useCurrentTrain } from "../stateBox/useCurrentTrain";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import lineColorList from "../assets/originData/lineColorList";
|
||||
@@ -12,6 +12,10 @@ import { UsefulBox } from "./TrainMenu/UsefulBox";
|
||||
import { MapsButton } from "./TrainMenu/MapsButton";
|
||||
import { useStationList } from "@/stateBox/useStationList";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { useUserPosition } from "@/stateBox/useUserPosition";
|
||||
const ANDROID_USER_LOCATION_MARKER_SIZE = 18;
|
||||
const ANDROID_USER_LOCATION_INNER_SIZE = 8;
|
||||
|
||||
export default function TrainMenu({ style }) {
|
||||
const { fixed, isDark } = useThemeColors();
|
||||
const { webview } = useCurrentTrain();
|
||||
@@ -24,6 +28,7 @@ export default function TrainMenu({ style }) {
|
||||
mapsStationData: stationData,
|
||||
} = useTrainMenu();
|
||||
const { originalStationList } = useStationList();
|
||||
const { position } = useUserPosition();
|
||||
useEffect(() => {
|
||||
const stationPinData = [];
|
||||
Object.keys(lineList_LineWebID).forEach((d, indexBase) => {
|
||||
@@ -49,7 +54,7 @@ export default function TrainMenu({ style }) {
|
||||
<View style={{ height: "100%", backgroundColor: fixed.primary, ...style }}>
|
||||
<MapView
|
||||
style={{ flex: 1, width: "100%", height: "100%" }}
|
||||
showsUserLocation={true}
|
||||
showsUserLocation={Platform.OS !== "android"}
|
||||
loadingEnabled={true}
|
||||
showsMyLocationButton={false}
|
||||
moveOnMarkerPress={false}
|
||||
@@ -64,6 +69,42 @@ export default function TrainMenu({ style }) {
|
||||
longitudeDelta: 1.8,
|
||||
}}
|
||||
>
|
||||
{Platform.OS === "android" && position ? (
|
||||
<Marker
|
||||
key="android-user-location"
|
||||
coordinate={{
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
}}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
tracksViewChanges={false}
|
||||
zIndex={999}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: ANDROID_USER_LOCATION_MARKER_SIZE,
|
||||
height: ANDROID_USER_LOCATION_MARKER_SIZE,
|
||||
borderRadius: ANDROID_USER_LOCATION_MARKER_SIZE / 2,
|
||||
backgroundColor: "rgba(10, 132, 255, 0.24)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(10, 132, 255, 0.42)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: ANDROID_USER_LOCATION_INNER_SIZE,
|
||||
height: ANDROID_USER_LOCATION_INNER_SIZE,
|
||||
borderRadius: ANDROID_USER_LOCATION_INNER_SIZE / 2,
|
||||
backgroundColor: "#0A84FF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#ffffff",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</Marker>
|
||||
) : null}
|
||||
{stationPin.map(({ D, d, latlng, indexBase, index }) => (
|
||||
<MapPin
|
||||
index={index}
|
||||
|
||||
@@ -3,11 +3,8 @@ import { Text, View, LayoutChangeEvent } from "react-native";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withDelay,
|
||||
cancelAnimation,
|
||||
useFrameCallback,
|
||||
} from "react-native-reanimated";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
|
||||
@@ -18,40 +15,65 @@ type Props = {
|
||||
export const ScrollingDescription: FC<Props> = ({ description }) => {
|
||||
const { fixed } = useThemeColors();
|
||||
const scrollX = useSharedValue(0);
|
||||
const isRunning = useSharedValue(false);
|
||||
const contentWidth = useSharedValue(0);
|
||||
const viewportWidth = useSharedValue(0);
|
||||
const [textWidth, setTextWidth] = useState(0);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const gap = 20;
|
||||
const pixelsPerSecond = 84;
|
||||
|
||||
// 改行を削除して1行にする
|
||||
const singleLineDescription = description?.replace(/\n/g, " ") || "";
|
||||
|
||||
useEffect(() => {
|
||||
cancelAnimation(scrollX);
|
||||
contentWidth.value = textWidth;
|
||||
viewportWidth.value = containerWidth;
|
||||
|
||||
if (!singleLineDescription || textWidth === 0 || containerWidth === 0) {
|
||||
isRunning.value = false;
|
||||
scrollX.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// テキストが画面幅より短い場合はスクロールしない
|
||||
if (textWidth <= containerWidth) {
|
||||
isRunning.value = false;
|
||||
scrollX.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const distance = textWidth + containerWidth;
|
||||
const duration = distance * 6; // スクロール速度
|
||||
|
||||
// 初期位置を設定(画面の右端から開始)
|
||||
scrollX.value = containerWidth;
|
||||
|
||||
scrollX.value = withRepeat(
|
||||
withSequence(
|
||||
withDelay(500, withTiming(-textWidth - 20, { duration })),
|
||||
withDelay(500, withTiming(containerWidth, { duration: 0 }))
|
||||
),
|
||||
-1
|
||||
);
|
||||
isRunning.value = true;
|
||||
|
||||
return () => {
|
||||
isRunning.value = false;
|
||||
cancelAnimation(scrollX);
|
||||
};
|
||||
}, [singleLineDescription, textWidth, containerWidth]);
|
||||
}, [
|
||||
singleLineDescription,
|
||||
textWidth,
|
||||
containerWidth,
|
||||
scrollX,
|
||||
isRunning,
|
||||
contentWidth,
|
||||
viewportWidth,
|
||||
]);
|
||||
|
||||
useFrameCallback((frameInfo) => {
|
||||
"worklet";
|
||||
|
||||
if (!isRunning.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedMs = frameInfo.timeSincePreviousFrame ?? 16.67;
|
||||
const nextX = scrollX.value - (pixelsPerSecond * elapsedMs) / 1000;
|
||||
const resetPoint = -contentWidth.value - gap;
|
||||
|
||||
scrollX.value = nextX <= resetPoint ? viewportWidth.value : nextX;
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: scrollX.value }],
|
||||
@@ -60,14 +82,20 @@ export const ScrollingDescription: FC<Props> = ({ description }) => {
|
||||
const handleTextLayout = (event: LayoutChangeEvent) => {
|
||||
const { width } = event.nativeEvent.layout;
|
||||
if (width > 0) {
|
||||
setTextWidth(Math.round(width));
|
||||
const nextWidth = Math.round(width);
|
||||
setTextWidth((currentWidth) =>
|
||||
currentWidth === nextWidth ? currentWidth : nextWidth
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerLayout = (event: LayoutChangeEvent) => {
|
||||
const { width } = event.nativeEvent.layout;
|
||||
if (width > 0) {
|
||||
setContainerWidth(Math.round(width));
|
||||
const nextWidth = Math.round(width);
|
||||
setContainerWidth((currentWidth) =>
|
||||
currentWidth === nextWidth ? currentWidth : nextWidth
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,15 @@ import { getTime, trainTimeFiltering } from "@/lib/trainTimeFiltering";
|
||||
import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { stackAwareNavigate } from "@/lib/rootNavigation";
|
||||
|
||||
const readBooleanSetting = async (key: string) => {
|
||||
try {
|
||||
return (await AS.getItem(key)) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -49,7 +58,7 @@ type props = {
|
||||
export const LED_vision: FC<props> = (props) => {
|
||||
const { station } = props;
|
||||
|
||||
const { navigate, addListener, isFocused } = useNavigation();
|
||||
const { navigate } = useNavigation();
|
||||
const { currentTrain } = useCurrentTrain();
|
||||
const [stationDiagram, setStationDiagram] = useState<{
|
||||
[key: string]: string;
|
||||
@@ -63,14 +72,14 @@ export const LED_vision: FC<props> = (props) => {
|
||||
const { fixed } = useThemeColors();
|
||||
|
||||
useEffect(() => {
|
||||
AS.getItem("LEDSettings/trainIDSwitch").then((data) => {
|
||||
setTrainIDSwitch(data === "true");
|
||||
});
|
||||
AS.getItem("LEDSettings/trainDescriptionSwitch").then((data) => {
|
||||
setTrainDescriptionSwitch(data === "true");
|
||||
});
|
||||
AS.getItem("LEDSettings/finalSwitch").then((data) => {
|
||||
setFinalSwitch(data === "true");
|
||||
void Promise.all([
|
||||
readBooleanSetting("LEDSettings/trainIDSwitch"),
|
||||
readBooleanSetting("LEDSettings/trainDescriptionSwitch"),
|
||||
readBooleanSetting("LEDSettings/finalSwitch"),
|
||||
]).then(([nextTrainIdSwitch, nextTrainDescriptionSwitch, nextFinalSwitch]) => {
|
||||
setTrainIDSwitch(nextTrainIdSwitch);
|
||||
setTrainDescriptionSwitch(nextTrainDescriptionSwitch);
|
||||
setFinalSwitch(nextFinalSwitch);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -163,7 +172,7 @@ export const LED_vision: FC<props> = (props) => {
|
||||
<AreaDescription
|
||||
numberOfLines={1}
|
||||
areaInfo={areaInfo}
|
||||
onClick={() => alert(areaInfo)}
|
||||
onClick={() => stackAwareNavigate("information")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
+19
-1
@@ -91,7 +91,7 @@ export const STORAGE_KEYS = {
|
||||
/** えれサイト使用設定 */
|
||||
USE_ELESITE: 'useElesite',
|
||||
|
||||
/** 投稿システム接続先(デバッグ用) */
|
||||
/** 投稿システム接続先 */
|
||||
JR_DATA_SYSTEM_ENV: 'jrDataSystemEnv',
|
||||
|
||||
/** えれサイトデータ */
|
||||
@@ -111,6 +111,24 @@ export const STORAGE_KEYS = {
|
||||
|
||||
/** カラーテーマ設定 ("light" | "system" | "dark") */
|
||||
COLOR_THEME: 'colorTheme',
|
||||
|
||||
/** 運行情報横倒し機能の有効化スイッチ(β) */
|
||||
OPERATION_INFO_LANDSCAPE_ENABLED: 'operationInfoLandscapeEnabled',
|
||||
|
||||
/** 運行情報スクリーンショット切り出し機能の有効化スイッチ(β) */
|
||||
OPERATION_INFO_CAPTURE_ENABLED: 'operationInfoCaptureEnabled',
|
||||
|
||||
/** モックAPI検証機能の有効化スイッチ(admin専用) */
|
||||
MOCK_API_FEATURE_ENABLED: 'mockApiFeatureEnabled',
|
||||
|
||||
/** 走行位置録画インデックス(admin専用 / 複数録画のメタ情報一覧) */
|
||||
MOCK_RECORDINGS_INDEX: 'mockRecordingsIndex',
|
||||
|
||||
/** 走行位置録画データプレフィックス(admin専用 / + id でキーを構成) */
|
||||
MOCK_RECORDING_DATA_PREFIX: 'mockRecordingData_',
|
||||
|
||||
/** 走行位置録画データ(旧フォーマット / マイグレーション用) */
|
||||
MOCK_RECORDING: 'mockRecording',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# 064d81d4 -> a959cf39 更新ログ(機能説明フォーカス版)
|
||||
|
||||
## この差分で何が良くなったか
|
||||
この更新では、大きな新機能追加よりも、既存機能を日常利用で安定して使えるようにする改善が中心です。
|
||||
|
||||
特に「外部連携の扱いやすさ」「画面遷移の安定性」「キーボード/アニメーション時の操作感」に効く変更がまとまって入っています。
|
||||
|
||||
---
|
||||
|
||||
## 1. 運用Hub(elesite)連携の運用性向上
|
||||
### 実装/更新された機能
|
||||
- elesite連携の権限ハンドリングを簡素化し、設定分岐を整理。
|
||||
- 列車ペアマッピングと貨物列車の車番処理を改善。
|
||||
- elesiteロゴ追加など、情報ソース表示の視認性を改善。
|
||||
- 投稿システム接続先を切り替えて検証できるデバッグ導線を追加。
|
||||
|
||||
### ユーザーにとっての効果
|
||||
- 情報ソース連携時の挙動が安定し、設定後の迷いが減ります。
|
||||
- 表示データの整合性が上がり、参照時の信頼感が向上します。
|
||||
|
||||
## 2. ナビゲーション基盤の強化
|
||||
### 実装/更新された機能
|
||||
- stackAwareNavigate導入で、遷移時のスタック管理を改善。
|
||||
- ルート状態監視(onStateChange)を見直し、アクティブルート判定を精度向上。
|
||||
- StatusBar表示制御をフォーカス状態ベースに改善。
|
||||
- WebView内ナビゲーションの扱いを調整。
|
||||
|
||||
### ユーザーにとっての効果
|
||||
- 画面遷移での予期しない戻り/重複遷移が起きにくくなります。
|
||||
- 画面表示の一貫性が上がり、操作中の違和感が減ります。
|
||||
|
||||
## 3. キーボード回避とActionSheet操作感の改善
|
||||
### 実装/更新された機能
|
||||
- キーボード回避を Animated.timing / spring ベースに移行。
|
||||
- SearchUnitBoxでanimatedOffsetを使った追従へ改善。
|
||||
- EachTrainInfo ActionSheetのスプリング破綻を修正。
|
||||
- 調整内容を技術ドキュメントとして記録。
|
||||
|
||||
### ユーザーにとっての効果
|
||||
- 連続操作時でもUIの位置ずれが起きにくくなります。
|
||||
- シート表示や検索入力時の「引っかかり」が減り、操作が滑らかになります。
|
||||
|
||||
## 4. 列車情報画面の表示品質改善
|
||||
### 実装/更新された機能
|
||||
- HeaderTextのtodayOperation判定を改善し、不要な表示混入を抑制。
|
||||
- TrainDataViewの長押し条件を実データ基準へ調整。
|
||||
- ExGridViewの不要ズーム設定を除去。
|
||||
- ListViewItemの循環アニメーションを改善。
|
||||
|
||||
### ユーザーにとっての効果
|
||||
- 列車情報の表示が実態に近づき、誤認しにくくなります。
|
||||
- 長押しや一覧切替の体験が自然になり、ストレスが減ります。
|
||||
|
||||
## 5. 設定・内部運用の整備
|
||||
### 実装/更新された機能
|
||||
- 内部バージョン表記を7.0.2へ更新。
|
||||
- 情報ソース設定まわりを拡張し、運用切替時の扱いを改善。
|
||||
- タブバーアニメーション/キーボード非表示設定の整理を実施。
|
||||
|
||||
### ユーザーにとっての効果
|
||||
- 設定変更後の挙動がより予測しやすくなります。
|
||||
- 端末や利用状況の違いによる操作ブレが軽減されます。
|
||||
|
||||
## 6. ドキュメント整備(将来拡張と保守性)
|
||||
### 実装/更新された機能
|
||||
- チュートリアル機能の設計案を追加。
|
||||
- プライバシーポリシー関連ドキュメントを整備。
|
||||
- キーボード/ActionSheet調整の修正履歴を記録。
|
||||
|
||||
### ユーザーにとっての効果
|
||||
- 直接見える機能ではありませんが、今後の改善や不具合修正が速くなり、結果として体験品質の向上につながります。
|
||||
|
||||
---
|
||||
|
||||
## まとめ
|
||||
この更新は、外部連携・画面遷移・入力時アニメーションなど、日常操作で「気になりやすい部分」を重点的に磨いた差分です。
|
||||
|
||||
新機能を増やすよりも、既存機能を安定して快適に使えるようにすることに重きを置いた内容になっています。
|
||||
|
||||
## 参照
|
||||
- コミット差分精査版: docs/changelog-064d81d-to-a959cf3.md
|
||||
@@ -0,0 +1,125 @@
|
||||
# コミット差分ログ(064d81d4 -> a959cf39)
|
||||
|
||||
## 対象範囲
|
||||
- From: `064d81d468f21db4318a55f8fad4c432745d2441`
|
||||
- To: `a959cf3973eb9635c9e61133d81de5c1f9719c18`
|
||||
- 集計メモ:
|
||||
- レンジ内コミット(merge含む): 31
|
||||
- 非mergeコミット: 22
|
||||
- 変更ファイル数: 44
|
||||
- 変更行数: +1566 / -305
|
||||
|
||||
## サマリー
|
||||
このレンジでは、運用Hub(elesite/投稿系)連携の実運用改善、キーボード回避とActionSheetのアニメーション安定化、ナビゲーションのスタック制御改善、設定画面とWebView周辺の挙動調整が中心に進められました。
|
||||
|
||||
機能追加は大規模というより、既存機能の「誤動作しにくさ」「切替時の安定性」「運用しやすさ」を高める改善が多い構成です。
|
||||
|
||||
---
|
||||
|
||||
## 1. 運用Hub/外部連携(elesite・投稿系)の改善
|
||||
- elesite連携の権限ハンドリングを簡素化し、設定/運用時の分岐を整理。
|
||||
- 列車ペアマッピングや貨物列車の車番処理を改善し、データ整合性を向上。
|
||||
- elesiteロゴ資産を追加し、情報ソース表示の視認性を改善。
|
||||
- 投稿システム接続先のデバッグ機能を追加し、環境切替の検証性を向上。
|
||||
|
||||
主なコミット:
|
||||
- `fac89f6` fix: simplify elesite permission handling and update version code to 7.0.2
|
||||
- `94eb84b` fix: update train pair mapping in BusAndTrainDataProvider
|
||||
- `ad5357c` 運用Hub情報の取得ロジックを改善し、貨物列車の車番処理を追加
|
||||
- `1b2ba08` feat: 投稿システム接続先のデバッグ機能を追加
|
||||
- `ff90841` Add new logo image for Elesite to relationLogo assets
|
||||
|
||||
## 2. 画面遷移・ナビゲーション基盤の安定化
|
||||
- `stackAwareNavigate` を導入し、遷移時のスタック管理を強化。
|
||||
- App状態遷移時のアクティブルート判定を改善し、ルーティング誤判定を抑制。
|
||||
- StatusBar表示ロジックを見直し、フォーカス状態を踏まえた表示制御に更新。
|
||||
- WebViewナビゲーション挙動を調整し、画面内遷移の扱いを改善。
|
||||
|
||||
主なコミット:
|
||||
- `5914646` stackAwareNavigate関数を導入し、遷移時のナビゲーションロジックを改善
|
||||
- `a54ef7c` スタック管理強化 + 設計メモ追加
|
||||
- `6b46c71` AppContainerのonStateChangeロジックを改善
|
||||
- `9e2abc9` StatusBarの表示ロジックを改善
|
||||
- `3ecb301` fix: update WebView navigation
|
||||
|
||||
## 3. キーボード回避・ActionSheetアニメーション改善
|
||||
- キーボード回避を `Animated.timing/spring` ベースへ移行し、切替時の位置ずれを緩和。
|
||||
- SearchUnitBoxで `animatedOffset` を利用したスムーズな追従に変更。
|
||||
- ActionSheet(EachTrainInfo)で発生していたスプリング破綻を修正。
|
||||
- 調整内容をドキュメント化し、再発時のトラブルシュート性を向上。
|
||||
|
||||
主なコミット:
|
||||
- `4017f82` fix: キーボード回避をAnimated.timing/springに移行
|
||||
- `36be780` fix(SearchUnitBox): use animatedOffset with Animated.View
|
||||
- `8b42644` fix: EachTrainInfo ActionSheetのスプリングアニメーション破綻を修正
|
||||
- `b87c6f8` docs: キーボードアニメーション調整に関するドキュメントを追加
|
||||
- `4809426` docs: ActionSheetアニメーション破綻の修正記録を追加
|
||||
|
||||
## 4. 列車情報表示・操作の品質改善
|
||||
- HeaderTextの `todayOperation` 判定を調整し、完了済み運用の混入を抑制。
|
||||
- TrainDataViewのロングプレス判定条件を `onLine` 依存から実データ依存へ改善。
|
||||
- ExGridViewの不要ズームスケール設定を除去し、スクロール挙動の安定性を向上。
|
||||
- ListViewItemの循環アニメーションを改善し、表示切替の自然さを向上。
|
||||
|
||||
主なコミット:
|
||||
- `5a1430d` fix(HeaderText): filter out completed operations
|
||||
- `07399f4` fix(HeaderText) + fix(TrainIconStatus)
|
||||
- `76a617c` fix(TrainDataView): onLongPress condition update
|
||||
- `a09ba45` fix(ExGridView): remove zoom scale properties
|
||||
- `94eb84b` fix: enhance ListViewItem cycling animation
|
||||
|
||||
## 5. 設定・内部運用の整備
|
||||
- 設定画面側の内部バージョン更新(7.0.2)を反映。
|
||||
- データソース設定画面を拡張し、運用切替時の扱いを改善。
|
||||
- タブバーアニメーション/キーボード関連の設定を整理し、UI挙動を安定化。
|
||||
|
||||
主なコミット:
|
||||
- `fac89f6` fix: update version code to 7.0.2
|
||||
- `374901c` fix: タブバーアニメーションとキーボード非表示設定を削除
|
||||
|
||||
## 6. ドキュメント拡充(運用・設計)
|
||||
- チュートリアル機能の設計案を追加。
|
||||
- プライバシーポリシー文書(本番/将来案)を追加。
|
||||
- アニメーション調整や修正履歴の技術ドキュメントを追加。
|
||||
|
||||
追加ドキュメント:
|
||||
- `docs/tutorial-feature-plan.md`
|
||||
- `docs/privacy-policy.md`
|
||||
- `docs/privacy-policy-future.md`
|
||||
- `docs/actionsheet-animation-fix.md`
|
||||
- `docs/keyboard-animation-tuning-2026-04-08.md`
|
||||
- `docs/keyboard-animation-tuning-2026-04-09.md`
|
||||
|
||||
---
|
||||
|
||||
## 変更リスクと確認ポイント
|
||||
- 画面遷移系:
|
||||
- stackAwareNavigate導入に伴い、既存deep link/戻る挙動との整合確認が必要。
|
||||
- キーボード/アニメーション系:
|
||||
- 端末ごとのキーボード表示速度差で再ズレが起きないか継続確認が必要。
|
||||
- 外部連携系:
|
||||
- elesite権限状態の境界ケース(未許可/再許可/切替直後)で表示崩れがないか要確認。
|
||||
|
||||
## 付録: 非mergeコミット一覧(22件)
|
||||
- `fac89f6` fix: simplify elesite permission handling and update version code to 7.0.2
|
||||
- `94eb84b` fix: enhance ListViewItem cycling animation and update train pair mapping in BusAndTrainDataProvider
|
||||
- `3ecb301` fix: update WebView navigation and adjust interval timing in CurrentTrainProvider
|
||||
- `5a1430d` fix(HeaderText): update todayOperation prop to filter out completed operations
|
||||
- `ff90841` Add new logo image for Elesite to relationLogo assets
|
||||
- `a09ba45` fix(ExGridView): remove zoom scale properties from Animated.ScrollView
|
||||
- `76a617c` fix(TrainDataView): update onLongPress condition to check currentTrainData instead of onLine
|
||||
- `07399f4` fix(HeaderText): update todayOperation to use allTodayOperation for accurate state filtering
|
||||
- `1b2ba08` feat: 投稿システム接続先のデバッグ機能を追加し、環境設定を管理できるようにした
|
||||
- `374901c` fix: タブバーのアニメーションとキーボード非表示設定を削除
|
||||
- `36be780` fix(SearchUnitBox): use animatedOffset with Animated.View for smooth keyboard avoidance
|
||||
- `b87c6f8` docs: キーボードアニメーション調整に関するドキュメントを追加
|
||||
- `4017f82` fix: キーボード回避をAnimated.timing/springに移行し、高速切替時の位置ずれとアニメーション不動を解消
|
||||
- `58ce5fa` feat: チュートリアル機能の設計案を追加し、ユーザーの初回体験を改善
|
||||
- `4809426` docs: ActionSheetアニメーション破綻の修正記録を追加
|
||||
- `8b42644` fix: EachTrainInfo ActionSheetのスプリングアニメーション破綻を修正
|
||||
- `5914646` stackAwareNavigate関数を導入し、遷移時のナビゲーションロジックを改善
|
||||
- `a54ef7c` ナビゲーションロジックを改善し、stackAwareNavigate関数を導入して遷移時のスタック管理を強化。プライバシーポリシーと設計メモを追加。
|
||||
- `6b46c71` AppContainerのonStateChangeロジックを改善し、アクティブなルートの状態を正確にチェックするように修正
|
||||
- `9e2abc9` StatusBarの表示ロジックを改善し、Appsコンポーネントにフォーカス状態を追加
|
||||
- `ad5357c` 運用Hub情報の取得ロジックを改善し、貨物列車の車番処理を追加
|
||||
- `045ed21` 噂機能のスタイル強化
|
||||
@@ -0,0 +1,82 @@
|
||||
# a959cf39 -> 0b349148 更新ログ(機能説明フォーカス版)
|
||||
|
||||
## この差分で何が良くなったか
|
||||
この更新では、列車位置を「その場で見る」だけでなく、「記録してあとから再生する」ための機能が大きく強化されました。
|
||||
|
||||
同時に、mockデータを使った検証導線やWebView側の安定性も改善され、実データが不安定な状況でも挙動確認や表示調整を進めやすくなっています。
|
||||
|
||||
---
|
||||
|
||||
## 1. 列車位置の記録・再生機能を追加
|
||||
### 実装/更新された機能
|
||||
- 列車位置の記録・再生機能を追加。
|
||||
- 再生タイムラインUIを実装し、再生・一時停止・シーク操作に対応。
|
||||
- 経過時間を表示するステータスバーを追加。
|
||||
- 再生中はkeep-awakeで画面が落ちにくいよう調整。
|
||||
- 再生開始時クラッシュや複数録画時の扱いを修正。
|
||||
- 録画一覧の保存・再生・削除を設定画面から扱えるようにした。
|
||||
- 再生は記録時の時間差を反映して自動進行し、ループ末尾では待機を入れるようにした。
|
||||
|
||||
### ユーザーにとっての効果
|
||||
- 運行状況の変化をあとから振り返りやすくなります。
|
||||
- ある時点の列車位置を止めて確認できるため、検証や比較がしやすくなります。
|
||||
- 以前の録画形式が残っていても、継続して参照しやすくなります。
|
||||
|
||||
## 2. mock列車位置データでの検証がしやすくなった
|
||||
### 実装/更新された機能
|
||||
- WebViewへmock列車位置データを注入する仕組みを追加。
|
||||
- 設定画面に管理者向けのmock API切替を追加。
|
||||
- mock切替時にWebViewを再読み込みするよう改善。
|
||||
- app側の列車位置表示にもmockデータを反映するよう調整。
|
||||
- XHRインターセプタの初期化タイミングと互換性を改善。
|
||||
- 録画開始時はmockを自動でOFFにし、再生開始時は自動でONに切り替えるようにした。
|
||||
- WebView側では二重注入を避ける保護を入れ、mock適用の安定性を改善。
|
||||
|
||||
### ユーザーにとっての効果
|
||||
- 実データに依存せず、特定状況の表示確認や再現テストを行いやすくなります。
|
||||
- 検証時に「一部だけ反映されない」状態が起きにくくなります。
|
||||
|
||||
## 3. WebViewの読み込み失敗時の扱いを改善
|
||||
### 実装/更新された機能
|
||||
- ローディング表示とエラー表示を追加。
|
||||
- 再読み込み導線を追加。
|
||||
- アプリのバックグラウンド復帰時のリマウント制御を調整。
|
||||
- 注入JavaScript側のヘッダー配色を見直し、見やすさを改善。
|
||||
|
||||
### ユーザーにとっての効果
|
||||
- 読み込み失敗時に状況が分かりやすくなり、復旧操作もしやすくなります。
|
||||
- 復帰直後の表示崩れや古い状態の残留が起きにくくなります。
|
||||
|
||||
## 4. 列車情報の表示品質を改善
|
||||
### 実装/更新された機能
|
||||
- 編成情報表示用に、静的表示・フェード切替・アクティブ強調のチップUIを追加。
|
||||
- 列車番号の空白、メモ接尾辞、貨物列車記号を考慮した正規化処理を導入。
|
||||
- 番線表示用の `platformNum` をデータ構造へ追加。
|
||||
- 表示文字列やスタイル適用のタイミングを見直し、ちらつきや欠落を修正。
|
||||
|
||||
### ユーザーにとっての効果
|
||||
- 列車情報の見分けがしやすくなります。
|
||||
- データ照合が安定し、表示漏れや誤一致が起きにくくなります。
|
||||
|
||||
## 5. 設定・内部運用の整備
|
||||
### 実装/更新された機能
|
||||
- 内部バージョン表記を7.0.3へ更新。
|
||||
- 前バージョン差分ログをドキュメント化。
|
||||
- 検証用の列車データを更新。
|
||||
- 情報ソース設定画面を拡張し、外部データ連携の説明、録画管理、mock検証、投稿システム接続先の切替を一か所にまとめた。
|
||||
- 投稿システム接続先は、本番 / 実験 と リリース / ベータの切替を整理した。
|
||||
|
||||
### ユーザーにとっての効果
|
||||
- バージョン識別と運用確認がしやすくなります。
|
||||
- 今後の検証や保守時に、変更履歴を追いやすくなります。
|
||||
- 検証設定や接続先の切替が分かりやすくなり、試験時の操作ミスを減らしやすくなります。
|
||||
|
||||
---
|
||||
|
||||
## まとめ
|
||||
この更新は、列車位置を記録・再生して振り返るための新しい土台を追加しつつ、mock検証とWebView表示の安定性を同時に底上げした差分です。
|
||||
|
||||
日常利用の見た目改善だけでなく、再現確認や運用検証のしやすさまで含めて、一段階機能が広がった更新になっています。
|
||||
|
||||
## 参照
|
||||
- コミット差分精査版: docs/changelog-7.0.2-to-7.0.3.md
|
||||
@@ -0,0 +1,142 @@
|
||||
# コミット差分ログ(a959cf39 -> 0b349148)
|
||||
|
||||
## 対象範囲
|
||||
- From: `a959cf3973eb9635c9e61133d81de5c1f9719c18`
|
||||
- To: `0b349148d37802b6e86a09b7f34b718d622e92ca`
|
||||
- 集計メモ:
|
||||
- レンジ内コミット(merge含む): 29
|
||||
- 非mergeコミット: 27
|
||||
- 変更ファイル数: 50
|
||||
- 変更行数: +10785 / -185
|
||||
|
||||
## サマリー
|
||||
このレンジでは、列車位置の記録・再生機能の追加が中心です。
|
||||
|
||||
それを支える形で、mock列車位置データをWebViewへ注入するための検証基盤が大きく拡張され、あわせてWebViewの読み込み安定性、列車番号マッチング精度、表示コンポーネントの見やすさが改善されています。
|
||||
|
||||
前レンジが既存機能の安定化中心だったのに対して、このレンジは「記録して再生する」「mockで再現する」という検証・観測機能が大きく前進したのが特徴です。実装レベルでは、設定画面に録画管理や接続先切替UIが追加され、旧録画形式からの移行処理も入っています。
|
||||
|
||||
---
|
||||
|
||||
## 1. 列車位置の記録・再生機能を追加
|
||||
- 列車位置の記録と再生機能を新規追加。
|
||||
- 再生タイムラインUIを実装し、再生・一時停止・シーク操作に対応。
|
||||
- 経過時間表示とkeep-awakeを備えた `RecordingStatusBar` を追加。
|
||||
- 再生開始時クラッシュの修正と、複数録画データの取り扱いに対応。
|
||||
- 再生フレーム変更時にWebView上の列車表示が追従するよう改善。
|
||||
- 録画一覧の保存・削除・再生開始を設定画面から操作できるようにした。
|
||||
- 再生ループはスナップショット間の経過時間差を反映しつつ、通常時は最小3秒、ループ終端では15秒待機する制御を実装。
|
||||
- 旧単一録画フォーマットから複数録画フォーマットへの自動マイグレーション処理を追加。
|
||||
|
||||
主なコミット:
|
||||
- `37c08ad` feat: add train position record & playback feature
|
||||
- `8144e8a` feat: add playback timeline UI with pause/resume/seek controls
|
||||
- `3587f72` feat: add RecordingStatusBar with elapsed timer and keep-awake
|
||||
- `4f4d3ca` fix: crash on playback start + support multiple recordings
|
||||
- `d71cc37` fix: sync WebView train display when playback frame changes
|
||||
|
||||
## 2. mock列車位置データの注入基盤を強化
|
||||
- WebViewに対するXHRインターセプタを導入し、mock列車位置データの差し替えに対応。
|
||||
- 設定画面に管理者向けmock API切替を追加し、検証導線を整備。
|
||||
- 地図画面側の個別MOCKスイッチを整理し、設定側トグルに集約。
|
||||
- mock切替時にWebViewを再読み込みするよう改善し、反映漏れを抑制。
|
||||
- インターセプタ初期化タイミングを `injectedJavaScriptBeforeContentLoaded` へ移し、XHRフックの安定性を改善。
|
||||
- `setRequestHeader` と競合しないようXhr open処理を修正し、既存通信との互換性を改善。
|
||||
- app側の `currentTrain` にもmock列車位置を適用し、WebView外の表示も揃うよう調整。
|
||||
- 録画開始時はmockを自動OFF、録画再生時は自動ONに切り替える制御を追加。
|
||||
- XHRインターセプタに二重注入ガードを追加し、多重パッチによる不安定化を防止。
|
||||
|
||||
主なコミット:
|
||||
- `170fbf0` feat: add WebView XHR interceptor for mock train position injection
|
||||
- `71e1ad8` feat: add admin mock API toggle in settings and map screen switch
|
||||
- `4247318` fix: reload WebView on mock toggle and fix XHR callback timing bug
|
||||
- `59821a4` fix: rewrite XHR interceptor using prototype patching
|
||||
- `8321a47` fix: move XHR interceptor to injectedJavaScriptBeforeContentLoaded
|
||||
- `24f0c82` fix: call _origOpen even when intercepting to allow setRequestHeader
|
||||
- `a359568` refactor: remove map-screen MOCK switch; settings toggle controls mock directly
|
||||
- `92f4b37` feat: apply mock data to currentTrain (app-side train positions)
|
||||
|
||||
## 3. WebViewまわりの安定性と操作性を改善
|
||||
- `GeneralWebView` にローディング表示、エラー表示、再読み込み導線を追加。
|
||||
- `setReload` をグローバルへ公開し、外部からの再読み込み制御を改善。
|
||||
- `useWebViewRemount` に `backgroundThresholdMs` オプションを追加し、アプリ状態遷移時のリマウント制御を改善。
|
||||
- 注入JavaScript側のヘッダー色を見直し、視認性を改善。
|
||||
|
||||
主なコミット:
|
||||
- `4c1a315` fix: add loading and error handling in GeneralWebView component with reload functionality
|
||||
- `a8c785b` fix: expose setReload function to the global window object for accessibility
|
||||
- `b5eb830` fix: update useWebViewRemount to include backgroundThresholdMs option for better app state handling
|
||||
- `3554233` fix: update header colors in injected JavaScript for better visibility
|
||||
|
||||
## 4. 列車データの表示品質とマッチング精度を改善
|
||||
- 編成・車両情報の視認性向上のため、静的表示・フェード循環表示・アクティブ枠アニメ表示の3系統から成る `FormationChips` 系コンポーネントを追加。
|
||||
- train number の前後空白、メモ接尾辞、貨物列車の `レ` 記号を考慮した正規化処理を導入し、並び替え・抽出・照合の精度を改善。
|
||||
- `TrainDataSources` と `useUnyohub` で列車番号の比較ロジックを強化。
|
||||
- 貨物列車向けに 30xx / 90xx / 2桁番号の相互比較を行う補正ロジックを追加。
|
||||
- `platformNum` プロパティをデータ構造へ追加し、番線表示の精度を改善。
|
||||
- 表示文字列適用タイミングを見直し、observer反映直後のちらつきを軽減。
|
||||
- 非同期再描画やmock有効時に表示スタイルが欠落するケースを修正。
|
||||
|
||||
主なコミット:
|
||||
- `3a4e083` feat: add FormationChips, FadingSubCycler, and ActiveFormationChipsCycler components for enhanced train data visualization
|
||||
- `a9668e6` fix: trim train numbers in sorting and filtering logic for accurate matching
|
||||
- `45cc68a` fix: normalize train numbers by stripping suffixes in TrainDataSources and useUnyohub
|
||||
- `899c655` fix: add platformNum property to train data structures for improved display
|
||||
- `a809d28` fix: eliminate UX flash by calling setStrings() immediately in observer
|
||||
- `7566910` fix: re-apply Tokyo UX after async train re-renders
|
||||
- `2cf6b67` fix: Tokyo UX stripped when mock is active
|
||||
|
||||
## 5. バージョン更新と関連整備
|
||||
- 内部バージョン表記を7.0.3へ更新。
|
||||
- 前レンジの差分ログをドキュメントとして追加し、変更履歴の参照性を向上。
|
||||
- live disrupted data を含む `train.json` を更新し、検証データを最新化。
|
||||
- 情報ソース設定画面を拡張し、外部データソースの説明カード化、mock検証、録画管理、投稿システム接続先の系統/ UIバージョン切替を集約。
|
||||
- 接続先は 本番 / 実験 と リリース / ベータ の組み合わせで解決されるよう整理し、旧環境キーからの後方互換正規化も追加。
|
||||
|
||||
主なコミット:
|
||||
- `6ce2cad` fix: update version code to 7.0.3
|
||||
- `43f8095` feat: add changelog for version update from 064d81d4 to a959cf39, highlighting improvements in elesite integration, navigation stability, keyboard handling, and overall user experience
|
||||
- `1dcc25d` chore: update train.json with live disrupted data (2026-05-01 19:42)
|
||||
|
||||
---
|
||||
|
||||
## 変更リスクと確認ポイント
|
||||
- 記録/再生系:
|
||||
- 長時間記録データでタイムライン操作やシーク時の追従遅延、終端ループ待機が意図どおり動くか確認が必要。
|
||||
- mock注入系:
|
||||
- 実通信とmock通信の切替直後にキャッシュや古いXHRフックが残らないか、録画開始時OFF / 再生開始時ONの自動切替も含めて継続確認が必要。
|
||||
- WebView系:
|
||||
- 読み込み失敗後の再試行導線が端末差や回線差で安定して動くか確認が必要。
|
||||
- 表示整合性:
|
||||
- 列車番号正規化により、例外的な命名規則や貨物列車番号が意図せず同一扱いされないか確認が必要。
|
||||
- 設定/運用系:
|
||||
- 投稿システム接続先の切替で、実験系と本番系の URL 解決や旧設定値の移行が破綻しないか確認が必要。
|
||||
|
||||
## 付録: 非mergeコミット一覧(27件)
|
||||
- `6ce2cad` fix: update version code to 7.0.3
|
||||
- `3554233` fix: update header colors in injected JavaScript for better visibility
|
||||
- `d71cc37` fix: sync WebView train display when playback frame changes
|
||||
- `3587f72` feat: add RecordingStatusBar with elapsed timer and keep-awake
|
||||
- `4f4d3ca` fix: crash on playback start + support multiple recordings
|
||||
- `8144e8a` feat: add playback timeline UI with pause/resume/seek controls
|
||||
- `37c08ad` feat: add train position record & playback feature
|
||||
- `b5eb830` fix: update useWebViewRemount to include backgroundThresholdMs option for better app state handling
|
||||
- `92f4b37` feat: apply mock data to currentTrain (app-side train positions)
|
||||
- `a359568` refactor: remove map-screen MOCK switch; settings toggle controls mock directly
|
||||
- `a809d28` fix: eliminate UX flash by calling setStrings() immediately in observer
|
||||
- `7566910` fix: re-apply Tokyo UX after async train re-renders
|
||||
- `2cf6b67` fix: Tokyo UX stripped when mock is active
|
||||
- `1dcc25d` chore: update train.json with live disrupted data (2026-05-01 19:42)
|
||||
- `24f0c82` fix: call _origOpen even when intercepting to allow setRequestHeader
|
||||
- `8321a47` fix: move XHR interceptor to injectedJavaScriptBeforeContentLoaded
|
||||
- `59821a4` fix: rewrite XHR interceptor using prototype patching
|
||||
- `4247318` fix: reload WebView on mock toggle and fix XHR callback timing bug
|
||||
- `71e1ad8` feat: add admin mock API toggle in settings and map screen switch
|
||||
- `170fbf0` feat: add WebView XHR interceptor for mock train position injection
|
||||
- `899c655` fix: add platformNum property to train data structures for improved display
|
||||
- `4c1a315` fix: add loading and error handling in GeneralWebView component with reload functionality
|
||||
- `a8c785b` fix: expose setReload function to the global window object for accessibility
|
||||
- `a9668e6` fix: trim train numbers in sorting and filtering logic for accurate matching
|
||||
- `3a4e083` feat: add FormationChips, FadingSubCycler, and ActiveFormationChipsCycler components for enhanced train data visualization
|
||||
- `45cc68a` fix: normalize train numbers by stripping suffixes in TrainDataSources and useUnyohub
|
||||
- `43f8095` feat: add changelog for version update from 064d81d4 to a959cf39, highlighting improvements in elesite integration, navigation stability, keyboard handling, and overall user experience
|
||||
@@ -0,0 +1,384 @@
|
||||
# JR四国非公式アプリ データ取得経路マップ
|
||||
|
||||
作成日: 2026-07-07
|
||||
|
||||
## 目的
|
||||
|
||||
Sentryで観測している `endpoint=positions / operation_info / train_operation / timetable` が、実際にどのURL、hook、画面、バックエンド、外部取得元に対応するかを整理する。
|
||||
|
||||
この文書は調査結果のみ。既存コードの変更案は含めるが、実装変更は行っていない。
|
||||
|
||||
## 対象範囲
|
||||
|
||||
- React Native / Expo アプリ側の `fetch`, `observedFetchJson`, `observedFetchText`
|
||||
- WebViewに注入しているJavaScript内の `fetch`
|
||||
- WebView内の公式サイトXHR、およびmock XHR interceptor
|
||||
- `lib/mockApi`
|
||||
- `stateBox` 配下のfetch
|
||||
- n8n webhook
|
||||
- Google Apps Script fallback
|
||||
- `jr-shikoku-backend-api-v1.haruk.in`
|
||||
- `jr-shikoku-backend-mock-api-v1.haruk.in`
|
||||
- `jr-shikoku-api-data-storage.haruk.in`
|
||||
- JR四国公式走行位置 `https://train.jr-shikoku.co.jp/sp.html`
|
||||
- JR四国公式運行情報 `https://www.jr-shikoku.co.jp/info/`
|
||||
- 鉄道運用Hub / えれサイトの集約JSON
|
||||
|
||||
## Sentry観測対象の取得一覧
|
||||
|
||||
| 論理endpoint | 呼び出し元ファイル | 関数/hook | 実URL/host | path | root_tab/screen | 取得タイミング | 更新間隔 | timeout | retry | cache | fallback | Sentry tag endpoint | 備考 |
|
||||
|---|---|---|---|---|---|---|---|---:|---|---|---|---|---|
|
||||
| positions current mock | `lib/mockApi/positionMasters.ts` | `fetchMockTrainPositions` | `jr-shikoku-backend-mock-api-v1.haruk.in` | `/train-positions/current` | positions / startup provider | mock API有効時、`useCurrentTrain` と `useTrainMenu` から呼ばれる | 15秒 | 8000ms | 最大1回 | module memory last-good | 前回成功データを返す | `positions_current` / source=`mock_api` / user_visible=`true` / preload=`false` / fetch_priority=`high` | Sentryでtimeout確認済みの主対象。バックエンド側計測が必要。 |
|
||||
| positions master mock | `lib/mockApi/positionMasters.ts` | `fetchPositionMasters` | `jr-shikoku-backend-mock-api-v1.haruk.in` | `/position-masters` | positions / settings-dependent | mock API有効化時、または起動時にmock有効なら取得 | 基本1回 | 8000ms | 最大1回 | module memory `_cache` | なし | `positions_master` / source=`mock_api` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | PosNum+LineからPos文字列を補完。頻度は低い。 |
|
||||
| positions n8n | `stateBox/useCurrentTrain.tsx` | `getCurrentTrain` | `n8n.haruk.in` | `/webhook/c501550c-7d1b-4e50-927b-4429fe18931a` | positions | 起動時、mock切替時、interval | 15秒 | 8000ms | 最大1回 | なし | Google Apps Script fallback | `positions_current` / source=`n8n` / user_visible=`true` / preload=`false` / fetch_priority=`high` | mock API無効時の主経路。成功時は録画snapshotにも使う。 |
|
||||
| positions GAS fallback | `stateBox/useCurrentTrain.tsx` | `getCurrentTrain` catch内 | `script.google.com` | `/macros/s/AKfyc.../exec` | positions | n8n走行位置取得失敗時のみ | n8n失敗時のみ | 8000ms | 最大1回 | 既存データとmerge | 前回表示データ維持 | `positions_gas_fallback` / source=`gas` / user_visible=`true` / preload=`false` / fetch_priority=`high` | Direction等が欠けるため既存データとmerge。 |
|
||||
| timetable today | `stateBox/useAllTrainDiagram.tsx` | `getTrainDiagram` | `jr-shikoku-api-data-storage.haruk.in` | `/tmp/diagram-today.json` または `/tmp/diagram-today-beta.json` | provider全体 / timetable consumers | provider mount直後 | 30秒、background継続指定あり。ただしobservedFetch側はbackground開始を抑制 | 15000ms | 最大1回 | AsyncStorage `ALL_TRAIN_DIAGRAM` | cache失敗時はalert | `timetable_today` / source=`static_storage` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | ダイヤ・発車標・列車詳細で広く使用。 |
|
||||
| train operation data | `stateBox/useAllTrainDiagram.tsx` | `getCustomTrainData` | `jr-shikoku-backend-api-v1.haruk.in` または beta | `/train-data` | provider全体 / train detail / diagram | provider mount直後 | 30秒、background継続指定あり。ただしobservedFetch側はbackground開始を抑制 | 15000ms | 最大1回 | なし | 失敗は握りつぶし | `train_operation_data` / source=`backend_api` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | 約1MB級。WebView内でも別途取得される。 |
|
||||
| operation logs | `stateBox/useAllTrainDiagram.tsx` | `getTodayOperation` | `jr-shikoku-backend-api-v1.haruk.in` または beta | `/operation-logs` | provider全体 / topMenu / train detail | provider mount直後 | 30秒、background継続指定あり。ただしobservedFetch側はbackground開始を抑制 | 15000ms | 最大1回 | なし | 失敗は握りつぶし | `operation_logs` / source=`backend_api` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | 列車別運行ログ。WebView内でも別途取得される。 |
|
||||
| operation flag | `stateBox/useAreaInfo.tsx` | `getAreaData` | `n8n.haruk.in` | `/webhook/jr-shikoku-trainfo-flag` | topMenu / information / provider全体 | mount後、InteractionManager後に1200ms遅延 | 60秒 | 10000ms | 最大1回 | なし | 失敗は握りつぶし | `operation_info_flag` / source=`n8n` / user_visible=`true` / preload=`true` / fetch_priority=`medium` | topMenuのバッジ・対象エリア判定。 |
|
||||
| operation text GAS | `stateBox/useAreaInfo.tsx` | `fetchAreaDescription` | `script.google.com` | `/macros/s/AKfycbz80.../exec` | topMenu / information | operation flagで対象駅ありの場合だけ800ms遅延 | flag結果依存 | 15000ms | なし | なし | 失敗は握りつぶし | `operation_info_text` / source=`gas` / user_visible=`true` / preload=`false` / fetch_priority=`medium` | 第1回観測でtimeout。現在は通常時の実行を抑制。 |
|
||||
|
||||
## WebView内の取得一覧
|
||||
|
||||
| 論理endpoint | 呼び出し元ファイル | 関数/hook | 実URL/host | path | root_tab/screen | 取得タイミング | 更新間隔 | timeout | retry | cache | fallback | Sentry tag endpoint | 備考 |
|
||||
|---|---|---|---|---|---|---|---|---:|---|---|---|---|---|
|
||||
| positions official page | `components/Apps/WebView.tsx` | `AppsWebView` | `train.jr-shikoku.co.jp` | `/sp.html` | positions | 走行位置root初回activate時 | WebView lifecycle依存 | WebView標準 | WebView標準 | WebView page cache / OS依存 | remount watchdog | 未計測 | RN SentryではWebView navigation breadcrumbのみ。 |
|
||||
| positions official XHR live | JR四国公式ページ内 | 公式サイトXHR | `train.jr-shikoku.co.jp` | `/g?arg1=train&arg2=train` | positions WebView | 公式ページロード後、公式サイト側ポーリング | 公式サイト実装依存。概ね数秒単位 | 公式サイト/ブラウザ依存 | 不明 | 公式サイト/ブラウザ依存 | mock interceptor可能 | 未計測 / 候補 `positions_webview` | RN wrapperを通らない。現状Sentryで通信詳細が見えない。 |
|
||||
| positions official XHR static | JR四国公式ページ内 | 公式サイトXHR | `train.jr-shikoku.co.jp` | `/g?arg1=lang...`, `/g?arg1=station...`, etc | positions WebView | 公式ページロード時 | 基本初回 | 公式サイト/ブラウザ依存 | 不明 | 公式サイト/ブラウザ依存 | mock static intercept optional | 未計測 | `lib/mockApi/webviewXhrInterceptor.ts` に一覧コメントあり。 |
|
||||
| positions WebView injected train-data | `lib/webViewInjectjavascript.ts` | `startPolling/DatalistUpdate` | `jr-shikoku-backend-api-v1.haruk.in` | `/train-data` | positions WebView | Phase 2後、polling開始 | 30秒 | なし | なし | WebView localStorage 1日 | 失敗は無視 | 未計測 / RN側は `train_operation` | RN側Providerと重複取得。WebView内なのでRN Sentryにspanなし。 |
|
||||
| positions WebView injected operation-logs | `lib/webViewInjectjavascript.ts` | `operationListUpdate` | `jr-shikoku-backend-api-v1.haruk.in` | `/operation-logs` | positions WebView | Phase 1、Phase 3 polling、visibility復帰 | 30秒 | なし | なし | WebView localStorage 1日 | 失敗は無視 | 未計測 / RN側は `operation_info` | RN側Providerと重複取得。 |
|
||||
| positions WebView injected diagram | `lib/webViewInjectjavascript.ts` | `TrainDiagramData2Update` | `jr-shikoku-api-data-storage.haruk.in` | `/tmp/diagram-today.json` | positions WebView | Phase 2、polling | 30秒 | なし | なし | WebView localStorage 1時間 | 失敗は無視 | 未計測 / RN側は `timetable` | RN側Providerと重複取得。環境切替URLが注入optionにあるが、現コード内fetch文字列は定数参照。 |
|
||||
| positions WebView injected station-list | `lib/webViewInjectjavascript.ts` | Phase 1 | `n8n.haruk.in` | `/webhook/jr-shikoku-station-list` | positions WebView | WebView注入script起動時 | 初回中心 | なし | なし | WebView localStorage 1週間 | 失敗は無視 | 未計測 / 候補 `station_info` | 走行位置WebView表示の補助データ。 |
|
||||
| positions WebView injected problems | `lib/webViewInjectjavascript.ts` | `getProblemsData` | `n8n.haruk.in` | `/webhook/jrshikoku-position-problems` | positions WebView | Phase 1、polling、visibility復帰 | 30秒 | なし | なし | WebView localStorage 1分 | 失敗は無視 | 未計測 / 候補 `positions_problem` | 位置情報問題データ。 |
|
||||
| positions WebView unyohub | `lib/webViewInjectjavascript.ts` | `unyohubDataUpdate` | `jr-shikoku-api-data-storage.haruk.in` | `/thirdparty/unyohub-unyo.json` | positions WebView | 設定ON時、Phase 3 | 30秒 | なし | なし | WebView localStorage 1時間 | 失敗はconsole | 未計測 / 候補 `thirdparty_unyohub` | 鉄道運用Hub由来の集約JSON。 |
|
||||
| positions WebView elesite | `lib/webViewInjectjavascript.ts` | `elesiteDataUpdate` | `jr-shikoku-api-data-storage.haruk.in` | `/thirdparty/elesite-unyo.json` | positions WebView | 設定ON時、Phase 3 | 30秒 | なし | なし | WebView localStorage 1時間 | 失敗はconsole | 未計測 / 候補 `thirdparty_elesite` | えれサイト由来の集約JSON。 |
|
||||
| operation official page | `ndView.tsx` | operation WebView | `www.jr-shikoku.co.jp` | `/info/` | information | 運行情報root初回activate時 | WebView lifecycle依存 | WebView標準 | WebView標準 | OS/WebView依存 | remount watchdog | 未計測 | injected scriptでページ内容を加工し、画像生成結果をpostMessage。 |
|
||||
| operation hidden preload | `Apps.tsx` | `HiddenStartupPreloadWebViews` | `www.jr-shikoku.co.jp` | `/info/` | startup hidden preload | 起動時preload条件成立時 | 1回 | WebView標準 | なし | OS/WebView依存 | なし | 未計測 | Sentry breadcrumb/contextのみ。 |
|
||||
| positions hidden preload | `Apps.tsx` | `HiddenStartupPreloadWebViews` | `train.jr-shikoku.co.jp` | `/` | startup hidden preload | 起動時preload条件成立時 | 1回 | WebView標準 | なし | OS/WebView依存 | なし | 未計測 | 走行位置WebViewの事前ウォームアップ。 |
|
||||
|
||||
## Sentry endpoint対象外だが存在するRN fetch
|
||||
|
||||
| 用途 | 呼び出し元ファイル | 実URL/host | path | タイミング | 更新間隔 | cache | 備考 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| update権限 / 外部データ利用権限 | `stateBox/useTrainMenu.tsx` | `jr-shikoku-backend-api-v1.haruk.in` または beta | `/check-permission?user_id=...` | push token取得後 | token/backend変更時 | なし | Sentry未計測。queryにuser_idあり。 |
|
||||
| バス・列車データ | `stateBox/useBusAndTrainData.tsx` | `script.google.com` | GAS | mount時、AsyncStorage miss時 | 基本1回 | AsyncStorage `BUS_AND_TRAIN` | Sentry未計測。 |
|
||||
| train pair data | `stateBox/useBusAndTrainData.tsx` | `script.google.com` | GAS | mount時 | 基本1回 | なし | Sentry未計測。 |
|
||||
| 遅延速報EX | `stateBox/useTrainDelayData.tsx` | `script.google.com` | GAS | `loadingDelayData`変化時 | 手動/状態依存 | なし | Sentry未計測。Android widgetにも同URLあり。 |
|
||||
| 特急列車情報 | `components/Menu/SpecialTrainInfoBox.tsx` | `n8n.haruk.in` | `/webhook/sptrainfo` | component mount | 1回 | なし | Sentry未計測。 |
|
||||
| 位置ID補助情報 GET | `components/発車時刻表/LED_inside_Component/TrainPosition.tsx`, `components/ActionSheetComponents/EachTrainInfo/TrainDataView.tsx` | `n8n.haruk.in` | `/webhook/JR-shikoku-PosID-v3?PosId=...` | 列車詳細/LED表示時 | 表示対象変更ごと | なし | Sentry未計測。UI細部表示用。 |
|
||||
| 位置ID補助情報 POST/DELETE | `TrainPositionDataPush.tsx`, `TrainPositionDataDelete.tsx` | `n8n.haruk.in` | `/webhook/JR-shikoku-PosID-v3` | ユーザー投稿/削除要求 | 操作時 | なし | Sentry未計測。mutating request。 |
|
||||
| 通知設定登録 | `components/Settings/NotificationSettings.tsx` | `n8n.haruk.in` | `/webhook/jr-shikoku-notification-configurations` | 設定保存操作時 | 操作時 | AsyncStorageに設定保存 | Sentry未計測。tokenを送る。 |
|
||||
| unyohub RN hook | `stateBox/useUnyohub.tsx` | `jr-shikoku-api-data-storage.haruk.in` | `/thirdparty/unyohub-unyo.json` | 設定ON時 | 10分 | AsyncStorage | WebView内でも別取得あり。 |
|
||||
| elesite RN hook | `stateBox/useElesite.tsx` | `jr-shikoku-api-data-storage.haruk.in` | `/thirdparty/elesite-unyo.json` | 設定ON時 | 10分 | AsyncStorage | WebView内でも別取得あり。 |
|
||||
| 駅住所LOD | `components/駅名表/AddressText.tsx` | station別 `jslodApi` | `.json` | 駅名標表示時 | 表示対象変更ごと | なし | 外部LOD。Sentry未計測。 |
|
||||
| Anpanman列車状態 | `components/ActionSheetComponents/EachTrainInfoCore/trainIconStatus.tsx` | `n8n.haruk.in` | `/webhook/{anpanmanApiPath}?trainNum=...` | 対象列車詳細表示時 | 対象変更ごと | なし | Sentry未計測。 |
|
||||
| Android operation widget | `components/AndroidWidget/InfoWidget.tsx` | `script.google.com` | operation info GAS | widget更新時 | widget runtime依存 | なし | RN画面外。 |
|
||||
| Android delay widget | `components/AndroidWidget/TraInfoEXWidget.tsx` | `script.google.com` | delay GAS | widget更新時 | widget runtime依存 | なし | RN画面外。 |
|
||||
| GeneralWebView recording import | `GeneralWebView.tsx` | arbitrary / blob / resolvedUri内 | download URL | 録画importリンク操作時 | 操作時 | なし | 任意URL由来。Sentry endpoint化すべきではない。 |
|
||||
|
||||
## endpoint別依存関係
|
||||
|
||||
### positions
|
||||
|
||||
```text
|
||||
positions screen
|
||||
├─ RN provider: useCurrentTrain
|
||||
│ ├─ mock ON: jr-shikoku-backend-mock-api-v1 /train-positions/current
|
||||
│ │ └─ fallback: module memory last-good cache
|
||||
│ └─ mock OFF: n8n /webhook/c501550c...
|
||||
│ └─ fallback: Google Apps Script positions fallback
|
||||
│ └─ fallback: currentTrain previous state if present
|
||||
├─ RN provider: useTrainMenu
|
||||
│ ├─ mock ON: /position-masters
|
||||
│ └─ mock ON: /train-positions/current, then inject to WebView mock XHR
|
||||
└─ WebView: train.jr-shikoku.co.jp/sp.html
|
||||
├─ official XHR /g?arg1=train&arg2=train
|
||||
├─ official XHR static /g?... station/lang/etc
|
||||
└─ injected enhancement fetches backend/storage/n8n auxiliary data
|
||||
```
|
||||
|
||||
表示対象:
|
||||
- 走行位置画面
|
||||
- 列車詳細ActionSheet
|
||||
- 発車標/LED系
|
||||
- fixed position box
|
||||
|
||||
現Sentry tag:
|
||||
- `endpoint=positions`
|
||||
- WebView公式XHRは未計測。候補は `positions_webview`。
|
||||
|
||||
### operation_info
|
||||
|
||||
```text
|
||||
topMenu / information / train detail
|
||||
├─ useAreaInfo
|
||||
│ ├─ n8n /webhook/jr-shikoku-trainfo-flag
|
||||
│ └─ if active area exists: Google Apps Script operation text
|
||||
├─ useAllTrainDiagram
|
||||
│ └─ jr-shikoku-backend-api-v1 /operation-logs
|
||||
├─ information WebView
|
||||
│ └─ www.jr-shikoku.co.jp/info/ official page
|
||||
│ └─ injected script parses/captures page and postMessage to RN
|
||||
└─ widgets / delay providers
|
||||
├─ operation info GAS
|
||||
└─ delay GAS
|
||||
```
|
||||
|
||||
表示対象:
|
||||
- topMenuバッジ・運行情報カード
|
||||
- informationタブ
|
||||
- 列車詳細内の運行ログ表示
|
||||
- Android widgets
|
||||
|
||||
現Sentry tag:
|
||||
- `endpoint=operation_info`
|
||||
- WebView公式ページは未計測。
|
||||
|
||||
### train_operation
|
||||
|
||||
```text
|
||||
train detail / diagram / WebView enhancement
|
||||
├─ RN: useAllTrainDiagram -> backend /train-data
|
||||
└─ WebView injected JS -> backend /train-data
|
||||
└─ localStorage cache 1 day
|
||||
```
|
||||
|
||||
表示対象:
|
||||
- 列車詳細
|
||||
- ダイヤ/発車標
|
||||
- 走行位置WebViewの列車アイコン・編成/運用装飾
|
||||
|
||||
現Sentry tag:
|
||||
- RN fetch: `endpoint=train_operation`
|
||||
- WebView fetch: 未計測
|
||||
|
||||
### timetable
|
||||
|
||||
```text
|
||||
timetable / station diagram / train detail
|
||||
├─ RN: useAllTrainDiagram -> data-storage /tmp/diagram-today.json
|
||||
│ └─ fallback: AsyncStorage ALL_TRAIN_DIAGRAM
|
||||
└─ WebView injected JS -> data-storage /tmp/diagram-today.json
|
||||
└─ localStorage cache 1 hour
|
||||
```
|
||||
|
||||
表示対象:
|
||||
- 時刻表
|
||||
- 発車標
|
||||
- 列車詳細停車駅/通過駅
|
||||
- 走行位置WebView内の補助表示
|
||||
|
||||
現Sentry tag:
|
||||
- RN fetch: `endpoint=timetable`
|
||||
- WebView fetch: 未計測
|
||||
|
||||
## 画面別fetch一覧
|
||||
|
||||
### アプリ起動直後
|
||||
|
||||
Providerがmountされるため、現在の構成では表示タブに関係なく以下が走り得る。
|
||||
|
||||
| 取得 | 起動時挙動 | 備考 |
|
||||
|---|---|---|
|
||||
| `useCurrentTrain.getCurrentTrain` | 即時1回、その後15秒interval | positions。mock ON/OFFで経路が変わる。 |
|
||||
| `useAllTrainDiagram.getTrainDiagram` | 即時1回、その後30秒interval | timetable。AsyncStorage fallbackあり。 |
|
||||
| `useAllTrainDiagram.getCustomTrainData` | 即時1回、その後30秒interval | train_operation。 |
|
||||
| `useAllTrainDiagram.getTodayOperation` | 即時1回、その後30秒interval | operation_info。 |
|
||||
| `useAreaInfo.getAreaData` | InteractionManager後 + 1200ms遅延。その後60秒interval | operation_info flag。対象ありならGAS textも800ms遅延。 |
|
||||
| `useTrainMenu.check-permission` | push token取得後 | Sentry未計測。 |
|
||||
| hidden preload WebViews | preload条件成立時 | positions公式/operation公式を不可視WebViewでロード。 |
|
||||
|
||||
### topMenu表示中
|
||||
|
||||
| 取得 | 発生源 | 備考 |
|
||||
|---|---|---|
|
||||
| positions RN polling | `useCurrentTrain` provider | 表示タブに関係なく継続。ただしbackgroundではinterval停止。 |
|
||||
| timetable / train_operation / operation_logs | `useAllTrainDiagram` provider | 表示タブに関係なく30秒ごと。 |
|
||||
| operation flag / text | `useAreaInfo` | topMenu表示内容に直結。 |
|
||||
| special train info | `SpecialTrainInfoBox` | 該当Sheet表示時のみ。 |
|
||||
|
||||
### positions表示中
|
||||
|
||||
| 取得 | 発生源 | 備考 |
|
||||
|---|---|---|
|
||||
| positions RN polling | `useCurrentTrain` | 15秒。画面表示に必須。 |
|
||||
| train.jr-shikoku公式WebView | `AppsWebView` | 公式XHRはRN Sentry不可視。 |
|
||||
| WebView injected train-data / operation-logs / diagram / station-list / problems | `webViewInjectjavascript` | RN providerと重複取得あり。WebView localStorage cacheあり。 |
|
||||
| unyohub / elesite | WebView injected JS、またはRN hooks | 設定ON時のみ。 |
|
||||
|
||||
### information表示中
|
||||
|
||||
| 取得 | 発生源 | 備考 |
|
||||
|---|---|---|
|
||||
| JR四国公式運行情報WebView | `ndView.tsx` | `https://www.jr-shikoku.co.jp/info/`。RN Sentryではpage load breadcrumb中心。 |
|
||||
| operation flag/text | `useAreaInfo` | バッジ/本文補助。 |
|
||||
| operation logs | `useAllTrainDiagram` | 列車別運行ログ。 |
|
||||
|
||||
### バックグラウンド復帰時
|
||||
|
||||
| 取得 | 発生源 | 備考 |
|
||||
|---|---|---|
|
||||
| `useInterval` | AppState active復帰時に即時実行 | userStoppedでなければ即時fetch。observedFetch側はbackground開始を抑制。 |
|
||||
| positions WebView | `useWebViewRemount` / WebView lifecycle | iOSはunfocused時watchdog pause設定あり。 |
|
||||
| WebView injected `refreshAllData` | document visibilitychange/pageshow | WebView内で train-data / operation-logs / position-problems を再取得。RN Sentry不可視。 |
|
||||
| GeneralWebView | background 10秒超でremount | 汎用WebViewのみ。 |
|
||||
|
||||
## 起動時/タブ切替時fetchタイムライン
|
||||
|
||||
```text
|
||||
T+0ms app providers mount
|
||||
├─ useCurrentTrain -> positions current fetch
|
||||
├─ useAllTrainDiagram -> timetable fetch
|
||||
├─ useAllTrainDiagram -> train_operation fetch
|
||||
├─ useAllTrainDiagram -> operation_logs fetch
|
||||
└─ useTrainMenu -> settings/env load, maybe check-permission after token
|
||||
|
||||
T+after interactions + 1200ms
|
||||
└─ useAreaInfo -> operation_info_flag
|
||||
└─ if active area exists: +800ms -> operation_info_text GAS
|
||||
|
||||
TopMenu first screen
|
||||
└─ no page-specific heavy WebView, but provider fetches above continue
|
||||
|
||||
positions first activation
|
||||
├─ AppsWebView loads train.jr-shikoku.co.jp/sp.html
|
||||
├─ official site starts /g?... XHRs
|
||||
├─ injected JS Phase 0: WebView localStorage cache read
|
||||
├─ injected JS Phase 1: station-list, operation-logs, position-problems
|
||||
├─ injected JS Phase 2: train-data, diagram-today
|
||||
└─ injected JS Phase 3: 30s polling, optional unyohub/elesite
|
||||
|
||||
information first activation
|
||||
├─ ndView WebView loads www.jr-shikoku.co.jp/info/
|
||||
└─ injected script parses/captures page and postMessage to RN when user capture actions occur
|
||||
|
||||
Tab switch back to positions/information
|
||||
├─ root component is kept after activation
|
||||
├─ WebView retention depends on current navigation/root gating and OS WebView process
|
||||
└─ provider-level RN fetches generally continue independent of active tab
|
||||
```
|
||||
|
||||
## 危険度・制御方針
|
||||
|
||||
### High priority
|
||||
|
||||
| 取得 | 分類理由 | 推奨方針 |
|
||||
|---|---|---|
|
||||
| `/train-positions/current` | 表示中timeoutがユーザー体感に直結。Sentryで実測済み。 | server-side duration/upstream/cacheログ、backend last-known-good、app persistent stale cache。 |
|
||||
| `useCurrentTrain` n8n positions | mock OFF時の主経路。走行位置画面に必須。 | stale cache導入、失敗時UI全体error回避、endpoint細分化。 |
|
||||
| JR公式WebView `/g?arg1=train&arg2=train` | 走行位置WebViewの本体だがRN Sentry不可視。 | WebView postMessage計測、またはRN側 proxy/cache化を検討。 |
|
||||
| `/operation-logs` | WebView/RNで重複、列車詳細表示に影響。 | backend span/cache hit/miss、WebView側計測。 |
|
||||
| `/train-data` | 大きいレスポンス、RN/WebViewで重複。 | stale cache、conditional fetch、WebView/RN共有設計。 |
|
||||
|
||||
### Medium priority
|
||||
|
||||
| 取得 | 分類理由 | 推奨方針 |
|
||||
|---|---|---|
|
||||
| `diagram-today.json` | 広範囲で使うがAsyncStorage fallbackあり。 | stale cache明示、WebView側との重複削減。 |
|
||||
| `operation_info_flag` | topMenuバッジ/運行情報表示に影響。 | endpoint細分化、失敗時は前回状態維持。 |
|
||||
| `operation_info_text` GAS | 遅延/timeout履歴あり。必須ではない詳細本文。 | preloadではなく遅延維持、stale cache、cellular抑制候補。 |
|
||||
| station-list / position-problems WebView | 表示補助。WebView内でのみ見えにくい。 | WebView breadcrumb/postMessage計測。 |
|
||||
| unyohub / elesite | 設定ON時のみだがWebView内は30秒polling。 | cellular抑制、10分程度へ間引き、Sentry breadcrumb。 |
|
||||
|
||||
### Low priority
|
||||
|
||||
| 取得 | 分類理由 | 推奨方針 |
|
||||
|---|---|---|
|
||||
| bus/train GAS | 初回またはcache miss中心。 | observedFetch化は後回し。 |
|
||||
| train pair GAS | 補助データ。 | cache導入候補。 |
|
||||
| special train info | Sheet表示時のみ。 | 失敗UIだけ整備。 |
|
||||
| notification config | ユーザー操作時のみ。 | mutationとして個別エラーハンドリング。 |
|
||||
| station LOD address | 駅名標詳細のみ。 | 失敗しても無表示でよい。 |
|
||||
| Anpanman status | 装飾表示。 | 失敗してもUI全体に影響させない。 |
|
||||
|
||||
## stale cacheを入れるべき取得
|
||||
|
||||
| 優先度 | 取得 | 理由 | 推奨cache |
|
||||
|---|---|---|---|
|
||||
| High | `/train-positions/current` | 走行位置表示が直接壊れる | backend last-known-good + app persistent cache。TTL 30-120秒。 |
|
||||
| High | n8n positions | mock OFF時の主経路 | app persistent last-good。TTL 30-120秒。 |
|
||||
| High | JR公式WebView train XHR | WebView表示本体 | 公式XHRをRN/proxy化できるならstale。難しければWebView localStorage/postMessage。 |
|
||||
| Medium | `/operation-logs` | 列車詳細/運行情報補助 | backend/app cache。TTL 1-5分。 |
|
||||
| Medium | `/train-data` | 大容量かつ重複取得 | app/WebView共有cache。TTL 1日でも許容しやすい。 |
|
||||
| Medium | `diagram-today.json` | 既にAsyncStorage fallbackあり | 既存cacheをstale表示として明示。TTL 1日。 |
|
||||
| Medium | `operation_info_flag` | バッジ/エリア判定 | 前回状態維持。TTL 1-5分。 |
|
||||
| Medium | `operation_info_text` | GAS遅延に弱い | 前回本文保持。TTL 5-30分。 |
|
||||
| Low | unyohub / elesite | 設定ON時の補助 | 既存AsyncStorage/WebView localStorageを活用。TTL 10-60分。 |
|
||||
|
||||
## バックエンド側に計測を入れるべきAPI
|
||||
|
||||
| API | host | 理由 | 必要な計測 |
|
||||
|---|---|---|---|
|
||||
| `/train-positions/current` | `jr-shikoku-backend-mock-api-v1.haruk.in` | Sentryでforeground timeout確認済み。 | handler duration, upstream duration, cache hit/miss, stale served, status, bytes, upstream error kind。 |
|
||||
| `/position-masters` | `jr-shikoku-backend-mock-api-v1.haruk.in` | mock経路の基礎データ。頻度低いが失敗時mock表示品質に影響。 | handler duration, cache hit/miss, db/storage duration。 |
|
||||
| `/train-data` | `jr-shikoku-backend-api-v1.haruk.in` | 大容量・RN/WebView重複取得。 | handler duration, data source duration, response bytes, compression, cache status。 |
|
||||
| `/operation-logs` | `jr-shikoku-backend-api-v1.haruk.in` | operation_infoとしてRN/WebView重複取得。 | handler duration, query/upstream duration, cache status, filtered count。 |
|
||||
| `/check-permission` | `jr-shikoku-backend-api-v1.haruk.in` | user_id queryあり、失敗しても表示は続くが権限UIに影響。 | duration, auth/lookup duration, no PII logging。 |
|
||||
| `diagram-today.json` publish pipeline | `jr-shikoku-api-data-storage.haruk.in` | 静的配信だが生成元障害は広範囲影響。 | CDN/storage access log, generation timestamp, object age, size。 |
|
||||
| `thirdparty/unyohub-unyo.json` | `jr-shikoku-api-data-storage.haruk.in` | 外部由来の集約結果。 | generation timestamp, upstream scrape duration, source error count。 |
|
||||
| `thirdparty/elesite-unyo.json` | `jr-shikoku-api-data-storage.haruk.in` | 外部由来の集約結果。 | generation timestamp, upstream scrape duration, source error count。 |
|
||||
|
||||
## Sentry endpoint tag改善案
|
||||
|
||||
RN側observedFetchについては、4分類から以下の低カーディナリティな論理endpointへ細分化済み。`area=data_fetch`, `result`, `root_tab`, `status` は維持し、`source`, `user_visible`, `preload`, `fetch_priority` を追加した。
|
||||
|
||||
### 現在のRN側endpoint分類
|
||||
|
||||
低カーディナリティを維持するため、URL単位ではなく論理単位で増やす。
|
||||
|
||||
| 旧endpoint | 現endpoint | 対象 |
|
||||
|---|---|---|
|
||||
| `positions` | `positions_current` | 現在位置本体。n8n current / mock currentを含めるかは要検討。 |
|
||||
| `positions` | `positions_master` | `/position-masters`。 |
|
||||
| `positions` | `positions_gas_fallback` | Google Apps Script fallback。 |
|
||||
| 未計測 | `positions_webview` | JR公式WebView XHR / postMessage計測。 |
|
||||
| `operation_info` | `operation_info_flag` | n8n flag。 |
|
||||
| `operation_info` | `operation_info_text` | Google Apps Script text。 |
|
||||
| `operation_info` | `operation_logs` | backend `/operation-logs`。 |
|
||||
| `train_operation` | `train_operation_data` | backend `/train-data`。 |
|
||||
| `timetable` | `timetable_today` | `diagram-today.json`。 |
|
||||
| 未計測 | `thirdparty_unyohub` | unyohub集約JSON。 |
|
||||
| 未計測 | `thirdparty_elesite` | elesite集約JSON。 |
|
||||
|
||||
### 追加tag
|
||||
|
||||
- `source=rn_fetch|mock_api|n8n|gas|backend_api|static_storage|webview_fetch`
|
||||
- `user_visible=true|false`
|
||||
- `preload=true|false`
|
||||
- `fetch_priority=high|medium|low`
|
||||
|
||||
### 注意
|
||||
|
||||
- `endpoint` tagにURL全文やID入りpathは入れない。
|
||||
- `urlHost` と `urlPathTemplate` はcontextに維持する。
|
||||
- `positions_current` をn8n/mock/GASで分けすぎるとissue数は追いやすいが、全体のpositions失敗率は見にくくなる。`source` tagまたはcontextで `mock_api / n8n / gas` を分ける案も有効。
|
||||
|
||||
## 変更案の優先順位
|
||||
|
||||
### High
|
||||
|
||||
1. `positions_current` のendpoint細分化、または `source=mock_api|n8n|gas` 追加。
|
||||
2. WebView公式XHR `/g?arg1=train&arg2=train` のpostMessage計測。
|
||||
3. backend mock API `/train-positions/current` にserver-side span/cache/upstream durationを追加。
|
||||
4. positionsのpersistent stale cache。
|
||||
|
||||
### Medium
|
||||
|
||||
1. `operation_info` を `operation_info_flag / operation_info_text / operation_logs` に分離。
|
||||
2. WebView injected JS fetchの軽量postMessage計測。
|
||||
3. `/train-data` と `diagram-today.json` のRN/WebView重複取得を整理。
|
||||
4. unyohub/elesiteのWebView polling間隔を30秒から設定/TTLベースへ見直し。
|
||||
|
||||
### Low
|
||||
|
||||
1. LED/駅詳細/通知/Widget系の直接fetchをobservedFetch化。
|
||||
2. 任意URLを扱うGeneralWebView importはSentry endpoint化せず、操作エラーとして別分類。
|
||||
3. station LOD address fetchの失敗をUI非表示に統一。
|
||||
|
||||
## 現時点の結論
|
||||
|
||||
- Sentryで見えている `positions` のtimeoutは、RN側のmock currentまたはn8n/GAS経路のどれかに紐づく。今回観測された `urlHost=jr-shikoku-backend-mock-api-v1.haruk.in`, `urlPathTemplate=/train-positions/current` はmock currentで確定。
|
||||
- 走行位置画面ではRN provider fetchとWebView公式XHR、さらにWebView injected fetchが並行して走る。見えているSentry spanはRN provider側だけで、WebView内の公式取得は不可視。
|
||||
- `operation_info` はflag、GAS text、backend operation logs、公式WebViewの4系統が混在しているため、細分化した方が原因追跡しやすい。
|
||||
- `train_operation` と `timetable` はRNとWebViewで重複取得している。ユーザー体感の安定化には、stale cacheと重複削減が効きやすい。
|
||||
- バックエンド側で最優先に計測すべきは `/train-positions/current`。次点で `/train-data` と `/operation-logs`。
|
||||
@@ -0,0 +1,277 @@
|
||||
# 通信計測ロガー実装報告 2026-07-07
|
||||
|
||||
## 目的
|
||||
|
||||
走行位置・運行情報・時刻表まわりの RN 側データ取得について、Sentry 上で endpoint 別の成功/失敗、HTTP status、通信時間、レスポンスサイズ、JSON 期待時の HTML 返却、JSON parse 失敗、timeout を追えるようにした。
|
||||
|
||||
今回の実装は低リスクな第1段階として、WebView 内 fetch と stale cache の実装は対象外にした。既存のバックグラウンド WebView 保持・タブ保持挙動には触れていない。
|
||||
|
||||
## 実装内容
|
||||
|
||||
### 追加した共通実装
|
||||
|
||||
- `lib/observability/network/types.ts`
|
||||
- `lib/observability/network/endpoints.ts`
|
||||
- `lib/observability/network/networkError.ts`
|
||||
- `lib/observability/network/sentryNetwork.ts`
|
||||
- `lib/observability/network/observedFetch.ts`
|
||||
|
||||
主な API:
|
||||
|
||||
```ts
|
||||
observedFetchJson<T>(url, options)
|
||||
observedFetchText(url, options)
|
||||
```
|
||||
|
||||
記録する分類:
|
||||
|
||||
- `success`
|
||||
- `slow_success`
|
||||
- `timeout`
|
||||
- `network_error`
|
||||
- `http_error`
|
||||
- `non_json_response` / Sentry tag result は `non_json`
|
||||
- `json_parse_error` / Sentry tag result は `parse_error`
|
||||
- `empty_response`
|
||||
- `unknown`
|
||||
|
||||
### Sentry 設定
|
||||
|
||||
`App.tsx` の `Sentry.init` に以下を追加した。
|
||||
|
||||
- `tracesSampleRate: __DEV__ ? 1.0 : 0.05`
|
||||
- `beforeSend` で `contexts.data_fetch.responseHead` を最大300文字に制限
|
||||
|
||||
既存の Replay / Feedback / Logs 設定は維持した。
|
||||
|
||||
### 計測対象にした RN fetch
|
||||
|
||||
#### 走行位置
|
||||
|
||||
- `stateBox/useCurrentTrain.tsx`
|
||||
- n8n の走行位置 webhook
|
||||
- Google Apps Script fallback
|
||||
- `lib/mockApi/positionMasters.ts`
|
||||
- mock position masters
|
||||
- mock train positions current
|
||||
|
||||
endpoint tag: `positions`
|
||||
|
||||
#### 運行情報
|
||||
|
||||
- `stateBox/useAllTrainDiagram.tsx`
|
||||
- `/operation-logs`
|
||||
- `stateBox/useAreaInfo.tsx`
|
||||
- 運行情報本文 text
|
||||
- `jr-shikoku-trainfo-flag`
|
||||
|
||||
endpoint tag: `operation_info`
|
||||
|
||||
#### 運用・時刻表系
|
||||
|
||||
- `stateBox/useAllTrainDiagram.tsx`
|
||||
- `/train-data`
|
||||
- `diagram-today.json`
|
||||
|
||||
endpoint tag:
|
||||
|
||||
- `/train-data`: `train_operation`
|
||||
- `diagram-today.json`: `timetable`
|
||||
|
||||
## Sentry に入る情報
|
||||
|
||||
### tags
|
||||
|
||||
- `area=data_fetch`
|
||||
- `endpoint=positions|operation_info|train_operation|timetable`
|
||||
- `result=success|slow_success|timeout|network_error|http_error|non_json|parse_error|empty_response|unknown`
|
||||
- `root_tab=positions|topMenu|information|unknown`
|
||||
- `platform=android|ios`
|
||||
- `source=rn_fetch`
|
||||
- `status=200` など、HTTP response がある場合のみ
|
||||
|
||||
`root_tab` は呼び出し側で明示指定しない場合、`rootNavigationRef` の active root route から補完する。
|
||||
|
||||
### contexts.data_fetch
|
||||
|
||||
- `endpoint`
|
||||
- `method`
|
||||
- `status`
|
||||
- `ok`
|
||||
- `durationMs`
|
||||
- `timeoutMs`
|
||||
- `bytes`
|
||||
- `contentType`
|
||||
- `responseHead` 失敗時中心、最大300文字
|
||||
- `retryCount`
|
||||
- `urlHost`
|
||||
- `urlPathTemplate`
|
||||
- `appState`
|
||||
|
||||
URL 全文や query parameter は送らず、host と path template のみを送る方針にした。
|
||||
|
||||
## 挙動仕様
|
||||
|
||||
- timeout は wrapper 内の `AbortController` で実装。
|
||||
- `retry: true` の GET 系取得のみ、最大1回 retry。
|
||||
- retry 対象は `timeout`, `network_error`, HTTP `502/503/504`。
|
||||
- `non_json_response`, `json_parse_error`, `empty_response`, HTTP `4xx` は retry しない。
|
||||
- 成功は Issue 化しない。breadcrumb と span にのみ記録。
|
||||
- `slow_success` の captureMessage は第1段階では無効化した。breadcrumb/span には残る。
|
||||
- JSON endpoint は HTTP 200 だけでは success とせず、JSON parse 成功後に success 記録する。
|
||||
|
||||
## 未実装・次段階
|
||||
|
||||
- WebView 内 fetch の計測 hook は未実装。
|
||||
- stale cache fallback の共通化は未実装。
|
||||
- connection type の取得は未実装。必要なら `@react-native-community/netinfo` 等の導入可否を確認する。
|
||||
- Sentry metrics は未実装。今回は span / breadcrumb / warning/error event に限定。
|
||||
- slow success の warning event 化は無効。Sentry ノイズを見てから sample rate を上げる。
|
||||
|
||||
## 実機テスト観点
|
||||
|
||||
### 通常系
|
||||
|
||||
1. アプリを起動する。
|
||||
2. トップメニューを表示する。
|
||||
3. 走行位置タブを開く。
|
||||
4. 運行情報タブを開く。
|
||||
5. しばらく放置して 30 秒 interval の再取得を待つ。
|
||||
|
||||
期待:
|
||||
|
||||
- UI 挙動が従来と変わらない。
|
||||
- Sentry の event / span / breadcrumb に `area:data_fetch` が見える。
|
||||
- `endpoint:positions`, `endpoint:operation_info`, `endpoint:train_operation`, `endpoint:timetable` で絞れる。
|
||||
|
||||
### 失敗系
|
||||
|
||||
1. 機内モード、または通信不安定状態で起動する。
|
||||
2. 走行位置・運行情報を開く。
|
||||
|
||||
期待:
|
||||
|
||||
- `result:timeout` または `result:network_error` が Sentry に warning として出る。
|
||||
- `contexts.data_fetch.durationMs`, `timeoutMs`, `endpoint`, `root_tab` が入る。
|
||||
- 既存 UI の fallback / error 表示が大きく変わらない。
|
||||
|
||||
### non_json / parse_error
|
||||
|
||||
実機で一時的に endpoint を HTML 返却 URL に向ける場合のみ確認。通常の配信ビルドでは無理に発生させない。
|
||||
|
||||
期待:
|
||||
|
||||
- HTML 返却は `result:non_json` / `kind:non_json_response`。
|
||||
- JSON parse 失敗は `result:parse_error` / `kind:json_parse_error`。
|
||||
- `responseHead` は最大300文字。
|
||||
|
||||
## Sentry 確認クエリ
|
||||
|
||||
```text
|
||||
area:data_fetch
|
||||
area:data_fetch endpoint:positions
|
||||
area:data_fetch endpoint:operation_info
|
||||
area:data_fetch result:timeout
|
||||
area:data_fetch result:network_error
|
||||
area:data_fetch result:non_json
|
||||
area:data_fetch result:parse_error
|
||||
area:data_fetch platform:android
|
||||
area:data_fetch platform:ios
|
||||
area:data_fetch source:rn_fetch
|
||||
```
|
||||
|
||||
Performance / spans 側では `op:http.client`、span name `fetch positions` などで確認する。
|
||||
|
||||
## 検証状況
|
||||
|
||||
- `git diff --check` は対象ファイルで通過。
|
||||
- `npx tsc --noEmit` は失敗するが、今回追加した network observability 由来の TypeScript エラーは出ていない。
|
||||
- 残っている主な既存エラー:
|
||||
- `Apps.tsx` の `event.preventDefault` 型
|
||||
- `expo-live-activity` 型解決
|
||||
- `components/trainMenu.web.tsx` の web 側型エラー
|
||||
- `lib/felicaStationMap.ts` の重複 object key
|
||||
|
||||
## 2026-07-07 第1回観測後の追加修正
|
||||
|
||||
第1回観測で `operation_info` の `script.google.com` が timeout したため、以下を追加修正した。
|
||||
|
||||
- timeout 時の Sentry primary error が `AbortError` に寄らないよう、timeout では `ObservedFetchError("Network request timed out")` を primary として `captureException` する。timeout 時は元の AbortError を `cause` に入れない。
|
||||
- `root_tab` が `unknown` になりにくいよう、`rootNavigationRef` から現在 route を取れない場合は `lastObservedRootRouteRef` を fallback にする。
|
||||
- `Apps.tsx` の root navigation state 更新時に、最後に観測した root route を `lastObservedRootRouteRef` に保存する。
|
||||
- `operation_info` の Google Apps Script text 取得は通常時に必ず走らせず、n8n の flag 取得で対象エリアがある場合だけ遅延実行する。
|
||||
- 起動直後や `topMenu` 表示中の負荷を避けるため、初回 `operation_info` flag 取得は `InteractionManager.runAfterInteractions` と `setTimeout` で遅延する。
|
||||
- 自作 span は `name` / `description` ともに `fetch <endpoint>` に統一した。実 URL が出る自動 HTTP span は Sentry SDK 側の自動計測として許容する。
|
||||
|
||||
この変更により、通常時の `script.google.com` text 取得は発生しにくくなり、運行情報対象駅がある場合のみ補助テキストとして取得される。
|
||||
|
||||
## 注意
|
||||
|
||||
今回の変更は観測基盤の第1段階であり、通信失敗時の UI 改善や stale cache 表示は含まない。実機で `area:data_fetch` のイベント量とノイズを確認してから、WebView fetch 計測と stale fallback を次段階で検討する。
|
||||
|
||||
|
||||
## 第2回観測対応: positions timeout / background noise / huge span
|
||||
|
||||
Sentry観測で、foreground / positions表示中 / online / wifi の状態で `endpoint=positions` が timeout していた。
|
||||
対象は `jr-shikoku-backend-mock-api-v1.haruk.in/train-positions/current`。
|
||||
観測値は `durationMs ~= 8028ms`, `timeoutMs = 8000`, `retryCount = 1` で、これはユーザー体感に直結する取得失敗として扱う。
|
||||
|
||||
### 実装したアプリ側対策
|
||||
|
||||
- `observedFetch` のtimeoutを `AbortController` 依存だけでなく `Promise.race` に変更した。
|
||||
- React Native側のfetch abortが即時rejectしない場合でも、自作Sentry spanはtimeout時点で終了する。
|
||||
- `operation_info` の29分級spanは、この経路が主因候補。
|
||||
- retry前にruntime状態を再確認するようにした。
|
||||
- `AppState.currentState === "background"` ならretryしない。
|
||||
- `navigator.onLine === false` が取れる環境ではoffline時もretryしない。
|
||||
- background/offline由来の取得抑制は `aborted` breadcrumb に留め、Issue化しない。
|
||||
- fetch失敗後にruntimeがbackground/offlineなら `captureException` しない。
|
||||
- `positions` のmock API取得にアプリ内last-good cacheを追加した。
|
||||
- `/train-positions/current` がtimeout/network errorになっても、前回成功データがあればそれを返す。
|
||||
- stale cache利用時は `fetch:stale_cache` breadcrumb を残す。
|
||||
- 走行位置画面側でも、前回表示データがある場合は取得失敗で `currentTrainLoading=error` に落とさず `success` を維持する。
|
||||
- `operation_info` の遅延fetch timerをcleanupするようにした。
|
||||
- `InteractionManager.runAfterInteractions` 後の `setTimeout` がProvider破棄後に残る経路を潰した。
|
||||
- Google Apps Script詳細取得の遅延timerも多重起動時に前回timerをclearする。
|
||||
|
||||
### このリポジトリで実装できなかったバックエンド側項目
|
||||
|
||||
このリポジトリ内を検索したが、`/train-positions/current` のサーバー実装は含まれていない。
|
||||
見つかったのはフロント側の呼び出し元 `lib/mockApi/positionMasters.ts` のみ。
|
||||
そのため、以下はバックエンド側リポジトリで実装が必要。
|
||||
|
||||
- `/train-positions/current` handler全体のserver-side duration log
|
||||
- Sentry transaction/span
|
||||
- upstream JR四国取得duration
|
||||
- cache hit/miss
|
||||
- stale/last-known-good cache返却
|
||||
- upstream timeout時に200 + stale payloadを返すか、少なくとも短時間で明示的なfallback responseを返す設計
|
||||
|
||||
### 外部APIの簡易計測
|
||||
|
||||
2026-07-07時点で手元から直接 `curl` 計測した結果:
|
||||
|
||||
```
|
||||
http_code=200
|
||||
time_total=1.449047
|
||||
time_connect=0.358893
|
||||
time_starttransfer=1.448716
|
||||
size_download=4887
|
||||
```
|
||||
|
||||
この1回の計測では8秒遅延は再現していない。
|
||||
Sentry上のtimeoutは一過性の上流遅延、バックエンド側のcache miss、または該当時刻の外部取得詰まりを疑う。
|
||||
|
||||
### 次にSentryで見るポイント
|
||||
|
||||
- `area:data_fetch endpoint:positions result:timeout` がIssue化されるのは foreground / online のみになっているか。
|
||||
- timeout後に `fetch:stale_cache` breadcrumb が付くか。
|
||||
- `operation_info` のspan durationが15秒timeout近辺で止まり、29分級spanが再発しないか。
|
||||
- background中の `network_error` がIssue化されずbreadcrumbに留まるか。
|
||||
|
||||
### 残課題
|
||||
|
||||
- offline判定は現時点では `navigator.onLine` が取れる環境に限定される。
|
||||
React Nativeで確実に判定するなら `@react-native-community/netinfo` などの導入が必要。
|
||||
- last-good cacheはアプリプロセス内メモリのみ。アプリ再起動後もstale表示したい場合は永続cacheが必要。
|
||||
- 根本対策はバックエンド側で `/train-positions/current` のlast-known-good cacheを返すこと。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,58 @@
|
||||
# 保存した運用録画のファイルインポート/エクスポート方針
|
||||
|
||||
## 目的
|
||||
|
||||
保存した走行位置録画を、クリップボードだけでなくファイルとして受け渡しできるようにする。
|
||||
録画データを JSON として書き出し、同じ JSON を選択して取り込める構成にする。
|
||||
|
||||
## 導入済みの必須ライブラリ
|
||||
|
||||
- `expo-file-system`: 録画 JSON を一時ファイルまたは Documents 配下へ書き出し、インポート時に選択済みファイルを読み込む。
|
||||
- `expo-document-picker`: ユーザーに録画 JSON ファイルを選ばせる。読み込み直後に扱いやすくするため、実装時は `copyToCacheDirectory: true` を使う。
|
||||
|
||||
既存の `expo-sharing` は単一ファイルの共有 fallback として残す。`react-native-share` は複数画像や複数ファイルの同時共有、共有先ごとの制御が必要なケースで使う。既存の `expo-clipboard` はコピペ export/import の fallback として残す。
|
||||
|
||||
## 実装済み
|
||||
|
||||
- 録画1件と全件の JSON envelope 書き出し。
|
||||
- `react-native-share` によるネイティブ共有/保存。
|
||||
- Web では `Blob` と `download` による JSON ダウンロード。
|
||||
- `expo-document-picker` と `expo-file-system` による JSON 読み込み。
|
||||
- 既存保存形式と同じ検証処理を通したインポート。
|
||||
- 録画本体は `Paths.document/train-recordings` 配下の JSON ファイルへ保存し、AsyncStorage/SQLite には軽量な一覧メタだけ保存する。
|
||||
- 旧SQLite保存の録画本体は、起動時/インポート時にファイルへ移行して旧キーを削除する。
|
||||
|
||||
## 想定する実装方針
|
||||
|
||||
### ネイティブ
|
||||
|
||||
1. 録画データを version 付き envelope JSON に変換する。
|
||||
2. `expo-file-system` で `cacheDirectory` に `.json` ファイルを書き出す。
|
||||
3. 単一 JSON は `expo-sharing` または `react-native-share` で共有する。
|
||||
4. 複数画像や複数ファイルを同時共有する場合は `react-native-share` の `urls` を使う。
|
||||
5. インポートは `expo-document-picker` で JSON を選択する。
|
||||
6. 選択したファイルを `expo-file-system` で読み込み、既存の検証処理を通して保存する。
|
||||
|
||||
### Web
|
||||
|
||||
Web ではローカル file URI の `expo-sharing` が使えないため、`Blob` と `URL.createObjectURL` による download を別実装にする。
|
||||
インポートはブラウザの file input 相当で読み込む方針にする。
|
||||
|
||||
|
||||
## 正式採用する共有ライブラリ
|
||||
|
||||
- `react-native-share`: 複数画像や複数ファイルを同時共有するために正式採用する。Expo config plugin は options 付きで登録し、初期状態では `ios` と `android` の query 追加を空配列にしている。特定アプリ向けの `shareSingle` や `isPackageInstalled` が必要になった時点で、対象アプリの scheme/package を追加する。
|
||||
- `enableBase64ShareAndroid`: 初期状態では `false`。ファイル URI 共有を基本にするため、Android の legacy storage permission を増やさない。base64 共有が必要になった場合だけ有効化する。
|
||||
|
||||
## 今回インストールしない候補
|
||||
|
||||
- `pako` または `fflate`: 録画 JSON が大きくなったときの gzip/deflate 圧縮候補。まずは plain JSON で運用し、共有できないサイズになった時点で入れる。
|
||||
- `expo-file-system` config plugin options: iOS の Files アプリからアプリ Documents を直接見せたい場合に `enableFileSharing` と `supportsOpeningDocumentsInPlace` を検討する。現時点では共有シートと DocumentPicker で足りる。
|
||||
- `expo-sharing` の共有受け取り設定: 他アプリから「このアプリで開く」導線を作る候補。まずはアプリ内のインポートボタンから選択する方式にする。
|
||||
- `react-native-blob-util`: 大容量/バックグラウンド/Android Storage Access Framework の細かい制御が必要になった場合の候補。Expo managed workflow では導入コストが上がるため、現時点では使わない。
|
||||
|
||||
## 参考
|
||||
|
||||
- https://docs.expo.dev/versions/latest/sdk/filesystem/
|
||||
- https://docs.expo.dev/versions/latest/sdk/document-picker/
|
||||
- https://docs.expo.dev/versions/latest/sdk/sharing/
|
||||
@@ -0,0 +1,372 @@
|
||||
# Sentry Crash 調査履歴
|
||||
|
||||
## 目的
|
||||
このメモは、現在追っているクラッシュ調査の経緯を ChatGPT 側で再考察しやすい形に整理したもの。
|
||||
|
||||
## 現象
|
||||
- `top menu` を表示した後、タブ切り替えや回転をきっかけにクラッシュする。
|
||||
- Android / iOS の両方で再現した。
|
||||
- 既存の `lazy` / `detachInactiveScreens` は必須前提として扱う。
|
||||
- 走行位置、運行情報、トップメニューの各画面で WebView / ネイティブ View のマウントが絡んでいる。
|
||||
|
||||
## 最初の主要シグナル
|
||||
- Sentry の主エラーは `RetryableMountingLayerException: Unable to find viewState for tag ...`
|
||||
- 典型的には Fabric / New Architecture のネイティブ UI ツリー更新失敗を示していた。
|
||||
- つまり JavaScript の例外というより、View の mount / unmount タイミング競合を疑うべき状態だった。
|
||||
|
||||
## 試した方向性の履歴
|
||||
|
||||
### 1. 遷移タイミングをずらす
|
||||
- `navigate` / `replace` / `reset` の直後に落ちるケースを疑い、遷移を一拍遅らせる案を検討した。
|
||||
- ただし症状は完全には止まらず、遷移先だけの問題ではなかった。
|
||||
|
||||
### 2. ライフサイクル依存の自動解放を抑える
|
||||
- iOS でブラウザや WebView がタブ切り替え時に開放される問題があり、`lazyload` を外す方向は破綻した。
|
||||
- そのため、WebView を「常時破棄しない」方向を維持する必要があった。
|
||||
|
||||
### 3. Sentry の観測点を増やす
|
||||
- 画面ごとに breadcrumb と context を追加した。
|
||||
- 追加した主な観測点:
|
||||
- `Apps.tsx`
|
||||
- `Top.tsx`
|
||||
- `components/Apps.tsx`
|
||||
- `components/Apps/WebView.tsx`
|
||||
- `MenuPage.tsx`
|
||||
- `ndView.tsx`
|
||||
- `lib/useWebViewRemount.ts`
|
||||
- これで「どの画面がいつ active になったか」「WebView がいつ remount されたか」を追えるようにした。
|
||||
|
||||
## 重要な発見
|
||||
|
||||
### 発見 1: 走行位置画面の WebView が、非表示なのに active 扱いになる
|
||||
- Sentry のイベントで、`root_tab = topMenu` なのに `positions_screen.activatedScreen = true` や `positions_webview.currentUrl = https://train.jr-shikoku.co.jp/sp.html` が見えていた。
|
||||
- Replay 上はトップメニューだけに見えても、内部 state は走行位置側が先に活性化していた。
|
||||
- これは「見た目では隠れているが、React Navigation / Fabric 的には既に mount されている」可能性を示した。
|
||||
|
||||
### 発見 2: `useIsFocused()` を初期 render の active 判定に使うのが危険
|
||||
- `useIsFocused()` 由来の初期状態が、ナビゲータ初期化直後に想定外の `true` を返す経路があった。
|
||||
- その結果、`positions` 側の root / stack / screen / webview が早すぎるタイミングで活性化した。
|
||||
- これが Fabric の mount 競合を起こしている可能性が高い。
|
||||
|
||||
### 発見 3: `positions` の gate を 1 枚かませても足りなかった
|
||||
- `DeferredPositionsRoot` を追加して root 側を遅延させたが、それでも crash は残った。
|
||||
- つまり root だけではなく、stack / screen / WebView 側にも同じ「初期 focused 扱い」の問題が潜んでいた。
|
||||
|
||||
## 反復した修正
|
||||
|
||||
### 実施した instrumentation
|
||||
- root navigation の state 変化を breadcrumb に残すようにした。
|
||||
- `positions_stack` / `positions_screen` / `positions_webview` / `operation_screen` などの context を追加した。
|
||||
- WebView remount の理由も記録するようにした。
|
||||
|
||||
### 実施した防御
|
||||
- 画面遷移時の WebView remount を観測しやすくした。
|
||||
- Android の回転時やバックグラウンド遷移時に何が起きるか追えるようにした。
|
||||
|
||||
## 現在の仮説
|
||||
- 根本原因は React Navigation そのものではなく、初期 render 時の `focused` 判定を前提にした activation が早すぎること。
|
||||
- その結果、非表示の `positions` ツリーが先に active になり、Fabric が mount state を失う。
|
||||
- 画面遷移先の問題というより、「起動直後の数タイミングで走行位置を経由したタブ遷移が入ると壊れる」挙動に近い。
|
||||
|
||||
## upstream issue 調査メモ
|
||||
- `software-mansion/react-native-reanimated#9785`
|
||||
- `open`
|
||||
- Android + Fabric + React Navigation stack animation 有効時に `SurfaceMountingManager` で crash。
|
||||
- Android だけ stack animation を `none` にすると止まる、という回避策まで一致している。
|
||||
- `software-mansion/react-native-reanimated#6908`
|
||||
- `closed`
|
||||
- Android Fabric で `entering/exiting animations` 使用時に大量 crash。
|
||||
- 本文中に `RetryableMountingLayerException: Unable to find viewState for tag ... Surface stopped: false` があり、Sentry の症状にかなり近い。
|
||||
- `software-mansion/react-native-reanimated#9636`
|
||||
- `closed`
|
||||
- `NativeProxy.preserveMountedTags` が Fabric の view preallocation 中に `Unable to find view for tag ... Surface stopped: false` で落ちる。
|
||||
- 重い native view が同期イベントを投げると crash しやすいという話で、WebView / map 系にも連想が効く。
|
||||
- `software-mansion/react-native-reanimated#8344`
|
||||
- `open`
|
||||
- multi-surface で `LayoutAnimationsProxy` が surface aware でない、という報告。
|
||||
- 直接 Android の同一事象ではないが、「surface をまたぐ animation / mount transaction が壊れる」という意味で構造が近い。
|
||||
|
||||
## 現時点での対策方針
|
||||
- Android の React Navigation stack animation は無効化する。
|
||||
- Android の Reanimated `entering/exiting/layout` は、実際に使っている箇所から順に止めて再現率を下げる。
|
||||
- 現在 repo 内で確認できた該当箇所は `components/Menu/Carousel/CarouselBox.tsx` の `FadeIn/FadeOut`。
|
||||
|
||||
## 直近の修正
|
||||
- `Apps.tsx`
|
||||
- `DeferredPositionsRoot` の activation 初期値を `false` に変更。
|
||||
- `Top.tsx`
|
||||
- positions stack の activation 初期値を `false` に変更。
|
||||
- `components/Apps.tsx`
|
||||
- positions screen / WebView の activation 初期値を `false` に変更。
|
||||
- `ndView.tsx`
|
||||
- operation WebView の activation 初期値を `false` に変更。
|
||||
|
||||
## 直近の OTA
|
||||
- branch: `gmarket`
|
||||
- message: `initialize tab activation gates from false`
|
||||
- update group ID: `82552333-cb5e-455f-ac58-65ada37f7acd`
|
||||
- Android update ID: `019f2133-c3e6-7050-911c-49052b6b17a1`
|
||||
- iOS update ID: `019f2133-c3e6-79b9-9776-88fd55ff72b8`
|
||||
- commit: `cf3995fcbe34366b32b09ee1cfb6b27a7e42982c`
|
||||
|
||||
## いま ChatGPT に見てほしい論点
|
||||
1. 初期 focused 判定を `false` にしても crash が続くなら、次はどの画面の mount ordering を疑うべきか。
|
||||
2. `positions` ツリーのどこまでが本当に先に active になるのかを、Sentry context と replay でどう切り分けるか。
|
||||
3. Fabric / New Architecture の mount race として扱うべきか、React Navigation の state propagation 問題として扱うべきか。
|
||||
|
||||
## 参考ファイル
|
||||
- [Apps.tsx](/home/ubuntu/jrshikoku/Apps.tsx)
|
||||
- [Top.tsx](/home/ubuntu/jrshikoku/Top.tsx)
|
||||
- [components/Apps.tsx](/home/ubuntu/jrshikoku/components/Apps.tsx)
|
||||
- [components/Apps/WebView.tsx](/home/ubuntu/jrshikoku/components/Apps/WebView.tsx)
|
||||
- [ndView.tsx](/home/ubuntu/jrshikoku/ndView.tsx)
|
||||
- [lib/useWebViewRemount.ts](/home/ubuntu/jrshikoku/lib/useWebViewRemount.ts)
|
||||
|
||||
|
||||
## 現在の安定化パッチの意味
|
||||
- 最新の `gmarket` 配信では、クラッシュ回避のために非表示タブの重い native view をかなり積極的に止めている。
|
||||
- その結果、クラッシュは減った一方で、以下の UX 劣化が意図的に入っている。
|
||||
- 走行位置 / 運行情報の WebView がタブ離脱後に再初期化されやすい
|
||||
- タブ間で状態保持されず、開き直しに見える
|
||||
- 動的ルーティングやバックグラウンド継続性が弱くなっている
|
||||
- トップメニューの重い要素は遷移直後に即 mount されない
|
||||
|
||||
## 現在の安定基準点
|
||||
- `gmarket` で配信済みの `topMenu` heavy mount 遅延パッチでは、少なくとも直近の再現テストでクラッシュ頻度は大きく下がっている。
|
||||
- したがって、ここを一旦「クラッシュしにくい基準点」として扱い、以降は UX を壊している防御を一つずつ戻していく方針が妥当。
|
||||
- この時点で重要なのは「全部戻す」ことではなく、「どの防御を戻すとクラッシュが再発するか」を Sentry で切り分けること。
|
||||
|
||||
## UX 劣化の主因になっている防御
|
||||
- `components/Apps.tsx`
|
||||
- `positions` の WebView を blur 時に teardown / deactivate している。
|
||||
- `Apps.tsx` / `Top.tsx` / `lib/rootNavigation.ts`
|
||||
- `positions` タブ離脱時に root / stack 側も落とす寄りの制御を入れている。
|
||||
- `ndView.tsx`
|
||||
- `information` の WebView も blur 中は deactivate している。
|
||||
- `menu.tsx`
|
||||
- `topMenu` の `MapView` / `CarouselBox` / `LED_vision` を Android で遅延 mount している。
|
||||
|
||||
## ここから戻す優先順位
|
||||
1. `positions` の blur 時 WebView teardown を部分的に戻す
|
||||
- 対象: `components/Apps.tsx`
|
||||
- 理由: 走行位置の開き直し、状態喪失、バックグラウンド継続切断に最も効いているため。
|
||||
2. `positions` の tab blur 時 stack / root teardown を緩める
|
||||
- 対象: `Apps.tsx`, `Top.tsx`, `lib/rootNavigation.ts`
|
||||
- 理由: タブ間データ共有、動的ルーティング、navigation state 維持の破綻に直結しているため。
|
||||
3. `information` の blur 時 WebView teardown を戻す
|
||||
- 対象: `ndView.tsx`
|
||||
- 理由: 運行情報の開き直しや iOS 側の裏ブラウザ保持に関係するため。
|
||||
4. `topMenu` の heavy mount 遅延を必要最小限まで縮める
|
||||
- 対象: `menu.tsx`
|
||||
- 理由: 初回表示のもたつき改善には効くが、状態維持より優先度は下。
|
||||
5. 最終的に teardown をやめて pause ベースに置き換える
|
||||
- 対象: `components/Apps.tsx`, `ndView.tsx` など
|
||||
- 理由: 安定性を維持しながら UX を戻す本命だが、切り分け後にやるべき段階。
|
||||
|
||||
## 次の実施方針
|
||||
- まず `positions` を中心に戻す。
|
||||
- 1 回の変更で戻す範囲は狭くし、再現したらその差分だけを Sentry で見る。
|
||||
- つまり今後の作業は「クラッシュ回避策の全撤廃」ではなく、「UX を壊している要素を優先順に切り戻し、再発ラインを特定する」ことが目的。
|
||||
|
||||
|
||||
## 2026-07-03 時点の基準点
|
||||
|
||||
### 現在の評価
|
||||
- 起動直後の `positions -> topMenu` / `positions -> information` で高頻度に落ちていた主クラッシュは、現時点ではかなり再現率が下がっている。
|
||||
- 少なくとも直近の観測では、以前の `RetryableMountingLayerException` 系の大崩れは前面には出ていない。
|
||||
- そのため、この時点は「主問題をかなり抑え込めた基準点」として保存する価値がある。
|
||||
|
||||
### この時点で残っている別系統の問題
|
||||
- `JR-SHIKOKU-UNOFFICIAL-APPS-S`
|
||||
- `Error: Unsupported top level event type "topUserLocationChange" dispatched`
|
||||
- `root_tab = positions`
|
||||
- `level = fatal`
|
||||
- `ReactFabric-prod` 起点
|
||||
- これは、今回の `topMenu / information / hidden prewarm` 系の問題とは別系統とみなす。
|
||||
- つまり、この時点での主クラッシュ対策の評価をする際は、この issue は切り離して考える。
|
||||
|
||||
### この時点での差分の分類
|
||||
|
||||
#### A. 主クラッシュ抑止のコア差分
|
||||
以下は、現時点の安定化に本質的に効いている可能性が高く、安易に戻さない。
|
||||
|
||||
- `Apps.tsx`
|
||||
- `DeferredPositionsRoot`
|
||||
- `hasVisitedPositions`
|
||||
- startup delayed prewarm
|
||||
- `detachInactiveScreens={false}`
|
||||
- root tab の `positionsLifecycleRef` 連携
|
||||
- `Top.tsx`
|
||||
- `positions stack` の background prewarm
|
||||
- stack activation gate
|
||||
- `components/Apps.tsx`
|
||||
- `positions screen` / `WebView` の activation gate
|
||||
- `hasStableWebViewSession`
|
||||
- blur 時 preserve
|
||||
- unstable session 中の tab exit guard
|
||||
- `components/Apps/WebView.tsx`
|
||||
- WebView 初回安定化通知
|
||||
- message / load / process termination の監視強化
|
||||
- `lib/rootNavigation.ts`
|
||||
- `positionsLifecycleRef`
|
||||
- `lib/useWebViewRemount.ts`
|
||||
- remount reason 付きの共通制御
|
||||
|
||||
#### B. 補助的な安定化差分
|
||||
以下は、主因とは言い切れないが、再現率低下に寄与している可能性がある。
|
||||
|
||||
- `menu.tsx`
|
||||
- top menu heavy content の delayed activation
|
||||
- Android で `MapView` や重い要素をすぐ mount しない制御
|
||||
- `ndView.tsx`
|
||||
- operation WebView の delayed activation
|
||||
- operation 側 remount / parse / settings load の観測
|
||||
- `lib/stackOption.ts`
|
||||
- Android で push animation を切る変更
|
||||
- `components/Settings/settings.tsx`
|
||||
- settings stack へ共通 animation option を適用
|
||||
|
||||
#### C. 観測専用で後から削れる差分
|
||||
以下は、挙動改善というより Sentry で成功/失敗比較を行うための記録であり、安定化の評価が済めば削減可能。
|
||||
|
||||
- `Apps.tsx` の `nav.root` / `nav.tab` / `positions.root` breadcrumb と context の大半
|
||||
- `Top.tsx` の `positions.stack` breadcrumb / context
|
||||
- `components/Apps.tsx` の `positions.screen` breadcrumb / context
|
||||
- `components/Apps/WebView.tsx` の `positions.webview` breadcrumb / context
|
||||
- `MenuPage.tsx` の `topMenu.screen` breadcrumb / context
|
||||
- `menu.tsx` の `top_menu_runtime` breadcrumb / context
|
||||
- `ndView.tsx` の `operation.screen` / `operation.settings` / `operation.webview` breadcrumb / context
|
||||
- `components/Menu/Carousel/CarouselBox.tsx` の `menu.carousel` breadcrumb
|
||||
|
||||
### この時点での重要な判断
|
||||
- 今の差分は広いが、全部が同じ重みではない。
|
||||
- まず守るべきなのは `positions` の activation / preserve / unstable guard 周辺であり、ここが今回の基礎。
|
||||
- 一方、Sentry breadcrumb の大半は観測用なので、安定化判断後にかなり整理できる。
|
||||
- `CarouselBox.tsx` は特に「Android fallback ロジック」と「breadcrumb」の両方が混ざっているため、後で整理する際は分離して扱う。
|
||||
|
||||
### 今後の戻し方の基準
|
||||
- 戻すときは、まず観測用差分からではなく、`UX を壊しているがコアではない差分` を対象にする。
|
||||
- つまり順番としては:
|
||||
1. 観測を維持したまま補助差分を少し戻す
|
||||
2. 再発しないことを確認する
|
||||
3. 最後に breadcrumb / context を掃除する
|
||||
- コア差分を先に剥がすと、再び「どこで壊れたか分からない状態」に戻るので避ける。
|
||||
|
||||
### この時点の結論
|
||||
- `positions` の hidden / delayed activation と lifecycle guard 群は、今回の主クラッシュ対策の核として維持する。
|
||||
- `topMenu` / `operation` / `carousel` 周辺の差分は、主因ではなく補助あるいは観測の可能性があるため、今後の整理対象にできる。
|
||||
- 新たに見つかった `topUserLocationChange` は別問題であり、この基準点の評価には混ぜない。
|
||||
|
||||
## 2026-07-04 iOS WebView 保持問題の解決メモ
|
||||
|
||||
### 解決した現象
|
||||
- iOS で `トップメニュー -> 走行位置 -> トップメニュー -> 走行位置` と遷移すると、走行位置 WebView が毎回開き直る問題。
|
||||
- iOS で `トップメニュー -> 運行情報 -> トップメニュー -> 運行情報` と遷移すると、運行情報 WebView が毎回開き直る問題。
|
||||
- ユーザー確認により、最新 `gmarket` OTA で iOS の上記挙動は完全に解消した。
|
||||
|
||||
### 解決に効いた差分
|
||||
- `App.tsx`
|
||||
- iOS のみ `react-native-screens` の最適化を起動時に無効化した。
|
||||
- `enableFreeze(false)`
|
||||
- `enableScreens(false)`
|
||||
- この結果、タブ切替時に native screen 最適化層が WebView を実質的に破棄・再生成する経路を避けられた。
|
||||
- したがって、iOS の WebView 保持問題については、現時点では `react-native-screens` / freeze / native screen detach 周辺が主因だったと扱う。
|
||||
|
||||
### 解決時点の OTA
|
||||
- branch: `gmarket`
|
||||
- message: `gmarket ios screens webview retention test`
|
||||
- update group ID: `8aec430f-eb6d-4017-af74-c2ae7dee7fd4`
|
||||
- Android update ID: `019f2dfb-69f6-72ab-8cdd-a3d1b9f4df8d`
|
||||
- iOS update ID: `019f2dfb-69f6-75dd-956b-435a73519a54`
|
||||
- commit: `6cea1ed4e86e30f356a7a1b5fc289f9fe1f9b5f5`
|
||||
|
||||
### この解決の扱い
|
||||
- iOS では `react-native-screens` を無効化したため、画面保持は戻った。
|
||||
- 一方で、screen 最適化を切ったことによる副作用は今後確認が必要。
|
||||
- iOS の画面遷移アニメーションが重くならないか。
|
||||
- トップメニューや設定画面のスクロールが再び重くならないか。
|
||||
- スタック画面が増えた状態でメモリ使用量が過剰にならないか。
|
||||
- ただし、今回のユーザー確認では iOS の走行位置・運行情報 WebView 保持は解決済みとして扱う。
|
||||
|
||||
|
||||
## 2026-07-04 以降に残る検証項目
|
||||
|
||||
### 優先度 高: Android のタブ切替クラッシュが再発していないか
|
||||
- iOS 向けの `enableScreens(false)` は `Platform.OS === "ios"` 限定なので、Android の挙動は直接変えていない。
|
||||
- これまで Android では、走行位置の読み込み中にトップメニュー・運行情報へタブ切替すると `RetryableMountingLayerException` 系のクラッシュが再発していた。
|
||||
- 確認手順:
|
||||
- Android で起動直後に走行位置を開く。
|
||||
- ページ読み込みが終わる前にトップメニューへ切り替える。
|
||||
- 同じく、ページ読み込みが終わる前に運行情報へ切り替える。
|
||||
- それぞれ複数回繰り返し、Sentry に新規 fatal が出るかを見る。
|
||||
- 判定:
|
||||
- fatal が出なければ、Android の現行 guard は維持。
|
||||
- fatal が出るなら、Sentry breadcrumb で `positions.root` / `positions.stack` / `positions.screen` / `positions.webview` のどこまで進んだかを確認する。
|
||||
|
||||
### 優先度 高: iOS の解決差分の副作用確認
|
||||
- iOS で screen 最適化を無効化したため、UX とメモリの確認が必要。
|
||||
- 確認手順:
|
||||
- 走行位置、トップメニュー、運行情報、設定画面を長時間行き来する。
|
||||
- 設定画面やトップメニューの縦スクロールが重くならないか確認する。
|
||||
- 運行情報の横画面表示、縦横切替、戻しを複数回試す。
|
||||
- バックグラウンド復帰後に走行位置・運行情報が保持されるか確認する。
|
||||
- 判定:
|
||||
- 体感劣化が軽微なら、iOS の `enableScreens(false)` は維持。
|
||||
- 明確な重さが出るなら、iOS だけ root tab 周辺を JS view ベースにしつつ、stack 側の screen 最適化だけ戻す案を検討する。
|
||||
|
||||
### 優先度 中: Android で `react-native-reanimated-carousel` を戻せるか
|
||||
- 過去に Android のカクつき・クラッシュ対策として carousel 周辺を弱めた。
|
||||
- ユーザー要望として、Android では `reanimated-carousel` をできれば使いたい。
|
||||
- 確認手順:
|
||||
- 現行の Android 安定性を先に確認する。
|
||||
- その後、carousel を段階的に戻す。
|
||||
- 戻す場合も Reanimated の `entering` / `exiting` / `layout` アニメーションとは分離して検証する。
|
||||
- 判定:
|
||||
- carousel 復帰で crash が出なければ採用。
|
||||
- `RetryableMountingLayerException` が戻るなら、Android では carousel 本体または Reanimated layout animation を避ける。
|
||||
|
||||
### 優先度 中: Android / iOS の Sentry ノイズ整理
|
||||
- 現在は切り分けのため breadcrumb / context が多い。
|
||||
- 安定化判断後は、Sentry に残すものと削るものを分ける。
|
||||
- 残す候補:
|
||||
- root tab 遷移
|
||||
- WebView remount reason
|
||||
- process termination
|
||||
- fatal 直前の active tab / orientation
|
||||
- 削る候補:
|
||||
- 通常 focus / blur の過剰な breadcrumb
|
||||
- loadStart / loadEnd の頻繁な記録
|
||||
- 一時的な activation gate の詳細 context
|
||||
|
||||
### 優先度 中: `topUserLocationChange` 系 fatal の別問題調査
|
||||
- 以前確認した `JR-SHIKOKU-UNOFFICIAL-APPS-S` は今回の WebView 保持問題とは別系統として扱う。
|
||||
- `Unsupported top level event type "topUserLocationChange" dispatched`
|
||||
- `root_tab = positions`
|
||||
- ReactFabric 起点。
|
||||
- 確認手順:
|
||||
- 位置情報取得中、走行位置表示中、タブ切替中のどのタイミングで出るか Sentry で再確認する。
|
||||
- `useUserPosition` / MapView / location watcher 周辺を優先して見る。
|
||||
|
||||
### 優先度 低: トップメニュー指定起動時のバックグラウンド preload
|
||||
- 過去の課題として、トップメニュー指定で起動した場合に走行位置・運行情報がバックグラウンドで読み込まれない問題が残っている。
|
||||
- iOS の WebView 保持が戻ったため、再度評価する価値がある。
|
||||
- 確認手順:
|
||||
- `topMenu` 初期起動。
|
||||
- 走行位置を開く前に、走行位置 WebView の prewarm が開始しているか Sentry context で見る。
|
||||
- 運行情報も同様に hidden prewarm されるか見る。
|
||||
- ただし、Android の起動直後クラッシュ再発リスクがあるため、優先度は低め。
|
||||
|
||||
### 優先度 低: 過去の回避策の整理
|
||||
- iOS WebView 保持は `enableScreens(false)` で解決したため、それ以前に入れた一部の回避策は不要になった可能性がある。
|
||||
- ただし Android のクラッシュ抑止にはまだ必要な可能性があるため、削除は Android 安定確認後に行う。
|
||||
- 整理候補:
|
||||
- `components/Apps/WebView.tsx` の iOS ping watchdog の必要性。
|
||||
- `lib/useWebViewRemount.ts` の blur grace window の必要性。
|
||||
- `Apps.tsx` の `PositionsTabScreen` 固定化の必要性。
|
||||
- `ndView.tsx` の operation WebView delayed activation の必要性。
|
||||
|
||||
### 次の実務順
|
||||
1. Android で走行位置読み込み中のタブ切替クラッシュが残っているか確認する。
|
||||
2. iOS で `enableScreens(false)` の副作用を確認する。
|
||||
3. Android が安定している場合のみ、`reanimated-carousel` 復帰検証に進む。
|
||||
4. 安定確認後、Sentry breadcrumb と暫定回避策を整理する。
|
||||
@@ -0,0 +1,525 @@
|
||||
# Sentry観測ログ整理メモ
|
||||
|
||||
作成日: 2026-07-07
|
||||
|
||||
対象配信:
|
||||
|
||||
- EAS channel: `gmarket`
|
||||
- Update group ID: `8fd66cda-2f4a-40e2-937f-7de8e62c72ec`
|
||||
- Runtime version: `exposdk:55.0.0`
|
||||
- Message: `gmarket app lifecycle crash sentinel`
|
||||
|
||||
## 目的
|
||||
|
||||
このメモは、現在のアプリがSentryへ送るはずのログ情報を、機能別に整理する。
|
||||
|
||||
今後、データ通信エラー、WebView白化、Sentryにfatal crashとして出ない体感クラッシュが発生したときに、どのSentry情報を見ればよいかを明確にする。
|
||||
|
||||
## 全体像
|
||||
|
||||
現在Sentryで観測できる主な系統は次の5つ。
|
||||
|
||||
| 系統 | 主な機能 | Sentry上の見え方 | 目的 |
|
||||
|---|---|---|---|
|
||||
| 通信計測 | RN側fetch wrapper | `area=data_fetch` のIssue、breadcrumb、span | endpoint別の失敗、timeout、HTTP status、JSON parse失敗を追う |
|
||||
| アプリライフサイクル | crash sentinel | `app.previous_session_unexpected_exit` | fatal crashとして出ない突然終了を次回起動で検知する |
|
||||
| root navigation | タブ遷移、root状態 | `root_navigation` context、`nav.root`/`nav.tab` breadcrumb | どのタブ、どの遷移直後に問題が起きたかを追う |
|
||||
| WebView状態 | 走行位置、運行情報、hidden preload | `active_webviews` context、各WebView breadcrumb | クラッシュ/白化直前にどのWebViewが生きていたかを見る |
|
||||
| 画面ゲート/プリロード | positions/information lazy activation | `positions_root_gate` / `operation_root_gate` context、root breadcrumb | preloadや初回activationのタイミング問題を追う |
|
||||
|
||||
## 1. 通信計測 data_fetch
|
||||
|
||||
実装場所:
|
||||
|
||||
- `lib/observability/network/observedFetch.ts`
|
||||
- `lib/observability/network/sentryNetwork.ts`
|
||||
- `lib/observability/network/endpoints.ts`
|
||||
- `lib/observability/network/types.ts`
|
||||
|
||||
### 何をしているか
|
||||
|
||||
RN側の通信を `observedFetchJson` / `observedFetchText` 経由にして、endpointごとに次を記録する。
|
||||
|
||||
- fetch開始 breadcrumb
|
||||
- 成功/失敗 breadcrumb
|
||||
- `http.client` span
|
||||
- 失敗時の `captureException`
|
||||
- timeout時の primary error: `ObservedFetchError("Network request timed out")`
|
||||
- JSON期待時のHTML返却検知
|
||||
- JSON parse失敗検知
|
||||
- retry回数
|
||||
- appState / online状態
|
||||
- URL全文ではなく host + path template
|
||||
|
||||
### Sentry tags
|
||||
|
||||
通信系Issueには主に次のtagが付く。
|
||||
|
||||
| tag | 値 | 用途 |
|
||||
|---|---|---|
|
||||
| `area` | `data_fetch` | 通信計測イベントの絞り込み |
|
||||
| `endpoint` | 低カーディナリティendpoint名 | どの論理取得か |
|
||||
| `result` | `timeout`, `network_error`, `http_error`, `non_json`, `parse_error`, `empty_response`, `success`, `slow_success`, `aborted` | 結果分類 |
|
||||
| `root_tab` | `positions`, `topMenu`, `information`, `unknown` | 発生時のroot tab |
|
||||
| `platform` | `ios`, `android` | OS差分 |
|
||||
| `source` | `rn_fetch`, `mock_api`, `n8n`, `gas`, `backend_api`, `static_storage`, `webview_fetch` | 取得元分類 |
|
||||
| `user_visible` | `true` / `false` | ユーザー体感に直結する取得か |
|
||||
| `preload` | `true` / `false` | preload扱いか |
|
||||
| `fetch_priority` | `high`, `medium`, `low` | 優先度 |
|
||||
| `status` | HTTP status文字列 | HTTP系の絞り込み |
|
||||
|
||||
### Sentry context: `data_fetch`
|
||||
|
||||
| context field | 内容 |
|
||||
|---|---|
|
||||
| `kind` | 失敗分類。captureException時に付く |
|
||||
| `endpoint` | 論理endpoint |
|
||||
| `method` | 現状ほぼ `GET` |
|
||||
| `status` | HTTP status |
|
||||
| `ok` | response.ok |
|
||||
| `durationMs` | 通信 + body read時間 |
|
||||
| `timeoutMs` | endpointごとのtimeout |
|
||||
| `bytes` | response body推定サイズ |
|
||||
| `contentType` | response content-type |
|
||||
| `responseHead` | 失敗時のみ最大300文字 |
|
||||
| `retryCount` | `0` または `1` |
|
||||
| `urlHost` | hostのみ |
|
||||
| `urlPathTemplate` | path templateのみ |
|
||||
| `appState` | `active`, `background`, `inactive` など |
|
||||
| `online` | `navigator.onLine` が取れた場合のオンライン状態 |
|
||||
|
||||
### breadcrumb
|
||||
|
||||
| category | message | level | 意味 |
|
||||
|---|---|---|---|
|
||||
| `data_fetch` | `fetch:start` | `info` | fetch開始 |
|
||||
| `data_fetch` | `fetch:success` | `info` | 成功 |
|
||||
| `data_fetch` | `fetch:slow_success` | `warning` | slowMs超過。ただしIssue化は現在無効 |
|
||||
| `data_fetch` | `fetch:timeout` | `error` | timeout |
|
||||
| `data_fetch` | `fetch:network_error` | `error` | fetch reject |
|
||||
| `data_fetch` | `fetch:http_error` | `error` | HTTP 4xx/5xx |
|
||||
| `data_fetch` | `fetch:non_json_response` | `error` | JSON期待だがJSONに見えない |
|
||||
| `data_fetch` | `fetch:json_parse_error` | `error` | JSON.parse失敗 |
|
||||
| `data_fetch` | `fetch:empty_response` | `error` | 空レスポンス |
|
||||
| `data_fetch` | `fetch:aborted` | `info` | background/offlineで開始抑制。Issue化しない |
|
||||
|
||||
### span
|
||||
|
||||
`Sentry.startSpan` が使える環境では次のspanを出す。
|
||||
|
||||
| field | 値 |
|
||||
|---|---|
|
||||
| `op` | `http.client` |
|
||||
| `name` | `fetch <endpoint>` |
|
||||
| `description` | `fetch <endpoint>` |
|
||||
| `app.area` | `data_fetch` |
|
||||
| `app.endpoint` | endpoint名 |
|
||||
| `app.root_tab` | root tab |
|
||||
| `http.method` | method |
|
||||
| `server.address` | URL host |
|
||||
|
||||
注意:
|
||||
|
||||
- 自作span名には実URLを入れない。
|
||||
- Sentry SDKの自動HTTP spanに実URLが出る可能性はある。
|
||||
|
||||
### Issue化ルール
|
||||
|
||||
| 結果 | Issue化 | level | 備考 |
|
||||
|---|---|---|---|
|
||||
| success | しない | breadcrumb/spanのみ | 正常系ノイズ防止 |
|
||||
| slow_success | 現在しない | warning予定 | `SLOW_SUCCESS_SAMPLE_RATE = 0` |
|
||||
| timeout | する | warning | `ObservedFetchError("Network request timed out")` |
|
||||
| network_error | active/onlineならする | warning | background/offline由来は抑制 |
|
||||
| http_error | する | warning | status付き |
|
||||
| non_json_response | する | warning | HTML返却調査用にresponseHead |
|
||||
| json_parse_error | する | error | 実装/データ形式不一致の疑い |
|
||||
| empty_response | する | warning | 空body |
|
||||
| aborted | しない | breadcrumbのみ | background/offline開始抑制 |
|
||||
|
||||
## 2. 現在の通信endpoint一覧
|
||||
|
||||
| endpoint | source | 機能 | 画面/利用箇所 | URL host/path | timeout | retry | user_visible | preload | priority |
|
||||
|---|---|---|---|---|---:|---|---|---|---|
|
||||
| `positions_current` | `n8n` | 現在走行位置 | 走行位置、列車詳細、発車標系 | `n8n.haruk.in` `/webhook/c501550c-7d1b-4e50-927b-4429fe18931a` | 8000ms | 最大1回 | true | false | high |
|
||||
| `positions_current` | `mock_api` | mock現在走行位置 | mock有効時の走行位置 | `jr-shikoku-backend-mock-api-v1.haruk.in` `/train-positions/current` | 8000ms | 最大1回 | true | false | high |
|
||||
| `positions_master` | `mock_api` | mock位置マスタ | mock有効時の補完データ | `jr-shikoku-backend-mock-api-v1.haruk.in` `/position-masters` | 8000ms | 最大1回 | false | true | medium |
|
||||
| `positions_gas_fallback` | `gas` | 走行位置fallback | n8n失敗時のみ | `script.google.com` GAS `/exec` | 8000ms | 最大1回 | true | false | high |
|
||||
| `operation_info_flag` | `n8n` | 運行情報フラグ/対象エリア | topMenu、information、badge | `n8n.haruk.in` `/webhook/jr-shikoku-trainfo-flag` | 10000ms | 最大1回 | true | true | medium |
|
||||
| `operation_info_text` | `gas` | 運行情報本文補助 | operation flagで対象ありの場合のみ | `script.google.com` GAS `/exec` | 15000ms | なし | true | false | medium |
|
||||
| `operation_logs` | `backend_api` | 列車別運行ログ | provider全体、列車詳細、WebView補助と重複 | `jr-shikoku-backend-api-v1.haruk.in` `/operation-logs` | 15000ms | 最大1回 | false | true | medium |
|
||||
| `train_operation_data` | `backend_api` | 編成/運用系データ | provider全体、列車詳細、走行位置WebView補助と重複 | `jr-shikoku-backend-api-v1.haruk.in` `/train-data` | 15000ms | 最大1回 | false | true | medium |
|
||||
| `timetable_today` | `static_storage` | 当日ダイヤJSON | provider全体、時刻表/発車標/列車詳細 | `jr-shikoku-api-data-storage.haruk.in` `/tmp/diagram-today.json` | 15000ms | 最大1回 | false | true | medium |
|
||||
|
||||
## 3. アプリライフサイクル crash sentinel
|
||||
|
||||
実装場所:
|
||||
|
||||
- `lib/observability/appLifecycleCrashSentinel.ts`
|
||||
- `App.tsx`
|
||||
- `Apps.tsx`
|
||||
- `components/Apps/WebView.tsx`
|
||||
- `ndView.tsx`
|
||||
|
||||
### 何をしているか
|
||||
|
||||
Sentryに `fatal`, `handled:false`, `event.type:crash` として出ない「ユーザー体感上の完全クラッシュ」を、次回起動時に検出する。
|
||||
|
||||
AsyncStorageに以下を保存する。
|
||||
|
||||
- `app_session_active=true`
|
||||
- `normal_background`
|
||||
- `sessionStartedAt`
|
||||
- `lastHeartbeatAt`
|
||||
- `lastKnownAppState`
|
||||
- `lastRootTab`
|
||||
- `previousRootNavigation`
|
||||
- `activeWebViews`
|
||||
- `nativeScreensMode`
|
||||
|
||||
稼働中は7秒ごとに `lastHeartbeatAt` を更新する。
|
||||
|
||||
AppStateが `background` になったら `normal_background=true` を記録する。
|
||||
|
||||
次回起動時、前回sessionが次を満たす場合だけ `app.previous_session_unexpected_exit` を送る。
|
||||
|
||||
- `app_session_active=true`
|
||||
- `normal_background=false`
|
||||
- `lastHeartbeatAt` が直近120秒以内
|
||||
- sessionが5秒以上続いていた
|
||||
- 同じ `sessionStartedAt` で未報告
|
||||
|
||||
### Sentry event
|
||||
|
||||
| event | level | 目的 |
|
||||
|---|---|---|
|
||||
| `app.previous_session_unexpected_exit` | warning | fatal crashとして出ない突然終了を補足 |
|
||||
|
||||
### tags
|
||||
|
||||
| tag | 内容 |
|
||||
|---|---|
|
||||
| `area=app_lifecycle` | lifecycle系の絞り込み |
|
||||
| `result=unexpected_exit` | 前回session異常終了 |
|
||||
| `platform` | `ios` / `android` |
|
||||
| `root_tab` | 起動時に把握できる現在root tab |
|
||||
| `last_root_tab` | 前回session最後のroot tab |
|
||||
| `native_screens_mode` | `screens-disabled` or `default` |
|
||||
|
||||
### context: `app_lifecycle`
|
||||
|
||||
| field | 内容 |
|
||||
|---|---|
|
||||
| `lastHeartbeatAt` | 前回session最後のheartbeat |
|
||||
| `sessionStartedAt` | 前回session開始時刻 |
|
||||
| `previousRootNavigation` | 前回最後に観測したroot/nested navigation |
|
||||
| `lastKnownAppState` | 前回最後のAppState |
|
||||
| `activeWebViews` | 前回最後にactive扱いだったWebView名 |
|
||||
| `memory` | `performance.memory` があればJS heap情報。RNではnullの可能性あり |
|
||||
| `expoUpdate` | `update_id`, `updateId`, `channel`, `runtimeVersion`, `createdAt`, `isEmbeddedLaunch` |
|
||||
| `normalBackground` | 前回正常background遷移したか |
|
||||
| `heartbeatAgeMs` | 現在から見た前回heartbeatの古さ |
|
||||
|
||||
### 関連context
|
||||
|
||||
| context | 内容 |
|
||||
|---|---|
|
||||
| `app_lifecycle_sentinel` | 現sessionの開始、heartbeat、appState、activeWebViews、expoUpdate |
|
||||
| `active_webviews` | 現在active扱いのWebView名一覧 |
|
||||
|
||||
### activeWebViews名
|
||||
|
||||
| name | 意味 |
|
||||
|---|---|
|
||||
| `positions_main` | 走行位置タブ本体のWebView |
|
||||
| `operation_info_main` | 運行情報タブ本体のWebView |
|
||||
| `startup_hidden_positions` | 起動時hidden preloadの走行位置WebView |
|
||||
| `startup_hidden_operation` | 起動時hidden preloadの運行情報WebView |
|
||||
|
||||
## 4. root navigation / tab操作ログ
|
||||
|
||||
実装場所:
|
||||
|
||||
- `Apps.tsx`
|
||||
- `lib/rootNavigation.ts`
|
||||
|
||||
### Sentry context: `root_navigation`
|
||||
|
||||
| field | 内容 |
|
||||
|---|---|
|
||||
| `rootIndex` | root tab index |
|
||||
| `rootRoute` | `positions`, `topMenu`, `information` |
|
||||
| `nestedIndex` | nested stack index |
|
||||
| `nestedRoute` | nested route名 |
|
||||
| `hasExtra` | 追加画面/stackが開いているか |
|
||||
| `source` | `ready` or `change` |
|
||||
| `operationOrientation` | `landscape` or `portrait` |
|
||||
| `hasVisitedPositions` | positions rootをactivate済みか |
|
||||
| `hasVisitedInformation` | information rootをactivate済みか |
|
||||
|
||||
### tags
|
||||
|
||||
| tag | 内容 |
|
||||
|---|---|
|
||||
| `root_tab` | 最後に観測したroot tab |
|
||||
| `native_screens_mode` | iOSは `screens-disabled`、Androidは `default` |
|
||||
|
||||
### breadcrumb
|
||||
|
||||
| category | message | 意味 |
|
||||
|---|---|---|
|
||||
| `runtime.flags` | `runtime navigation flags applied` | native screens/freeze設定 |
|
||||
| `nav.root` | `navigation ready` | NavigationContainer ready |
|
||||
| `nav.root` | `root state initialized` | 初期root state |
|
||||
| `nav.root` | `root state change` | root state変化 |
|
||||
| `nav.tab` | `root tabPress` | タブ押下 |
|
||||
| `nav.tab` | `root tabPress intercepted for unstable positions` | 不安定な走行位置離脱を検知 |
|
||||
| `nav.tab` | `root tabPress blocked while positions unstable` | 不安定な走行位置離脱をブロック |
|
||||
| `nav.tab` | `root tab focus` | root tab focus |
|
||||
| `nav.tab` | `root tab blur` | root tab blur |
|
||||
| `nav.tab` | `root tab state` | tab state event |
|
||||
|
||||
## 5. root gate / preloadログ
|
||||
|
||||
実装場所:
|
||||
|
||||
- `Apps.tsx`
|
||||
|
||||
### context
|
||||
|
||||
| context | 内容 |
|
||||
|---|---|
|
||||
| `positions_root_gate` | `focused`, `activated`, `shouldActivate` |
|
||||
| `operation_root_gate` | `focused`, `activated`, `shouldActivate` |
|
||||
| `startup_hidden_preload` | hidden preloadのshould/loaded状態 |
|
||||
|
||||
### breadcrumb
|
||||
|
||||
| category | message | 意味 |
|
||||
|---|---|---|
|
||||
| `positions.root` | `positions startup prewarm scheduled` | topMenu起動中にpositions prewarm予約 |
|
||||
| `positions.root` | `positions startup prewarm activated` | positions prewarm実行 |
|
||||
| `positions.root` | `positions root activation` | positions root初回activate |
|
||||
| `positions.root` | `positions root tabPress activation` | positionsタブ押下でactivate |
|
||||
| `positions.root` | `positions root focus activation` | positions focusでactivate |
|
||||
| `positions.root` | `positions root blur preserved` | blur後も保持 |
|
||||
| `positions.root` | `positions root exit blocked while session unstable` | 不安定セッションからの離脱ブロック |
|
||||
| `operation.root` | `operation startup prewarm scheduled` | information prewarm予約 |
|
||||
| `operation.root` | `operation startup prewarm activated` | information prewarm実行 |
|
||||
| `operation.root` | `operation root activation` | operation root初回activate |
|
||||
| `operation.root` | `operation root tabPress activation` | informationタブ押下でactivate |
|
||||
| `operation.root` | `operation root focus activation` | information focusでactivate |
|
||||
| `operation.root` | `operation root blur preserved` | blur後も保持 |
|
||||
| `startup.preload` | `positions hidden preload loadStart/loadEnd/error` | hidden positions WebViewのロード状態 |
|
||||
| `startup.preload` | `operation hidden preload loadStart/loadEnd/error` | hidden operation WebViewのロード状態 |
|
||||
|
||||
## 6. 走行位置WebViewログ
|
||||
|
||||
実装場所:
|
||||
|
||||
- `components/Apps/WebView.tsx`
|
||||
- `lib/useWebViewRemount.ts`
|
||||
|
||||
### context: `positions_webview`
|
||||
|
||||
| field | 内容 |
|
||||
|---|---|
|
||||
| `focused` | screen focus状態 |
|
||||
| `landscape` | 横画面か |
|
||||
| `mockApi` | mock API有効か |
|
||||
| `remountKey` | WebView remount key |
|
||||
| `currentUrl` | WebView現在URL。取れていない場合null |
|
||||
|
||||
### breadcrumb category: `positions.webview`
|
||||
|
||||
| message | 意味 |
|
||||
|---|---|
|
||||
| `webview focused` / `webview blurred` | focus変化 |
|
||||
| `webview pending initial inject resumed on focus` | focus復帰時に保留injectを実行 |
|
||||
| `webview remount key` | remount key変化 |
|
||||
| `webview remount requested` | WebView watchdog等でremount要求 |
|
||||
| `webview cleanup start` | unmount/cleanup開始 |
|
||||
| `webview unmounted` | unmount完了 |
|
||||
|
||||
注意:
|
||||
|
||||
- 公式サイト内XHR `/g?arg1=train&arg2=train` はRN側fetch wrapperを通らないため、現状 `data_fetch` には出ない。
|
||||
- WebView内部の公式通信や injected JS fetch は、現時点ではbreadcrumb/postMessage中心で、通信詳細は未計測。
|
||||
|
||||
## 7. 運行情報WebViewログ
|
||||
|
||||
実装場所:
|
||||
|
||||
- `ndView.tsx`
|
||||
- `lib/useWebViewRemount.ts`
|
||||
|
||||
### context: `operation_screen`
|
||||
|
||||
| field | 内容 |
|
||||
|---|---|
|
||||
| `focused` | information画面focus |
|
||||
| `orientation` | `portrait` / `landscape` |
|
||||
| `landscapeMode` | 運行情報横画面モード有効か |
|
||||
| `captureEnabled` | 画像切り出し有効か |
|
||||
| `activatedWebView` | WebView activate済みか |
|
||||
| `layoutWidth` / `layoutHeight` | root layout |
|
||||
| `contentTop` | safe area反映後top |
|
||||
| `signageSafeLeft` / `signageSafeRight` | 横画面safe area |
|
||||
| `remountKey` | WebView remount key |
|
||||
|
||||
### breadcrumb
|
||||
|
||||
| category | message | 意味 |
|
||||
|---|---|---|
|
||||
| `operation.screen` | `operation screen mounted/unmounted` | 画面mount状態 |
|
||||
| `operation.screen` | `operation focused/blurred` | focus変化 |
|
||||
| `operation.screen` | `operation layout` | layout/orientation変化 |
|
||||
| `operation.screen` | `operation webview activation scheduled` | WebView activate予約 |
|
||||
| `operation.screen` | `operation webview activated` | WebView activate実行 |
|
||||
| `operation.screen` | `operation appState` | AppState変化 |
|
||||
| `operation.settings` | `operation settings load scheduled/success/failed` | 運行情報設定の読込 |
|
||||
| `operation.webview` | `operation remount requested` | watchdog等のremount要求 |
|
||||
| `operation.webview` | `operation remount after background resume` | background復帰後remount |
|
||||
| `operation.webview` | `operation resume preserved existing webview` | background復帰後WebView保持 |
|
||||
| `operation.webview` | `operation script scheduled/injected` | injected script投入 |
|
||||
| `operation.webview` | `operation render process gone` | Android WebView render process gone |
|
||||
| `operation.webview` | `operation content process terminated` | iOS WebView content process termination |
|
||||
| `operation.webview` | `operation webview loadStart/loadEnd/error` | WebView load状態 |
|
||||
| `operation.webview` | `operation capture message received` | WebViewから画像切り出しmessage |
|
||||
| `operation.webview` | `operation message parse failed` | postMessage parse失敗 |
|
||||
|
||||
## 8. 調査時の優先クエリ
|
||||
|
||||
### 通信エラー全体
|
||||
|
||||
```text
|
||||
area:data_fetch
|
||||
```
|
||||
|
||||
### 走行位置の体感不具合
|
||||
|
||||
```text
|
||||
area:data_fetch endpoint:positions_current
|
||||
```
|
||||
|
||||
見る項目:
|
||||
|
||||
- `source`
|
||||
- `result`
|
||||
- `durationMs`
|
||||
- `timeoutMs`
|
||||
- `retryCount`
|
||||
- `root_tab`
|
||||
- `appState`
|
||||
- `online`
|
||||
- `urlHost`
|
||||
- `urlPathTemplate`
|
||||
|
||||
### GAS運行情報timeout
|
||||
|
||||
```text
|
||||
area:data_fetch endpoint:operation_info_text result:timeout
|
||||
```
|
||||
|
||||
見る項目:
|
||||
|
||||
- `durationMs`
|
||||
- `retryCount`
|
||||
- `root_tab`
|
||||
- `user_visible`
|
||||
- `preload`
|
||||
- `appState`
|
||||
|
||||
### background/offlineノイズ
|
||||
|
||||
```text
|
||||
area:data_fetch result:aborted
|
||||
```
|
||||
|
||||
原則Issue化されない。breadcrumbで確認する。
|
||||
|
||||
### JSONではなくHTMLが返ったケース
|
||||
|
||||
```text
|
||||
area:data_fetch result:non_json
|
||||
```
|
||||
|
||||
見る項目:
|
||||
|
||||
- `contentType`
|
||||
- `responseHead`
|
||||
- `status`
|
||||
- `urlHost`
|
||||
- `urlPathTemplate`
|
||||
|
||||
### Sentryにfatal crashが出ない体感クラッシュ
|
||||
|
||||
```text
|
||||
area:app_lifecycle result:unexpected_exit
|
||||
```
|
||||
|
||||
見る項目:
|
||||
|
||||
- `last_root_tab`
|
||||
- `app_lifecycle.lastHeartbeatAt`
|
||||
- `app_lifecycle.previousRootNavigation`
|
||||
- `app_lifecycle.lastKnownAppState`
|
||||
- `app_lifecycle.activeWebViews`
|
||||
- `app_lifecycle.expoUpdate.update_id`
|
||||
- `native_screens_mode`
|
||||
|
||||
### WebView白化/終了疑い
|
||||
|
||||
```text
|
||||
operation.webview
|
||||
positions.webview
|
||||
```
|
||||
|
||||
breadcrumbで見る。
|
||||
|
||||
関連context:
|
||||
|
||||
- `positions_webview`
|
||||
- `operation_screen`
|
||||
- `active_webviews`
|
||||
- `app_lifecycle`
|
||||
|
||||
## 9. 機能別に何が分かるか
|
||||
|
||||
| 機能 | Sentryで分かること | まだ分からないこと |
|
||||
|---|---|---|
|
||||
| RN走行位置取得 | n8n/mock/GASのどれが失敗したか、timeout/HTTP/parse、duration、retry | 上流サーバー内部の処理時間。backend側計測が必要 |
|
||||
| RN運行情報取得 | flag/GAS text/operation logsのどれが失敗したか | 公式WebViewページ内部の通信詳細 |
|
||||
| ダイヤ/運用取得 | static storage/backend APIの失敗、遅延、サイズ | WebView内の重複fetch詳細 |
|
||||
| WebView保持 | focus/blur、remount、process gone、content termination、activeWebViews | WebView内fetchのstatus/durationは未計測 |
|
||||
| タブ切替 | tabPress/focus/blur、root state、root_tab | native側のFabric mount詳細 |
|
||||
| 体感クラッシュ | 次回起動時に前回sessionの突然終了をwarning化 | OS killとnative crashの厳密な区別 |
|
||||
|
||||
## 10. 現時点の注意点
|
||||
|
||||
- 成功通信はIssue化しない。breadcrumb/spanで見る。
|
||||
- `slow_success` のIssue送信は現在 `SLOW_SUCCESS_SAMPLE_RATE = 0` のため無効。
|
||||
- background/offline由来の通信失敗はIssue化を抑制している。
|
||||
- URL全文はtagに入れない。host/path templateのみcontextに入る。
|
||||
- response body全文は送らない。`responseHead` は最大300文字。
|
||||
- WebView内の公式XHRや injected JS fetch は、RN `observedFetch` を通らないため通信詳細が見えない。
|
||||
- `app.previous_session_unexpected_exit` は「前回sessionが直近heartbeatのまま正常backgroundなしで消えた」ことを示す。Sentry native crashそのものではない。
|
||||
|
||||
## 11. 次に増やすとよい計測
|
||||
|
||||
優先度 high:
|
||||
|
||||
- WebView内fetchのpostMessage計測。
|
||||
- 特に `positions_webview` と公式XHR `/g?arg1=train&arg2=train`。
|
||||
- backend mock API `/train-positions/current` のサーバー側duration、cache hit/miss、upstream duration。
|
||||
|
||||
優先度 medium:
|
||||
|
||||
- `useBusAndTrainData`, `useTrainDelayData`, `SpecialTrainInfoBox` など未計測RN fetchのobservedFetch化。
|
||||
- `operation.webview` のpostMessageにページ状態 summary を追加。
|
||||
- `activeWebViews` に `GeneralWebView` も必要に応じて追加。
|
||||
|
||||
優先度 low:
|
||||
|
||||
- slow success samplingを `0.01` から再開し、十分落ち着いたら調整。
|
||||
- cellular/connection type取得。現状は `navigator.onLine` 程度。
|
||||
@@ -47,6 +47,12 @@
|
||||
},
|
||||
"production7.0": {
|
||||
"channel": "familymart"
|
||||
},
|
||||
"beta7.1": {
|
||||
"channel": "gmarket"
|
||||
},
|
||||
"production7.1": {
|
||||
"channel": "geekbuying"
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
|
||||
@@ -45,6 +45,7 @@ export type CustomTrainData = {
|
||||
type: trainTypeID;
|
||||
train_name: string;
|
||||
train_info_img: string;
|
||||
train_info_img_hub?: string | null;
|
||||
train_info_url: string;
|
||||
infogram: string;
|
||||
via_data: string;
|
||||
@@ -96,6 +97,8 @@ export type OperationLogs = {
|
||||
unit_ids?: string[];
|
||||
vehicle_img: string;
|
||||
vehicle_img_right: string;
|
||||
vehicle_img_hub?: string | null;
|
||||
vehicle_img_right_hub?: string | null;
|
||||
vehicle_info_url: string;
|
||||
related_train_ids?: string[];
|
||||
state: number | null;
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
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;
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export const getStringConfig: types = (type, id) => {
|
||||
case "SPCL_EXP":
|
||||
return ["臨時特急", true, false];
|
||||
case "Party":
|
||||
return ["団体臨時", true, false];
|
||||
return ["団体", true, false];
|
||||
case "Freight":
|
||||
return ["貨物", false, false];
|
||||
case "Forwarding":
|
||||
|
||||
+3
-3
@@ -22,7 +22,7 @@ type trainTypeString =
|
||||
| "普通列車(ワンマン)"
|
||||
| "臨時快速"
|
||||
| "臨時特急"
|
||||
| "団体臨時"
|
||||
| "団体"
|
||||
| "貨物"
|
||||
| "回送"
|
||||
| "単機回送"
|
||||
@@ -129,8 +129,8 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
case "Party":
|
||||
return {
|
||||
color: "#ff7300ff",
|
||||
name: "団体臨時",
|
||||
shortName: "団体臨時",
|
||||
name: "団体",
|
||||
shortName: "団体",
|
||||
fontAvailable: true,
|
||||
isOneMan: false,
|
||||
data: "normal",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export type IconDisplayMode = "default" | "original" | "hub";
|
||||
|
||||
export const normalizeIconDisplayMode = (
|
||||
value: unknown,
|
||||
): IconDisplayMode => {
|
||||
if (value === "hub") return "hub";
|
||||
if (value === "default" || value === "false" || value === false) {
|
||||
return "default";
|
||||
}
|
||||
return "original";
|
||||
};
|
||||
|
||||
export const isHubIconDisplayMode = (value: unknown): boolean =>
|
||||
normalizeIconDisplayMode(value) === "hub";
|
||||
|
||||
export const usesCustomTrainIcons = (value: unknown): boolean =>
|
||||
normalizeIconDisplayMode(value) !== "default";
|
||||
@@ -1,27 +1,42 @@
|
||||
export const BACKEND_API_BASE_URLS = {
|
||||
production: "https://jr-shikoku-backend-api-v1.haruk.in",
|
||||
experimental: "https://jr-shikoku-backend-api-v1-beta.haruk.in",
|
||||
} as const;
|
||||
|
||||
export const JR_DATA_SYSTEM_ENVS = {
|
||||
production: {
|
||||
label: "本番",
|
||||
caption: "現在の本番環境",
|
||||
baseUrl: "https://jr-shikoku-data-system.pages.dev",
|
||||
production_release: {
|
||||
label: "本番 / リリース",
|
||||
caption: "一般公開向け運用",
|
||||
baseUrl: "https://shikoku-railinfo.haruk.in",
|
||||
track: "production",
|
||||
uiVariant: "release",
|
||||
backendApiBaseUrl: BACKEND_API_BASE_URLS.production,
|
||||
},
|
||||
// chatgpt: {
|
||||
// label: "ChatGPT",
|
||||
// caption: "experiment-ux-refactoring-co-3crz",
|
||||
// baseUrl:
|
||||
// "https://experiment-ux-refactoring-co-3crz.jr-shikoku-data-system.pages.dev",
|
||||
// },
|
||||
claude: {
|
||||
label: "Claude",
|
||||
caption: "experiment-ux-refactoring-co-6cw7",
|
||||
baseUrl:
|
||||
"https://experiment-ux-refactoring-co-6cw7.jr-shikoku-data-system.pages.dev",
|
||||
production_beta: {
|
||||
label: "本番 / ベータ",
|
||||
caption: "UI検証向け運用",
|
||||
baseUrl: "https://nightly.shikoku-railinfo.haruk.in",
|
||||
track: "production",
|
||||
uiVariant: "beta",
|
||||
backendApiBaseUrl: BACKEND_API_BASE_URLS.production,
|
||||
},
|
||||
experimental: {
|
||||
label: "実験 / 実験場",
|
||||
caption: "毎日リセットされる実験環境",
|
||||
baseUrl: "https://experimental.shikoku-railinfo.haruk.in",
|
||||
track: "experimental",
|
||||
uiVariant: "release",
|
||||
backendApiBaseUrl: BACKEND_API_BASE_URLS.experimental,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type JrDataSystemEnvironmentKey = keyof typeof JR_DATA_SYSTEM_ENVS;
|
||||
|
||||
export const DEFAULT_JR_DATA_SYSTEM_ENV: JrDataSystemEnvironmentKey =
|
||||
"production";
|
||||
"production_release";
|
||||
|
||||
export type JrDataSystemTrack = "production" | "experimental";
|
||||
export type JrDataSystemUiVariant = "release" | "beta";
|
||||
|
||||
export const JR_DATA_SYSTEM_ENV_OPTIONS = (
|
||||
Object.entries(JR_DATA_SYSTEM_ENVS) as [
|
||||
@@ -36,12 +51,63 @@ export const JR_DATA_SYSTEM_ENV_OPTIONS = (
|
||||
export const normalizeJrDataSystemEnvironment = (
|
||||
value: unknown,
|
||||
): JrDataSystemEnvironmentKey => {
|
||||
// Backward compatibility for legacy keys.
|
||||
if (value === "production") return "production_release";
|
||||
if (value === "chatgpt" || value === "claude") return "production_beta";
|
||||
|
||||
if (typeof value === "string" && value in JR_DATA_SYSTEM_ENVS) {
|
||||
return value as JrDataSystemEnvironmentKey;
|
||||
}
|
||||
return DEFAULT_JR_DATA_SYSTEM_ENV;
|
||||
};
|
||||
|
||||
export const resolveJrDataSystemEnvironment = (
|
||||
track: JrDataSystemTrack,
|
||||
uiVariant: JrDataSystemUiVariant,
|
||||
): JrDataSystemEnvironmentKey => {
|
||||
if (track === "experimental") {
|
||||
return "experimental";
|
||||
}
|
||||
return uiVariant === "beta" ? "production_beta" : "production_release";
|
||||
};
|
||||
|
||||
export const getJrDataSystemTrack = (
|
||||
environment: unknown,
|
||||
): JrDataSystemTrack => {
|
||||
const envKey = normalizeJrDataSystemEnvironment(environment);
|
||||
return JR_DATA_SYSTEM_ENVS[envKey].track;
|
||||
};
|
||||
|
||||
export const getJrDataSystemUiVariant = (
|
||||
environment: unknown,
|
||||
): JrDataSystemUiVariant => {
|
||||
const envKey = normalizeJrDataSystemEnvironment(environment);
|
||||
return JR_DATA_SYSTEM_ENVS[envKey].uiVariant;
|
||||
};
|
||||
|
||||
export const getBackendApiBaseUrl = (environment: unknown): string => {
|
||||
const envKey = normalizeJrDataSystemEnvironment(environment);
|
||||
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);
|
||||
for (const base of Object.values(BACKEND_API_BASE_URLS)) {
|
||||
if (url.startsWith(base)) {
|
||||
return url.replace(base, target);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const rewriteJrDataSystemUrl = (
|
||||
uri: string,
|
||||
environment: unknown,
|
||||
@@ -51,14 +117,20 @@ export const rewriteJrDataSystemUrl = (
|
||||
}
|
||||
|
||||
const envKey = normalizeJrDataSystemEnvironment(environment);
|
||||
if (envKey === DEFAULT_JR_DATA_SYSTEM_ENV) {
|
||||
return uri;
|
||||
}
|
||||
|
||||
const productionBaseUrl = JR_DATA_SYSTEM_ENVS.production.baseUrl;
|
||||
const targetBaseUrl = JR_DATA_SYSTEM_ENVS[envKey].baseUrl;
|
||||
|
||||
return uri.startsWith(productionBaseUrl)
|
||||
? uri.replace(productionBaseUrl, targetBaseUrl)
|
||||
: uri;
|
||||
const knownBaseUrls = [
|
||||
"https://jr-shikoku-data-system.pages.dev",
|
||||
JR_DATA_SYSTEM_ENVS.production_release.baseUrl,
|
||||
JR_DATA_SYSTEM_ENVS.production_beta.baseUrl,
|
||||
JR_DATA_SYSTEM_ENVS.experimental.baseUrl,
|
||||
];
|
||||
|
||||
for (const baseUrl of knownBaseUrls) {
|
||||
if (uri.startsWith(baseUrl)) {
|
||||
return uri.replace(baseUrl, targetBaseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return uri;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Mock API index
|
||||
*
|
||||
* Re-exports the XHR interceptor generator and pre-loaded sample data
|
||||
* captured from the official JR Shikoku train position site.
|
||||
*
|
||||
* Usage example:
|
||||
* import { MOCK_TRAIN_POSITIONS } from '@/lib/mockApi';
|
||||
* const { setMockApiEnabled, setMockTrainPositions } = useTrainMenu();
|
||||
*
|
||||
* // Enable mock mode with sample data
|
||||
* setMockTrainPositions(MOCK_TRAIN_POSITIONS);
|
||||
* setMockApiEnabled(true);
|
||||
*/
|
||||
|
||||
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';
|
||||
export const MOCK_TRAIN_POSITIONS = trainJson.filter(
|
||||
(entry): entry is import('./webviewXhrInterceptor').TrainEntry =>
|
||||
'TrainNum' in entry,
|
||||
);
|
||||
@@ -0,0 +1,592 @@
|
||||
[
|
||||
{
|
||||
"BetweenStation": "高松~鬼無",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "香西",
|
||||
"StationNumber": "Y01",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kozai.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "端岡~鴨川",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "国分",
|
||||
"StationNumber": "Y04",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kokubu.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "讃岐府中",
|
||||
"StationNumber": "Y05",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sanuki-fuchu.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "鴨川~坂出",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "八十場",
|
||||
"StationNumber": "Y07",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yasoba.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "丸亀~多度津",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "讃岐塩屋",
|
||||
"StationNumber": "Y11",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sanuki-shioya.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "詫間~高瀬",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "みの",
|
||||
"StationNumber": "Y15",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/mino.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "高瀬~本山",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "比地大",
|
||||
"StationNumber": "Y17",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/hijidai.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "伊予寒川~伊予土居",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "赤星",
|
||||
"StationNumber": "Y25",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/akaboshi.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "石鎚山~伊予小松",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "伊予氷見",
|
||||
"StationNumber": "Y33",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-himi.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "伊予小松~壬生川",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "玉之江",
|
||||
"StationNumber": "Y35",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tamanoe.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "伊予北条~粟井",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "柳原",
|
||||
"StationNumber": "Y49",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yanagihara.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "粟井~堀江",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "光洋台",
|
||||
"StationNumber": "Y51",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/koyodai.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "高松~栗林",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "昭和町",
|
||||
"StationNumber": "T27",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/showacho.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "栗林公園北口",
|
||||
"StationNumber": "T26",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ritsurinkoen-kitaguchi.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "栗林~屋島",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "木太町",
|
||||
"StationNumber": "T24",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kitacho.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "屋島~八栗口",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "古高松南",
|
||||
"StationNumber": "T22",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/furutakamatsu-minami.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "八栗口~志度",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "讃岐牟礼",
|
||||
"StationNumber": "T20",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sanuki-mure.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "造田~讃岐津田",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "神前",
|
||||
"StationNumber": "T16",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kanzaki.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "板野~板東",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "阿波川端",
|
||||
"StationNumber": "T06",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-kawabata.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "北伊予~伊予市",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "南伊予",
|
||||
"StationNumber": "U02-1",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/minami-iyo.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "伊予横田",
|
||||
"StationNumber": "U03",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-yokota.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "鳥ノ木",
|
||||
"StationNumber": "U04",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/torinoki.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "向井原~伊予中山",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "伊予大平",
|
||||
"StationNumber": "U07",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-ohira.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "内子~新谷",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "五十崎",
|
||||
"StationNumber": "U11",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ikazaki.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "喜多山",
|
||||
"StationNumber": "U12",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kitayama.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "伊予大洲~伊予平野",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "西大洲",
|
||||
"StationNumber": "U15",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/nishi-ozu.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "伊予石城~卯之町",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "上宇和",
|
||||
"StationNumber": "U21",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kami-uwa.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "伊予吉田~北宇和島",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "高光",
|
||||
"StationNumber": "U26",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/takamitsu.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "向井原~伊予上灘",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "高野川",
|
||||
"StationNumber": "S07",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/konokawa.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "伊予上灘~伊予長浜",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "下灘",
|
||||
"StationNumber": "S09",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shimonada.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "串",
|
||||
"StationNumber": "S10",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kushi.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "喜多灘",
|
||||
"StationNumber": "S11",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kitanada.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "伊予長浜~伊予白滝",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "伊予出石",
|
||||
"StationNumber": "S13",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-izushi.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "伊予白滝~伊予大洲",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "八多喜",
|
||||
"StationNumber": "S15",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/hataki.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "春賀",
|
||||
"StationNumber": "S16",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/haruka.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "五郎",
|
||||
"StationNumber": "S17",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/goro.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "塩入~讃岐財田",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "黒川",
|
||||
"StationNumber": "D17",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kurokawa.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "三縄~阿波川口",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "祖谷口",
|
||||
"StationNumber": "D24",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyaguchi.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "大田口~大杉",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "土佐穴内",
|
||||
"StationNumber": "D31",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-ananai.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "土佐北川~繁藤",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "角茂谷",
|
||||
"StationNumber": "D34",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kakumodani.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "土佐山田~後免",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "山田西町",
|
||||
"StationNumber": "D38",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yamadanishimachi.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "土佐長岡",
|
||||
"StationNumber": "D39",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-nagaoka.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "土佐大津~土佐一宮",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "布師田",
|
||||
"StationNumber": "D42",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/nunoshida.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "高知~旭",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "入明",
|
||||
"StationNumber": "K01",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iriake.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "円行寺口",
|
||||
"StationNumber": "K02",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/engyojiguchi.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "旭~朝倉",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "高知商業前",
|
||||
"StationNumber": "K04",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kochi-shogyomae.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "朝倉~伊野",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "枝川",
|
||||
"StationNumber": "K06",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/edagawa.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "伊野~日下",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "波川",
|
||||
"StationNumber": "K08",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/hakawa.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "小村神社前",
|
||||
"StationNumber": "K08-1",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/omurajinjamae.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "日下~土佐加茂",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "岡花",
|
||||
"StationNumber": "K10",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/okabana.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "佐川~斗賀野",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "襟野々",
|
||||
"StationNumber": "K14",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/erinono.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "多ノ郷~須崎",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "大間",
|
||||
"StationNumber": "K18",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/oma.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "須崎~土佐久礼",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "土佐新荘",
|
||||
"StationNumber": "K20",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-shinjo.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "安和",
|
||||
"StationNumber": "K21",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "影野~窪川",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "六反地",
|
||||
"StationNumber": "K24",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/rokutanji.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "仁井田",
|
||||
"StationNumber": "K25",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/niida.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "蔵本~府中",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "鮎喰",
|
||||
"StationNumber": "B03",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/akui.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "石井~牛島",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "下浦",
|
||||
"StationNumber": "B06",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shimoura.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "牛島~鴨島",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "麻植塚",
|
||||
"StationNumber": "B08",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/oezuka.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "鴨島~阿波川島",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "西麻植",
|
||||
"StationNumber": "B10",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/nishi-oe.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "山瀬~川田",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "阿波山川",
|
||||
"StationNumber": "B14",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-yamakawa.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "貞光~江口",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "阿波半田",
|
||||
"StationNumber": "B19",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-handa.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "江口~阿波加茂",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "三加茂",
|
||||
"StationNumber": "B21",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/mikamo.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"BetweenStation": "鳴門~池谷",
|
||||
"Datas": [
|
||||
{
|
||||
"StationName": "撫養",
|
||||
"StationNumber": "N09",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/muya.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "金比羅前",
|
||||
"StationNumber": "N08",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kompiramae.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "教会前",
|
||||
"StationNumber": "N07",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kyokaimae.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "立道",
|
||||
"StationNumber": "N06",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tatsumichi.pdf"
|
||||
},
|
||||
{
|
||||
"StationName": "阿波大谷",
|
||||
"StationNumber": "N05",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-otani.pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
[
|
||||
{
|
||||
"StartStationName": "坂出",
|
||||
"EndStationName": "宇多津",
|
||||
"LineName": "seto",
|
||||
"Point": "Up",
|
||||
"Half": "F-End",
|
||||
"DispPos": "seto_0_児島"
|
||||
},
|
||||
{
|
||||
"StartStationName": "多度津",
|
||||
"EndStationName": "海岸寺",
|
||||
"LineName": "dosan",
|
||||
"Point": "Down",
|
||||
"Half": "Start",
|
||||
"DispPos": "dosan_0_多度津"
|
||||
},
|
||||
{
|
||||
"StartStationName": "向井原",
|
||||
"EndStationName": "伊予中山",
|
||||
"LineName": "uwajima2",
|
||||
"Point": "Up",
|
||||
"Half": "Start",
|
||||
"DispPos": "uwajima2_0_向井原"
|
||||
},
|
||||
{
|
||||
"StartStationName": "新谷",
|
||||
"EndStationName": "伊予大洲",
|
||||
"LineName": "uwajima2",
|
||||
"Point": "Up",
|
||||
"Half": "End",
|
||||
"DispPos": "uwajima2_3_伊予大洲"
|
||||
},
|
||||
{
|
||||
"StartStationName": "箸蔵",
|
||||
"EndStationName": "佃",
|
||||
"LineName": "tokushima",
|
||||
"Point": "Down",
|
||||
"Half": "End",
|
||||
"DispPos": "tokushima_16_佃"
|
||||
},
|
||||
{
|
||||
"StartStationName": "吉成",
|
||||
"EndStationName": "佐古",
|
||||
"LineName": "tokushima",
|
||||
"Point": "Up",
|
||||
"Half": "End",
|
||||
"DispPos": "tokushima_0_徳島"
|
||||
},
|
||||
{
|
||||
"StartStationName": "佐古",
|
||||
"EndStationName": "蔵本",
|
||||
"LineName": "koutoku",
|
||||
"Point": "Up",
|
||||
"Half": "Start",
|
||||
"DispPos": "koutoku_19_佐古"
|
||||
},
|
||||
{
|
||||
"StartStationName": "辻",
|
||||
"EndStationName": "佃",
|
||||
"LineName": "dosan",
|
||||
"Point": "Up",
|
||||
"Half": "End",
|
||||
"DispPos": "dosan_7_佃"
|
||||
},
|
||||
{
|
||||
"StartStationName": "板東",
|
||||
"EndStationName": "池谷",
|
||||
"LineName": "naruto",
|
||||
"Point": "Down",
|
||||
"Half": "End",
|
||||
"DispPos": "naruto_0_鳴門"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"TrainNum": "1219M",
|
||||
"String": "※土曜・休日は多度津-琴平間運休"
|
||||
},
|
||||
{
|
||||
"TrainNum": "5223M",
|
||||
"String": "※休日は高松-多度津間運休"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
"70",
|
||||
"71",
|
||||
"73",
|
||||
"74",
|
||||
"75",
|
||||
"76",
|
||||
"3070",
|
||||
"3071",
|
||||
"3072",
|
||||
"3073",
|
||||
"3076",
|
||||
"3077",
|
||||
"3078",
|
||||
"3079",
|
||||
"8070",
|
||||
"8071",
|
||||
"8072",
|
||||
"8077"
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"Line": "tokushimaa",
|
||||
"Datas": [
|
||||
{
|
||||
"Name": "徳島",
|
||||
"Color": "koutoku"
|
||||
},
|
||||
{
|
||||
"Name": "徳島~佐古",
|
||||
"Color": "koutoku"
|
||||
},
|
||||
{
|
||||
"Name": "佃~阿波池田",
|
||||
"Color": "dosan"
|
||||
},
|
||||
{
|
||||
"Name": "阿波池田",
|
||||
"Color": "dosan"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Line": "setoa",
|
||||
"Datas": [
|
||||
{
|
||||
"Name": "児島",
|
||||
"Color": "other"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
[
|
||||
{
|
||||
"LineName": "yosan",
|
||||
"Position": "end",
|
||||
"OtherLineName": "uwajima",
|
||||
"DispPos": "uwajima_0_松山"
|
||||
},
|
||||
{
|
||||
"LineName": "yosan",
|
||||
"Position": "start",
|
||||
"OtherLineName": "koutoku",
|
||||
"DispPos": "koutoku_0_高松"
|
||||
},
|
||||
{
|
||||
"LineName": "uwajima",
|
||||
"Position": "start",
|
||||
"OtherLineName": "yosan",
|
||||
"DispPos": "yosan_42_松山"
|
||||
},
|
||||
{
|
||||
"LineName": "uwajima2",
|
||||
"Position": "start",
|
||||
"OtherLineName": "uwajima",
|
||||
"DispPos": "uwajima_3_向井原"
|
||||
},
|
||||
{
|
||||
"LineName": "uwajima2",
|
||||
"Position": "end",
|
||||
"OtherLineName": "uwajima",
|
||||
"DispPos": "uwajima_8_伊予大洲"
|
||||
},
|
||||
{
|
||||
"LineName": "seto",
|
||||
"Position": "end",
|
||||
"OtherLineName": "yosan",
|
||||
"DispPos": "yosan_3_坂出"
|
||||
},
|
||||
{
|
||||
"LineName": "dosan",
|
||||
"Position": "start",
|
||||
"OtherLineName": "yosan",
|
||||
"DispPos": "yosan_6_多度津"
|
||||
},
|
||||
{
|
||||
"LineName": "dosan",
|
||||
"Position": "end",
|
||||
"OtherLineName": "dosan2",
|
||||
"DispPos": "dosan2_0_高知"
|
||||
},
|
||||
{
|
||||
"LineName": "tokushima",
|
||||
"Position": "start",
|
||||
"OtherLineName": "koutoku",
|
||||
"DispPos": "koutoku_20_徳島"
|
||||
},
|
||||
{
|
||||
"LineName": "koutoku",
|
||||
"Position": "start",
|
||||
"OtherLineName": "yosan",
|
||||
"DispPos": "yosan_0_高松"
|
||||
},
|
||||
{
|
||||
"LineName": "dosan2",
|
||||
"Position": "start",
|
||||
"OtherLineName": "dosan",
|
||||
"DispPos": "dosan_25_高知"
|
||||
},
|
||||
{
|
||||
"LineName": "tokushima",
|
||||
"Position": "end",
|
||||
"OtherLineName": "dosan",
|
||||
"DispPos": "dosan_8_阿波池田"
|
||||
},
|
||||
{
|
||||
"LineName": "naruto",
|
||||
"Position": "end",
|
||||
"OtherLineName": "koutoku",
|
||||
"DispPos": "koutoku_16_池谷"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
"伊予灘ものがたり",
|
||||
"千年ものがたり",
|
||||
"アンパンマントロッコ",
|
||||
"夜明けのものがたり",
|
||||
"藍よしのがわトロッコ"
|
||||
]
|
||||
@@ -0,0 +1,272 @@
|
||||
[
|
||||
{
|
||||
"Station_JP": "多度津",
|
||||
"Station_EN": "Tadotsu",
|
||||
"MyStation": "0",
|
||||
"StationNumber": "D12",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tadotsu.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.271088,133.756735",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/tadotsu/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "金蔵寺",
|
||||
"Station_EN": "Konzōji",
|
||||
"MyStation": "0",
|
||||
"StationNumber": "D13",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/konzoji.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.247861,133.777594",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "善通寺",
|
||||
"Station_EN": "Zentsūji",
|
||||
"MyStation": "1",
|
||||
"StationNumber": "D14",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/zentsuji.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.229958,133.789141",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/zentsuji/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "琴平",
|
||||
"Station_EN": "Kotohira",
|
||||
"MyStation": "2",
|
||||
"StationNumber": "D15",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kotohira.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.191903,133.821295",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/kotohira/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "塩入",
|
||||
"Station_EN": "Shioiri",
|
||||
"MyStation": "3",
|
||||
"StationNumber": "D16",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shioiri.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.156034,133.849974",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "讃岐財田",
|
||||
"Station_EN": "Sanuki-Saida",
|
||||
"MyStation": "4",
|
||||
"StationNumber": "D18",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sanuki-saida.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.117586,133.814181",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "坪尻",
|
||||
"Station_EN": "Tsubojiri",
|
||||
"MyStation": "5",
|
||||
"StationNumber": "D19",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tsubojiri.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.054035,133.823675",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "箸蔵",
|
||||
"Station_EN": "Hashikura",
|
||||
"MyStation": "6",
|
||||
"StationNumber": "D20",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/hashikura.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.040343,133.848761",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "佃",
|
||||
"Station_EN": "Tsukuda",
|
||||
"MyStation": "7",
|
||||
"StationNumber": "D21",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tsukuda.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.032458,133.857363",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波池田",
|
||||
"Station_EN": "Awa-Ikeda",
|
||||
"MyStation": "8",
|
||||
"StationNumber": "D22",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-ikeda.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.027177,133.804619",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/awaikeda/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "三縄",
|
||||
"Station_EN": "Minawa",
|
||||
"MyStation": "9",
|
||||
"StationNumber": "D23",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/minawa.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.006837,133.787427",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波川口",
|
||||
"Station_EN": "Awa-Kawaguchi",
|
||||
"MyStation": "10",
|
||||
"StationNumber": "D25",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-kawaguchi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.963705,133.754617",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "小歩危",
|
||||
"Station_EN": "Koboke",
|
||||
"MyStation": "11",
|
||||
"StationNumber": "D26",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/koboke.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.923338,133.758747",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "大歩危",
|
||||
"Station_EN": "Ōboke",
|
||||
"MyStation": "12",
|
||||
"StationNumber": "D27",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/oboke.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.876483,133.767298",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/oboke/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐岩原",
|
||||
"Station_EN": "Tosa-Iwahara",
|
||||
"MyStation": "13",
|
||||
"StationNumber": "D28",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-iwahara.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.822635,133.788204",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "豊永",
|
||||
"Station_EN": "Toyonaga",
|
||||
"MyStation": "14",
|
||||
"StationNumber": "D29",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/toyonaga.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.796841,133.759716",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "大田口",
|
||||
"Station_EN": "Ōtaguchi",
|
||||
"MyStation": "15",
|
||||
"StationNumber": "D30",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/otaguchi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.785517,133.726602",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "大杉",
|
||||
"Station_EN": "Ōsugi",
|
||||
"MyStation": "16",
|
||||
"StationNumber": "D32",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/osugi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.760938,133.664483",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐北川",
|
||||
"Station_EN": "Tosa-Kitagawa",
|
||||
"MyStation": "17",
|
||||
"StationNumber": "D33",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-kitagawa.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.714781,133.686312",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "繁藤",
|
||||
"Station_EN": "Shigetō",
|
||||
"MyStation": "18",
|
||||
"StationNumber": "D35",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shigeto.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.680386,133.6902",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "新改",
|
||||
"Station_EN": "Shingai",
|
||||
"MyStation": "19",
|
||||
"StationNumber": "D36",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shingai.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.649629,133.695788",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐山田",
|
||||
"Station_EN": "Tosa-Yamada",
|
||||
"MyStation": "20",
|
||||
"StationNumber": "D37",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-yamada.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.607099,133.684992",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/tosayamada/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "後免",
|
||||
"Station_EN": "Gomen",
|
||||
"MyStation": "21",
|
||||
"StationNumber": "D40",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/gomen.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.579234,133.645357",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/gomen/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐大津",
|
||||
"Station_EN": "Tosa-Ōtsu",
|
||||
"MyStation": "22",
|
||||
"StationNumber": "D41",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-otsu.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.576384,133.611446",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐一宮",
|
||||
"Station_EN": "Tosa-Ikku",
|
||||
"MyStation": "23",
|
||||
"StationNumber": "D43",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-ikku.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.579247,133.576891",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "薊野",
|
||||
"Station_EN": "Azōno",
|
||||
"MyStation": "24",
|
||||
"StationNumber": "D44",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/azono.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.578616,133.560692",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "高知",
|
||||
"Station_EN": "Kōchi",
|
||||
"MyStation": "25",
|
||||
"StationNumber": "D45",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kochi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.566525,133.543638",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/kochi/"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,152 @@
|
||||
[
|
||||
{
|
||||
"Station_JP": "高知",
|
||||
"Station_EN": "Kōchi",
|
||||
"MyStation": "0",
|
||||
"StationNumber": "K00",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kochi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.566525,133.543638",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/kochi/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "旭",
|
||||
"Station_EN": "Asahi",
|
||||
"MyStation": "0",
|
||||
"StationNumber": "K03",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/asahi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.558853,133.508817",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "朝倉",
|
||||
"Station_EN": "Asakura",
|
||||
"MyStation": "1",
|
||||
"StationNumber": "K05",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/asakura.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.551389,133.485354",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/asakura/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊野",
|
||||
"Station_EN": "Ino",
|
||||
"MyStation": "2",
|
||||
"StationNumber": "K07",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ino.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.547505,133.430131",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "日下",
|
||||
"Station_EN": "Kusaka",
|
||||
"MyStation": "3",
|
||||
"StationNumber": "K09",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kusaka.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.53338,133.371236",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐加茂",
|
||||
"Station_EN": "Tosa-Kamo",
|
||||
"MyStation": "4",
|
||||
"StationNumber": "K11",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-kamo.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.523569,133.321135",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "西佐川",
|
||||
"Station_EN": "Nishi-Sakawa",
|
||||
"MyStation": "5",
|
||||
"StationNumber": "K12",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/nishi-sakawa.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.512623,133.286508",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "佐川",
|
||||
"Station_EN": "Sakawa",
|
||||
"MyStation": "6",
|
||||
"StationNumber": "K13",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sakawa.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.500105,133.292438",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/sakawa/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "斗賀野",
|
||||
"Station_EN": "Togano",
|
||||
"MyStation": "7",
|
||||
"StationNumber": "K15",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/togano.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.475415,133.286372",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "吾桑",
|
||||
"Station_EN": "Asō",
|
||||
"MyStation": "8",
|
||||
"StationNumber": "K16",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/aso.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.430181,133.295638",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "多ノ郷",
|
||||
"Station_EN": "Ōnogō",
|
||||
"MyStation": "9",
|
||||
"StationNumber": "K17",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/onogo.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.410727,133.294630",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "須崎",
|
||||
"Station_EN": "Susaki",
|
||||
"MyStation": "10",
|
||||
"StationNumber": "K19",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/susaki.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.392624,133.293189",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/susaki/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐久礼",
|
||||
"Station_EN": "Tosa-Kure",
|
||||
"MyStation": "11",
|
||||
"StationNumber": "K22",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-kure.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.329028,133.226483",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "影野",
|
||||
"Station_EN": "Kageno",
|
||||
"MyStation": "12",
|
||||
"StationNumber": "K23",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kageno.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.275882,133.17358",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "窪川",
|
||||
"Station_EN": "Kubokawa",
|
||||
"MyStation": "13",
|
||||
"StationNumber": "K26",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kubokawa.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.212436,133.13716",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/kubokawa/"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,222 @@
|
||||
[
|
||||
{
|
||||
"Station_JP": "高松",
|
||||
"Station_EN": "Takamatsu",
|
||||
"MyStation": "0",
|
||||
"StationNumber": "T28",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/takamatsu.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.350682,134.046938",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/takamatsu/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "栗林",
|
||||
"Station_EN": "Ritsurin",
|
||||
"MyStation": "0",
|
||||
"StationNumber": "T25",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ritsurin.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.332203,134.053588",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/ritsurin/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "屋島",
|
||||
"Station_EN": "Yashima",
|
||||
"MyStation": "1",
|
||||
"StationNumber": "T23",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yashima.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.336655,134.109102",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/yashima/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "八栗口",
|
||||
"Station_EN": "Yakuriguchi",
|
||||
"MyStation": "2",
|
||||
"StationNumber": "T21",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yakuriguchi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.339097,134.136488",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "志度",
|
||||
"Station_EN": "Shido",
|
||||
"MyStation": "3",
|
||||
"StationNumber": "T19",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shido.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.321624,134.1728",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/shido/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "オレンジタウン",
|
||||
"Station_EN": "Orange-Town",
|
||||
"MyStation": "4",
|
||||
"StationNumber": "T18",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/orange-town.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.301827,134.180946",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "造田",
|
||||
"Station_EN": "Zōda",
|
||||
"MyStation": "5",
|
||||
"StationNumber": "T17",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/zoda.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.282822,134.185946",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "讃岐津田",
|
||||
"Station_EN": "Sanuki-Tsuda",
|
||||
"MyStation": "6",
|
||||
"StationNumber": "T15",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sanuki-tsuda.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.288442,134.248154",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "鶴羽",
|
||||
"Station_EN": "Tsuruwa",
|
||||
"MyStation": "7",
|
||||
"StationNumber": "T14",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tsuruwa.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.280008,134.273842",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "丹生",
|
||||
"Station_EN": "Nibu",
|
||||
"MyStation": "8",
|
||||
"StationNumber": "T13",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/nibu.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.254508,134.301487",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "三本松",
|
||||
"Station_EN": "Sambommatsu",
|
||||
"MyStation": "9",
|
||||
"StationNumber": "T12",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sambommatsu.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.251538,134.334473",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/sanbonmatsu/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "讃岐白鳥",
|
||||
"Station_EN": "Sanuki-Shirotori",
|
||||
"MyStation": "10",
|
||||
"StationNumber": "T11",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sanuki-shirotori.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.243642,134.366203",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "引田",
|
||||
"Station_EN": "Hiketa",
|
||||
"MyStation": "11",
|
||||
"StationNumber": "T10",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/hiketa.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.224467,134.402083",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "讃岐相生",
|
||||
"Station_EN": "Sanuki-Aioi",
|
||||
"MyStation": "12",
|
||||
"StationNumber": "T09",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sanuki-aioi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.211994,134.424534",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波大宮",
|
||||
"Station_EN": "Awa-Ōmiya",
|
||||
"MyStation": "13",
|
||||
"StationNumber": "T08",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-omiya.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.17859,134.448947",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "板野",
|
||||
"Station_EN": "Itano",
|
||||
"MyStation": "14",
|
||||
"StationNumber": "T07",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/itano.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.142193,134.46597",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/itano/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "板東",
|
||||
"Station_EN": "Bandō",
|
||||
"MyStation": "15",
|
||||
"StationNumber": "T05",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/bando.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.155432,134.506866",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "池谷",
|
||||
"Station_EN": "Ikenotani",
|
||||
"MyStation": "16",
|
||||
"StationNumber": "T04",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ikenotani.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.15293,134.528905",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "勝瑞",
|
||||
"Station_EN": "Shōzui",
|
||||
"MyStation": "17",
|
||||
"StationNumber": "T03",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shozui.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.128644,134.528267",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "吉成",
|
||||
"Station_EN": "Yoshinari",
|
||||
"MyStation": "18",
|
||||
"StationNumber": "T02",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yoshinari.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.118511,134.530745",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "佐古",
|
||||
"Station_EN": "Sako",
|
||||
"MyStation": "19",
|
||||
"StationNumber": "T01",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sako.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.080616,134.538576",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "徳島",
|
||||
"Station_EN": "Tokushima",
|
||||
"MyStation": "20",
|
||||
"StationNumber": "T00",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tokushima.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.074642,134.550764",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/tokushima/"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"Station_JP": "鳴門",
|
||||
"Station_EN": "Naruto",
|
||||
"MyStation": "0",
|
||||
"StationNumber": "N10",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/naruto.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.17925,134.608536",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/naruto/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "池谷",
|
||||
"Station_EN": "Ikenotani",
|
||||
"MyStation": "0",
|
||||
"StationNumber": "N04",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ikenotani.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.15293,134.528905",
|
||||
"JrHpUrl": ""
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,192 @@
|
||||
[
|
||||
{
|
||||
"Station_JP": "徳島",
|
||||
"Station_EN": "Tokushima",
|
||||
"MyStation": "0",
|
||||
"StationNumber": "T00",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tokushima.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.074642,134.550764",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/tokushima/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "佐古",
|
||||
"Station_EN": "Sako",
|
||||
"MyStation": "0",
|
||||
"StationNumber": "B01",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sako.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.080616,134.538576",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "蔵本",
|
||||
"Station_EN": "Kuramoto",
|
||||
"MyStation": "1",
|
||||
"StationNumber": "B02",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kuramoto.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.079332,134.518705",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "府中",
|
||||
"Station_EN": "Kō",
|
||||
"MyStation": "2",
|
||||
"StationNumber": "B04",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ko.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.074134,134.482939",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "石井",
|
||||
"Station_EN": "Ishii",
|
||||
"MyStation": "3",
|
||||
"StationNumber": "B05",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ishii.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.070188,134.444343",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "牛島",
|
||||
"Station_EN": "Ushinoshima",
|
||||
"MyStation": "4",
|
||||
"StationNumber": "B07",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ushinoshima.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.076709,134.397553",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "鴨島",
|
||||
"Station_EN": "Kamojima",
|
||||
"MyStation": "5",
|
||||
"StationNumber": "B09",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kamojima.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.073615,134.356559",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/kamojima/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波川島",
|
||||
"Station_EN": "Awa-Kawashima",
|
||||
"MyStation": "6",
|
||||
"StationNumber": "B11",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-kawashima.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.061857,134.320768",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "学",
|
||||
"Station_EN": "Gaku",
|
||||
"MyStation": "7",
|
||||
"StationNumber": "B12",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/gaku.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.057766,134.286411",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "山瀬",
|
||||
"Station_EN": "Yamase",
|
||||
"MyStation": "8",
|
||||
"StationNumber": "B13",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yamase.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.060226,134.256297",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "川田",
|
||||
"Station_EN": "Kawata",
|
||||
"MyStation": "9",
|
||||
"StationNumber": "B15",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kawata.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.061533,134.204329",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "穴吹",
|
||||
"Station_EN": "Anabuki",
|
||||
"MyStation": "10",
|
||||
"StationNumber": "B16",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/anabuki.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.05615,134.163064",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/anabuki/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "小島",
|
||||
"Station_EN": "Oshima",
|
||||
"MyStation": "11",
|
||||
"StationNumber": "B17",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/oshima.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.050665,134.106521",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "貞光",
|
||||
"Station_EN": "Sadamitsu",
|
||||
"MyStation": "12",
|
||||
"StationNumber": "B18",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sadamitsu.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.039354,134.058774",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "江口",
|
||||
"Station_EN": "Eguchi",
|
||||
"MyStation": "13",
|
||||
"StationNumber": "B20",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/eguchi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.037364,133.973229",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波加茂",
|
||||
"Station_EN": "Awa-Kamo",
|
||||
"MyStation": "14",
|
||||
"StationNumber": "B22",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-kamo.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.037063,133.926505",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "辻",
|
||||
"Station_EN": "Tsuji",
|
||||
"MyStation": "15",
|
||||
"StationNumber": "B23",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tsuji.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.028992,133.873234",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "佃",
|
||||
"Station_EN": "Tsukuda",
|
||||
"MyStation": "16",
|
||||
"StationNumber": "B24",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tsukuda.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.032458,133.857363",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波池田",
|
||||
"Station_EN": "Awa-Ikeda",
|
||||
"MyStation": "17",
|
||||
"StationNumber": "B25",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-ikeda.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.027177,133.804619",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/awaikeda/"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,212 @@
|
||||
[
|
||||
{
|
||||
"Station_JP": "松山",
|
||||
"Station_EN": "Matsuyama",
|
||||
"MyStation": "0",
|
||||
"StationNumber": "U00",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/matsuyama.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.84039,132.75139",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/matsuyama/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "市坪",
|
||||
"Station_EN": "Ichitsubo",
|
||||
"MyStation": "0",
|
||||
"StationNumber": "U01",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ichitsubo.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.809441,132.749325",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "北伊予",
|
||||
"Station_EN": "Kita-Iyo",
|
||||
"MyStation": "1",
|
||||
"StationNumber": "U02",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kita-iyo.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.787699,132.748963",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予市",
|
||||
"Station_EN": "Iyoshi",
|
||||
"MyStation": "2",
|
||||
"StationNumber": "U05",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyoshi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.755352,132.702327",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "向井原",
|
||||
"Station_EN": "Mukaibara",
|
||||
"MyStation": "3",
|
||||
"StationNumber": "U06",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/mukaibara.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.736043,132.695825",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予中山",
|
||||
"Station_EN": "Iyo-Nakayama",
|
||||
"MyStation": "4",
|
||||
"StationNumber": "U08",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-nakayama.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.649292,132.711857",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予立川",
|
||||
"Station_EN": "Iyo-Tachikawa",
|
||||
"MyStation": "5",
|
||||
"StationNumber": "U09",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-tachikawa.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.601197,132.677898",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "内子",
|
||||
"Station_EN": "Uchiko",
|
||||
"MyStation": "6",
|
||||
"StationNumber": "U10",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/uchiko.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.549461,132.646304",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/uchiko/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "新谷",
|
||||
"Station_EN": "Niiya",
|
||||
"MyStation": "7",
|
||||
"StationNumber": "U13",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/niiya.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.534106,132.59904",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予大洲",
|
||||
"Station_EN": "Iyo-Ōzu",
|
||||
"MyStation": "8",
|
||||
"StationNumber": "U14",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-ozu.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.518495,132.544878",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/iyozu/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予平野",
|
||||
"Station_EN": "Iyo-Hirano",
|
||||
"MyStation": "9",
|
||||
"StationNumber": "U16",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-hirano.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.495827,132.518108",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "千丈",
|
||||
"Station_EN": "Senjō",
|
||||
"MyStation": "10",
|
||||
"StationNumber": "U17",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/senjo.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.465646,132.457416",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "八幡浜",
|
||||
"Station_EN": "Yawatahama",
|
||||
"MyStation": "11",
|
||||
"StationNumber": "U18",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yawatahama.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.458146,132.436002",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/yawatahama/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "双岩",
|
||||
"Station_EN": "Futaiwa",
|
||||
"MyStation": "12",
|
||||
"StationNumber": "U19",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/futaiwa.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.424669,132.457934",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予石城",
|
||||
"Station_EN": "Iyo-Iwaki",
|
||||
"MyStation": "13",
|
||||
"StationNumber": "U20",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-iwaki.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.391413,132.473259",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "卯之町",
|
||||
"Station_EN": "Unomachi",
|
||||
"MyStation": "14",
|
||||
"StationNumber": "U22",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/unomachi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.363175,132.509959",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/unomachi/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "下宇和",
|
||||
"Station_EN": "Shimo-Uwa",
|
||||
"MyStation": "15",
|
||||
"StationNumber": "U23",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shimo-uwa.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.349786,132.531172",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "立間",
|
||||
"Station_EN": "Tachima",
|
||||
"MyStation": "16",
|
||||
"StationNumber": "U24",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tachima.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.296721,132.539509",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予吉田",
|
||||
"Station_EN": "Iyo-Yoshida",
|
||||
"MyStation": "17",
|
||||
"StationNumber": "U25",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-yoshida.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.273287,132.544098",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "北宇和島",
|
||||
"Station_EN": "Kita-Uwajima",
|
||||
"MyStation": "18",
|
||||
"StationNumber": "U27",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kita-uwajima.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.238636,132.569909",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "宇和島",
|
||||
"Station_EN": "Uwajima",
|
||||
"MyStation": "19",
|
||||
"StationNumber": "U28",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/uwajima.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.225793,132.567498",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/uwajima/"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,442 @@
|
||||
[
|
||||
{
|
||||
"Station_JP": "高松",
|
||||
"Station_EN": "Takamatsu",
|
||||
"MyStation": "0",
|
||||
"StationNumber": "Y00",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/takamatsu.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.350682,134.046938",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/takamatsu/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "鬼無",
|
||||
"Station_EN": "Kinashi",
|
||||
"MyStation": "0",
|
||||
"StationNumber": "Y02",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kinashi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.325494,133.993861",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "端岡",
|
||||
"Station_EN": "Hashioka",
|
||||
"MyStation": "1",
|
||||
"StationNumber": "Y03",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/hashioka.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.305027,133.967643",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "鴨川",
|
||||
"Station_EN": "Kamogawa",
|
||||
"MyStation": "2",
|
||||
"StationNumber": "Y06",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kamogawa.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.306877,133.905229",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "坂出",
|
||||
"Station_EN": "Sakaide",
|
||||
"MyStation": "3",
|
||||
"StationNumber": "Y08",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sakaide.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.313222,133.856325",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/sakaide/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "宇多津",
|
||||
"Station_EN": "Utazu",
|
||||
"MyStation": "4",
|
||||
"StationNumber": "Y09",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/utazu.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.306379,133.813784",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/utazu/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "丸亀",
|
||||
"Station_EN": "Marugame",
|
||||
"MyStation": "5",
|
||||
"StationNumber": "Y10",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/marugame.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.292006,133.793175",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/marugame/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "多度津",
|
||||
"Station_EN": "Tadotsu",
|
||||
"MyStation": "6",
|
||||
"StationNumber": "Y12",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tadotsu.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.271088,133.756735",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/tadotsu/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "海岸寺",
|
||||
"Station_EN": "Kaiganji",
|
||||
"MyStation": "7",
|
||||
"StationNumber": "Y13",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kaiganji.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.253932,133.729307",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "詫間",
|
||||
"Station_EN": "Takuma",
|
||||
"MyStation": "8",
|
||||
"StationNumber": "Y14",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/takuma.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.220826,133.692737",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/takuma/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "高瀬",
|
||||
"Station_EN": "Takase",
|
||||
"MyStation": "9",
|
||||
"StationNumber": "Y16",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/takase.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.184083,133.711397",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "本山",
|
||||
"Station_EN": "Motoyama",
|
||||
"MyStation": "10",
|
||||
"StationNumber": "Y18",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/motoyama.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.146075,133.686473",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "観音寺",
|
||||
"Station_EN": "Kan-onji",
|
||||
"MyStation": "11",
|
||||
"StationNumber": "Y19",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kan-onji.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.124478,133.655709",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/kanonji/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "豊浜",
|
||||
"Station_EN": "Toyohama",
|
||||
"MyStation": "12",
|
||||
"StationNumber": "Y20",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/toyohama.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.079775,133.644206",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "箕浦",
|
||||
"Station_EN": "Minoura",
|
||||
"MyStation": "13",
|
||||
"StationNumber": "Y21",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/minoura.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.048043,133.618998",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "川之江",
|
||||
"Station_EN": "Kawanoe",
|
||||
"MyStation": "14",
|
||||
"StationNumber": "Y22",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kawanoe.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.014209,133.575856",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/kawanoe/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予三島",
|
||||
"Station_EN": "Iyo-Mishima",
|
||||
"MyStation": "15",
|
||||
"StationNumber": "Y23",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-mishima.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.979583,133.541984",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/iyomishima/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予寒川",
|
||||
"Station_EN": "Iyo-Sangawa",
|
||||
"MyStation": "16",
|
||||
"StationNumber": "Y24",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-sangawa.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.967803,133.500153",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予土居",
|
||||
"Station_EN": "Iyo-Doi",
|
||||
"MyStation": "17",
|
||||
"StationNumber": "Y26",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-doi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.959024,133.428294",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "関川",
|
||||
"Station_EN": "Sekigawa",
|
||||
"MyStation": "18",
|
||||
"StationNumber": "Y27",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sekigawa.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.955777,133.392567",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "多喜浜",
|
||||
"Station_EN": "Takihama",
|
||||
"MyStation": "19",
|
||||
"StationNumber": "Y28",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/takihama.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.970564,133.32415",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "新居浜",
|
||||
"Station_EN": "Niihama",
|
||||
"MyStation": "20",
|
||||
"StationNumber": "Y29",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/niihama.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.947996,133.294341",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/niihama/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "中萩",
|
||||
"Station_EN": "Nakahagi",
|
||||
"MyStation": "21",
|
||||
"StationNumber": "Y30",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/nakahagi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.926168,133.253391",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予西条",
|
||||
"Station_EN": "Iyo-Saijo",
|
||||
"MyStation": "22",
|
||||
"StationNumber": "Y31",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-saijo.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.912492,133.187578",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/iyosaijo/index.html"
|
||||
},
|
||||
{
|
||||
"Station_JP": "石鎚山",
|
||||
"Station_EN": "Ishizuchiyama",
|
||||
"MyStation": "23",
|
||||
"StationNumber": "Y32",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ishizuchiyama.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.895746,133.157257",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予小松",
|
||||
"Station_EN": "Iyo-Komatsu",
|
||||
"MyStation": "24",
|
||||
"StationNumber": "Y34",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-komatsu.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.897567,133.116717",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "壬生川",
|
||||
"Station_EN": "Nyūgawa",
|
||||
"MyStation": "25",
|
||||
"StationNumber": "Y36",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/nyugawa.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.929851,133.08552",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/nyugawa/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予三芳",
|
||||
"Station_EN": "Iyo-Miyoshi",
|
||||
"MyStation": "26",
|
||||
"StationNumber": "Y37",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-miyoshi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.954251,133.06422",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予桜井",
|
||||
"Station_EN": "Iyo-Sakurai",
|
||||
"MyStation": "27",
|
||||
"StationNumber": "Y38",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-sakurai.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.012073,133.03593",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予富田",
|
||||
"Station_EN": "Iyo-Tomita",
|
||||
"MyStation": "28",
|
||||
"StationNumber": "Y39",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-tomita.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.036447,133.008989",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "今治",
|
||||
"Station_EN": "Imabari",
|
||||
"MyStation": "29",
|
||||
"StationNumber": "Y40",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/imabari.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.064167,132.993655",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/imabari/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "波止浜",
|
||||
"Station_EN": "Hashihama",
|
||||
"MyStation": "30",
|
||||
"StationNumber": "Y41",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/hashihama.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.098067,132.968786",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "波方",
|
||||
"Station_EN": "Namikata",
|
||||
"MyStation": "31",
|
||||
"StationNumber": "Y42",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/namikata.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.095134,132.941888",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "大西",
|
||||
"Station_EN": "Ōnishi",
|
||||
"MyStation": "32",
|
||||
"StationNumber": "Y43",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/onishi.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.066125,132.929367",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予亀岡",
|
||||
"Station_EN": "Iyo-kameoka",
|
||||
"MyStation": "33",
|
||||
"StationNumber": "Y44",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-kameoka.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.053886,132.874104",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "菊間",
|
||||
"Station_EN": "Kikuma",
|
||||
"MyStation": "34",
|
||||
"StationNumber": "Y45",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kikuma.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.033421,132.840883",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "浅海",
|
||||
"Station_EN": "Asanami",
|
||||
"MyStation": "35",
|
||||
"StationNumber": "Y46",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/asanami.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/34.010028,132.802691",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "大浦",
|
||||
"Station_EN": "Ōura",
|
||||
"MyStation": "36",
|
||||
"StationNumber": "Y47",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/oura.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.999461,132.77555",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予北条",
|
||||
"Station_EN": "Iyo-Hōjō",
|
||||
"MyStation": "37",
|
||||
"StationNumber": "Y48",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-hojo.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.972595,132.775097",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/iyohojo/"
|
||||
},
|
||||
{
|
||||
"Station_JP": "粟井",
|
||||
"Station_EN": "Awai",
|
||||
"MyStation": "38",
|
||||
"StationNumber": "Y50",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awai.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.942729,132.77044",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "堀江",
|
||||
"Station_EN": "Horie",
|
||||
"MyStation": "39",
|
||||
"StationNumber": "Y52",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/horie.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.905558,132.753212",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予和気",
|
||||
"Station_EN": "Iyo-Wake",
|
||||
"MyStation": "40",
|
||||
"StationNumber": "Y53",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-wake.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.889798,132.740708",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "三津浜",
|
||||
"Station_EN": "Mitsuhama",
|
||||
"MyStation": "41",
|
||||
"StationNumber": "Y54",
|
||||
"DispNum": "2",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/mitsuhama.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.861165,132.728612",
|
||||
"JrHpUrl": ""
|
||||
},
|
||||
{
|
||||
"Station_JP": "松山",
|
||||
"Station_EN": "Matsuyama",
|
||||
"MyStation": "42",
|
||||
"StationNumber": "Y55",
|
||||
"DispNum": "3",
|
||||
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/matsuyama.pdf",
|
||||
"StationMap": "https://www.google.co.jp/maps/place/33.84039,132.75139",
|
||||
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/matsuyama/"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,945 @@
|
||||
[
|
||||
{
|
||||
"Index": 2,
|
||||
"TrainNum": "363D",
|
||||
"Pos": "高松",
|
||||
"PosNum": 279,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 8,
|
||||
"TrainNum": "5253M",
|
||||
"Pos": "高松",
|
||||
"PosNum": 277,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 23,
|
||||
"TrainNum": "3160M",
|
||||
"Pos": "高松~鬼無(下り)",
|
||||
"PosNum": 286,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "rapid:マリンライナー60号\r",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 68,
|
||||
"TrainNum": "152M",
|
||||
"Pos": "端岡",
|
||||
"PosNum": 9,
|
||||
"delay": 0,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 72,
|
||||
"TrainNum": "5145M",
|
||||
"Pos": "端岡~鴨川(下り)",
|
||||
"PosNum": 14,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 86,
|
||||
"TrainNum": "3157M",
|
||||
"Pos": "児島予告窓",
|
||||
"PosNum": 246,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "rapid:マリンライナー57号\r",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 87,
|
||||
"TrainNum": "25M",
|
||||
"Pos": "児島予告窓",
|
||||
"PosNum": 93,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "express:しおかぜ25号\r",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 114,
|
||||
"TrainNum": "3158M",
|
||||
"Pos": "児島~宇多津(上り)",
|
||||
"PosNum": 236,
|
||||
"delay": 14,
|
||||
"Direction": 0,
|
||||
"Type": "rapid:マリンライナー58号\r",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 129,
|
||||
"TrainNum": "5251M",
|
||||
"Pos": "坂出~宇多津(下り)",
|
||||
"PosNum": 26,
|
||||
"delay": 7,
|
||||
"Direction": 1,
|
||||
"Type": "rapid:サンポート南風リレー\r",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 145,
|
||||
"TrainNum": "9028M",
|
||||
"Pos": "宇多津",
|
||||
"PosNum": 27,
|
||||
"delay": "入線",
|
||||
"Direction": 0,
|
||||
"Type": "express:いしづち28号\r",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 149,
|
||||
"TrainNum": "9196R",
|
||||
"Pos": "宇多津",
|
||||
"PosNum": 227,
|
||||
"delay": 0,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 274,
|
||||
"TrainNum": "141M",
|
||||
"Pos": "多度津",
|
||||
"PosNum": 41,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 289,
|
||||
"TrainNum": "1250M",
|
||||
"Pos": "多度津予告窓",
|
||||
"PosNum": 45,
|
||||
"delay": "入線",
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 291,
|
||||
"TrainNum": "56D",
|
||||
"Pos": "多度津予告窓",
|
||||
"PosNum": 303,
|
||||
"delay": "入線",
|
||||
"Direction": 0,
|
||||
"Type": "express:南風26号\r",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 297,
|
||||
"TrainNum": "23M",
|
||||
"Pos": "詫間",
|
||||
"PosNum": 53,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "express:しおかぜ23号\r",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 305,
|
||||
"TrainNum": "28M",
|
||||
"Pos": "詫間~高瀬",
|
||||
"PosNum": 55,
|
||||
"delay": 22,
|
||||
"Direction": 0,
|
||||
"Type": "express:しおかぜ28号\r",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 308,
|
||||
"TrainNum": "1606M",
|
||||
"Pos": "高瀬",
|
||||
"PosNum": 56,
|
||||
"delay": 0,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 321,
|
||||
"TrainNum": "143M",
|
||||
"Pos": "観音寺",
|
||||
"PosNum": 64,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 325,
|
||||
"TrainNum": "4608M",
|
||||
"Pos": "観音寺",
|
||||
"PosNum": 66,
|
||||
"delay": "入線",
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 341,
|
||||
"TrainNum": "550M",
|
||||
"Pos": "箕浦~川之江",
|
||||
"PosNum": 100,
|
||||
"delay": 14,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 375,
|
||||
"TrainNum": "4563M",
|
||||
"Pos": "関川~多喜浜",
|
||||
"PosNum": 121,
|
||||
"delay": 13,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 385,
|
||||
"TrainNum": "5552M",
|
||||
"Pos": "多喜浜~新居浜",
|
||||
"PosNum": 125,
|
||||
"delay": 4,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 517,
|
||||
"TrainNum": "30M",
|
||||
"Pos": "伊予小松~壬生川",
|
||||
"PosNum": 148,
|
||||
"delay": 6,
|
||||
"Direction": 0,
|
||||
"Type": "express:しおかぜ30号\r",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 530,
|
||||
"TrainNum": "21M",
|
||||
"Pos": "伊予三芳",
|
||||
"PosNum": 166,
|
||||
"delay": 6,
|
||||
"Direction": 1,
|
||||
"Type": "express:しおかぜ21号\r",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 534,
|
||||
"TrainNum": "561M",
|
||||
"Pos": "伊予桜井",
|
||||
"PosNum": 171,
|
||||
"delay": 3,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 545,
|
||||
"TrainNum": "5556M",
|
||||
"Pos": "伊予富田~今治",
|
||||
"PosNum": 177,
|
||||
"delay": 2,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 585,
|
||||
"TrainNum": "559M",
|
||||
"Pos": "菊間~浅海",
|
||||
"PosNum": 199,
|
||||
"delay": 3,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 600,
|
||||
"TrainNum": "558M",
|
||||
"Pos": "伊予北条",
|
||||
"PosNum": 206,
|
||||
"delay": 3,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 614,
|
||||
"TrainNum": "1042M",
|
||||
"Pos": "粟井~堀江",
|
||||
"PosNum": 212,
|
||||
"delay": 1,
|
||||
"Direction": 0,
|
||||
"Type": "express:いしづち102号\r",
|
||||
"Line": "yosan"
|
||||
},
|
||||
{
|
||||
"Index": 3,
|
||||
"TrainNum": "363D",
|
||||
"Pos": "高松",
|
||||
"PosNum": 279,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "koutoku"
|
||||
},
|
||||
{
|
||||
"Index": 5,
|
||||
"TrainNum": "5253M",
|
||||
"Pos": "高松",
|
||||
"PosNum": 277,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "koutoku"
|
||||
},
|
||||
{
|
||||
"Index": 24,
|
||||
"TrainNum": "4360D",
|
||||
"Pos": "屋島~八栗口",
|
||||
"PosNum": 412,
|
||||
"delay": 2,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "koutoku"
|
||||
},
|
||||
{
|
||||
"Index": 25,
|
||||
"TrainNum": "361D",
|
||||
"Pos": "八栗口",
|
||||
"PosNum": 514,
|
||||
"delay": 2,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "koutoku"
|
||||
},
|
||||
{
|
||||
"Index": 55,
|
||||
"TrainNum": "5359D",
|
||||
"Pos": "鶴羽",
|
||||
"PosNum": 521,
|
||||
"delay": 2,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "koutoku"
|
||||
},
|
||||
{
|
||||
"Index": 56,
|
||||
"TrainNum": "3027D",
|
||||
"Pos": "鶴羽~丹生",
|
||||
"PosNum": 522,
|
||||
"delay": 2,
|
||||
"Direction": 1,
|
||||
"Type": "express:うずしお27号\r",
|
||||
"Line": "koutoku"
|
||||
},
|
||||
{
|
||||
"Index": 68,
|
||||
"TrainNum": "366D",
|
||||
"Pos": "三本松",
|
||||
"PosNum": 435,
|
||||
"delay": 0,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "koutoku"
|
||||
},
|
||||
{
|
||||
"Index": 96,
|
||||
"TrainNum": "3028D",
|
||||
"Pos": "阿波大宮~板野",
|
||||
"PosNum": 459,
|
||||
"delay": 0,
|
||||
"Direction": 0,
|
||||
"Type": "express:うずしお28号\r",
|
||||
"Line": "koutoku"
|
||||
},
|
||||
{
|
||||
"Index": 100,
|
||||
"TrainNum": "355D",
|
||||
"Pos": "板野~板東",
|
||||
"PosNum": 465,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "koutoku"
|
||||
},
|
||||
{
|
||||
"Index": 103,
|
||||
"TrainNum": "976D",
|
||||
"Pos": "鳴門",
|
||||
"PosNum": 472,
|
||||
"delay": 4,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "koutoku"
|
||||
},
|
||||
{
|
||||
"Index": 117,
|
||||
"TrainNum": "364D",
|
||||
"Pos": "池谷~勝瑞",
|
||||
"PosNum": 480,
|
||||
"delay": 1,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "koutoku"
|
||||
},
|
||||
{
|
||||
"Index": 130,
|
||||
"TrainNum": "480D",
|
||||
"Pos": "蔵本",
|
||||
"PosNum": 605,
|
||||
"delay": 0,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "koutoku"
|
||||
},
|
||||
{
|
||||
"Index": 136,
|
||||
"TrainNum": "979D",
|
||||
"Pos": "佐古",
|
||||
"PosNum": 494,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "koutoku"
|
||||
},
|
||||
{
|
||||
"Index": 148,
|
||||
"TrainNum": "5481D",
|
||||
"Pos": "石井",
|
||||
"PosNum": 611,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "koutoku"
|
||||
},
|
||||
{
|
||||
"Index": 7,
|
||||
"TrainNum": "979D",
|
||||
"Pos": "佐古",
|
||||
"PosNum": 494,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "tokushima"
|
||||
},
|
||||
{
|
||||
"Index": 19,
|
||||
"TrainNum": "480D",
|
||||
"Pos": "蔵本",
|
||||
"PosNum": 605,
|
||||
"delay": 0,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "tokushima"
|
||||
},
|
||||
{
|
||||
"Index": 25,
|
||||
"TrainNum": "5481D",
|
||||
"Pos": "石井",
|
||||
"PosNum": 611,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "tokushima"
|
||||
},
|
||||
{
|
||||
"Index": 37,
|
||||
"TrainNum": "4482D",
|
||||
"Pos": "鴨島",
|
||||
"PosNum": 623,
|
||||
"delay": 0,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "tokushima"
|
||||
},
|
||||
{
|
||||
"Index": 49,
|
||||
"TrainNum": "5479D",
|
||||
"Pos": "学",
|
||||
"PosNum": 629,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "tokushima"
|
||||
},
|
||||
{
|
||||
"Index": 65,
|
||||
"TrainNum": "484D",
|
||||
"Pos": "穴吹",
|
||||
"PosNum": 645,
|
||||
"delay": "入線",
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "tokushima"
|
||||
},
|
||||
{
|
||||
"Index": 85,
|
||||
"TrainNum": "475D",
|
||||
"Pos": "江口~阿波加茂",
|
||||
"PosNum": 661,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "tokushima"
|
||||
},
|
||||
{
|
||||
"Index": 99,
|
||||
"TrainNum": "5486D",
|
||||
"Pos": "佃予告窓",
|
||||
"PosNum": 669,
|
||||
"delay": "入線",
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "tokushima"
|
||||
},
|
||||
{
|
||||
"Index": 100,
|
||||
"TrainNum": "488D",
|
||||
"Pos": "佃予告窓",
|
||||
"PosNum": 670,
|
||||
"delay": "入線",
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "tokushima"
|
||||
},
|
||||
{
|
||||
"Index": 0,
|
||||
"TrainNum": "21M",
|
||||
"Pos": "",
|
||||
"PosNum": 228,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "express:しおかぜ21号\r",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 1,
|
||||
"TrainNum": "559M",
|
||||
"Pos": "",
|
||||
"PosNum": 227,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 10,
|
||||
"TrainNum": "4555M",
|
||||
"Pos": "松山",
|
||||
"PosNum": 243,
|
||||
"delay": 1,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 12,
|
||||
"TrainNum": "925D",
|
||||
"Pos": "松山",
|
||||
"PosNum": 241,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 22,
|
||||
"TrainNum": "19E",
|
||||
"Pos": "市坪",
|
||||
"PosNum": 18,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 37,
|
||||
"TrainNum": "9046E",
|
||||
"Pos": "",
|
||||
"PosNum": 35,
|
||||
"delay": "入線",
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 38,
|
||||
"TrainNum": "560E",
|
||||
"Pos": "",
|
||||
"PosNum": 34,
|
||||
"delay": "入線",
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 43,
|
||||
"TrainNum": "1077D",
|
||||
"Pos": "向井原~伊予中山",
|
||||
"PosNum": 49,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "express:宇和海27号\r",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 51,
|
||||
"TrainNum": "4926D",
|
||||
"Pos": "伊予上灘~伊予長浜",
|
||||
"PosNum": 197,
|
||||
"delay": 0,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 54,
|
||||
"TrainNum": "4655D",
|
||||
"Pos": "伊予立川~内子",
|
||||
"PosNum": 68,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 69,
|
||||
"TrainNum": "1078D",
|
||||
"Pos": "新谷~伊予大洲",
|
||||
"PosNum": 87,
|
||||
"delay": 4,
|
||||
"Direction": 0,
|
||||
"Type": "express:宇和海28号\r",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 83,
|
||||
"TrainNum": "923D",
|
||||
"Pos": "千丈",
|
||||
"PosNum": 113,
|
||||
"delay": 4,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 86,
|
||||
"TrainNum": "4928D",
|
||||
"Pos": "八幡浜",
|
||||
"PosNum": 123,
|
||||
"delay": "入線",
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 89,
|
||||
"TrainNum": "4659D",
|
||||
"Pos": "八幡浜",
|
||||
"PosNum": 124,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 101,
|
||||
"TrainNum": "1075D",
|
||||
"Pos": "卯之町~下宇和",
|
||||
"PosNum": 150,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "express:宇和海25号\r",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 110,
|
||||
"TrainNum": "4825D",
|
||||
"Pos": "北宇和島~宮野下方予告窓",
|
||||
"PosNum": 185,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 116,
|
||||
"TrainNum": "1080D",
|
||||
"Pos": "宇和島",
|
||||
"PosNum": 188,
|
||||
"delay": "入線",
|
||||
"Direction": 0,
|
||||
"Type": "express:宇和海30号\r",
|
||||
"Line": "uwajima"
|
||||
},
|
||||
{
|
||||
"Index": 15,
|
||||
"TrainNum": "1250M",
|
||||
"Pos": "善通寺",
|
||||
"PosNum": 24,
|
||||
"delay": 1,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 18,
|
||||
"TrainNum": "5249M",
|
||||
"Pos": "善通寺~琴平",
|
||||
"PosNum": 29,
|
||||
"delay": 1,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 24,
|
||||
"TrainNum": "4255D",
|
||||
"Pos": "琴平",
|
||||
"PosNum": 34,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 44,
|
||||
"TrainNum": "475D",
|
||||
"Pos": "佃予告窓",
|
||||
"PosNum": 68,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 45,
|
||||
"TrainNum": "5479D",
|
||||
"Pos": "佃予告窓",
|
||||
"PosNum": 69,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 55,
|
||||
"TrainNum": "5486D",
|
||||
"Pos": "阿波池田",
|
||||
"PosNum": 82,
|
||||
"delay": 0,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 68,
|
||||
"TrainNum": "51D",
|
||||
"Pos": "阿波川口",
|
||||
"PosNum": 98,
|
||||
"delay": 5,
|
||||
"Direction": 1,
|
||||
"Type": "express:南風21号\r",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 70,
|
||||
"TrainNum": "56D",
|
||||
"Pos": "阿波川口~小歩危",
|
||||
"PosNum": 101,
|
||||
"delay": 4,
|
||||
"Direction": 0,
|
||||
"Type": "express:南風26号\r",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 73,
|
||||
"TrainNum": "4253D",
|
||||
"Pos": "小歩危~大歩危",
|
||||
"PosNum": 107,
|
||||
"delay": 4,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 109,
|
||||
"TrainNum": "4256D",
|
||||
"Pos": "新改~土佐山田",
|
||||
"PosNum": 164,
|
||||
"delay": 7,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 113,
|
||||
"TrainNum": "4257D",
|
||||
"Pos": "土佐山田",
|
||||
"PosNum": 169,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 117,
|
||||
"TrainNum": "5885D",
|
||||
"Pos": "後免予告窓",
|
||||
"PosNum": 176,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 118,
|
||||
"TrainNum": "5887D",
|
||||
"Pos": "後免予告窓",
|
||||
"PosNum": 177,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 119,
|
||||
"TrainNum": "5883D",
|
||||
"Pos": "なはり方~後免",
|
||||
"PosNum": 178,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 125,
|
||||
"TrainNum": "753D",
|
||||
"Pos": "後免",
|
||||
"PosNum": 179,
|
||||
"delay": 5,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 127,
|
||||
"TrainNum": "49D",
|
||||
"Pos": "後免~土佐大津",
|
||||
"PosNum": 189,
|
||||
"delay": 7,
|
||||
"Direction": 1,
|
||||
"Type": "express:南風19号\r",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 129,
|
||||
"TrainNum": "58D",
|
||||
"Pos": "土佐大津",
|
||||
"PosNum": 191,
|
||||
"delay": 4,
|
||||
"Direction": 0,
|
||||
"Type": "express:南風28号\r",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 132,
|
||||
"TrainNum": "3759A",
|
||||
"Pos": "土佐一宮予告窓",
|
||||
"PosNum": 197,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 133,
|
||||
"TrainNum": "289A",
|
||||
"Pos": "土佐一宮予告窓",
|
||||
"PosNum": 198,
|
||||
"delay": "入線",
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 141,
|
||||
"TrainNum": "756D",
|
||||
"Pos": "薊野",
|
||||
"PosNum": 210,
|
||||
"delay": 1,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "dosan"
|
||||
},
|
||||
{
|
||||
"Index": 2,
|
||||
"TrainNum": "4758D",
|
||||
"Pos": "高知~旭",
|
||||
"PosNum": 258,
|
||||
"delay": 0,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "kubokawa"
|
||||
},
|
||||
{
|
||||
"Index": 12,
|
||||
"TrainNum": "751D",
|
||||
"Pos": "伊野~日下",
|
||||
"PosNum": 277,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "normal",
|
||||
"Line": "kubokawa"
|
||||
},
|
||||
{
|
||||
"Index": 27,
|
||||
"TrainNum": "2081D",
|
||||
"Pos": "斗賀野",
|
||||
"PosNum": 306,
|
||||
"delay": 0,
|
||||
"Direction": 1,
|
||||
"Type": "express:あしずり11号\r",
|
||||
"Line": "kubokawa"
|
||||
},
|
||||
{
|
||||
"Index": 31,
|
||||
"TrainNum": "4760D",
|
||||
"Pos": "吾桑~多ノ郷",
|
||||
"PosNum": 314,
|
||||
"delay": 0,
|
||||
"Direction": 0,
|
||||
"Type": "normal",
|
||||
"Line": "kubokawa"
|
||||
},
|
||||
{
|
||||
"Index": 38,
|
||||
"TrainNum": "2004D",
|
||||
"Pos": "須崎~土佐久礼",
|
||||
"PosNum": 327,
|
||||
"delay": 0,
|
||||
"Direction": 0,
|
||||
"Type": "express:しまんと4号\r",
|
||||
"Line": "kubokawa"
|
||||
},
|
||||
{
|
||||
"GetDateTime": "2026/05/01 19:42:21"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,946 @@
|
||||
[
|
||||
{
|
||||
"Station_JP": "高知商業前",
|
||||
"Station_EN": "Kōchi-Shōgyōmae"
|
||||
},
|
||||
{
|
||||
"Station_JP": "古高松南",
|
||||
"Station_EN": "Furutakamatsu-Minami"
|
||||
},
|
||||
{
|
||||
"Station_JP": "高松",
|
||||
"Station_EN": "Takamatsu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "香西",
|
||||
"Station_EN": "Kōzai"
|
||||
},
|
||||
{
|
||||
"Station_JP": "鬼無",
|
||||
"Station_EN": "Kinashi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "端岡",
|
||||
"Station_EN": "Hashioka"
|
||||
},
|
||||
{
|
||||
"Station_JP": "国分",
|
||||
"Station_EN": "Kokubu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "讃岐府中",
|
||||
"Station_EN": "Sanuki-Fuchū"
|
||||
},
|
||||
{
|
||||
"Station_JP": "鴨川",
|
||||
"Station_EN": "Kamogawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "八十場",
|
||||
"Station_EN": "Yasoba"
|
||||
},
|
||||
{
|
||||
"Station_JP": "坂出",
|
||||
"Station_EN": "Sakaide"
|
||||
},
|
||||
{
|
||||
"Station_JP": "宇多津",
|
||||
"Station_EN": "Utazu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "丸亀",
|
||||
"Station_EN": "Marugame"
|
||||
},
|
||||
{
|
||||
"Station_JP": "讃岐塩屋",
|
||||
"Station_EN": "Sanuki-Shioya"
|
||||
},
|
||||
{
|
||||
"Station_JP": "多度津",
|
||||
"Station_EN": "Tadotsu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "海岸寺",
|
||||
"Station_EN": "Kaiganji"
|
||||
},
|
||||
{
|
||||
"Station_JP": "詫間",
|
||||
"Station_EN": "Takuma"
|
||||
},
|
||||
{
|
||||
"Station_JP": "みの",
|
||||
"Station_EN": "Mino"
|
||||
},
|
||||
{
|
||||
"Station_JP": "高瀬",
|
||||
"Station_EN": "Takase"
|
||||
},
|
||||
{
|
||||
"Station_JP": "比地大",
|
||||
"Station_EN": "Hijidai"
|
||||
},
|
||||
{
|
||||
"Station_JP": "本山",
|
||||
"Station_EN": "Motoyama"
|
||||
},
|
||||
{
|
||||
"Station_JP": "観音寺",
|
||||
"Station_EN": "Kan-onji"
|
||||
},
|
||||
{
|
||||
"Station_JP": "豊浜",
|
||||
"Station_EN": "Toyohama"
|
||||
},
|
||||
{
|
||||
"Station_JP": "箕浦",
|
||||
"Station_EN": "Minoura"
|
||||
},
|
||||
{
|
||||
"Station_JP": "川之江",
|
||||
"Station_EN": "Kawanoe"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予三島",
|
||||
"Station_EN": "Iyo-Mishima"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予寒川",
|
||||
"Station_EN": "Iyo-Sangawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "赤星",
|
||||
"Station_EN": "Akaboshi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予土居",
|
||||
"Station_EN": "Iyo-Doi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "関川",
|
||||
"Station_EN": "Sekigawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "多喜浜",
|
||||
"Station_EN": "Takihama"
|
||||
},
|
||||
{
|
||||
"Station_JP": "新居浜",
|
||||
"Station_EN": "Niihama"
|
||||
},
|
||||
{
|
||||
"Station_JP": "中萩",
|
||||
"Station_EN": "Nakahagi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予西条",
|
||||
"Station_EN": "Iyo-Saijo"
|
||||
},
|
||||
{
|
||||
"Station_JP": "石鎚山",
|
||||
"Station_EN": "Ishizuchiyama"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予氷見",
|
||||
"Station_EN": "Iyo-Himi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予小松",
|
||||
"Station_EN": "Iyo-Komatsu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "玉之江",
|
||||
"Station_EN": "Tamanoe"
|
||||
},
|
||||
{
|
||||
"Station_JP": "壬生川",
|
||||
"Station_EN": "Nyūgawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予三芳",
|
||||
"Station_EN": "Iyo-Miyoshi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予桜井",
|
||||
"Station_EN": "Iyo-Sakurai"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予富田",
|
||||
"Station_EN": "Iyo-Tomita"
|
||||
},
|
||||
{
|
||||
"Station_JP": "今治",
|
||||
"Station_EN": "Imabari"
|
||||
},
|
||||
{
|
||||
"Station_JP": "波止浜",
|
||||
"Station_EN": "Hashihama"
|
||||
},
|
||||
{
|
||||
"Station_JP": "波方",
|
||||
"Station_EN": "Namikata"
|
||||
},
|
||||
{
|
||||
"Station_JP": "大西",
|
||||
"Station_EN": "Ōnishi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予亀岡",
|
||||
"Station_EN": "Iyo-kameoka"
|
||||
},
|
||||
{
|
||||
"Station_JP": "菊間",
|
||||
"Station_EN": "Kikuma"
|
||||
},
|
||||
{
|
||||
"Station_JP": "浅海",
|
||||
"Station_EN": "Asanami"
|
||||
},
|
||||
{
|
||||
"Station_JP": "大浦",
|
||||
"Station_EN": "Ōura"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予北条",
|
||||
"Station_EN": "Iyo-Hōjō"
|
||||
},
|
||||
{
|
||||
"Station_JP": "柳原",
|
||||
"Station_EN": "Yanagihara"
|
||||
},
|
||||
{
|
||||
"Station_JP": "粟井",
|
||||
"Station_EN": "Awai"
|
||||
},
|
||||
{
|
||||
"Station_JP": "光洋台",
|
||||
"Station_EN": "Kōyōdai"
|
||||
},
|
||||
{
|
||||
"Station_JP": "堀江",
|
||||
"Station_EN": "Horie"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予和気",
|
||||
"Station_EN": "Iyo-Wake"
|
||||
},
|
||||
{
|
||||
"Station_JP": "三津浜",
|
||||
"Station_EN": "Mitsuhama"
|
||||
},
|
||||
{
|
||||
"Station_JP": "松山",
|
||||
"Station_EN": "Matsuyama"
|
||||
},
|
||||
{
|
||||
"Station_JP": "松山",
|
||||
"Station_EN": "Matsuyama"
|
||||
},
|
||||
{
|
||||
"Station_JP": "市坪",
|
||||
"Station_EN": "Ichitsubo"
|
||||
},
|
||||
{
|
||||
"Station_JP": "北伊予",
|
||||
"Station_EN": "Kita-Iyo"
|
||||
},
|
||||
{
|
||||
"Station_JP": "南伊予",
|
||||
"Station_EN": "Minami-Iyo"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予横田",
|
||||
"Station_EN": "Iyo-Yokota"
|
||||
},
|
||||
{
|
||||
"Station_JP": "鳥ノ木",
|
||||
"Station_EN": "Torinoki"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予市",
|
||||
"Station_EN": "Iyoshi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "向井原",
|
||||
"Station_EN": "Mukaibara"
|
||||
},
|
||||
{
|
||||
"Station_JP": "高野川",
|
||||
"Station_EN": "Kōnokawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予上灘",
|
||||
"Station_EN": "Iyo-Kaminada"
|
||||
},
|
||||
{
|
||||
"Station_JP": "下灘",
|
||||
"Station_EN": "Shimonada"
|
||||
},
|
||||
{
|
||||
"Station_JP": "串",
|
||||
"Station_EN": "Kushi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "喜多灘",
|
||||
"Station_EN": "Kitanada"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予長浜",
|
||||
"Station_EN": "Iyo-Nagahama"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予出石",
|
||||
"Station_EN": "Iyo-Izushi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予白滝",
|
||||
"Station_EN": "Iyo-Shirataki"
|
||||
},
|
||||
{
|
||||
"Station_JP": "八多喜",
|
||||
"Station_EN": "Hataki"
|
||||
},
|
||||
{
|
||||
"Station_JP": "春賀",
|
||||
"Station_EN": "Haruka"
|
||||
},
|
||||
{
|
||||
"Station_JP": "五郎",
|
||||
"Station_EN": "Gorō"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予大洲",
|
||||
"Station_EN": "Iyo-Ōzu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "西大洲",
|
||||
"Station_EN": "Nishi-Ōzu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予平野",
|
||||
"Station_EN": "Iyo-Hirano"
|
||||
},
|
||||
{
|
||||
"Station_JP": "千丈",
|
||||
"Station_EN": "Senjō"
|
||||
},
|
||||
{
|
||||
"Station_JP": "八幡浜",
|
||||
"Station_EN": "Yawatahama"
|
||||
},
|
||||
{
|
||||
"Station_JP": "双岩",
|
||||
"Station_EN": "Futaiwa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予石城",
|
||||
"Station_EN": "Iyo-Iwaki"
|
||||
},
|
||||
{
|
||||
"Station_JP": "上宇和",
|
||||
"Station_EN": "Kami-Uwa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "卯之町",
|
||||
"Station_EN": "Unomachi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "下宇和",
|
||||
"Station_EN": "Shimo-Uwa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "立間",
|
||||
"Station_EN": "Tachima"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予吉田",
|
||||
"Station_EN": "Iyo-Yoshida"
|
||||
},
|
||||
{
|
||||
"Station_JP": "高光",
|
||||
"Station_EN": "Takamitsu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "北宇和島",
|
||||
"Station_EN": "Kita-Uwajima"
|
||||
},
|
||||
{
|
||||
"Station_JP": "宇和島",
|
||||
"Station_EN": "Uwajima"
|
||||
},
|
||||
{
|
||||
"Station_JP": "佐古",
|
||||
"Station_EN": "Sako"
|
||||
},
|
||||
{
|
||||
"Station_JP": "蔵本",
|
||||
"Station_EN": "Kuramoto"
|
||||
},
|
||||
{
|
||||
"Station_JP": "鮎喰",
|
||||
"Station_EN": "Akui"
|
||||
},
|
||||
{
|
||||
"Station_JP": "府中",
|
||||
"Station_EN": "Kō"
|
||||
},
|
||||
{
|
||||
"Station_JP": "石井",
|
||||
"Station_EN": "Ishii"
|
||||
},
|
||||
{
|
||||
"Station_JP": "下浦",
|
||||
"Station_EN": "Shimoura"
|
||||
},
|
||||
{
|
||||
"Station_JP": "牛島",
|
||||
"Station_EN": "Ushinoshima"
|
||||
},
|
||||
{
|
||||
"Station_JP": "麻植塚",
|
||||
"Station_EN": "Oezuka"
|
||||
},
|
||||
{
|
||||
"Station_JP": "鴨島",
|
||||
"Station_EN": "Kamojima"
|
||||
},
|
||||
{
|
||||
"Station_JP": "西麻植",
|
||||
"Station_EN": "Nishi-Oe"
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波川島",
|
||||
"Station_EN": "Awa-Kawashima"
|
||||
},
|
||||
{
|
||||
"Station_JP": "学",
|
||||
"Station_EN": "Gaku"
|
||||
},
|
||||
{
|
||||
"Station_JP": "山瀬",
|
||||
"Station_EN": "Yamase"
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波山川",
|
||||
"Station_EN": "Awa-Yamakawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "川田",
|
||||
"Station_EN": "Kawata"
|
||||
},
|
||||
{
|
||||
"Station_JP": "穴吹",
|
||||
"Station_EN": "Anabuki"
|
||||
},
|
||||
{
|
||||
"Station_JP": "小島",
|
||||
"Station_EN": "Oshima"
|
||||
},
|
||||
{
|
||||
"Station_JP": "貞光",
|
||||
"Station_EN": "Sadamitsu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波半田",
|
||||
"Station_EN": "Awa-Handa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "江口",
|
||||
"Station_EN": "Eguchi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "三加茂",
|
||||
"Station_EN": "Mikamo"
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波加茂",
|
||||
"Station_EN": "Awa-Kamo"
|
||||
},
|
||||
{
|
||||
"Station_JP": "辻",
|
||||
"Station_EN": "Tsuji"
|
||||
},
|
||||
{
|
||||
"Station_JP": "高松",
|
||||
"Station_EN": "Takamatsu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "昭和町",
|
||||
"Station_EN": "Shōwachō"
|
||||
},
|
||||
{
|
||||
"Station_JP": "栗林公園北口",
|
||||
"Station_EN": "Ritsurinkōen-Kitaguchi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "栗林",
|
||||
"Station_EN": "Ritsurin"
|
||||
},
|
||||
{
|
||||
"Station_JP": "木太町",
|
||||
"Station_EN": "Kitachō"
|
||||
},
|
||||
{
|
||||
"Station_JP": "屋島",
|
||||
"Station_EN": "Yashima"
|
||||
},
|
||||
{
|
||||
"Station_JP": "八栗口",
|
||||
"Station_EN": "Yakuriguchi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "讃岐牟礼",
|
||||
"Station_EN": "Sanuki-Mure"
|
||||
},
|
||||
{
|
||||
"Station_JP": "志度",
|
||||
"Station_EN": "Shido"
|
||||
},
|
||||
{
|
||||
"Station_JP": "オレンジタウン",
|
||||
"Station_EN": "Orange-Town"
|
||||
},
|
||||
{
|
||||
"Station_JP": "造田",
|
||||
"Station_EN": "Zōda"
|
||||
},
|
||||
{
|
||||
"Station_JP": "神前",
|
||||
"Station_EN": "Kanzaki"
|
||||
},
|
||||
{
|
||||
"Station_JP": "讃岐津田",
|
||||
"Station_EN": "Sanuki-Tsuda"
|
||||
},
|
||||
{
|
||||
"Station_JP": "鶴羽",
|
||||
"Station_EN": "Tsuruwa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "丹生",
|
||||
"Station_EN": "Nibu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "三本松",
|
||||
"Station_EN": "Sambommatsu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "讃岐白鳥",
|
||||
"Station_EN": "Sanuki-Shirotori"
|
||||
},
|
||||
{
|
||||
"Station_JP": "引田",
|
||||
"Station_EN": "Hiketa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "讃岐相生",
|
||||
"Station_EN": "Sanuki-Aioi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波大宮",
|
||||
"Station_EN": "Awa-Ōmiya"
|
||||
},
|
||||
{
|
||||
"Station_JP": "板野",
|
||||
"Station_EN": "Itano"
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波川端",
|
||||
"Station_EN": "Awa-Kawabata"
|
||||
},
|
||||
{
|
||||
"Station_JP": "板東",
|
||||
"Station_EN": "Bandō"
|
||||
},
|
||||
{
|
||||
"Station_JP": "池谷",
|
||||
"Station_EN": "Ikenotani"
|
||||
},
|
||||
{
|
||||
"Station_JP": "勝瑞",
|
||||
"Station_EN": "Shōzui"
|
||||
},
|
||||
{
|
||||
"Station_JP": "吉成",
|
||||
"Station_EN": "Yoshinari"
|
||||
},
|
||||
{
|
||||
"Station_JP": "佐古",
|
||||
"Station_EN": "Sako"
|
||||
},
|
||||
{
|
||||
"Station_JP": "徳島",
|
||||
"Station_EN": "Tokushima"
|
||||
},
|
||||
{
|
||||
"Station_JP": "多度津",
|
||||
"Station_EN": "Tadotsu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "金蔵寺",
|
||||
"Station_EN": "Konzōji"
|
||||
},
|
||||
{
|
||||
"Station_JP": "善通寺",
|
||||
"Station_EN": "Zentsūji"
|
||||
},
|
||||
{
|
||||
"Station_JP": "琴平",
|
||||
"Station_EN": "Kotohira"
|
||||
},
|
||||
{
|
||||
"Station_JP": "塩入",
|
||||
"Station_EN": "Shioiri"
|
||||
},
|
||||
{
|
||||
"Station_JP": "黒川",
|
||||
"Station_EN": "Kurokawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "讃岐財田",
|
||||
"Station_EN": "Sanuki-Saida"
|
||||
},
|
||||
{
|
||||
"Station_JP": "坪尻",
|
||||
"Station_EN": "Tsubojiri"
|
||||
},
|
||||
{
|
||||
"Station_JP": "箸蔵",
|
||||
"Station_EN": "Hashikura"
|
||||
},
|
||||
{
|
||||
"Station_JP": "佃",
|
||||
"Station_EN": "Tsukuda"
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波池田",
|
||||
"Station_EN": "Awa-Ikeda"
|
||||
},
|
||||
{
|
||||
"Station_JP": "三縄",
|
||||
"Station_EN": "Minawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "祖谷口",
|
||||
"Station_EN": "Iyaguchi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波川口",
|
||||
"Station_EN": "Awa-Kawaguchi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "小歩危",
|
||||
"Station_EN": "Koboke"
|
||||
},
|
||||
{
|
||||
"Station_JP": "大歩危",
|
||||
"Station_EN": "Ōboke"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐岩原",
|
||||
"Station_EN": "Tosa-Iwahara"
|
||||
},
|
||||
{
|
||||
"Station_JP": "豊永",
|
||||
"Station_EN": "Toyonaga"
|
||||
},
|
||||
{
|
||||
"Station_JP": "大田口",
|
||||
"Station_EN": "Ōtaguchi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐穴内",
|
||||
"Station_EN": "Tosa-Ananai"
|
||||
},
|
||||
{
|
||||
"Station_JP": "大杉",
|
||||
"Station_EN": "Ōsugi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐北川",
|
||||
"Station_EN": "Tosa-Kitagawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "角茂谷",
|
||||
"Station_EN": "Kakumodani"
|
||||
},
|
||||
{
|
||||
"Station_JP": "繁藤",
|
||||
"Station_EN": "Shigetō"
|
||||
},
|
||||
{
|
||||
"Station_JP": "新改",
|
||||
"Station_EN": "Shingai"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐山田",
|
||||
"Station_EN": "Tosa-Yamada"
|
||||
},
|
||||
{
|
||||
"Station_JP": "山田西町",
|
||||
"Station_EN": "Yamadanishimachi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐長岡",
|
||||
"Station_EN": "Tosa-Nagaoka"
|
||||
},
|
||||
{
|
||||
"Station_JP": "後免",
|
||||
"Station_EN": "Gomen"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐大津",
|
||||
"Station_EN": "Tosa-Ōtsu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "布師田",
|
||||
"Station_EN": "Nunoshida"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐一宮",
|
||||
"Station_EN": "Tosa-Ikku"
|
||||
},
|
||||
{
|
||||
"Station_JP": "薊野",
|
||||
"Station_EN": "Azōno"
|
||||
},
|
||||
{
|
||||
"Station_JP": "高知",
|
||||
"Station_EN": "Kōchi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "入明",
|
||||
"Station_EN": "Iriake"
|
||||
},
|
||||
{
|
||||
"Station_JP": "円行寺口",
|
||||
"Station_EN": "Engyōjiguchi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "旭",
|
||||
"Station_EN": "Asahi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "朝倉",
|
||||
"Station_EN": "Asakura"
|
||||
},
|
||||
{
|
||||
"Station_JP": "枝川",
|
||||
"Station_EN": "Edagawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊野",
|
||||
"Station_EN": "Ino"
|
||||
},
|
||||
{
|
||||
"Station_JP": "波川",
|
||||
"Station_EN": "Hakawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "小村神社前",
|
||||
"Station_EN": "Omurajinjamae"
|
||||
},
|
||||
{
|
||||
"Station_JP": "日下",
|
||||
"Station_EN": "Kusaka"
|
||||
},
|
||||
{
|
||||
"Station_JP": "岡花",
|
||||
"Station_EN": "Okabana"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐加茂",
|
||||
"Station_EN": "Tosa-Kamo"
|
||||
},
|
||||
{
|
||||
"Station_JP": "西佐川",
|
||||
"Station_EN": "Nishi-Sakawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "佐川",
|
||||
"Station_EN": "Sakawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "襟野々",
|
||||
"Station_EN": "Erinono"
|
||||
},
|
||||
{
|
||||
"Station_JP": "斗賀野",
|
||||
"Station_EN": "Togano"
|
||||
},
|
||||
{
|
||||
"Station_JP": "吾桑",
|
||||
"Station_EN": "Asō"
|
||||
},
|
||||
{
|
||||
"Station_JP": "多ノ郷",
|
||||
"Station_EN": "Ōnogō"
|
||||
},
|
||||
{
|
||||
"Station_JP": "大間",
|
||||
"Station_EN": "Ōma"
|
||||
},
|
||||
{
|
||||
"Station_JP": "須崎",
|
||||
"Station_EN": "Susaki"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐新荘",
|
||||
"Station_EN": "Tosa-Shinjō"
|
||||
},
|
||||
{
|
||||
"Station_JP": "安和",
|
||||
"Station_EN": "Awa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐久礼",
|
||||
"Station_EN": "Tosa-Kure"
|
||||
},
|
||||
{
|
||||
"Station_JP": "影野",
|
||||
"Station_EN": "Kageno"
|
||||
},
|
||||
{
|
||||
"Station_JP": "六反地",
|
||||
"Station_EN": "Rokutanji"
|
||||
},
|
||||
{
|
||||
"Station_JP": "仁井田",
|
||||
"Station_EN": "Niida"
|
||||
},
|
||||
{
|
||||
"Station_JP": "窪川",
|
||||
"Station_EN": "Kubokawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "児島",
|
||||
"Station_EN": "Kojima"
|
||||
},
|
||||
{
|
||||
"Station_JP": "上の町",
|
||||
"Station_EN": "KaminochŌ"
|
||||
},
|
||||
{
|
||||
"Station_JP": "木見",
|
||||
"Station_EN": "Kimi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "植松",
|
||||
"Station_EN": "Uematsu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "茶屋町",
|
||||
"Station_EN": "Chayamachi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "久々原",
|
||||
"Station_EN": "Kuguhara"
|
||||
},
|
||||
{
|
||||
"Station_JP": "早島",
|
||||
"Station_EN": "kojima"
|
||||
},
|
||||
{
|
||||
"Station_JP": "備中箕島",
|
||||
"Station_EN": "Bitchū-Mishima"
|
||||
},
|
||||
{
|
||||
"Station_JP": "妹尾",
|
||||
"Station_EN": "Senoo"
|
||||
},
|
||||
{
|
||||
"Station_JP": "備前西市",
|
||||
"Station_EN": "Bizen-Nishiichi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "大元",
|
||||
"Station_EN": "Ōmoto"
|
||||
},
|
||||
{
|
||||
"Station_JP": "岡山",
|
||||
"Station_EN": "Okayama"
|
||||
},
|
||||
{
|
||||
"Station_JP": "新谷",
|
||||
"Station_EN": "Niiya"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予大洲",
|
||||
"Station_EN": "Iyo-Ōzu"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予平野",
|
||||
"Station_EN": "Iyo-Hirano"
|
||||
},
|
||||
{
|
||||
"Station_JP": "内子",
|
||||
"Station_EN": "Uchiko"
|
||||
},
|
||||
{
|
||||
"Station_JP": "五十崎",
|
||||
"Station_EN": "Ikazaki"
|
||||
},
|
||||
{
|
||||
"Station_JP": "喜多山",
|
||||
"Station_EN": "Kitayama"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予立川",
|
||||
"Station_EN": "Iyo-Tachikawa"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予大平",
|
||||
"Station_EN": "Iyo-Ōhira"
|
||||
},
|
||||
{
|
||||
"Station_JP": "伊予中山",
|
||||
"Station_EN": "Iyo-Nakayama"
|
||||
},
|
||||
{
|
||||
"Station_JP": "中村",
|
||||
"Station_EN": "Nakamura"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐入野",
|
||||
"Station_EN": "Tosa-irino"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐上川口",
|
||||
"Station_EN": "Tosa-kamikawaguchi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "土佐佐賀",
|
||||
"Station_EN": "Tosasaga"
|
||||
},
|
||||
{
|
||||
"Station_JP": "浮鞭",
|
||||
"Station_EN": "Ukibuchi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "安芸",
|
||||
"Station_EN": "Aki"
|
||||
},
|
||||
{
|
||||
"Station_JP": "阿波大谷",
|
||||
"Station_EN": "Awa-Ōtani"
|
||||
},
|
||||
{
|
||||
"Station_JP": "立道",
|
||||
"Station_EN": "Tatsumichi"
|
||||
},
|
||||
{
|
||||
"Station_JP": "教会前",
|
||||
"Station_EN": "Kyōkaimae"
|
||||
},
|
||||
{
|
||||
"Station_JP": "金比羅前",
|
||||
"Station_EN": "Kompiramae"
|
||||
},
|
||||
{
|
||||
"Station_JP": "撫養",
|
||||
"Station_EN": "Muya"
|
||||
},
|
||||
{
|
||||
"Station_JP": "鳴門",
|
||||
"Station_EN": "Naruto"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,292 @@
|
||||
[
|
||||
{
|
||||
"1M": {
|
||||
"1001M": "いしづち1号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1001M": {
|
||||
"1M": "しおかぜ1号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"3M": {
|
||||
"1003M": "いしづち3号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1003M": {
|
||||
"3M": "しおかぜ3号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"5M": {
|
||||
"1005M": "いしづち5号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1005M": {
|
||||
"5M": "しおかぜ5号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"7M": {
|
||||
"1007M": "いしづち7号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1007M": {
|
||||
"7M": "しおかぜ7号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"9M": {
|
||||
"1009M": "いしづち9号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1009M": {
|
||||
"9M": "しおかぜ9号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"11M": {
|
||||
"1011M": "いしづち11号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1011M": {
|
||||
"11M": "しおかぜ11号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"13M": {
|
||||
"1013M": "いしづち13号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1013M": {
|
||||
"13M": "しおかぜ13号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"15M": {
|
||||
"1015M": "いしづち15号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1015M": {
|
||||
"15M": "しおかぜ15号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"17M": {
|
||||
"1017M": "いしづち17号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1017M": {
|
||||
"17M": "しおかぜ17号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"19M": {
|
||||
"1019M": "いしづち19号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1019M": {
|
||||
"19M": "しおかぜ19号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"21M": {
|
||||
"1021M": "いしづち21号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1021M": {
|
||||
"21M": "しおかぜ21号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"23M": {
|
||||
"1023M": "いしづち23号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1023M": {
|
||||
"23M": "しおかぜ23号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"25M": {
|
||||
"1025M": "いしづち25号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1025M": {
|
||||
"25M": "しおかぜ25号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"27M": {
|
||||
"1027M": "いしづち27号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1027M": {
|
||||
"27M": "しおかぜ27号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"29M": {
|
||||
"1029M": "いしづち29号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1029M": {
|
||||
"29M": "しおかぜ29号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"4M": {
|
||||
"1004M": "いしづち4号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1004M": {
|
||||
"4M": "しおかぜ4号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"6M": {
|
||||
"1006M": "いしづち6号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1006M": {
|
||||
"6M": "しおかぜ6号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"8M": {
|
||||
"1008M": "いしづち8号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1008M": {
|
||||
"8M": "しおかぜ8号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"10M": {
|
||||
"1010M": "いしづち10号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1010M": {
|
||||
"10M": "しおかぜ10号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"12M": {
|
||||
"1012M": "いしづち12号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1012M": {
|
||||
"12M": "しおかぜ12号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"14M": {
|
||||
"1014M": "いしづち14号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1014M": {
|
||||
"14M": "しおかぜ14号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"16M": {
|
||||
"1016M": "いしづち16号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1016M": {
|
||||
"16M": "しおかぜ16号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"18M": {
|
||||
"1018M": "いしづち18号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1018M": {
|
||||
"18M": "しおかぜ18号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"20M": {
|
||||
"1020M": "いしづち20号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1020M": {
|
||||
"20M": "しおかぜ20号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"22M": {
|
||||
"1022M": "いしづち22号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1022M": {
|
||||
"22M": "しおかぜ22号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"24M": {
|
||||
"1024M": "いしづち24号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1024M": {
|
||||
"24M": "しおかぜ24号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"26M": {
|
||||
"1026M": "いしづち26号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1026M": {
|
||||
"26M": "しおかぜ26号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"28M": {
|
||||
"1028M": "いしづち28号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1028M": {
|
||||
"28M": "しおかぜ28号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"30M": {
|
||||
"1030M": "いしづち30号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"1030M": {
|
||||
"30M": "しおかぜ30号"
|
||||
}
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import { observedFetchJson } from "@/lib/observability/network/observedFetch";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
let _lastGoodTrainPositions: any[] | null = null;
|
||||
let _lastGoodTrainPositionsAt: number | 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 data = await observedFetchJson<PositionMaster[]>(POSITION_MASTERS_URL, {
|
||||
endpoint: "positions_master",
|
||||
source: "mock_api",
|
||||
userVisible: false,
|
||||
preload: true,
|
||||
fetchPriority: "medium",
|
||||
timeoutMs: 8000,
|
||||
retry: true,
|
||||
urlPathTemplate: "/position-masters",
|
||||
});
|
||||
_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[]> => {
|
||||
try {
|
||||
const data = await observedFetchJson<any[]>(MOCK_TRAIN_POSITIONS_URL, {
|
||||
endpoint: "positions_current",
|
||||
source: "mock_api",
|
||||
userVisible: true,
|
||||
preload: false,
|
||||
fetchPriority: "high",
|
||||
timeoutMs: 8000,
|
||||
retry: true,
|
||||
urlPathTemplate: "/train-positions/current",
|
||||
});
|
||||
_lastGoodTrainPositions = data;
|
||||
_lastGoodTrainPositionsAt = Date.now();
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (_lastGoodTrainPositions) {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "data_fetch",
|
||||
level: "warning",
|
||||
message: "fetch:stale_cache",
|
||||
data: {
|
||||
endpoint: "positions_current",
|
||||
source: "mock_api",
|
||||
stale: true,
|
||||
cacheAgeSeconds: _lastGoodTrainPositionsAt ? Math.round((Date.now() - _lastGoodTrainPositionsAt) / 1000) : undefined,
|
||||
},
|
||||
});
|
||||
return _lastGoodTrainPositions;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,371 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Directory, File, Paths } from 'expo-file-system';
|
||||
|
||||
import { AS } from '../../storageControl';
|
||||
import { STORAGE_KEYS } from '../../constants/storage';
|
||||
import { TrainEntry } from './webviewXhrInterceptor';
|
||||
|
||||
export type TrainSnapshot = {
|
||||
/** ms elapsed from recording start */
|
||||
t: number;
|
||||
trains: TrainEntry[];
|
||||
};
|
||||
|
||||
/** 録画のメタ情報(一覧表示用・軽量) */
|
||||
export type RecordingMeta = {
|
||||
id: string;
|
||||
recordedAt: string;
|
||||
durationMs: number;
|
||||
snapshotCount: number;
|
||||
};
|
||||
|
||||
/** フル録画データ(再生時のみロード) */
|
||||
export type TrainRecording = {
|
||||
id: string;
|
||||
recordedAt: string;
|
||||
durationMs: number;
|
||||
snapshots: TrainSnapshot[];
|
||||
};
|
||||
|
||||
export type RecordingExportEnvelope = {
|
||||
format: 'jrshikoku-train-recording';
|
||||
version: 1;
|
||||
exportedAt: string;
|
||||
recording: TrainRecording;
|
||||
};
|
||||
|
||||
export type RecordingsExportEnvelope = {
|
||||
format: 'jrshikoku-train-recordings';
|
||||
version: 1;
|
||||
exportedAt: string;
|
||||
recordings: TrainRecording[];
|
||||
};
|
||||
|
||||
export type RecordingImportResult = {
|
||||
importedCount: number;
|
||||
overwrittenCount: number;
|
||||
};
|
||||
|
||||
const RECORDING_EXPORT_FORMAT = 'jrshikoku-train-recording' as const;
|
||||
const RECORDINGS_EXPORT_FORMAT = 'jrshikoku-train-recordings' as const;
|
||||
const RECORDING_FILE_DIRECTORY = 'train-recordings';
|
||||
|
||||
const getRecordingsDirectory = () => new Directory(Paths.document, RECORDING_FILE_DIRECTORY);
|
||||
|
||||
const ensureRecordingsDirectory = () => {
|
||||
const directory = getRecordingsDirectory();
|
||||
directory.create({ idempotent: true, intermediates: true });
|
||||
return directory;
|
||||
};
|
||||
|
||||
const getRecordingFileName = (id: string) => `${encodeURIComponent(id)}.json`;
|
||||
|
||||
const getRecordingFile = (id: string, createDirectory = false) =>
|
||||
new File(createDirectory ? ensureRecordingsDirectory() : getRecordingsDirectory(), getRecordingFileName(id));
|
||||
|
||||
const getLegacyRecordingStorageKey = (id: string) =>
|
||||
(STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + id) as string;
|
||||
|
||||
const removeLegacyRecordingStorage = async (id: string) => {
|
||||
const key = getLegacyRecordingStorageKey(id);
|
||||
await AS.removeItem(key).catch(() => {});
|
||||
};
|
||||
|
||||
const cleanupLegacyRecordingStorage = async () => {
|
||||
const prefix = STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX;
|
||||
const keys = await AsyncStorage.getAllKeys().catch(() => []);
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.map((key) => AsyncStorage.removeItem(key).catch(() => {})),
|
||||
);
|
||||
};
|
||||
|
||||
const parse = <T>(raw: string | null | boolean): T | null => {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(typeof raw === 'string' ? raw : JSON.stringify(raw)) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** 録画IDを生成(recordedAt ISO文字列からファイル名に使えるIDへ) */
|
||||
export const generateRecordingId = (recordedAt: string) =>
|
||||
recordedAt.replace(/[:.]/g, '-');
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const normalizeDirection = (value: unknown): 0 | 1 => (value === 1 ? 1 : 0);
|
||||
|
||||
const normalizeDelay = (value: unknown): number | string =>
|
||||
typeof value === 'number' || typeof value === 'string' ? value : 0;
|
||||
|
||||
const normalizeTrainEntry = (value: unknown): TrainEntry | null => {
|
||||
if (!isPlainObject(value)) return null;
|
||||
if (typeof value.TrainNum !== 'string') return null;
|
||||
if (typeof value.Line !== 'string') return null;
|
||||
if (typeof value.Type !== 'string') return null;
|
||||
|
||||
return {
|
||||
Index: typeof value.Index === 'number' ? value.Index : 0,
|
||||
TrainNum: value.TrainNum,
|
||||
Pos: typeof value.Pos === 'string' ? value.Pos : '',
|
||||
PosNum: typeof value.PosNum === 'number' ? value.PosNum : 0,
|
||||
delay: normalizeDelay(value.delay),
|
||||
Direction: normalizeDirection(value.Direction),
|
||||
Type: value.Type,
|
||||
Line: value.Line,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeSnapshot = (value: unknown): TrainSnapshot | null => {
|
||||
if (!isPlainObject(value)) return null;
|
||||
if (!Array.isArray(value.trains)) return null;
|
||||
|
||||
const trains = value.trains
|
||||
.map(normalizeTrainEntry)
|
||||
.filter((entry): entry is TrainEntry => entry !== null);
|
||||
|
||||
if (trains.length !== value.trains.length) return null;
|
||||
|
||||
return {
|
||||
t: typeof value.t === 'number' && Number.isFinite(value.t) ? Math.max(0, value.t) : 0,
|
||||
trains,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeRecording = (value: unknown): TrainRecording | null => {
|
||||
if (!isPlainObject(value)) return null;
|
||||
if (typeof value.recordedAt !== 'string') return null;
|
||||
if (!Array.isArray(value.snapshots)) return null;
|
||||
|
||||
const snapshots = value.snapshots
|
||||
.map(normalizeSnapshot)
|
||||
.filter((snapshot): snapshot is TrainSnapshot => snapshot !== null);
|
||||
|
||||
if (snapshots.length !== value.snapshots.length) return null;
|
||||
|
||||
const durationMs =
|
||||
typeof value.durationMs === 'number' && Number.isFinite(value.durationMs)
|
||||
? Math.max(0, value.durationMs)
|
||||
: snapshots[snapshots.length - 1]?.t ?? 0;
|
||||
|
||||
const id =
|
||||
typeof value.id === 'string' && value.id.trim().length > 0
|
||||
? value.id
|
||||
: generateRecordingId(value.recordedAt);
|
||||
|
||||
return {
|
||||
id,
|
||||
recordedAt: value.recordedAt,
|
||||
durationMs,
|
||||
snapshots,
|
||||
};
|
||||
};
|
||||
|
||||
const extractRecordingsFromImport = (value: unknown): TrainRecording[] => {
|
||||
if (isPlainObject(value) && value.format === RECORDING_EXPORT_FORMAT) {
|
||||
const recording = normalizeRecording(value.recording);
|
||||
return recording ? [recording] : [];
|
||||
}
|
||||
|
||||
if (isPlainObject(value) && value.format === RECORDINGS_EXPORT_FORMAT && Array.isArray(value.recordings)) {
|
||||
return value.recordings
|
||||
.map(normalizeRecording)
|
||||
.filter((recording): recording is TrainRecording => recording !== null);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(normalizeRecording)
|
||||
.filter((recording): recording is TrainRecording => recording !== null);
|
||||
}
|
||||
|
||||
const recording = normalizeRecording(value);
|
||||
return recording ? [recording] : [];
|
||||
};
|
||||
|
||||
/** 録画インデックス(メタ一覧)を読み込む */
|
||||
export const loadRecordingList = async (): Promise<RecordingMeta[]> => {
|
||||
const raw = await AS.getItem(STORAGE_KEYS.MOCK_RECORDINGS_INDEX).catch(() => null);
|
||||
return parse<RecordingMeta[]>(raw) ?? [];
|
||||
};
|
||||
|
||||
/** フル録画データをIDで読み込む */
|
||||
export const loadRecordingById = async (id: string): Promise<TrainRecording | null> => {
|
||||
const file = getRecordingFile(id);
|
||||
if (file.exists) {
|
||||
const raw = await file.text().catch(() => null);
|
||||
const recording = parse<TrainRecording>(raw);
|
||||
if (recording) return recording;
|
||||
}
|
||||
|
||||
const legacyKey = getLegacyRecordingStorageKey(id);
|
||||
const legacyRaw = await AS.getItem(legacyKey).catch(() => null);
|
||||
const legacyRecording = parse<TrainRecording>(legacyRaw);
|
||||
if (!legacyRecording) return null;
|
||||
|
||||
await writeRecordingFile(legacyRecording);
|
||||
await removeLegacyRecordingStorage(id);
|
||||
return legacyRecording;
|
||||
};
|
||||
|
||||
/** 全録画を新しい順で読み込む */
|
||||
export const loadAllRecordings = async (): Promise<TrainRecording[]> => {
|
||||
const list = await loadRecordingList();
|
||||
const recordings = await Promise.all(list.map((meta) => loadRecordingById(meta.id)));
|
||||
return recordings.filter((recording): recording is TrainRecording => recording !== null);
|
||||
};
|
||||
|
||||
const migrateIndexedLegacyRecordings = async (): Promise<boolean> => {
|
||||
const list = await loadRecordingList();
|
||||
if (list.length === 0) return true;
|
||||
|
||||
const results = await Promise.all(
|
||||
list.map(async (meta) => {
|
||||
const recording = await loadRecordingById(meta.id).catch(() => null);
|
||||
return recording !== null;
|
||||
}),
|
||||
);
|
||||
|
||||
return results.every(Boolean);
|
||||
};
|
||||
|
||||
const writeRecordingFile = async (recording: TrainRecording): Promise<void> => {
|
||||
const file = getRecordingFile(recording.id, true);
|
||||
if (file.exists) {
|
||||
file.delete();
|
||||
}
|
||||
file.create({ overwrite: true });
|
||||
file.write(JSON.stringify(recording));
|
||||
};
|
||||
|
||||
/** 録画を保存してインデックスに追加する */
|
||||
export const saveRecording = async (recording: TrainRecording): Promise<void> => {
|
||||
await writeRecordingFile(recording);
|
||||
await removeLegacyRecordingStorage(recording.id);
|
||||
const list = await loadRecordingList();
|
||||
const meta: RecordingMeta = {
|
||||
id: recording.id,
|
||||
recordedAt: recording.recordedAt,
|
||||
durationMs: recording.durationMs,
|
||||
snapshotCount: recording.snapshots.length,
|
||||
};
|
||||
// 先頭に追加(新しい順)、同IDは重複排除
|
||||
const newList = [meta, ...list.filter((m) => m.id !== recording.id)];
|
||||
await AS.setItem(STORAGE_KEYS.MOCK_RECORDINGS_INDEX, JSON.stringify(newList));
|
||||
};
|
||||
|
||||
/** 録画をIDで削除する */
|
||||
export const deleteRecordingById = async (id: string): Promise<void> => {
|
||||
const file = getRecordingFile(id);
|
||||
if (file.exists) {
|
||||
file.delete();
|
||||
}
|
||||
await removeLegacyRecordingStorage(id);
|
||||
const list = await loadRecordingList();
|
||||
await AS.setItem(
|
||||
STORAGE_KEYS.MOCK_RECORDINGS_INDEX,
|
||||
JSON.stringify(list.filter((m) => m.id !== id)),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 旧フォーマット(MOCK_RECORDING/SQLite保存)からファイル保存へ移行する。
|
||||
*/
|
||||
export const migrateOldRecording = async (): Promise<void> => {
|
||||
const indexedRecordingsMigrated = await migrateIndexedLegacyRecordings();
|
||||
const list = await loadRecordingList();
|
||||
if (list.length > 0) {
|
||||
if (indexedRecordingsMigrated) {
|
||||
await cleanupLegacyRecordingStorage();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = await AS.getItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => null);
|
||||
const old = parse<Omit<TrainRecording, 'id'>>(raw);
|
||||
if (old) {
|
||||
const id = generateRecordingId(old.recordedAt);
|
||||
await saveRecording({ ...old, id });
|
||||
await AS.removeItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => {});
|
||||
}
|
||||
|
||||
await cleanupLegacyRecordingStorage();
|
||||
};
|
||||
|
||||
/** 録画1件をファイル/クリップボード用JSON文字列へ変換する */
|
||||
export const buildRecordingExportText = async (id: string): Promise<string> => {
|
||||
const recording = await loadRecordingById(id);
|
||||
if (!recording) {
|
||||
throw new Error('録画データが見つかりませんでした。');
|
||||
}
|
||||
|
||||
const payload: RecordingExportEnvelope = {
|
||||
format: RECORDING_EXPORT_FORMAT,
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
recording,
|
||||
};
|
||||
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
/** 全録画をファイル/クリップボード用JSON文字列へ変換する */
|
||||
export const buildAllRecordingsExportText = async (): Promise<string> => {
|
||||
const recordings = await loadAllRecordings();
|
||||
if (recordings.length === 0) {
|
||||
throw new Error('書き出せる録画がありません。');
|
||||
}
|
||||
|
||||
const payload: RecordingsExportEnvelope = {
|
||||
format: RECORDINGS_EXPORT_FORMAT,
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
recordings,
|
||||
};
|
||||
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
/** コピペ/ファイルから読み込んだJSON文字列を録画として保存する */
|
||||
export const importRecordingsFromText = async (text: string): Promise<RecordingImportResult> => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('インポートするJSONが空です。');
|
||||
}
|
||||
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error('JSONとして読み取れませんでした。ファイル内容を確認してください。');
|
||||
}
|
||||
|
||||
const recordings = extractRecordingsFromImport(parsedValue);
|
||||
if (recordings.length === 0) {
|
||||
throw new Error('有効な録画データが見つかりませんでした。');
|
||||
}
|
||||
|
||||
if (await migrateIndexedLegacyRecordings()) {
|
||||
await cleanupLegacyRecordingStorage();
|
||||
}
|
||||
|
||||
const existingIds = new Set((await loadRecordingList()).map((meta) => meta.id));
|
||||
let overwrittenCount = 0;
|
||||
|
||||
for (const recording of recordings) {
|
||||
if (existingIds.has(recording.id)) {
|
||||
overwrittenCount += 1;
|
||||
}
|
||||
await saveRecording(recording);
|
||||
existingIds.add(recording.id);
|
||||
}
|
||||
|
||||
return {
|
||||
importedCount: recordings.length,
|
||||
overwrittenCount,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import { PositionMaster, buildPosLookup, serializePosLookupForJs } from './positionMasters';
|
||||
|
||||
/**
|
||||
* WebView XHR Interceptor for JR Shikoku official train position site
|
||||
*
|
||||
* The official site (train.jr-shikoku.co.jp/sp.html) makes XMLHttpRequest calls
|
||||
* to its own /g?arg1=...&arg2=... API. This module generates a JavaScript string
|
||||
* that, when injected into the WebView before page load, overrides XMLHttpRequest
|
||||
* so those calls can be served with mock/alternative data.
|
||||
*
|
||||
* API endpoint reference:
|
||||
* Static (loaded once on startup):
|
||||
* GET /g?arg1=lang&arg2=lang - i18n strings
|
||||
* GET /g?arg1=station&arg2=traintimeinfo&arg3=dia - station timetable info
|
||||
* GET /g?arg1=train&arg2=trainpare - train pair info (Shinkansen links)
|
||||
* GET /g?arg1=station&arg2=between - between-station hidden stops
|
||||
* GET /g?arg1=station&arg2=othercolor - colour overrides for other lines
|
||||
* GET /g?arg1=train&arg2=holidaydisp - holiday train display rules
|
||||
* GET /g?arg1=train&arg2=sightseeingtrainname - sightseeing train names
|
||||
* GET /g?arg1=station&arg2=cross - cross-line station data
|
||||
* GET /g?arg1=station&arg2=otherline - other-line station data
|
||||
* GET /g?arg1=line&arg2=train_lang - line/train i18n names
|
||||
* GET /g?arg1=train&arg2=ignore - trains to hide from display
|
||||
* GET /g?arg1=station&arg2={line} - station list for a line
|
||||
* line values: yosan | koutoku | tokushima | dosan | dosan2 | uwajima | naruto
|
||||
*
|
||||
* Dynamic (polled ~every few seconds):
|
||||
* GET /g?arg1=train&arg2=train - live train positions ← main target
|
||||
*
|
||||
* Train position response schema:
|
||||
* Array of { Index, TrainNum, Pos, PosNum, delay, Direction, Type, Line }
|
||||
* with a trailing { GetDateTime: "YYYY/MM/DD HH:MM:SS" } element.
|
||||
*/
|
||||
|
||||
export interface TrainEntry {
|
||||
Index: number;
|
||||
TrainNum: string;
|
||||
/** 走行位置テキスト e.g. "高松〜鬼無(上り)" */
|
||||
Pos: string;
|
||||
PosNum: number;
|
||||
/** 遅延分数 or "入線" */
|
||||
delay: number | string;
|
||||
/** 0: 下り, 1: 上り */
|
||||
Direction: 0 | 1;
|
||||
/** "normal" | "rapid:..." | "express:..." | "ltd:..." */
|
||||
Type: string;
|
||||
/** "yosan" | "koutoku" | "tokushima" | "dosan" | "dosan2" | "uwajima" | "naruto" */
|
||||
Line: string;
|
||||
}
|
||||
|
||||
export interface MockApiConfig {
|
||||
/**
|
||||
* Mock train position data injected in place of the live /g?arg1=train&arg2=train API.
|
||||
* When provided, the WebView will use this data instead of polling the real server.
|
||||
*/
|
||||
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
|
||||
* polling API is intercepted; all other calls reach the real server.
|
||||
*/
|
||||
interceptStaticApis?: boolean;
|
||||
|
||||
/**
|
||||
* Optional map of query-string patterns to JSON response strings for the
|
||||
* static APIs. Only used when interceptStaticApis is true.
|
||||
* Keys are the full query string, e.g. "arg1=lang&arg2=lang".
|
||||
*/
|
||||
staticData?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a JavaScript string that should be prepended to the WebView's
|
||||
* injectedJavaScript (runs before page scripts). It overrides XMLHttpRequest
|
||||
* so the specified /g?arg1=...&arg2=... calls are intercepted and served
|
||||
* from mock data.
|
||||
*/
|
||||
export const generateXhrInterceptorJs = (config: MockApiConfig): string => {
|
||||
const trainJson = JSON.stringify([
|
||||
...config.trainPositions,
|
||||
{ GetDateTime: new Date().toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }).replace(/\//g, "/") },
|
||||
]);
|
||||
|
||||
const staticEntries = config.interceptStaticApis && config.staticData
|
||||
? Object.entries(config.staticData)
|
||||
.map(([qs, data]) => `${JSON.stringify(qs)}: ${JSON.stringify(data)}`)
|
||||
.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.
|
||||
// The guard ensures the prototype is only patched once.
|
||||
if (window.__jrsMockActive) return;
|
||||
|
||||
'use strict';
|
||||
|
||||
// ── Mock data ──────────────────────────────────────────────────────────────
|
||||
var _MOCK_TRAIN = ${trainJson};
|
||||
var _INTERCEPT_STATIC = ${config.interceptStaticApis ? "true" : "false"};
|
||||
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,
|
||||
// we patch the prototype methods directly. This ensures:
|
||||
// • instanceof XMLHttpRequest still works
|
||||
// • All native properties (responseType, withCredentials, etc.) work
|
||||
// • 'this' context in callbacks is always the real XHR instance
|
||||
var _proto = window.XMLHttpRequest.prototype;
|
||||
var _origOpen = _proto.open;
|
||||
var _origSend = _proto.send;
|
||||
|
||||
_proto.open = function(method, url) {
|
||||
var qs = (url || '').replace(/^[^?]+\\?/, '');
|
||||
if (qs === 'arg1=train&arg2=train') {
|
||||
this.__jrsMockBody = JSON.stringify(_enrichTrain(_MOCK_TRAIN));
|
||||
_origOpen.apply(this, arguments);
|
||||
return;
|
||||
}
|
||||
if (_INTERCEPT_STATIC && _STATIC_MAP[qs] !== undefined) {
|
||||
this.__jrsMockBody = _STATIC_MAP[qs];
|
||||
_origOpen.apply(this, arguments);
|
||||
return;
|
||||
}
|
||||
this.__jrsMockBody = null;
|
||||
_origOpen.apply(this, arguments);
|
||||
};
|
||||
|
||||
_proto.send = function(body) {
|
||||
var self = this;
|
||||
if (self.__jrsMockBody != null) {
|
||||
var mockStr = self.__jrsMockBody;
|
||||
// Support responseType='json': return parsed object from .response
|
||||
var parsed = null;
|
||||
try { parsed = JSON.parse(mockStr); } catch(e) {}
|
||||
|
||||
// Override instance properties to serve mock data
|
||||
// (configurable:true allows re-override if needed)
|
||||
Object.defineProperties(self, {
|
||||
readyState: { get: function() { return 4; }, configurable: true, enumerable: true },
|
||||
status: { get: function() { return 200; }, configurable: true, enumerable: true },
|
||||
statusText: { get: function() { return 'OK'; }, configurable: true, enumerable: true },
|
||||
responseText: { get: function() { return mockStr; }, configurable: true, enumerable: true },
|
||||
response: {
|
||||
get: function() {
|
||||
var rt = self.responseType;
|
||||
if (rt === 'json') return parsed;
|
||||
if (rt === 'arraybuffer' || rt === 'blob') return mockStr; // best-effort
|
||||
return mockStr;
|
||||
},
|
||||
configurable: true, enumerable: true
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(function() {
|
||||
// Fire events via dispatchEvent only.
|
||||
// dispatchEvent fires both addEventListener callbacks AND onXxx property handlers,
|
||||
// so calling onreadystatechange.call() / onload.call() separately would double-fire.
|
||||
if (typeof self.dispatchEvent === 'function') {
|
||||
try { self.dispatchEvent(new Event('readystatechange')); } catch(e) {}
|
||||
try { self.dispatchEvent(new ProgressEvent('load')); } catch(e) {
|
||||
try { self.dispatchEvent(new Event('load')); } catch(e2) {}
|
||||
}
|
||||
try { self.dispatchEvent(new ProgressEvent('loadend')); } catch(e) {
|
||||
try { self.dispatchEvent(new Event('loadend')); } catch(e2) {}
|
||||
}
|
||||
} else {
|
||||
// Fallback for environments without dispatchEvent
|
||||
if (typeof self.onreadystatechange === 'function') {
|
||||
try { self.onreadystatechange.call(self); } catch(e) {}
|
||||
}
|
||||
if (typeof self.onload === 'function') {
|
||||
try { self.onload.call(self); } catch(e) {}
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
_origSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
// ── Live-update hook ───────────────────────────────────────────────────────
|
||||
// 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;
|
||||
console.log('[JRS Mock] XHR interceptor active (prototype-patch) – train position data is mocked');
|
||||
})();
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 再生コマが変化したときに WebView へ injectJavaScript で流し込む更新スクリプトを生成する。
|
||||
* window.__jrsMockUpdateTrain で _MOCK_TRAIN を差し替え、window.setReload() で再描画を促す。
|
||||
*/
|
||||
export const generateMockUpdateScript = (trainPositions: TrainEntry[]): string => {
|
||||
const trainJson = JSON.stringify([
|
||||
...trainPositions,
|
||||
{ GetDateTime: new Date().toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }).replace(/\//g, "/") },
|
||||
]);
|
||||
return `
|
||||
(function() {
|
||||
if (typeof window.__jrsMockUpdateTrain === 'function') {
|
||||
window.__jrsMockUpdateTrain(${trainJson});
|
||||
}
|
||||
if (typeof window.setReload === 'function') {
|
||||
window.setReload();
|
||||
}
|
||||
})();
|
||||
true;
|
||||
`;
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { AppState, AppStateStatus, Platform } from "react-native";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import * as Updates from "expo-updates";
|
||||
import { lastObservedRootRouteRef } from "@/lib/rootNavigation";
|
||||
|
||||
const STORAGE_KEY = "@jrshikoku/app_crash_sentinel_v1";
|
||||
const HEARTBEAT_INTERVAL_MS = 7000;
|
||||
const RECENT_HEARTBEAT_MS = 120000;
|
||||
const MIN_SESSION_AGE_MS = 5000;
|
||||
|
||||
type RootNavigationSnapshot = {
|
||||
rootRoute: string | null;
|
||||
nestedRoute?: string | null;
|
||||
rootIndex?: number;
|
||||
nestedIndex?: number;
|
||||
source?: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type CrashSentinelState = {
|
||||
app_session_active: boolean;
|
||||
normal_background: boolean;
|
||||
sessionStartedAt: string;
|
||||
lastHeartbeatAt: string;
|
||||
lastKnownAppState: AppStateStatus | "unknown";
|
||||
lastRootTab: string | null;
|
||||
previousRootNavigation: RootNavigationSnapshot | null;
|
||||
activeWebViews: string[];
|
||||
nativeScreensMode: string;
|
||||
reportedUnexpectedExitForSessionStartedAt?: string;
|
||||
};
|
||||
|
||||
type StartOptions = {
|
||||
nativeScreensMode: string;
|
||||
};
|
||||
|
||||
let currentState: CrashSentinelState | null = null;
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let appStateSubscription: { remove: () => void } | null = null;
|
||||
let latestNavigationSnapshot: RootNavigationSnapshot | null = null;
|
||||
const activeWebViews = new Set<string>();
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
const safeJsonParse = <T>(value: string | null): T | null => {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getExpoUpdateContext = () => ({
|
||||
update_id: (Updates as any).updateId ?? null,
|
||||
updateId: (Updates as any).updateId ?? null,
|
||||
channel: (Updates as any).channel ?? null,
|
||||
runtimeVersion: (Updates as any).runtimeVersion ?? null,
|
||||
createdAt: (Updates as any).createdAt ?? null,
|
||||
isEmbeddedLaunch: (Updates as any).isEmbeddedLaunch ?? null,
|
||||
});
|
||||
|
||||
const getMemoryInfo = () => {
|
||||
const performanceMemory = (globalThis as any)?.performance?.memory;
|
||||
if (!performanceMemory) return null;
|
||||
return {
|
||||
jsHeapSizeLimit: performanceMemory.jsHeapSizeLimit ?? null,
|
||||
totalJSHeapSize: performanceMemory.totalJSHeapSize ?? null,
|
||||
usedJSHeapSize: performanceMemory.usedJSHeapSize ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const getRootTab = () => lastObservedRootRouteRef.current ?? currentState?.lastRootTab ?? "unknown";
|
||||
|
||||
const persistCurrentState = async (patch?: Partial<CrashSentinelState>) => {
|
||||
if (!currentState) return;
|
||||
currentState = {
|
||||
...currentState,
|
||||
...patch,
|
||||
lastRootTab: getRootTab(),
|
||||
previousRootNavigation: latestNavigationSnapshot ?? currentState.previousRootNavigation,
|
||||
activeWebViews: Array.from(activeWebViews).sort(),
|
||||
};
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(currentState));
|
||||
} catch (error) {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "app.lifecycle",
|
||||
level: "warning",
|
||||
message: "crash sentinel persist failed",
|
||||
data: {
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const shouldReportUnexpectedExit = (previous: CrashSentinelState, nowMs: number) => {
|
||||
if (!previous.app_session_active) return false;
|
||||
if (previous.normal_background) return false;
|
||||
if (previous.reportedUnexpectedExitForSessionStartedAt === previous.sessionStartedAt) return false;
|
||||
|
||||
const heartbeatMs = Date.parse(previous.lastHeartbeatAt);
|
||||
const startedMs = Date.parse(previous.sessionStartedAt);
|
||||
if (!Number.isFinite(heartbeatMs) || !Number.isFinite(startedMs)) return false;
|
||||
|
||||
const heartbeatAgeMs = nowMs - heartbeatMs;
|
||||
const sessionAgeMs = heartbeatMs - startedMs;
|
||||
return heartbeatAgeMs >= 0 && heartbeatAgeMs <= RECENT_HEARTBEAT_MS && sessionAgeMs >= MIN_SESSION_AGE_MS;
|
||||
};
|
||||
|
||||
const reportUnexpectedExit = async (previous: CrashSentinelState, nativeScreensMode: string) => {
|
||||
const rootTab = getRootTab();
|
||||
Sentry.captureMessage("app.previous_session_unexpected_exit", {
|
||||
level: "warning",
|
||||
tags: {
|
||||
area: "app_lifecycle",
|
||||
result: "unexpected_exit",
|
||||
platform: Platform.OS,
|
||||
root_tab: rootTab,
|
||||
last_root_tab: previous.lastRootTab ?? "unknown",
|
||||
native_screens_mode: nativeScreensMode,
|
||||
},
|
||||
contexts: {
|
||||
app_lifecycle: {
|
||||
lastHeartbeatAt: previous.lastHeartbeatAt,
|
||||
sessionStartedAt: previous.sessionStartedAt,
|
||||
previousRootNavigation: previous.previousRootNavigation,
|
||||
lastKnownAppState: previous.lastKnownAppState,
|
||||
activeWebViews: previous.activeWebViews,
|
||||
memory: getMemoryInfo(),
|
||||
expoUpdate: getExpoUpdateContext(),
|
||||
normalBackground: previous.normal_background,
|
||||
heartbeatAgeMs: Date.now() - Date.parse(previous.lastHeartbeatAt),
|
||||
},
|
||||
},
|
||||
fingerprint: ["app_lifecycle", "unexpected_exit", Platform.OS],
|
||||
});
|
||||
|
||||
await AsyncStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
...previous,
|
||||
reportedUnexpectedExitForSessionStartedAt: previous.sessionStartedAt,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export async function startAppLifecycleCrashSentinel(options: StartOptions) {
|
||||
const startedAt = nowIso();
|
||||
const previous = safeJsonParse<CrashSentinelState>(await AsyncStorage.getItem(STORAGE_KEY));
|
||||
|
||||
if (previous && shouldReportUnexpectedExit(previous, Date.now())) {
|
||||
await reportUnexpectedExit(previous, options.nativeScreensMode);
|
||||
}
|
||||
|
||||
currentState = {
|
||||
app_session_active: true,
|
||||
normal_background: false,
|
||||
sessionStartedAt: startedAt,
|
||||
lastHeartbeatAt: startedAt,
|
||||
lastKnownAppState: AppState.currentState ?? "unknown",
|
||||
lastRootTab: getRootTab(),
|
||||
previousRootNavigation: latestNavigationSnapshot,
|
||||
activeWebViews: Array.from(activeWebViews).sort(),
|
||||
nativeScreensMode: options.nativeScreensMode,
|
||||
};
|
||||
|
||||
Sentry.setContext("app_lifecycle_sentinel", {
|
||||
sessionStartedAt: currentState.sessionStartedAt,
|
||||
lastHeartbeatAt: currentState.lastHeartbeatAt,
|
||||
lastKnownAppState: currentState.lastKnownAppState,
|
||||
activeWebViews: currentState.activeWebViews,
|
||||
expoUpdate: getExpoUpdateContext(),
|
||||
});
|
||||
|
||||
await persistCurrentState();
|
||||
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = setInterval(() => {
|
||||
void persistCurrentState({
|
||||
lastHeartbeatAt: nowIso(),
|
||||
lastKnownAppState: AppState.currentState ?? "unknown",
|
||||
normal_background: AppState.currentState === "background" ? true : currentState?.normal_background ?? false,
|
||||
});
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
appStateSubscription?.remove();
|
||||
appStateSubscription = AppState.addEventListener("change", (nextState) => {
|
||||
const patch: Partial<CrashSentinelState> = {
|
||||
lastKnownAppState: nextState,
|
||||
lastHeartbeatAt: nowIso(),
|
||||
};
|
||||
if (nextState === "background") {
|
||||
patch.normal_background = true;
|
||||
} else if (nextState === "active") {
|
||||
patch.normal_background = false;
|
||||
}
|
||||
void persistCurrentState(patch);
|
||||
});
|
||||
}
|
||||
|
||||
export function stopAppLifecycleCrashSentinel() {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
appStateSubscription?.remove();
|
||||
appStateSubscription = null;
|
||||
}
|
||||
|
||||
export function recordAppLifecycleRootNavigation(snapshot: Omit<RootNavigationSnapshot, "updatedAt">) {
|
||||
latestNavigationSnapshot = {
|
||||
...snapshot,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
if (snapshot.rootRoute) {
|
||||
lastObservedRootRouteRef.current = snapshot.rootRoute;
|
||||
}
|
||||
void persistCurrentState();
|
||||
}
|
||||
|
||||
export function setAppLifecycleWebViewActive(name: string, active: boolean) {
|
||||
if (active) {
|
||||
activeWebViews.add(name);
|
||||
} else {
|
||||
activeWebViews.delete(name);
|
||||
}
|
||||
Sentry.setContext("active_webviews", {
|
||||
names: Array.from(activeWebViews).sort(),
|
||||
});
|
||||
void persistCurrentState();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { DataEndpoint, ExpectedContentType } from "./types";
|
||||
|
||||
type DataEndpointConfig = {
|
||||
expectedContentType: ExpectedContentType;
|
||||
timeoutMs: number;
|
||||
slowMs: number;
|
||||
allowStale: boolean;
|
||||
};
|
||||
|
||||
export const DATA_ENDPOINT_CONFIG: Record<DataEndpoint, DataEndpointConfig> = {
|
||||
positions_current: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2500, allowStale: true },
|
||||
positions_master: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2500, allowStale: true },
|
||||
positions_gas_fallback: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2500, allowStale: true },
|
||||
positions_webview: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2500, allowStale: true },
|
||||
operation_info_flag: { expectedContentType: "json", timeoutMs: 10000, slowMs: 3000, allowStale: true },
|
||||
operation_info_text: { expectedContentType: "text", timeoutMs: 15000, slowMs: 5000, allowStale: true },
|
||||
operation_logs: { expectedContentType: "json", timeoutMs: 15000, slowMs: 3000, allowStale: true },
|
||||
train_operation_data: { expectedContentType: "json", timeoutMs: 15000, slowMs: 3500, allowStale: true },
|
||||
timetable_today: { expectedContentType: "json", timeoutMs: 15000, slowMs: 3500, allowStale: true },
|
||||
positions: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2500, allowStale: true },
|
||||
operation_info: { expectedContentType: "json", timeoutMs: 10000, slowMs: 3000, allowStale: true },
|
||||
train_operation: { expectedContentType: "json", timeoutMs: 10000, slowMs: 3500, allowStale: true },
|
||||
timetable: { expectedContentType: "json", timeoutMs: 10000, slowMs: 3500, allowStale: true },
|
||||
station_info: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2500, allowStale: true },
|
||||
notice: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2500, allowStale: true },
|
||||
app_config: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2000, allowStale: false },
|
||||
unknown: { expectedContentType: "any", timeoutMs: 8000, slowMs: 3000, allowStale: false },
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NetworkFailureKind } from "./types";
|
||||
|
||||
export class ObservedFetchError extends Error {
|
||||
kind: NetworkFailureKind;
|
||||
status?: number;
|
||||
retryable: boolean;
|
||||
|
||||
constructor(message: string, kind: NetworkFailureKind, options?: { status?: number; retryable?: boolean; cause?: unknown }) {
|
||||
super(message);
|
||||
this.name = "ObservedFetchError";
|
||||
this.kind = kind;
|
||||
this.status = options?.status;
|
||||
this.retryable = options?.retryable ?? false;
|
||||
if (options?.cause !== undefined) {
|
||||
(this as Error & { cause?: unknown }).cause = options.cause;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const isObservedFetchError = (error: unknown): error is ObservedFetchError =>
|
||||
error instanceof ObservedFetchError ||
|
||||
(typeof error === "object" && error !== null && (error as any).name === "ObservedFetchError");
|
||||
|
||||
export const shouldRetryNetworkError = (error: unknown) => {
|
||||
if (isObservedFetchError(error)) {
|
||||
return error.retryable;
|
||||
}
|
||||
|
||||
const message = String((error as any)?.message ?? error);
|
||||
return message.includes("Network request failed") || message.includes("AbortError");
|
||||
};
|
||||
@@ -0,0 +1,377 @@
|
||||
import { AppState, Platform } from "react-native";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import { DATA_ENDPOINT_CONFIG } from "./endpoints";
|
||||
import { ObservedFetchOptions } from "./types";
|
||||
import { ObservedFetchError, shouldRetryNetworkError } from "./networkError";
|
||||
import { reportDataFetchFailure, reportDataFetchSuccess, truncateResponseHead } from "./sentryNetwork";
|
||||
import { lastObservedRootRouteRef, rootNavigationRef } from "@/lib/rootNavigation";
|
||||
|
||||
type ParsedUrl = { host: string; pathTemplate: string };
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const getRootTab = (fallback?: string) => {
|
||||
if (fallback) return fallback;
|
||||
try {
|
||||
const state = rootNavigationRef.getState?.();
|
||||
const routeName = state?.routes?.[state.index ?? 0]?.name;
|
||||
if (routeName) {
|
||||
lastObservedRootRouteRef.current = routeName;
|
||||
return routeName;
|
||||
}
|
||||
} catch {}
|
||||
return lastObservedRootRouteRef.current ?? "unknown";
|
||||
};
|
||||
|
||||
const getKnownOnline = () => {
|
||||
const online = (globalThis as any)?.navigator?.onLine;
|
||||
return typeof online === "boolean" ? online : true;
|
||||
};
|
||||
|
||||
const getRuntimeState = () => ({
|
||||
appState: AppState.currentState,
|
||||
online: getKnownOnline(),
|
||||
});
|
||||
|
||||
const canStartFetch = (options: ObservedFetchOptions) => {
|
||||
const runtime = getRuntimeState();
|
||||
return (options.allowBackground || runtime.appState !== "background") && runtime.online !== false;
|
||||
};
|
||||
|
||||
const shouldCaptureRuntimeFailure = () => {
|
||||
const runtime = getRuntimeState();
|
||||
return runtime.appState !== "background" && runtime.online !== false;
|
||||
};
|
||||
|
||||
const getOptionTags = (options: ObservedFetchOptions) => ({
|
||||
source: options.source ?? "rn_fetch",
|
||||
user_visible: String(options.userVisible ?? false),
|
||||
preload: String(options.preload ?? false),
|
||||
fetch_priority: options.fetchPriority ?? "medium",
|
||||
});
|
||||
|
||||
const safeParseUrl = (url: string, urlPathTemplate?: string): ParsedUrl => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.host,
|
||||
pathTemplate: urlPathTemplate ?? parsed.pathname,
|
||||
};
|
||||
} catch {
|
||||
return { host: "unknown", pathTemplate: urlPathTemplate ?? "unknown" };
|
||||
}
|
||||
};
|
||||
|
||||
const estimateBytes = (text: string) => {
|
||||
if (typeof Blob !== "undefined") {
|
||||
return new Blob([text]).size;
|
||||
}
|
||||
return text.length;
|
||||
};
|
||||
|
||||
const looksJson = (contentType: string, text: string) => {
|
||||
const trimmed = text.trimStart();
|
||||
return contentType.includes("application/json") || trimmed.startsWith("{") || trimmed.startsWith("[");
|
||||
};
|
||||
|
||||
const getFetchSignal = (controller: AbortController, externalSignal?: AbortSignal) => {
|
||||
if (!externalSignal) return controller.signal;
|
||||
if (externalSignal.aborted) controller.abort();
|
||||
const abort = () => controller.abort();
|
||||
externalSignal.addEventListener("abort", abort, { once: true });
|
||||
return controller.signal;
|
||||
};
|
||||
|
||||
const buildContext = (args: {
|
||||
endpoint: string;
|
||||
status?: number;
|
||||
ok?: boolean;
|
||||
durationMs: number;
|
||||
timeoutMs: number;
|
||||
bytes?: number;
|
||||
contentType?: string;
|
||||
responseHead?: string;
|
||||
urlHost: string;
|
||||
urlPathTemplate: string;
|
||||
retryCount: number;
|
||||
}) => ({
|
||||
endpoint: args.endpoint,
|
||||
method: "GET",
|
||||
status: args.status,
|
||||
ok: args.ok,
|
||||
durationMs: args.durationMs,
|
||||
timeoutMs: args.timeoutMs,
|
||||
bytes: args.bytes,
|
||||
contentType: args.contentType,
|
||||
responseHead: truncateResponseHead(args.responseHead),
|
||||
retryCount: args.retryCount,
|
||||
urlHost: args.urlHost,
|
||||
urlPathTemplate: args.urlPathTemplate,
|
||||
...getRuntimeState(),
|
||||
});
|
||||
|
||||
async function observedFetchTextOnce(url: string, options: ObservedFetchOptions, reportSuccess = true): Promise<{ text: string; response: Response; durationMs: number; bytes: number; contentType: string; context: Record<string, unknown>; slow: boolean; statusTag: string }> {
|
||||
const endpoint = options.endpoint ?? "unknown";
|
||||
const config = DATA_ENDPOINT_CONFIG[endpoint];
|
||||
const timeoutMs = options.timeoutMs ?? config.timeoutMs;
|
||||
const slowMs = options.slowMs ?? config.slowMs;
|
||||
const startedAt = Date.now();
|
||||
const parsedUrl = safeParseUrl(url, options.urlPathTemplate);
|
||||
const retryCount = options.retryCount ?? 0;
|
||||
const method = options.method ?? "GET";
|
||||
const rootTab = getRootTab(options.rootTab);
|
||||
const baseTags = {
|
||||
area: "data_fetch",
|
||||
endpoint,
|
||||
result: "unknown",
|
||||
root_tab: rootTab,
|
||||
platform: Platform.OS,
|
||||
...getOptionTags(options),
|
||||
};
|
||||
|
||||
if (!canStartFetch(options)) {
|
||||
const runtime = getRuntimeState();
|
||||
const error = new ObservedFetchError(
|
||||
runtime.online === false ? "Data fetch skipped while offline" : "Data fetch skipped while app is background",
|
||||
"aborted",
|
||||
{ retryable: false }
|
||||
);
|
||||
reportDataFetchFailure({
|
||||
kind: "aborted",
|
||||
error,
|
||||
tags: { ...baseTags, result: "aborted" },
|
||||
context: buildContext({
|
||||
endpoint,
|
||||
durationMs: 0,
|
||||
timeoutMs,
|
||||
urlHost: parsedUrl.host,
|
||||
urlPathTemplate: parsedUrl.pathTemplate,
|
||||
retryCount,
|
||||
}),
|
||||
capture: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const signal = getFetchSignal(controller, options.signal);
|
||||
let timedOut = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
reject(new Error("Network request timed out"));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
Sentry.addBreadcrumb({
|
||||
category: "data_fetch",
|
||||
level: "info",
|
||||
message: "fetch:start",
|
||||
data: { endpoint, method, urlHost: parsedUrl.host, urlPathTemplate: parsedUrl.pathTemplate, timeoutMs, retryCount },
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const { endpoint: _endpoint, expectedContentType: _expectedContentType, timeoutMs: _timeoutMs, slowMs: _slowMs, rootTab: _rootTab, source: _source, userVisible: _userVisible, preload: _preload, fetchPriority: _fetchPriority, urlPathTemplate: _urlPathTemplate, retryCount: _retryCount, retry: _retry, allowBackground: _allowBackground, signal: _signal, ...requestOptions } = options;
|
||||
const { response, text, contentType } = await Promise.race([
|
||||
(async () => {
|
||||
const response = await fetch(url, {
|
||||
...requestOptions,
|
||||
signal,
|
||||
});
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
const text = await response.text();
|
||||
return { response, text, contentType };
|
||||
})(),
|
||||
timeoutPromise,
|
||||
]);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const bytes = estimateBytes(text);
|
||||
const context = buildContext({
|
||||
endpoint,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
durationMs,
|
||||
timeoutMs,
|
||||
bytes,
|
||||
contentType,
|
||||
urlHost: parsedUrl.host,
|
||||
urlPathTemplate: parsedUrl.pathTemplate,
|
||||
retryCount,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const retryable = [502, 503, 504].includes(response.status);
|
||||
const error = new ObservedFetchError("HTTP " + response.status, "http_error", { status: response.status, retryable });
|
||||
reportDataFetchFailure({
|
||||
kind: "http_error",
|
||||
error,
|
||||
tags: { ...baseTags, result: "http_error", status: String(response.status) },
|
||||
context: { ...context, responseHead: truncateResponseHead(text) },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const slow = durationMs >= slowMs;
|
||||
if (reportSuccess) {
|
||||
reportDataFetchSuccess({
|
||||
tags: { ...baseTags, result: slow ? "slow_success" : "success", status: String(response.status) },
|
||||
context,
|
||||
slow,
|
||||
});
|
||||
}
|
||||
|
||||
return { text, response, durationMs, bytes, contentType, context, slow, statusTag: String(response.status) };
|
||||
} catch (error) {
|
||||
if (error instanceof ObservedFetchError) throw error;
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const isAbort = timedOut || (error as any)?.name === "AbortError";
|
||||
const kind = isAbort ? "timeout" : String((error as any)?.message ?? "").includes("Network request failed") ? "network_error" : "unknown";
|
||||
const retryable = kind === "timeout" || kind === "network_error";
|
||||
const wrapped = new ObservedFetchError(
|
||||
isAbort ? "Network request timed out" : String((error as any)?.message ?? error),
|
||||
kind,
|
||||
isAbort ? { retryable } : { retryable, cause: error }
|
||||
);
|
||||
reportDataFetchFailure({
|
||||
kind,
|
||||
error: wrapped,
|
||||
tags: { ...baseTags, result: kind },
|
||||
context: buildContext({
|
||||
endpoint,
|
||||
durationMs,
|
||||
timeoutMs,
|
||||
urlHost: parsedUrl.host,
|
||||
urlPathTemplate: parsedUrl.pathTemplate,
|
||||
retryCount,
|
||||
}),
|
||||
capture: shouldCaptureRuntimeFailure(),
|
||||
});
|
||||
throw wrapped;
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
const spanOptions = {
|
||||
name: "fetch " + endpoint,
|
||||
description: "fetch " + endpoint,
|
||||
op: "http.client",
|
||||
attributes: {
|
||||
"app.area": "data_fetch",
|
||||
"app.endpoint": endpoint,
|
||||
"app.root_tab": rootTab,
|
||||
"http.method": method,
|
||||
"server.address": parsedUrl.host,
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof (Sentry as any).startSpan === "function") {
|
||||
return (Sentry as any).startSpan(spanOptions, run);
|
||||
}
|
||||
|
||||
return run();
|
||||
}
|
||||
|
||||
export async function observedFetchText(url: string, options: ObservedFetchOptions): Promise<string> {
|
||||
const retryCount = options.retryCount ?? 0;
|
||||
try {
|
||||
const result = await observedFetchTextOnce(url, { ...options, retryCount });
|
||||
return result.text;
|
||||
} catch (error) {
|
||||
if (!options.retry || retryCount >= 1 || !shouldRetryNetworkError(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (!canStartFetch(options)) {
|
||||
throw error;
|
||||
}
|
||||
await sleep(750 + Math.random() * 500);
|
||||
return observedFetchText(url, { ...options, retryCount: retryCount + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function observedFetchJson<T>(url: string, options: ObservedFetchOptions): Promise<T> {
|
||||
const endpoint = options.endpoint ?? "unknown";
|
||||
const config = DATA_ENDPOINT_CONFIG[endpoint];
|
||||
const expectedContentType = options.expectedContentType ?? config.expectedContentType;
|
||||
const parsedUrl = safeParseUrl(url, options.urlPathTemplate);
|
||||
const retryCount = options.retryCount ?? 0;
|
||||
const rootTab = getRootTab(options.rootTab);
|
||||
|
||||
try {
|
||||
const result = await observedFetchTextOnce(url, { ...options, retryCount }, false);
|
||||
const trimmed = result.text.trim();
|
||||
const context = buildContext({
|
||||
endpoint,
|
||||
status: result.response.status,
|
||||
ok: result.response.ok,
|
||||
durationMs: result.durationMs,
|
||||
timeoutMs: options.timeoutMs ?? config.timeoutMs,
|
||||
bytes: result.bytes,
|
||||
contentType: result.contentType,
|
||||
urlHost: parsedUrl.host,
|
||||
urlPathTemplate: parsedUrl.pathTemplate,
|
||||
retryCount,
|
||||
});
|
||||
|
||||
if (trimmed.length === 0) {
|
||||
const error = new ObservedFetchError("Empty response", "empty_response", { retryable: false });
|
||||
reportDataFetchFailure({
|
||||
kind: "empty_response",
|
||||
error,
|
||||
tags: { area: "data_fetch", endpoint, result: "empty_response", root_tab: rootTab, platform: Platform.OS, ...getOptionTags(options), status: String(result.response.status) },
|
||||
context,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (expectedContentType === "json" && !looksJson(result.contentType, result.text)) {
|
||||
const error = new ObservedFetchError("Non JSON response", "non_json_response", { retryable: false });
|
||||
reportDataFetchFailure({
|
||||
kind: "non_json_response",
|
||||
error,
|
||||
tags: { area: "data_fetch", endpoint, result: "non_json", root_tab: rootTab, platform: Platform.OS, ...getOptionTags(options), status: String(result.response.status) },
|
||||
context: { ...context, responseHead: truncateResponseHead(result.text) },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(result.text) as T;
|
||||
reportDataFetchSuccess({
|
||||
tags: {
|
||||
area: "data_fetch",
|
||||
endpoint,
|
||||
result: result.slow ? "slow_success" : "success",
|
||||
root_tab: rootTab,
|
||||
platform: Platform.OS,
|
||||
...getOptionTags(options),
|
||||
status: result.statusTag,
|
||||
},
|
||||
context,
|
||||
slow: result.slow,
|
||||
});
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
const wrapped = new ObservedFetchError("JSON parse error", "json_parse_error", { retryable: false, cause: error });
|
||||
reportDataFetchFailure({
|
||||
kind: "json_parse_error",
|
||||
error: wrapped,
|
||||
tags: { area: "data_fetch", endpoint, result: "parse_error", root_tab: rootTab, platform: Platform.OS, ...getOptionTags(options), status: String(result.response.status) },
|
||||
context: { ...context, responseHead: truncateResponseHead(result.text) },
|
||||
});
|
||||
throw wrapped;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!options.retry || retryCount >= 1 || !shouldRetryNetworkError(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (!canStartFetch(options)) {
|
||||
throw error;
|
||||
}
|
||||
await sleep(750 + Math.random() * 500);
|
||||
return observedFetchJson<T>(url, { ...options, retryCount: retryCount + 1 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import { NetworkFailureKind } from "./types";
|
||||
|
||||
const SLOW_SUCCESS_SAMPLE_RATE = 0;
|
||||
|
||||
export const truncateResponseHead = (value: unknown) =>
|
||||
typeof value === "string" ? value.slice(0, 300) : value;
|
||||
|
||||
export function reportDataFetchSuccess(args: {
|
||||
tags: Record<string, string>;
|
||||
context: Record<string, unknown>;
|
||||
slow: boolean;
|
||||
}) {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "data_fetch",
|
||||
level: args.slow ? "warning" : "info",
|
||||
message: args.slow ? "fetch:slow_success" : "fetch:success",
|
||||
data: args.context,
|
||||
});
|
||||
|
||||
if (args.slow && SLOW_SUCCESS_SAMPLE_RATE > 0 && Math.random() < SLOW_SUCCESS_SAMPLE_RATE) {
|
||||
Sentry.captureMessage("data_fetch.slow_success", {
|
||||
level: "warning",
|
||||
tags: args.tags,
|
||||
contexts: { data_fetch: args.context },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function reportDataFetchFailure(args: {
|
||||
kind: NetworkFailureKind;
|
||||
error: unknown;
|
||||
tags: Record<string, string>;
|
||||
context: Record<string, unknown>;
|
||||
capture?: boolean;
|
||||
}) {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "data_fetch",
|
||||
level: args.kind === "aborted" ? "info" : "error",
|
||||
message: "fetch:" + args.kind,
|
||||
data: args.context,
|
||||
});
|
||||
|
||||
if (args.kind === "aborted" || args.capture === false) return;
|
||||
|
||||
const level = args.kind === "json_parse_error" ? "error" : "warning";
|
||||
const captureError = args.error instanceof Error ? args.error : new Error(String(args.error));
|
||||
|
||||
Sentry.captureException(captureError, {
|
||||
level,
|
||||
tags: args.tags,
|
||||
contexts: {
|
||||
data_fetch: {
|
||||
kind: args.kind,
|
||||
...args.context,
|
||||
},
|
||||
},
|
||||
fingerprint: ["data_fetch", args.kind, args.tags.endpoint ?? "unknown"],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export type DataEndpoint =
|
||||
| "positions_current"
|
||||
| "positions_master"
|
||||
| "positions_gas_fallback"
|
||||
| "positions_webview"
|
||||
| "operation_info_flag"
|
||||
| "operation_info_text"
|
||||
| "operation_logs"
|
||||
| "train_operation_data"
|
||||
| "timetable_today"
|
||||
| "positions"
|
||||
| "operation_info"
|
||||
| "train_operation"
|
||||
| "timetable"
|
||||
| "station_info"
|
||||
| "notice"
|
||||
| "app_config"
|
||||
| "unknown";
|
||||
|
||||
export type ExpectedContentType = "json" | "text" | "html" | "any";
|
||||
|
||||
export type DataFetchSource =
|
||||
| "rn_fetch"
|
||||
| "mock_api"
|
||||
| "n8n"
|
||||
| "gas"
|
||||
| "backend_api"
|
||||
| "static_storage"
|
||||
| "webview_fetch";
|
||||
|
||||
export type FetchPriority = "high" | "medium" | "low";
|
||||
|
||||
export type DataFetchResult =
|
||||
| "success"
|
||||
| "slow_success"
|
||||
| "network_error"
|
||||
| "timeout"
|
||||
| "http_error"
|
||||
| "non_json"
|
||||
| "parse_error"
|
||||
| "empty_response"
|
||||
| "aborted"
|
||||
| "unknown";
|
||||
|
||||
export type NetworkFailureKind =
|
||||
| "timeout"
|
||||
| "network_error"
|
||||
| "http_error"
|
||||
| "non_json_response"
|
||||
| "json_parse_error"
|
||||
| "empty_response"
|
||||
| "aborted"
|
||||
| "unknown";
|
||||
|
||||
export type ObservedFetchOptions = Omit<RequestInit, "signal"> & {
|
||||
endpoint: DataEndpoint;
|
||||
expectedContentType?: ExpectedContentType;
|
||||
timeoutMs?: number;
|
||||
slowMs?: number;
|
||||
rootTab?: string;
|
||||
source?: DataFetchSource;
|
||||
userVisible?: boolean;
|
||||
preload?: boolean;
|
||||
fetchPriority?: FetchPriority;
|
||||
urlPathTemplate?: string;
|
||||
retryCount?: number;
|
||||
retry?: boolean;
|
||||
allowBackground?: boolean;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { OperationLogs } from "@/lib/CommonTypes";
|
||||
import {
|
||||
normalizeIconDisplayMode,
|
||||
type IconDisplayMode,
|
||||
} from "@/lib/iconDisplayMode";
|
||||
|
||||
type OperationIconPair = {
|
||||
forward: string;
|
||||
rear: string;
|
||||
};
|
||||
|
||||
export const resolveOperationIconPair = (
|
||||
operation: Pick<
|
||||
OperationLogs,
|
||||
| "vehicle_img"
|
||||
| "vehicle_img_right"
|
||||
| "vehicle_img_hub"
|
||||
| "vehicle_img_right_hub"
|
||||
>,
|
||||
iconDisplayMode: IconDisplayMode,
|
||||
fallback = "",
|
||||
): OperationIconPair => {
|
||||
if (iconDisplayMode === "hub") {
|
||||
return {
|
||||
forward: operation.vehicle_img_hub || operation.vehicle_img || fallback,
|
||||
rear:
|
||||
operation.vehicle_img_right_hub ||
|
||||
operation.vehicle_img_hub ||
|
||||
operation.vehicle_img_right ||
|
||||
operation.vehicle_img ||
|
||||
fallback,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
forward: operation.vehicle_img || fallback,
|
||||
rear: operation.vehicle_img_right || operation.vehicle_img || fallback,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveDirectionalOperationIcon = (
|
||||
operation: Pick<
|
||||
OperationLogs,
|
||||
| "vehicle_img"
|
||||
| "vehicle_img_right"
|
||||
| "vehicle_img_hub"
|
||||
| "vehicle_img_right_hub"
|
||||
>,
|
||||
iconDisplayModeValue: unknown,
|
||||
direction: boolean,
|
||||
fallback = "",
|
||||
): string => {
|
||||
const iconDisplayMode = normalizeIconDisplayMode(iconDisplayModeValue);
|
||||
const { forward, rear } = resolveOperationIconPair(
|
||||
operation,
|
||||
iconDisplayMode,
|
||||
fallback,
|
||||
);
|
||||
return direction ? forward || rear : rear || forward;
|
||||
};
|
||||
@@ -2,9 +2,29 @@ import { createNavigationContainerRef, StackActions } from "@react-navigation/na
|
||||
|
||||
export const rootNavigationRef = createNavigationContainerRef<any>();
|
||||
|
||||
export const lastObservedRootRouteRef: { current: string | null } = { current: null };
|
||||
export const startupExplicitTargetRef: { current: boolean } = { current: false };
|
||||
const startupPendingResolversRef: { current: number } = { current: 2 };
|
||||
|
||||
/** positions タブの Stack.Navigator navigation を登録するグローバルref */
|
||||
export const positionsStackNavRef: { current: any } = { current: null };
|
||||
|
||||
export const positionsLifecycleRef: {
|
||||
current: {
|
||||
isUnstable: boolean;
|
||||
blockTabExit: boolean;
|
||||
resetBeforeLeave: (() => void) | null;
|
||||
deactivateStack: (() => void) | null;
|
||||
};
|
||||
} = {
|
||||
current: {
|
||||
isUnstable: false,
|
||||
blockTabExit: false,
|
||||
resetBeforeLeave: null,
|
||||
deactivateStack: null,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 遷移先タブのネストスタックを一度 popToTop してからナビゲートする。
|
||||
* ウィジェットや外部リンクからの遷移時に、既存の開いている画面を閉じてから目的の画面へ移動するために使用する。
|
||||
@@ -45,3 +65,24 @@ export function stackAwareNavigate(tabName: string, params?: any) {
|
||||
doNavigate();
|
||||
}
|
||||
}
|
||||
|
||||
export function markStartupExplicitTarget() {
|
||||
startupExplicitTargetRef.current = true;
|
||||
}
|
||||
|
||||
export function resolveStartupNavigationSource() {
|
||||
startupPendingResolversRef.current = Math.max(
|
||||
0,
|
||||
startupPendingResolversRef.current - 1
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForStartupNavigationResolution(timeoutMs = 2000) {
|
||||
const startedAt = Date.now();
|
||||
while (startupPendingResolversRef.current > 0) {
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -28,8 +28,9 @@ const forModalPresentationAndroid = (
|
||||
|
||||
export const optionData = {
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.ModalPresentationIOS,
|
||||
...(Platform.OS === "ios" ? TransitionPresets.ModalPresentationIOS : {}),
|
||||
...(Platform.OS === "android" && {
|
||||
animationEnabled: false,
|
||||
cardStyleInterpolator: forModalPresentationAndroid,
|
||||
}),
|
||||
cardOverlayEnabled: true,
|
||||
@@ -37,3 +38,10 @@ export const optionData = {
|
||||
headerShown: false,
|
||||
detachPreviousScreen: false,
|
||||
};
|
||||
|
||||
export const pushTransitionOptions =
|
||||
Platform.OS === "ios"
|
||||
? TransitionPresets.SlideFromRightIOS
|
||||
: {
|
||||
animationEnabled: false,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { CustomTrainData } from "@/lib/CommonTypes";
|
||||
import {
|
||||
normalizeIconDisplayMode,
|
||||
type IconDisplayMode,
|
||||
} from "@/lib/iconDisplayMode";
|
||||
|
||||
export const resolveTrainDataIcon = (
|
||||
trainData: Pick<CustomTrainData, "train_info_img" | "train_info_img_hub">,
|
||||
iconDisplayMode: IconDisplayMode,
|
||||
fallback = "",
|
||||
): string => {
|
||||
if (iconDisplayMode === "hub") {
|
||||
return trainData.train_info_img_hub || trainData.train_info_img || fallback;
|
||||
}
|
||||
|
||||
return trainData.train_info_img || fallback;
|
||||
};
|
||||
|
||||
export const resolveTrainDataIconFromValue = (
|
||||
trainData: Pick<CustomTrainData, "train_info_img" | "train_info_img_hub">,
|
||||
iconDisplayModeValue: unknown,
|
||||
fallback = "",
|
||||
): string =>
|
||||
resolveTrainDataIcon(
|
||||
trainData,
|
||||
normalizeIconDisplayMode(iconDisplayModeValue),
|
||||
fallback,
|
||||
);
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { CustomTrainData, OperationLogs } from "@/lib/CommonTypes";
|
||||
import type { IconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
import { resolveOperationIconPair } from "@/lib/operationLogIcon";
|
||||
import { resolveTrainDataIcon } from "@/lib/trainDataIcon";
|
||||
|
||||
export type TrainIconEntry = {
|
||||
vehicle_info_img: string;
|
||||
vehicle_info_right_img: string;
|
||||
vehicle_info_url: string;
|
||||
};
|
||||
|
||||
type TrainDataForIcon = Pick<
|
||||
CustomTrainData,
|
||||
"train_info_img" | "train_info_img_hub" | "vehicle_info_url"
|
||||
>;
|
||||
|
||||
const extractOrderNumber = (trainId: string): number => {
|
||||
const parts = trainId.split(",");
|
||||
if (parts.length <= 1) return Infinity;
|
||||
|
||||
const num = parseInt(parts[1].trim(), 10);
|
||||
return isNaN(num) ? Infinity : num;
|
||||
};
|
||||
|
||||
const findMatchingTrainId = (
|
||||
operation: OperationLogs,
|
||||
trainNum: string,
|
||||
): string | null => {
|
||||
const allTrainIds = [
|
||||
...(operation.train_ids || []),
|
||||
...(operation.related_train_ids || []),
|
||||
];
|
||||
|
||||
for (const trainId of allTrainIds) {
|
||||
const prefix = trainId.split(",")[0];
|
||||
if (prefix === trainNum) return trainId;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const sortOperationsForTrainIcon = (
|
||||
operations: OperationLogs[],
|
||||
trainNum: string,
|
||||
): OperationLogs[] =>
|
||||
[...operations].sort((a, b) => {
|
||||
const aTrainId = findMatchingTrainId(a, trainNum);
|
||||
const bTrainId = findMatchingTrainId(b, trainNum);
|
||||
|
||||
if (!aTrainId || !bTrainId) {
|
||||
return aTrainId ? -1 : bTrainId ? 1 : 0;
|
||||
}
|
||||
|
||||
return extractOrderNumber(aTrainId) - extractOrderNumber(bTrainId);
|
||||
});
|
||||
|
||||
export const resolveTrainIconEntries = ({
|
||||
trainNum,
|
||||
customTrainData,
|
||||
todayOperation,
|
||||
iconDisplayMode,
|
||||
}: {
|
||||
trainNum: string;
|
||||
customTrainData: TrainDataForIcon;
|
||||
todayOperation: OperationLogs[];
|
||||
iconDisplayMode: IconDisplayMode;
|
||||
}): TrainIconEntry[] => {
|
||||
const fallbackIcon = resolveTrainDataIcon(customTrainData, iconDisplayMode);
|
||||
|
||||
if (todayOperation.length > 0) {
|
||||
return sortOperationsForTrainIcon(todayOperation, trainNum)
|
||||
.map((operation) => {
|
||||
const { forward, rear } = resolveOperationIconPair(
|
||||
operation,
|
||||
iconDisplayMode,
|
||||
fallbackIcon,
|
||||
);
|
||||
|
||||
return {
|
||||
vehicle_info_img: forward,
|
||||
vehicle_info_right_img: rear,
|
||||
vehicle_info_url: operation.vehicle_info_url,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.vehicle_info_img || entry.vehicle_info_right_img);
|
||||
}
|
||||
|
||||
if (!fallbackIcon) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
vehicle_info_img: fallbackIcon,
|
||||
vehicle_info_right_img: fallbackIcon,
|
||||
vehicle_info_url: customTrainData.vehicle_info_url || "",
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AppState, AppStateStatus } from "react-native";
|
||||
import WebView from "react-native-webview";
|
||||
|
||||
type WebViewRemountReason =
|
||||
| "manual"
|
||||
| "app_background"
|
||||
| "render_process_gone"
|
||||
| "content_process_terminated"
|
||||
| "loading_timeout"
|
||||
| "pong_timeout"
|
||||
| "blank_detected";
|
||||
|
||||
type WebViewRemountData = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
type UseWebViewRemountOptions = {
|
||||
pingEnabled?: boolean;
|
||||
backgroundThresholdMs?: number | null;
|
||||
isFocused?: boolean;
|
||||
pauseWatchdogWhenUnfocused?: boolean;
|
||||
ignoreProcessTerminationWhenUnfocused?: boolean;
|
||||
onRemount?: (reason: WebViewRemountReason, data?: WebViewRemountData) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* WebView のメモリ解放・プロセス終了による白画面を自動復帰させるフック。
|
||||
*
|
||||
* 使い方:
|
||||
* const { remountKey, remount, processHandlers, pingHandlers, webViewRef } = useWebViewRemount();
|
||||
* <WebView key={remountKey} ref={webViewRef} {...processHandlers} {...pingHandlers} ... />
|
||||
*
|
||||
* 既存の ref がある場合は webViewRef を使わず processHandlers / pingHandlers だけ使ってもよい。
|
||||
* pingHandlers を使う場合は onMessage を上書きせず spread すること。
|
||||
* ping による白画面検知を有効にするには pingEnabled: true を渡す。
|
||||
*/
|
||||
export function useWebViewRemount(options?: UseWebViewRemountOptions) {
|
||||
const pingEnabled = options?.pingEnabled ?? false;
|
||||
const backgroundThresholdMs = options?.backgroundThresholdMs ?? null;
|
||||
const isFocused = options?.isFocused ?? true;
|
||||
const pauseWatchdogWhenUnfocused = options?.pauseWatchdogWhenUnfocused ?? false;
|
||||
const ignoreProcessTerminationWhenUnfocused = options?.ignoreProcessTerminationWhenUnfocused ?? false;
|
||||
const onRemount = options?.onRemount;
|
||||
const [remountKey, setRemountKey] = useState(0);
|
||||
const backgroundedAt = useRef<number | null>(null);
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const ignoredProcessTerminationRef = useRef<WebViewRemountReason | null>(null);
|
||||
const focusedRef = useRef(isFocused);
|
||||
const lastBlurAtRef = useRef<number | null>(null);
|
||||
const processTerminationBlurGraceMs = 1200;
|
||||
|
||||
// ping watchdog 用
|
||||
const lastPongAt = useRef(Date.now());
|
||||
const isLoadingRef = useRef(true);
|
||||
|
||||
const triggerRemount = useCallback((reason: WebViewRemountReason, data?: WebViewRemountData) => {
|
||||
onRemount?.(reason, data);
|
||||
lastPongAt.current = Date.now();
|
||||
isLoadingRef.current = true;
|
||||
setRemountKey((k) => k + 1);
|
||||
}, [onRemount]);
|
||||
|
||||
const remount = useCallback(() => {
|
||||
triggerRemount("manual");
|
||||
}, [triggerRemount]);
|
||||
|
||||
useEffect(() => {
|
||||
focusedRef.current = isFocused;
|
||||
if (!isFocused) {
|
||||
lastBlurAtRef.current = Date.now();
|
||||
}
|
||||
}, [isFocused]);
|
||||
|
||||
const shouldIgnoreProcessTermination = useCallback(() => {
|
||||
if (!ignoreProcessTerminationWhenUnfocused) return false;
|
||||
if (!focusedRef.current) return true;
|
||||
if (lastBlurAtRef.current === null) return false;
|
||||
return Date.now() - lastBlurAtRef.current < processTerminationBlurGraceMs;
|
||||
}, [ignoreProcessTerminationWhenUnfocused]);
|
||||
|
||||
// 明示指定がある場合のみ、長時間バックグラウンド復帰で再マウントする。
|
||||
useEffect(() => {
|
||||
if (backgroundThresholdMs === null || backgroundThresholdMs <= 0) {
|
||||
return;
|
||||
}
|
||||
const onAppStateChange = (nextState: AppStateStatus) => {
|
||||
if (nextState.match(/inactive|background/)) {
|
||||
backgroundedAt.current = Date.now();
|
||||
} else if (nextState === "active" && backgroundedAt.current !== null) {
|
||||
const elapsed = Date.now() - backgroundedAt.current;
|
||||
backgroundedAt.current = null;
|
||||
if (elapsed > backgroundThresholdMs) {
|
||||
triggerRemount("app_background", { elapsedMs: elapsed });
|
||||
}
|
||||
}
|
||||
};
|
||||
const subscription = AppState.addEventListener("change", onAppStateChange);
|
||||
return () => subscription.remove();
|
||||
}, [backgroundThresholdMs, triggerRemount]);
|
||||
|
||||
// ping watchdog: 5秒ごとに生存確認と白画面検知を行う
|
||||
// - ローディング中(isLoadingRef=true)でも45秒超なら remount(レンダラー死亡でonLoadEndが来ないケース)
|
||||
// - ロード完了後は30秒 pong 無応答で remount
|
||||
const maxTextLenRef = useRef(0);
|
||||
const blankCountRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!pingEnabled) return;
|
||||
const id = setInterval(() => {
|
||||
if (pauseWatchdogWhenUnfocused && !isFocused) {
|
||||
lastPongAt.current = Date.now();
|
||||
return;
|
||||
}
|
||||
const elapsed = Date.now() - lastPongAt.current;
|
||||
if (isLoadingRef.current) {
|
||||
// ローディング中でも45秒超はレンダラー死亡と判定
|
||||
if (elapsed > 45_000) triggerRemount("loading_timeout", { elapsedMs: elapsed });
|
||||
return;
|
||||
}
|
||||
// ロード完了後30秒 pong 無応答 → レンダラー死亡
|
||||
if (elapsed > 30_000) {
|
||||
triggerRemount("pong_timeout", { elapsedMs: elapsed });
|
||||
return;
|
||||
}
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`(function(){var t=document.body?(document.body.innerText||'').replace(/\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
|
||||
);
|
||||
}, 5_000);
|
||||
return () => clearInterval(id);
|
||||
}, [isFocused, pauseWatchdogWhenUnfocused, pingEnabled, triggerRemount]);
|
||||
|
||||
const processHandlers = {
|
||||
onRenderProcessGone: (event?: any) => {
|
||||
if (shouldIgnoreProcessTermination()) {
|
||||
ignoredProcessTerminationRef.current = "render_process_gone";
|
||||
lastPongAt.current = Date.now();
|
||||
return;
|
||||
}
|
||||
triggerRemount("render_process_gone", { didCrash: event?.nativeEvent?.didCrash ?? null });
|
||||
},
|
||||
onContentProcessDidTerminate: () => {
|
||||
if (shouldIgnoreProcessTermination()) {
|
||||
ignoredProcessTerminationRef.current = "content_process_terminated";
|
||||
lastPongAt.current = Date.now();
|
||||
return;
|
||||
}
|
||||
triggerRemount("content_process_terminated");
|
||||
},
|
||||
} as const;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused || !ignoredProcessTerminationRef.current) return;
|
||||
ignoredProcessTerminationRef.current = null;
|
||||
lastPongAt.current = Date.now();
|
||||
}, [isFocused]);
|
||||
|
||||
// pingHandlers は onLoadEnd と onMessage を提供する
|
||||
// 既存の onMessage がある場合は手動でマージすること
|
||||
const pingHandlers = pingEnabled ? {
|
||||
onLoadEnd: () => {
|
||||
isLoadingRef.current = false;
|
||||
lastPongAt.current = Date.now();
|
||||
maxTextLenRef.current = 0;
|
||||
blankCountRef.current = 0;
|
||||
// ロード完了3秒後に初回コンテンツチェック
|
||||
setTimeout(() => {
|
||||
if (!isLoadingRef.current) {
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`(function(){var t=document.body?(document.body.innerText||'').replace(/\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
|
||||
);
|
||||
}
|
||||
}, 3000);
|
||||
},
|
||||
onLoadStart: () => {
|
||||
isLoadingRef.current = true;
|
||||
lastPongAt.current = Date.now(); // ナビゲーション開始時にタイムアウトリセット
|
||||
maxTextLenRef.current = 0;
|
||||
blankCountRef.current = 0;
|
||||
},
|
||||
onMessage: (event: any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(event.nativeEvent.data);
|
||||
if (parsed.type === "__ping") {
|
||||
lastPongAt.current = Date.now(); // 応答ごとにタイムアウトリセット
|
||||
const len: number = parsed.len ?? 0;
|
||||
if (len > maxTextLenRef.current) maxTextLenRef.current = len;
|
||||
// 一度でも20文字超になったページが5文字未満になったら白画面と判定
|
||||
if (maxTextLenRef.current > 20 && len < 5) {
|
||||
blankCountRef.current += 1;
|
||||
if (blankCountRef.current >= 3) {
|
||||
const blankCount = blankCountRef.current;
|
||||
const maxTextLength = maxTextLenRef.current;
|
||||
blankCountRef.current = 0;
|
||||
maxTextLenRef.current = 0;
|
||||
triggerRemount("blank_detected", {
|
||||
blankCount,
|
||||
textLength: len,
|
||||
maxTextLength,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
blankCountRef.current = 0;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
} : {};
|
||||
|
||||
return { remountKey, remount, processHandlers, pingHandlers, webViewRef };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user