Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 907ddf9dae | |||
| 929c444474 | |||
| b6800d6566 | |||
| b0a0d62344 | |||
| bfd90b50e4 | |||
| 553735ac1d | |||
| 5f1641ff90 | |||
| 351854f128 | |||
| e6f0391484 | |||
| b1cfad4528 | |||
| 76b13a70a9 | |||
| eec2fd2bb7 | |||
| 8c2930c814 | |||
| c7fdf6f0b1 | |||
| 9746a4d041 | |||
| 740c825122 | |||
| f6b25ce5d3 | |||
| 390ab3ac69 | |||
| 2eb926890c | |||
| bf2e68a3a5 | |||
| dd4892f6b7 | |||
| 61548ea3f9 | |||
| bf0c4d90d9 | |||
| 47d6f043b4 | |||
| aacae06743 | |||
| dab60c47fa | |||
| 39fe154b33 | |||
| a8582c9fd6 | |||
| 3133535606 | |||
| a5e1b7d297 | |||
| 021a9b0101 | |||
| 6cea1ed4e8 | |||
| 17f4db58bc | |||
| 9740a7639b | |||
| 191a536f5c | |||
| 07514bf92d | |||
| 997d910d1c |
@@ -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,10 +26,19 @@ 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',
|
||||
@@ -45,6 +55,16 @@ Sentry.init({
|
||||
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__,
|
||||
});
|
||||
@@ -54,6 +74,14 @@ LogBox.ignoreLogs([
|
||||
"ColorPropType will be removed",
|
||||
]);
|
||||
|
||||
const nativeScreensDisabled =
|
||||
Platform.OS === "ios" || Platform.OS === "android";
|
||||
|
||||
if (nativeScreensDisabled) {
|
||||
enableFreeze(false);
|
||||
enableScreens(false);
|
||||
}
|
||||
|
||||
if (Platform.OS === "android") {
|
||||
if (UIManager.setLayoutAnimationEnabledExperimental) {
|
||||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||
@@ -61,6 +89,35 @@ if (Platform.OS === "android") {
|
||||
}
|
||||
|
||||
export default Sentry.wrap(function App() {
|
||||
useEffect(() => {
|
||||
const nativeScreensMode = nativeScreensDisabled
|
||||
? "screens-disabled"
|
||||
: "default";
|
||||
const rootTabSceneMode =
|
||||
Platform.OS === "android" ? "opacity-fixed-z-v1" : "default";
|
||||
Sentry.setTag("native_screens_mode", nativeScreensMode);
|
||||
Sentry.setTag("root_tab_scene_mode", rootTabSceneMode);
|
||||
Sentry.setContext("runtime_navigation_flags", {
|
||||
platform: Platform.OS,
|
||||
nativeScreensMode,
|
||||
rootTabSceneMode,
|
||||
});
|
||||
Sentry.addBreadcrumb({
|
||||
category: "runtime.flags",
|
||||
level: "info",
|
||||
message: "runtime navigation flags applied",
|
||||
data: {
|
||||
platform: Platform.OS,
|
||||
nativeScreensMode,
|
||||
rootTabSceneMode,
|
||||
},
|
||||
});
|
||||
void startAppLifecycleCrashSentinel({ nativeScreensMode });
|
||||
return () => {
|
||||
stopAppLifecycleCrashSentinel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
UpdateAsync();
|
||||
}, []);
|
||||
@@ -98,46 +155,69 @@ export default Sentry.wrap(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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { NavigationContainer, DarkTheme, DefaultTheme } from "@react-navigation/native";
|
||||
import { NavigationContainer, DarkTheme, DefaultTheme, type EventArg } from "@react-navigation/native";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar, useWindowDimensions, InteractionManager } from "react-native";
|
||||
import { useNavigationState } from "@react-navigation/native";
|
||||
@@ -14,10 +14,21 @@ import { useTrainMenu } from "./stateBox/useTrainMenu";
|
||||
import lineColorList from "./assets/originData/lineColorList";
|
||||
import { stationIDPair } from "./lib/getStationList";
|
||||
import "./components/ActionSheetComponents/sheets";
|
||||
import { positionsLifecycleRef, rootNavigationRef } from "./lib/rootNavigation";
|
||||
import { lastObservedRootRouteRef, positionsLifecycleRef, rootNavigationRef } from "./lib/rootNavigation";
|
||||
import {
|
||||
startupExplicitTargetRef,
|
||||
waitForStartupNavigationResolution,
|
||||
} 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 { AS } from "./storageControl";
|
||||
import { STORAGE_KEYS } from "./constants/storage";
|
||||
import {
|
||||
recordAppLifecycleRootNavigation,
|
||||
setAppLifecycleWebViewActive,
|
||||
} from "./lib/observability/appLifecycleCrashSentinel";
|
||||
|
||||
type RootTabParamList = {
|
||||
positions: undefined;
|
||||
@@ -36,6 +47,122 @@ 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);
|
||||
|
||||
@@ -67,15 +194,72 @@ const DeferredPositionsRoot = ({ shouldActivate, isRootFocused }: { shouldActiva
|
||||
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 [startupInitialRoute, setStartupInitialRoute] = React.useState<keyof RootTabParamList | null>(null);
|
||||
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;
|
||||
@@ -101,6 +285,45 @@ export function AppContainer() {
|
||||
const { isDark } = useThemeColors();
|
||||
const lastRootNavStateRef = React.useRef("");
|
||||
const lineColorDark = lineColor ? darkenHex(lineColor, 0.78) : null;
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
await waitForStartupNavigationResolution();
|
||||
if (cancelled) return;
|
||||
|
||||
let nextInitialRoute: keyof RootTabParamList = "topMenu";
|
||||
if (!startupExplicitTargetRef.current) {
|
||||
try {
|
||||
const startPage = await AS.getItem(STORAGE_KEYS.START_PAGE);
|
||||
if (startPage === "true") {
|
||||
nextInitialRoute = "positions";
|
||||
}
|
||||
} catch {
|
||||
// Keep the default route when the setting is unset or unreadable.
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setStartupInitialRoute(nextInitialRoute);
|
||||
setHasVisitedPositions(nextInitialRoute === "positions");
|
||||
Sentry.addBreadcrumb({
|
||||
category: "nav.root",
|
||||
level: "info",
|
||||
message: "startup initial root route resolved",
|
||||
data: {
|
||||
startupExplicitTarget: startupExplicitTargetRef.current,
|
||||
initialRoute: nextInitialRoute,
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const linking = {
|
||||
prefixes: ["jrshikoku://"],
|
||||
config: {
|
||||
@@ -142,8 +365,64 @@ export function AppContainer() {
|
||||
|
||||
},
|
||||
});
|
||||
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"),
|
||||
"JR-WEST-PLUS": require("./assets/fonts/JR-WEST-PLUS.otf"),
|
||||
Zou: require("./assets/fonts/DelaGothicOne-Regular.ttf"),
|
||||
"JNR-font": require("./assets/fonts/JNRfont_pict.ttf"),
|
||||
"DiaPro": require("./assets/fonts/DiaPro-Regular.otf"),
|
||||
@@ -168,30 +447,65 @@ export function AppContainer() {
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
timer = setTimeout(() => {
|
||||
if (cancelled || hasVisitedPositions) return;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.root",
|
||||
level: "info",
|
||||
message: "positions startup prewarm activated",
|
||||
data: {
|
||||
currentRootRoute,
|
||||
},
|
||||
});
|
||||
setHasVisitedPositions(true);
|
||||
}, 1500);
|
||||
});
|
||||
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;
|
||||
task.cancel?.();
|
||||
if (timer) clearTimeout(timer);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [currentRootRoute, fontLoaded, hasVisitedPositions, navigationReady]);
|
||||
|
||||
if (!fontLoaded) {
|
||||
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 || !startupInitialRoute) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<ActivityIndicator size="large" />
|
||||
@@ -209,56 +523,31 @@ export function AppContainer() {
|
||||
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;
|
||||
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: "root state change",
|
||||
data: {
|
||||
rootIndex: state?.index ?? 0,
|
||||
rootRoute: activeRoute?.name ?? null,
|
||||
nestedIndex: nestedState?.index ?? 0,
|
||||
nestedRoute,
|
||||
hasExtra,
|
||||
},
|
||||
});
|
||||
}
|
||||
const rootRouteName = (activeRoute?.name ?? null) as keyof RootTabParamList | null;
|
||||
Sentry.setTag("root_tab", rootRouteName ?? "unknown");
|
||||
Sentry.setContext("root_navigation", {
|
||||
rootIndex: state?.index ?? 0,
|
||||
rootRoute: rootRouteName,
|
||||
nestedIndex: nestedState?.index ?? 0,
|
||||
nestedRoute,
|
||||
hasExtra,
|
||||
operationOrientation: width > height ? "landscape" : "portrait",
|
||||
hasVisitedPositions,
|
||||
});
|
||||
setCurrentRootRoute(rootRouteName);
|
||||
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"
|
||||
initialRouteName={startupInitialRoute}
|
||||
screenListeners={({ route }) => ({
|
||||
tabPress: (event) => {
|
||||
tabPress: (event: EventArg<"tabPress", true>) => {
|
||||
const rootState = rootNavigationRef.getState();
|
||||
const activeRootRouteName =
|
||||
(rootState?.routes?.[rootState.index ?? 0]?.name as keyof RootTabParamList | undefined) ??
|
||||
@@ -290,16 +579,30 @@ export function AppContainer() {
|
||||
|
||||
if (blockingUnstableExit) {
|
||||
event.preventDefault();
|
||||
positionsLifecycleRef.current.resetBeforeLeave?.();
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
rootNavigationRef.navigate(route.name as never);
|
||||
}, 0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (leavingUnstablePositions) {
|
||||
event.preventDefault();
|
||||
positionsLifecycleRef.current.resetBeforeLeave?.();
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
rootNavigationRef.navigate(route.name as never);
|
||||
}, 0);
|
||||
});
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.root",
|
||||
level: "info",
|
||||
message: "positions root exit blocked while session unstable",
|
||||
data: { route: route.name },
|
||||
message: "positions root exit reset and continued while session unstable",
|
||||
data: {
|
||||
route: route.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -372,6 +675,7 @@ export function AppContainer() {
|
||||
>
|
||||
<Tab.Screen
|
||||
{...getTabProps("positions", "走行位置", "bar-chart", "AntDesign")}
|
||||
component={PositionsTabScreen}
|
||||
listeners={{
|
||||
tabPress: () => {
|
||||
Sentry.addBreadcrumb({
|
||||
@@ -397,14 +701,7 @@ export function AppContainer() {
|
||||
});
|
||||
},
|
||||
}}
|
||||
>
|
||||
{() => (
|
||||
<DeferredPositionsRoot
|
||||
shouldActivate={hasVisitedPositions}
|
||||
isRootFocused={currentRootRoute === "positions"}
|
||||
/>
|
||||
)}
|
||||
</Tab.Screen>
|
||||
/>
|
||||
<Tab.Screen
|
||||
{...getTabProps("topMenu", "トップメニュー", "radio", "Ionicons")}
|
||||
component={MenuPage}
|
||||
@@ -419,10 +716,40 @@ export function AppContainer() {
|
||||
areaInfo ? areaIconBadgeText : undefined,
|
||||
isInfo
|
||||
)}
|
||||
>
|
||||
{() => <TNDView />}
|
||||
</Tab.Screen>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
+65
-36
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Alert, ActivityIndicator, AppState, AppStateStatus, BackHandler, StyleSheet, Text, TouchableOpacity, 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";
|
||||
@@ -125,6 +125,8 @@ export default ({ route }) => {
|
||||
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,
|
||||
);
|
||||
@@ -136,8 +138,6 @@ export default ({ route }) => {
|
||||
const [errorMessage, setErrorMessage] = React.useState("");
|
||||
// WebViewをforce remountするためのkey
|
||||
const [webViewKey, setWebViewKey] = React.useState(0);
|
||||
// バックグラウンド移行時刻の記録
|
||||
const backgroundedAt = React.useRef<number | null>(null);
|
||||
// RN-side watchdog: WebViewプロセスの死活確認用
|
||||
const lastPongAt = React.useRef<number>(Date.now());
|
||||
const isLoadingRef = React.useRef(true);
|
||||
@@ -164,6 +164,9 @@ export default ({ route }) => {
|
||||
if (!isMounted) return;
|
||||
const nextEnvironment = normalizeJrDataSystemEnvironment(value);
|
||||
const rawUri = typeof uri === "string" ? uri : "";
|
||||
historyStackRef.current = [];
|
||||
nativeCanGoBackRef.current = false;
|
||||
setCanGoBack(false);
|
||||
setSelectedEnvironment(nextEnvironment);
|
||||
setResolvedUri(
|
||||
importRecordingDownloads
|
||||
@@ -191,6 +194,41 @@ export default ({ route }) => {
|
||||
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 {
|
||||
@@ -229,29 +267,11 @@ export default ({ route }) => {
|
||||
[handleImportRecordingText],
|
||||
);
|
||||
|
||||
// AppState監視: バックグラウンド10秒超で復帰したらWebViewを再マウント
|
||||
React.useEffect(() => {
|
||||
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 > 10_000) {
|
||||
remount();
|
||||
}
|
||||
}
|
||||
};
|
||||
const subscription = AppState.addEventListener("change", onAppStateChange);
|
||||
return () => subscription.remove();
|
||||
}, [remount]);
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
const onHardwareBack = () => {
|
||||
if (canGoBack) {
|
||||
webViewRef.current?.goBack();
|
||||
return true;
|
||||
if (handleWebViewBack()) return true;
|
||||
}
|
||||
goBack();
|
||||
return true;
|
||||
@@ -259,7 +279,7 @@ 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 }}>
|
||||
@@ -344,19 +364,28 @@ export default ({ route }) => {
|
||||
}
|
||||
}}
|
||||
onNavigationStateChange={(navState) => {
|
||||
setCanGoBack(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: "完了" },
|
||||
]);
|
||||
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;
|
||||
let parsed: any;
|
||||
@@ -411,7 +440,7 @@ export default ({ route }) => {
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (type === "back") return webViewRef.current?.goBack();
|
||||
if (type === "back") return handleWebViewBack();
|
||||
if (type === "windowClose") return goBack();
|
||||
}}
|
||||
/>
|
||||
|
||||
+3
-9
@@ -65,15 +65,6 @@ export function MenuPage() {
|
||||
}, [height, isFocused, width]);
|
||||
|
||||
useEffect(() => {
|
||||
AS.getItem(STORAGE_KEYS.START_PAGE)
|
||||
.then((res) => {
|
||||
if (res == "true") navigation.navigate("positions");
|
||||
})
|
||||
.catch((e) => {
|
||||
//6.0以降false
|
||||
AS.setItem(STORAGE_KEYS.START_PAGE, "false");
|
||||
});
|
||||
|
||||
//ニュース表示
|
||||
AS.getItem(STORAGE_KEYS.NEWS_STATUS)
|
||||
.then((d) => {
|
||||
@@ -85,6 +76,8 @@ export function MenuPage() {
|
||||
if (isSetIcon == "true") SheetManager.show("TrainIconUpdate");
|
||||
})
|
||||
.catch((error) => logger.error("Error fetching icon setting:", error));
|
||||
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const scrollRef = useRef(null);
|
||||
@@ -166,6 +159,7 @@ export function MenuPage() {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
id={null}
|
||||
detachInactiveScreens={false}
|
||||
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
|
||||
screenListeners={({ route, navigation: stackNav }) => {
|
||||
stackNavRef.current = stackNav;
|
||||
|
||||
@@ -35,8 +35,18 @@ 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);
|
||||
@@ -69,23 +79,33 @@ export const Top = () => {
|
||||
},
|
||||
});
|
||||
let cancelled = false;
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.stack",
|
||||
level: "info",
|
||||
message: "positions stack activation committed",
|
||||
data: {
|
||||
reason: isTabFocused ? "first_focus" : "background_prewarm",
|
||||
},
|
||||
});
|
||||
setHasActivatedStack(true);
|
||||
}, isTabFocused ? 0 : 300);
|
||||
});
|
||||
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;
|
||||
task.cancel?.();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [hasActivatedStack, isTabFocused]);
|
||||
|
||||
@@ -130,6 +150,20 @@ export const Top = () => {
|
||||
setTimeout(() => navigate("topMenu", { screen: "menu" }), 100);
|
||||
return;
|
||||
}
|
||||
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();
|
||||
@@ -140,7 +174,7 @@ export const Top = () => {
|
||||
navigate("positions", { screen: "trainMenu" });
|
||||
else webview.current?.injectJavaScript(`AccordionClassEvent()`);
|
||||
return;
|
||||
}, [isTabFocused, navigate, webview]);
|
||||
}, [hasActivatedStack, isTabFocused, navigate, webview]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = addListener("tabPress", goToTrainMenu);
|
||||
@@ -154,6 +188,7 @@ export const Top = () => {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
id={null}
|
||||
detachInactiveScreens={false}
|
||||
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
|
||||
screenListeners={({ route, navigation: stackNav }) => {
|
||||
stackNavRef.current = stackNav;
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"buildNumber": "65",
|
||||
"buildNumber": "66",
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "jrshikokuinfo.xprocess.hrkn",
|
||||
"appleTeamId": "54CRDT797G",
|
||||
@@ -57,7 +57,7 @@
|
||||
},
|
||||
"android": {
|
||||
"package": "jrshikokuinfo.xprocess.hrkn",
|
||||
"versionCode": 31,
|
||||
"versionCode": 32,
|
||||
"intentFilters": [
|
||||
{
|
||||
"action": "VIEW",
|
||||
@@ -117,6 +117,7 @@
|
||||
{
|
||||
"fonts": [
|
||||
"./assets/fonts/jr-nishi.otf",
|
||||
"./assets/fonts/JR-WEST-PLUS.otf",
|
||||
"./assets/fonts/DelaGothicOne-Regular.ttf",
|
||||
"./assets/fonts/JNRfont_pict.ttf",
|
||||
"./assets/fonts/DiaPro-Regular.otf"
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
西日本方向ロゴ拡張「JR-WEST-PLUS」
|
||||
|
||||
2026/07/11 by Mameh1nata
|
||||
|
||||
「回送」とか専用の書体の方がかっこよくね?と思い制作したフォントです
|
||||
現時点で対応している種別は以下の通りです
|
||||
回送 試運転 貸切 修学旅行 救援 配給 訓練 検測 競技 検定 教習
|
||||
|
||||
ver1.0 プライベート公開
|
||||
ver1.01 修学旅行を追加
|
||||
ver1.1 救援 配給 訓練の追加
|
||||
ver1.2 競技 検定 教習の追加
|
||||
|
||||
-注意-
|
||||
・このフォントは西日本方向幕ロゴの制作者とは何の関係もありません
|
||||
・常識の範囲内で使うことをお勧めします(常識の範囲外でも自己責任でお願いします)
|
||||
・文字サイズが多少違ったりするかもしれませんがご了承ください
|
||||
・フォント初制作なのであまりにも致命的なミス以外は許してください
|
||||
|
||||
配布元: https://uu.getuploader.com/mameh1nata_dustbox/download/1
|
||||
|
||||
アプリ同梱版の調整:
|
||||
・JR-Nishi と字面を揃えるため、各グリフの表示領域を 1040 units に正規化
|
||||
・送り幅は 930 units とし、広げた字形の右側はアプリ側で小幅の DiaPro NBSP を付加して保護
|
||||
・縦の表示領域を 836 units、ベースラインを -86〜750 に正規化
|
||||
・フォント全体の ascender / descender を JR-Nishi と同じ 781 / -118 に調整
|
||||
・文字コードと内部フォント名は元ファイルから維持
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { FC, useMemo, useState } from "react";
|
||||
import React, { FC, useMemo } from "react";
|
||||
import { Text, View, TextStyle, TouchableOpacity } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { migrateTrainName } from "../../../lib/eachTrainInfoCoreLib/migrateTrainName";
|
||||
@@ -16,6 +16,7 @@ import type { NavigateFunction } from "@/types";
|
||||
import { useUnyohub } from "@/stateBox/useUnyohub";
|
||||
import { useElesite } from "@/stateBox/useElesite";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { formatWrappedTrainType, shouldWrapTrainType } from "@/lib/trainTypeWrap";
|
||||
import { useResponsive } from "@/lib/responsive";
|
||||
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
|
||||
@@ -75,7 +76,7 @@ export const HeaderText: FC<Props> = ({
|
||||
const [
|
||||
typeName,
|
||||
trainName,
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -96,7 +97,7 @@ export const HeaderText: FC<Props> = ({
|
||||
to_data,
|
||||
directions,
|
||||
} = result;
|
||||
const [typeString, fontAvailable, isOneMan] = getStringConfig(
|
||||
const [typeString, fontFamily, isOneMan] = getStringConfig(
|
||||
type,
|
||||
trainNum,
|
||||
);
|
||||
@@ -108,7 +109,7 @@ export const HeaderText: FC<Props> = ({
|
||||
(train_num_distance !== "" && !isNaN(parseInt(train_num_distance))
|
||||
? ` ${parseInt(trainNum) - parseInt(train_num_distance)}号`
|
||||
: ""),
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -121,7 +122,7 @@ export const HeaderText: FC<Props> = ({
|
||||
return [
|
||||
typeString,
|
||||
"",
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -134,7 +135,7 @@ export const HeaderText: FC<Props> = ({
|
||||
return [
|
||||
typeString,
|
||||
`${to_data}行き`,
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -149,7 +150,7 @@ export const HeaderText: FC<Props> = ({
|
||||
migrateTrainName(
|
||||
`${trainData[trainData.length - 1].split(",")[0]}行き`,
|
||||
),
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -211,7 +212,7 @@ export const HeaderText: FC<Props> = ({
|
||||
hasUnyohubFormation ||
|
||||
hasElesiteFormation;
|
||||
|
||||
const [isWrapped, setIsWrapped] = useState(false);
|
||||
const wrapType = shouldWrapTrainType(typeName, trainName);
|
||||
|
||||
const openTrainInfoUrl = () => {
|
||||
if (!trainInfoUrl) return;
|
||||
@@ -251,15 +252,23 @@ export const HeaderText: FC<Props> = ({
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={wrapType ? 2 : 1}
|
||||
style={{
|
||||
fontSize: fontScale(20),
|
||||
color: fixed.textOnPrimary,
|
||||
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
|
||||
fontWeight: !fontAvailable ? "bold" : undefined,
|
||||
fontFamily,
|
||||
fontWeight: fontFamily ? undefined : "bold",
|
||||
marginRight: 5,
|
||||
flexShrink: 0,
|
||||
...(wrapType
|
||||
? { width: fontScale(44), lineHeight: fontScale(22) }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{isWrapped ? typeName.replace(/(.{2})/g, "$1\n").trim() : typeName}
|
||||
{wrapType ? formatWrappedTrainType(typeName) : typeName}
|
||||
{fontFamily === "JR-WEST-PLUS" ? (
|
||||
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
{isOneMan && <OneManText />}
|
||||
</View>
|
||||
@@ -291,9 +300,6 @@ export const HeaderText: FC<Props> = ({
|
||||
: { fontSize: fontScale(17) }),
|
||||
flexShrink: 1,
|
||||
}}
|
||||
onTextLayout={(e) => {
|
||||
if (e.nativeEvent.lines.length > 1) setIsWrapped(true);
|
||||
}}
|
||||
>
|
||||
{trainName}
|
||||
</Text>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { lineListPair, stationIDPair } from '@/lib/getStationList';
|
||||
import { useStationList } from '@/stateBox/useStationList';
|
||||
import { OriginalStationList } from '@/lib/CommonTypes';
|
||||
|
||||
const computeThroughStations = (
|
||||
trainData: string[],
|
||||
stationList: any[][],
|
||||
originalStationList: Record<string, any[]>
|
||||
originalStationList: OriginalStationList
|
||||
): { trainDataWithThrough: string[]; haveThrough: boolean } => {
|
||||
if (!trainData.length) return { trainDataWithThrough: [], haveThrough: false };
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -1082,7 +1082,7 @@ const TrainInfoDetail: FC<{
|
||||
uwasa,
|
||||
optional_text,
|
||||
} = data;
|
||||
const { fontAvailable } = getTrainType({
|
||||
const { fontFamily } = getTrainType({
|
||||
type: data.type,
|
||||
whiteMode: false,
|
||||
});
|
||||
@@ -1120,13 +1120,16 @@ const TrainInfoDetail: FC<{
|
||||
style={[
|
||||
styles.typeTagText,
|
||||
{
|
||||
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
|
||||
fontWeight: !fontAvailable ? "bold" : undefined,
|
||||
paddingTop: fontAvailable ? 2 : 0,
|
||||
fontFamily,
|
||||
fontWeight: fontFamily ? undefined : "bold",
|
||||
paddingTop: fontFamily ? 2 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{typeName}
|
||||
{fontFamily === "JR-WEST-PLUS" ? (
|
||||
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
import { InfogramText } from "./ActionSheetComponents/EachTrainInfoCore/HeaderTextParts/InfogramText";
|
||||
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
|
||||
import { TrainIconStack } from "./ActionSheetComponents/EachTrainInfoCore/TrainIconStack";
|
||||
import { formatWrappedTrainType, shouldWrapTrainType } from "@/lib/trainTypeWrap";
|
||||
|
||||
export const AllTrainDiagramView: FC = () => {
|
||||
const { colors, fixed } = useThemeColors();
|
||||
@@ -107,9 +108,7 @@ export const AllTrainDiagramView: FC = () => {
|
||||
if (directions != undefined) {
|
||||
iconTrainDirection = directions ? true : false;
|
||||
}
|
||||
const [isWrapped, setIsWrapped] = useState(false);
|
||||
|
||||
const [typeString, fontAvailable, isOneMan] = getStringConfig(type, id);
|
||||
const [typeString, fontFamily, isOneMan] = getStringConfig(type, id);
|
||||
|
||||
const trainNameString = (() => {
|
||||
switch (true) {
|
||||
@@ -134,6 +133,7 @@ export const AllTrainDiagramView: FC = () => {
|
||||
return migrateTrainName(hoge + "行き");
|
||||
}
|
||||
})();
|
||||
const wrapType = shouldWrapTrainType(typeString, trainNameString);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
@@ -164,17 +164,23 @@ export const AllTrainDiagramView: FC = () => {
|
||||
>
|
||||
{typeString && (
|
||||
<Text
|
||||
numberOfLines={wrapType ? 2 : 1}
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: fixed.textOnPrimary,
|
||||
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
|
||||
fontWeight: !fontAvailable ? "bold" : undefined,
|
||||
fontFamily,
|
||||
fontWeight: fontFamily ? undefined : "bold",
|
||||
marginRight: 5,
|
||||
flexShrink: 0,
|
||||
...(wrapType
|
||||
? { width: 44, lineHeight: 22 }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{isWrapped
|
||||
? typeString.replace(/(.{2})/g, "$1\n").trim()
|
||||
: typeString}
|
||||
{wrapType ? formatWrappedTrainType(typeString) : typeString}
|
||||
{fontFamily === "JR-WEST-PLUS" ? (
|
||||
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
)}
|
||||
{isOneMan && <OneManText />}
|
||||
@@ -195,11 +201,6 @@ export const AllTrainDiagramView: FC = () => {
|
||||
color: fixed.textOnPrimary,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
onTextLayout={(e) => {
|
||||
if (e.nativeEvent.lines.length > 1) {
|
||||
setIsWrapped(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trainNameString}
|
||||
</Text>
|
||||
|
||||
+29
-19
@@ -165,34 +165,44 @@ export default function Apps() {
|
||||
},
|
||||
});
|
||||
let cancelled = false;
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
setTimeout(() => {
|
||||
if (cancelled) {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions screen activation cancelled before webview mount",
|
||||
data: {
|
||||
reason: activationReason,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const activate = () => {
|
||||
if (cancelled) {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions screen activation committed",
|
||||
message: "positions screen activation cancelled before webview mount",
|
||||
data: {
|
||||
reason: activationReason,
|
||||
},
|
||||
});
|
||||
setHasActivatedScreen(true);
|
||||
setHasActivatedWebView(true);
|
||||
}, isFocused ? 250 : 700);
|
||||
});
|
||||
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;
|
||||
task.cancel?.();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [hasActivatedScreen, hasActivatedWebView, hasStableWebViewSession, isFocused]);
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ type props = {
|
||||
|
||||
export const FixedStation: FC<props> = ({ stationID }) => {
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const { mapSwitch } = useTrainMenu();
|
||||
const { mapSwitch, playbackCurrentTimeIso } = useTrainMenu();
|
||||
const {
|
||||
currentTrain,
|
||||
fixedPosition,
|
||||
@@ -151,11 +151,11 @@ export const FixedStation: FC<props> = ({ stationID }) => {
|
||||
if (!currentTrain) return () => {};
|
||||
const data = trainTimeAndNumber
|
||||
.filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo]
|
||||
.filter((d) => trainTimeFiltering({ d, currentTrain, station })) //時間フィルター
|
||||
.filter((d) => trainTimeFiltering({ d, currentTrain, station, stationList, now: playbackCurrentTimeIso })) //時間フィルター
|
||||
.filter((d) => !d.isThrough)
|
||||
.filter((d) => d.lastStation != station[0].Station_JP); //最終列車表示設定
|
||||
setSelectedTrain(data);
|
||||
}, [trainTimeAndNumber, currentTrain, tick /*liveActivity periodic refresh*/]);
|
||||
}, [trainTimeAndNumber, currentTrain, stationList, playbackCurrentTimeIso, tick /*liveActivity periodic refresh*/]);
|
||||
|
||||
useEffect(() => {
|
||||
liveNotifyIdRef.current = liveNotifyId;
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
import { resolveTrainDataIcon } from "@/lib/trainDataIcon";
|
||||
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
|
||||
import {
|
||||
startTrainFollowActivity,
|
||||
updateTrainFollowActivity,
|
||||
@@ -39,6 +40,45 @@ type props = {
|
||||
trainID: string;
|
||||
};
|
||||
|
||||
type TrainPathEntry = {
|
||||
raw: string;
|
||||
station: string;
|
||||
se: string;
|
||||
time: string;
|
||||
index: number;
|
||||
isThrough: boolean;
|
||||
hasTime: boolean;
|
||||
};
|
||||
|
||||
const normalizeTrainPosLabel = (value: string) =>
|
||||
value
|
||||
.replace(
|
||||
/(下り)|(上り)|\(下り\)|\(上り\)|(徳島線)|(高徳線)|(坂出方)|(児島方)/g,
|
||||
""
|
||||
)
|
||||
.trim();
|
||||
|
||||
const isHiddenThroughPointEntry = (raw: string) => {
|
||||
const [station = "", se = ""] = (raw || "").split(",");
|
||||
return station.startsWith(".") && se.includes("通");
|
||||
};
|
||||
|
||||
const calcDistanceMinute = (
|
||||
time: string,
|
||||
delayTime: number,
|
||||
playbackCurrentTimeIso?: string | null
|
||||
) => {
|
||||
if (!time || time === "") return null;
|
||||
const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
|
||||
const hour = parseInt(time.split(":")[0], 10);
|
||||
const target = now
|
||||
.hour(hour < 4 ? hour + 24 : hour)
|
||||
.minute(parseInt(time.split(":")[1], 10));
|
||||
let diff = target.diff(now, "minute") + delayTime;
|
||||
if (now.hour() < 4 && hour < 4) diff -= 1440;
|
||||
return diff;
|
||||
};
|
||||
|
||||
export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const {
|
||||
@@ -50,8 +90,9 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
setFixedPositionSize,
|
||||
} = useCurrentTrain();
|
||||
|
||||
const { mapSwitch, iconSetting } = useTrainMenu();
|
||||
const { allCustomTrainData, allTrainDiagram } = useAllTrainDiagram();
|
||||
const { mapSwitch, iconSetting, playbackCurrentTimeIso } = useTrainMenu();
|
||||
const { allCustomTrainData, allTrainDiagram, getTodayOperationByTrainId } =
|
||||
useAllTrainDiagram();
|
||||
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
|
||||
|
||||
const [liveNotifyId, setLiveNotifyId] = useState<string | null>(null);
|
||||
@@ -62,7 +103,23 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
const [customData, setCustomData] = useState<CustomTrainData>(
|
||||
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
|
||||
);
|
||||
const customTrainIcon = resolveTrainDataIcon(customData, iconDisplayMode);
|
||||
const todayOperation = useMemo(
|
||||
() => (getTodayOperationByTrainId(trainID) ?? []).filter((d) => d.state !== 100),
|
||||
[getTodayOperationByTrainId, trainID]
|
||||
);
|
||||
const customTrainIcon = useMemo(() => {
|
||||
const iconEntry = resolveTrainIconEntries({
|
||||
trainNum: trainID,
|
||||
customTrainData: customData,
|
||||
todayOperation,
|
||||
iconDisplayMode,
|
||||
})[0];
|
||||
|
||||
return (
|
||||
iconEntry?.vehicle_info_img ||
|
||||
resolveTrainDataIcon(customData, iconDisplayMode)
|
||||
);
|
||||
}, [customData, iconDisplayMode, todayOperation, trainID]);
|
||||
useEffect(() => {
|
||||
setCustomData(
|
||||
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
|
||||
@@ -89,8 +146,11 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
);
|
||||
|
||||
const computedTrainDataWithThrough = useMemo(() => {
|
||||
const trainData = allTrainDiagram[trainID]?.split("#");
|
||||
if (!trainData) return [];
|
||||
const trainData =
|
||||
allTrainDiagram[trainID]
|
||||
?.split("#")
|
||||
.filter((entry) => !isHiddenThroughPointEntry(entry)) ?? [];
|
||||
if (trainData.length === 0) return [];
|
||||
|
||||
// 駅名ごとに駅情報を集約するマップを構築
|
||||
const stationByNameMap = new Map();
|
||||
@@ -136,6 +196,8 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
originalStationList[
|
||||
lineListPair[stationIDPair[betweenStationLine]]
|
||||
].forEach((d) => {
|
||||
const isHiddenPassPoint = d.Station_JP?.startsWith(".");
|
||||
if (isHiddenPassPoint) return;
|
||||
if (
|
||||
d.StationNumber > baseStationNumberFirst &&
|
||||
d.StationNumber < baseStationNumberSecond
|
||||
@@ -193,6 +255,22 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
setStopStationList(computedStopStationIDList);
|
||||
}, [computedStopStationIDList]);
|
||||
const [currentPosition, setCurrentPosition] = useState<string[]>([]);
|
||||
const parsedTrainPath = useMemo<TrainPathEntry[]>(
|
||||
() =>
|
||||
trainDataWidhThrough.map((raw, index) => {
|
||||
const [station = "", se = "", time = ""] = (raw || "").split(",");
|
||||
return {
|
||||
raw,
|
||||
station,
|
||||
se,
|
||||
time,
|
||||
index,
|
||||
isThrough: se.includes("通"),
|
||||
hasTime: time !== "",
|
||||
};
|
||||
}),
|
||||
[trainDataWidhThrough]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let position = getPosition(train);
|
||||
@@ -227,96 +305,127 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
}
|
||||
}, [train, stopStationIDList]);
|
||||
|
||||
const [nextStationData, setNextStationData] = useState<StationProps[]>([]);
|
||||
const [untilStationData, setUntilStationData] = useState<StationProps[]>([]);
|
||||
const [currentPointData, setCurrentPointData] = useState<StationProps[]>([]);
|
||||
const [nextStopStationData, setNextStopStationData] = useState<StationProps[]>([]);
|
||||
const [untilStationData, setUntilStationData] = useState<string[]>([]);
|
||||
const [probably, setProbably] = useState(false);
|
||||
const [isCurrentPointDisplay, setIsCurrentPointDisplay] = useState(false);
|
||||
const [currentDisplayIndex, setCurrentDisplayIndex] = useState(0);
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (playbackCurrentTimeIso) return;
|
||||
const interval = setInterval(() => setTick((value) => value + 1), 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, [playbackCurrentTimeIso]);
|
||||
|
||||
useEffect(() => {
|
||||
//棒線駅判定を入れて、棒線駅なら時間を見て分数がマイナスならcontinue;
|
||||
const points = findReversalPoints(currentPosition, stopStationIDList);
|
||||
if (!points) return;
|
||||
if (points.length == 0) return;
|
||||
let searchCountFirst = points.findIndex((d) => d == true);
|
||||
let searchCountLast = points.findLastIndex((d) => d == true);
|
||||
if (!points || points.length === 0) return;
|
||||
|
||||
const delayTime = train?.delay == "入線" ? 0 : train?.delay;
|
||||
let additionalSkipCount = 0;
|
||||
const pointIndexes = points.reduce(
|
||||
(acc: number[], isCurrent, index) => {
|
||||
if (isCurrent) acc.push(index);
|
||||
return acc;
|
||||
},
|
||||
[] as number[]
|
||||
);
|
||||
if (!pointIndexes.length) return;
|
||||
|
||||
// 2駅間走行中の場合: ダイヤ順で後ろのインデックスが進行方向の駅
|
||||
// Direction に関係なく、travel-order で大きい方が「向かう駅」
|
||||
let searchStart: number;
|
||||
if (currentPosition.length === 2) {
|
||||
const idx0 = stopStationIDList.findIndex(d => d.includes(currentPosition[0]));
|
||||
const idx1 = stopStationIDList.findIndex(d => d.includes(currentPosition[1]));
|
||||
const aheadIdx = Math.max(
|
||||
idx0 >= 0 ? idx0 : -1,
|
||||
idx1 >= 0 ? idx1 : -1
|
||||
);
|
||||
searchStart = aheadIdx >= 0 ? aheadIdx : searchCountLast;
|
||||
} else {
|
||||
searchStart = searchCountFirst;
|
||||
}
|
||||
const firstMatchedIndex = pointIndexes[0];
|
||||
const isCurrentStationCluster = currentPosition.length <= 1;
|
||||
const delayTime =
|
||||
train?.delay == "入線" ? 0 : parseInt(String(train?.delay), 10) || 0;
|
||||
|
||||
for (
|
||||
let searchCount = searchStart;
|
||||
searchCount < trainDataWidhThrough.length;
|
||||
searchCount++
|
||||
) {
|
||||
const nextPos = trainDataWidhThrough[searchCount];
|
||||
let anchorIndex = firstMatchedIndex;
|
||||
let usedTimeEstimation = false;
|
||||
const shouldShowCurrentPoint = isCurrentStationCluster;
|
||||
const hasIntermediateCurrentPoints = pointIndexes.length > 1;
|
||||
|
||||
if (nextPos) {
|
||||
const [station, se, time] = nextPos.split(",");
|
||||
if (!isCurrentStationCluster && hasIntermediateCurrentPoints) {
|
||||
let upcomingTimedIndex = -1;
|
||||
let lastPassedTimedIndex = -1;
|
||||
|
||||
// 通過駅はスキップ
|
||||
if (se.includes("通")) {
|
||||
for (
|
||||
let searchCount = firstMatchedIndex;
|
||||
searchCount < parsedTrainPath.length;
|
||||
searchCount++
|
||||
) {
|
||||
const entry = parsedTrainPath[searchCount];
|
||||
if (!entry?.raw || !entry.hasTime) continue;
|
||||
|
||||
const distanceMinute = calcDistanceMinute(
|
||||
entry.time,
|
||||
delayTime,
|
||||
playbackCurrentTimeIso
|
||||
);
|
||||
if (distanceMinute == null) continue;
|
||||
|
||||
if (distanceMinute < 0) {
|
||||
lastPassedTimedIndex = searchCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 駅に停車中(1点一致)の場合は時刻判定不要
|
||||
if (searchCountFirst == searchCountLast) {
|
||||
setNextStationData(getStationDataFromName(station));
|
||||
break;
|
||||
}
|
||||
|
||||
// 2駅間走行中: 時刻で既に通過済みか判定
|
||||
let distanceMinute = 0;
|
||||
if (time != "") {
|
||||
const now = dayjs();
|
||||
const hour = parseInt(time.split(":")[0]);
|
||||
const distanceTime = now
|
||||
.hour(hour < 4 ? hour + 24 : hour)
|
||||
.minute(parseInt(time.split(":")[1]));
|
||||
distanceMinute = distanceTime.diff(now, "minute") + delayTime;
|
||||
if (now.hour() < 4) {
|
||||
if (hour < 4) {
|
||||
distanceMinute = distanceMinute - 1440;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (distanceMinute >= 0) {
|
||||
setNextStationData(getStationDataFromName(station));
|
||||
break;
|
||||
} else {
|
||||
additionalSkipCount++;
|
||||
continue;
|
||||
}
|
||||
upcomingTimedIndex = searchCount;
|
||||
break;
|
||||
}
|
||||
|
||||
const baseIndex =
|
||||
lastPassedTimedIndex >= 0 ? lastPassedTimedIndex + 1 : firstMatchedIndex;
|
||||
|
||||
if (upcomingTimedIndex >= 0) {
|
||||
const hasUntimedGapBeforeUpcoming = parsedTrainPath
|
||||
.slice(baseIndex, upcomingTimedIndex)
|
||||
.some((entry) => entry?.raw && !entry.hasTime);
|
||||
|
||||
anchorIndex = hasUntimedGapBeforeUpcoming ? baseIndex : upcomingTimedIndex;
|
||||
} else if (lastPassedTimedIndex >= 0) {
|
||||
const lastPassedEntry = parsedTrainPath[lastPassedTimedIndex];
|
||||
anchorIndex = lastPassedEntry?.isThrough
|
||||
? Math.min(lastPassedTimedIndex + 1, parsedTrainPath.length - 1)
|
||||
: lastPassedTimedIndex;
|
||||
}
|
||||
|
||||
usedTimeEstimation = anchorIndex !== firstMatchedIndex;
|
||||
}
|
||||
let trainList = [];
|
||||
for (
|
||||
let searchCount = searchCountFirst - 1;
|
||||
searchCount < points.length;
|
||||
searchCount++
|
||||
) {
|
||||
trainList.push(trainDataWidhThrough[searchCount]);
|
||||
}
|
||||
if (additionalSkipCount > 0) {
|
||||
trainList = trainList.slice(additionalSkipCount);
|
||||
setProbably(true);
|
||||
} else {
|
||||
setProbably(false);
|
||||
}
|
||||
|
||||
const anchorEntry = parsedTrainPath[anchorIndex];
|
||||
const currentPointName = anchorEntry?.station || "";
|
||||
setCurrentPointData(
|
||||
currentPointName ? getStationDataFromName(currentPointName) : []
|
||||
);
|
||||
|
||||
const nextStopSearchStart = shouldShowCurrentPoint
|
||||
? anchorIndex + 1
|
||||
: anchorIndex;
|
||||
const nextStopEntry = parsedTrainPath.find(
|
||||
(entry, index) =>
|
||||
index >= nextStopSearchStart && !!entry?.station && !entry.isThrough
|
||||
);
|
||||
const nextStopName = nextStopEntry?.station || "";
|
||||
setNextStopStationData(
|
||||
nextStopName ? getStationDataFromName(nextStopName) : []
|
||||
);
|
||||
|
||||
const visibleStart = Math.max(anchorIndex - 1, 0);
|
||||
const trainList = parsedTrainPath
|
||||
.slice(visibleStart)
|
||||
.map((entry) => entry.raw)
|
||||
.filter((entry) => !!entry);
|
||||
|
||||
setProbably(usedTimeEstimation);
|
||||
setIsCurrentPointDisplay(shouldShowCurrentPoint);
|
||||
setCurrentDisplayIndex(Math.max(anchorIndex - visibleStart, 0));
|
||||
setUntilStationData(trainList);
|
||||
}, [currentPosition, trainDataWidhThrough]);
|
||||
}, [
|
||||
currentPosition,
|
||||
parsedTrainPath,
|
||||
playbackCurrentTimeIso,
|
||||
stopStationIDList,
|
||||
tick,
|
||||
train?.delay,
|
||||
getStationDataFromName,
|
||||
]);
|
||||
const [ToData, setToData] = useState("");
|
||||
useEffect(() => {
|
||||
if (customData.to_data && customData.to_data != "") {
|
||||
@@ -335,7 +444,9 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
setStation(data);
|
||||
}, [ToData]);
|
||||
const lineColor =
|
||||
station.length > 0
|
||||
customData.to_data_color && customData.to_data_color.length > 0
|
||||
? customData.to_data_color[0]
|
||||
: station.length > 0
|
||||
? lineColorList[station[0]?.StationNumber?.slice(0, 1)]
|
||||
: "black";
|
||||
//const lineColor = "red";
|
||||
@@ -355,7 +466,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
(last: number, d: string, i: number) => (d ? i : last), -1
|
||||
);
|
||||
const filteredTrainData = trainDataWidhThrough.filter((d, idx) => {
|
||||
if (!d) return false;
|
||||
if (!d || isHiddenThroughPointEntry(d)) return false;
|
||||
const [, se] = d.split(",");
|
||||
if (!se) return true;
|
||||
// 着を含み発を含まないエントリは終着駅のみ許可
|
||||
@@ -407,13 +518,22 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
};
|
||||
});
|
||||
|
||||
const shouldShowCurrentLabel =
|
||||
isCurrentPointDisplay || currentPointData[0]?.Station_JP === train?.Pos;
|
||||
const currentStationLabel = shouldShowCurrentLabel
|
||||
? currentPointData[0]?.Station_JP || train?.Pos || ""
|
||||
: train?.Pos || "";
|
||||
const bannerStationData = shouldShowCurrentLabel
|
||||
? currentPointData
|
||||
: nextStopStationData;
|
||||
|
||||
// 全駅リスト中の現在地インデックス
|
||||
const currentStationIndex = (() => {
|
||||
const pos = train?.Pos || "";
|
||||
const pos = currentStationLabel;
|
||||
if (!pos) return 0;
|
||||
// Pos は "駅名" (駅にいる時) or "駅A~駅B" (走行中) の形式
|
||||
const posStations = pos.split("~").map((s: string) =>
|
||||
s.replace(/(下り)|(上り)|\(下り\)|\(上り\)/g, "").trim()
|
||||
normalizeTrainPosLabel(s)
|
||||
);
|
||||
// 完全一致
|
||||
const firstIdx = allStations.findIndex((s) => s.name === posStations[0]);
|
||||
@@ -427,7 +547,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
})();
|
||||
|
||||
const nextStationIndex = (() => {
|
||||
const name = nextStationData[0]?.Station_JP;
|
||||
const name = nextStopStationData[0]?.Station_JP;
|
||||
if (!name) return -1;
|
||||
const idx = stationStops.indexOf(name);
|
||||
if (idx >= 0) return idx;
|
||||
@@ -452,11 +572,10 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
if (!liveNotifyId || !train) return;
|
||||
const delayNum = train.delay === "入線" ? 0 : parseInt(String(train.delay)) || 0;
|
||||
const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻";
|
||||
const positionStatus =
|
||||
nextStationData[0]?.Station_JP === train.Pos ? "ただいま" : "次は";
|
||||
const positionStatus = shouldShowCurrentLabel ? "ただいま" : "次は";
|
||||
updateTrainFollowActivity(liveNotifyId, {
|
||||
currentStation: train.Pos || "",
|
||||
nextStation: nextStationData[0]?.Station_JP || "",
|
||||
currentStation: currentStationLabel,
|
||||
nextStation: nextStopStationData[0]?.Station_JP || "",
|
||||
delayMinutes: delayNum,
|
||||
scheduledArrival: "",
|
||||
trainNumber: trainID,
|
||||
@@ -472,7 +591,16 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
allStations,
|
||||
currentStationIndex,
|
||||
}).catch(() => {});
|
||||
}, [train, nextStationData, liveNotifyId, stationStops, nextStationIndex, currentStationIndex]);
|
||||
}, [
|
||||
train,
|
||||
nextStopStationData,
|
||||
liveNotifyId,
|
||||
stationStops,
|
||||
nextStationIndex,
|
||||
currentStationIndex,
|
||||
currentStationLabel,
|
||||
shouldShowCurrentLabel,
|
||||
]);
|
||||
|
||||
// バナー表示と同時にLive Activityを自動開始
|
||||
// iOSのみ一時的に無効化中(Androidは有効)
|
||||
@@ -489,15 +617,14 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
}
|
||||
const delayNum = train?.delay === "入線" ? 0 : parseInt(String(train?.delay)) || 0;
|
||||
const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻";
|
||||
const positionStatus =
|
||||
nextStationData[0]?.Station_JP === train?.Pos ? "ただいま" : "次は";
|
||||
const positionStatus = shouldShowCurrentLabel ? "ただいま" : "次は";
|
||||
try {
|
||||
const id = await startTrainFollowActivity({
|
||||
trainNumber: trainID,
|
||||
lineName: "",
|
||||
destination: ToData,
|
||||
currentStation: train?.Pos || "",
|
||||
nextStation: nextStationData[0]?.Station_JP || "",
|
||||
currentStation: currentStationLabel,
|
||||
nextStation: nextStopStationData[0]?.Station_JP || "",
|
||||
delayMinutes: delayNum,
|
||||
scheduledArrival: "",
|
||||
trainType: customTrainType.shortName,
|
||||
@@ -517,7 +644,22 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
}
|
||||
};
|
||||
startActivity();
|
||||
}, [train]);
|
||||
}, [
|
||||
train,
|
||||
nextStopStationData,
|
||||
currentStationLabel,
|
||||
shouldShowCurrentLabel,
|
||||
ToData,
|
||||
trainID,
|
||||
customTrainType.shortName,
|
||||
trainNameText,
|
||||
customTrainType.color,
|
||||
lineColor,
|
||||
stationStops,
|
||||
nextStationIndex,
|
||||
allStations,
|
||||
currentStationIndex,
|
||||
]);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -576,19 +718,18 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
<Text
|
||||
style={{
|
||||
fontSize: trainNameText.length > 4 ? 12 : 14,
|
||||
fontFamily: customTrainType.fontAvailable
|
||||
? "JR-Nishi"
|
||||
: undefined,
|
||||
fontWeight: !customTrainType.fontAvailable
|
||||
? "bold"
|
||||
: undefined,
|
||||
marginTop: customTrainType.fontAvailable ? 3 : 0,
|
||||
fontFamily: customTrainType.fontFamily,
|
||||
fontWeight: customTrainType.fontFamily ? undefined : "bold",
|
||||
marginTop: customTrainType.fontFamily ? 3 : 0,
|
||||
color: fixed.textOnPrimary,
|
||||
textAlignVertical: "center",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{customTrainType.shortName}
|
||||
{customTrainType.fontFamily === "JR-WEST-PLUS" ? (
|
||||
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
{customData.train_name && (
|
||||
<Text
|
||||
@@ -693,9 +834,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
marginVertical: -1,
|
||||
}}
|
||||
>
|
||||
{nextStationData[0]?.Station_JP == train?.Pos
|
||||
? "ただいま"
|
||||
: "次は"}
|
||||
{shouldShowCurrentLabel ? "ただいま" : "次は"}
|
||||
</Text>
|
||||
{probably && (
|
||||
<Text
|
||||
@@ -713,7 +852,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
)}
|
||||
</View>
|
||||
<StationNumberMaker
|
||||
currentStation={nextStationData}
|
||||
currentStation={bannerStationData}
|
||||
singleSize={20}
|
||||
useEach={true}
|
||||
/>
|
||||
@@ -725,7 +864,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{nextStationData[0]?.Station_JP || "不明"}
|
||||
{bannerStationData[0]?.Station_JP || "不明"}
|
||||
</Text>
|
||||
{fixedPositionSize !== 226 && (
|
||||
<View
|
||||
@@ -749,6 +888,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
train={train}
|
||||
lineColor={lineColor}
|
||||
trainDataWithThrough={untilStationData}
|
||||
currentDisplayIndex={currentDisplayIndex}
|
||||
isSmall={fixedPositionSize !== 226}
|
||||
/>
|
||||
</View>
|
||||
@@ -875,6 +1015,7 @@ const CurrentPositionBox = ({
|
||||
train,
|
||||
lineColor,
|
||||
trainDataWithThrough,
|
||||
currentDisplayIndex,
|
||||
isSmall,
|
||||
}) => {
|
||||
const { colors } = useThemeColors();
|
||||
@@ -884,13 +1025,13 @@ const CurrentPositionBox = ({
|
||||
const { isBetween, Pos: PosData } = trainPosition(train);
|
||||
if (isBetween === true) {
|
||||
const { from, to } = PosData;
|
||||
firstText = from;
|
||||
secondText = to;
|
||||
firstText = normalizeTrainPosLabel(from);
|
||||
secondText = normalizeTrainPosLabel(to);
|
||||
marginText = "→";
|
||||
} else {
|
||||
const { Pos } = PosData;
|
||||
if (Pos !== "") {
|
||||
firstText = Pos;
|
||||
firstText = normalizeTrainPosLabel(Pos);
|
||||
}
|
||||
}
|
||||
const delayTime = train?.delay == "入線" ? 0 : parseInt(train?.delay);
|
||||
@@ -943,6 +1084,7 @@ const CurrentPositionBox = ({
|
||||
(() => {
|
||||
// 着→発ペアを同一駅で統合(EachStopListと同様)
|
||||
const merged: { d: string; arrivalTime: string | null }[] = [];
|
||||
let mergedCurrentDisplayIndex = Math.max(currentDisplayIndex, 0);
|
||||
for (let i = 0; i < trainDataWithThrough.length; i++) {
|
||||
const d = trainDataWithThrough[i];
|
||||
if (!d) continue;
|
||||
@@ -963,11 +1105,17 @@ const CurrentPositionBox = ({
|
||||
const [prevSt, prevSe, prevTime] = prev.split(",");
|
||||
if (prevSt === st && prevSe?.includes("着")) {
|
||||
merged.push({ d, arrivalTime: prevTime });
|
||||
if (i === currentDisplayIndex || i - 1 === currentDisplayIndex) {
|
||||
mergedCurrentDisplayIndex = merged.length - 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
merged.push({ d, arrivalTime: null });
|
||||
if (i === currentDisplayIndex) {
|
||||
mergedCurrentDisplayIndex = merged.length - 1;
|
||||
}
|
||||
}
|
||||
return merged.map(({ d, arrivalTime }, index) => (
|
||||
<EachStopData
|
||||
@@ -977,6 +1125,7 @@ const CurrentPositionBox = ({
|
||||
delayTime={delayTime}
|
||||
isSmall={isSmall}
|
||||
secondText={secondText}
|
||||
currentDisplayIndex={mergedCurrentDisplayIndex}
|
||||
arrivalTime={arrivalTime}
|
||||
/>
|
||||
));
|
||||
@@ -992,18 +1141,28 @@ type eachStopType = {
|
||||
isSmall: boolean;
|
||||
index: number;
|
||||
secondText: string;
|
||||
currentDisplayIndex: number;
|
||||
arrivalTime?: string | null;
|
||||
};
|
||||
|
||||
const EachStopData: FC<eachStopType> = (props) => {
|
||||
const { colors } = useThemeColors();
|
||||
const { d, delayTime, isSmall, index, secondText, arrivalTime } = props;
|
||||
const { playbackCurrentTimeIso } = useTrainMenu();
|
||||
const {
|
||||
d,
|
||||
delayTime,
|
||||
isSmall,
|
||||
index,
|
||||
secondText,
|
||||
currentDisplayIndex,
|
||||
arrivalTime,
|
||||
} = props;
|
||||
if (!d) return null;
|
||||
if (d == "") return null;
|
||||
const [station, se, time] = d.split(",");
|
||||
const calcMinute = (t: string) => {
|
||||
if (!t || t === "") return null;
|
||||
const now = dayjs();
|
||||
const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
|
||||
const hour = parseInt(t.split(":")[0]);
|
||||
const dt = now
|
||||
.hour(hour < 4 ? hour + 24 : hour)
|
||||
@@ -1078,7 +1237,7 @@ const EachStopData: FC<eachStopType> = (props) => {
|
||||
style={{
|
||||
fontSize: isSmall ? 8 : 14,
|
||||
color:
|
||||
index == 1 && secondText == ""
|
||||
index === currentDisplayIndex && secondText === ""
|
||||
? "#ffe852ff"
|
||||
: se.includes("通")
|
||||
? "#020202ff"
|
||||
@@ -1088,14 +1247,14 @@ const EachStopData: FC<eachStopType> = (props) => {
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{index == 1 && secondText == ""
|
||||
{index === currentDisplayIndex && secondText === ""
|
||||
? "→"
|
||||
: se.includes("通")
|
||||
? null
|
||||
: "●"}
|
||||
</Text>
|
||||
</View>
|
||||
{index == 0 && secondText != "" && (
|
||||
{index === 0 && secondText !== "" && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
|
||||
import { useStationList } from "@/stateBox/useStationList";
|
||||
import { OriginalStationList } from "@/lib/CommonTypes";
|
||||
|
||||
interface StationIDPair {
|
||||
[key: string]: string;
|
||||
@@ -10,12 +11,10 @@ interface LineListPair {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface OriginalStationList {
|
||||
[key: string]: Array<{
|
||||
Station_JP: string;
|
||||
StationNumber: string;
|
||||
}>;
|
||||
}
|
||||
const isHiddenThroughPointEntry = (raw: string) => {
|
||||
const [station = "", se = ""] = (raw || "").split(",");
|
||||
return station.startsWith(".") && se.includes("通");
|
||||
};
|
||||
|
||||
/**
|
||||
* 通過駅を含む列車データを生成するカスタムフック
|
||||
@@ -33,8 +32,11 @@ export const useTrainDataWithThrough = (
|
||||
const [trainDataWithThrough, setTrainDataWithThrough] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const trainData = allTrainDiagram[trainID]?.split("#");
|
||||
if (!trainData) return;
|
||||
const trainData =
|
||||
allTrainDiagram[trainID]
|
||||
?.split("#")
|
||||
.filter((entry) => !isHiddenThroughPointEntry(entry)) ?? [];
|
||||
if (trainData.length === 0) return;
|
||||
|
||||
// 各停車駅の情報を取得
|
||||
const stopStationList = trainData.map((i) => {
|
||||
@@ -76,6 +78,8 @@ export const useTrainDataWithThrough = (
|
||||
if (!lineStations) return [];
|
||||
|
||||
lineStations.forEach((d) => {
|
||||
const isHiddenPassPoint = d.Station_JP?.startsWith(".");
|
||||
if (isHiddenPassPoint) return;
|
||||
// 順方向の判定
|
||||
if (
|
||||
d.StationNumber > baseStationNumberFirst &&
|
||||
|
||||
+76
-14
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { WebView } from "react-native-webview";
|
||||
|
||||
@@ -22,7 +22,9 @@ import { useThemeColors } from "@/lib/theme";
|
||||
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();
|
||||
@@ -53,7 +55,9 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
|
||||
data,
|
||||
});
|
||||
};
|
||||
const { remountKey, processHandlers } = useWebViewRemount({
|
||||
const { remountKey, remount, processHandlers, pingHandlers, webViewRef } = useWebViewRemount({
|
||||
pingEnabled: Platform.OS === "ios",
|
||||
backgroundThresholdMs: null,
|
||||
isFocused,
|
||||
pauseWatchdogWhenUnfocused: Platform.OS === "ios",
|
||||
ignoreProcessTerminationWhenUnfocused: Platform.OS === "ios",
|
||||
@@ -66,13 +70,33 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
|
||||
});
|
||||
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);
|
||||
const previousMockApiFeatureEnabledRef = useRef(mockApiFeatureEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
const previousEnabled = previousMockApiFeatureEnabledRef.current;
|
||||
if (previousEnabled === mockApiFeatureEnabled) return;
|
||||
|
||||
previousMockApiFeatureEnabledRef.current = mockApiFeatureEnabled;
|
||||
addWebViewBreadcrumb("mock mode changed; remounting webview", {
|
||||
previousEnabled,
|
||||
enabled: mockApiFeatureEnabled,
|
||||
focused: focusedRef.current,
|
||||
});
|
||||
remount();
|
||||
}, [mockApiFeatureEnabled, remount]);
|
||||
|
||||
useEffect(() => {
|
||||
focusedRef.current = isFocused;
|
||||
@@ -80,12 +104,25 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
|
||||
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]);
|
||||
|
||||
@@ -129,6 +166,11 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
|
||||
webview?.current?.injectJavaScript(script);
|
||||
}, [mockApiFeatureEnabled, mockTrainPositions, webview]);
|
||||
|
||||
const attachWebViewRefs = useCallback((instance) => {
|
||||
webview.current = instance;
|
||||
webViewRef.current = instance;
|
||||
}, [webViewRef, webview]);
|
||||
|
||||
const onNavigationStateChange = ({ url }) => {
|
||||
if (url == urlCacheRef.current) return;
|
||||
//URL二重判定回避
|
||||
@@ -147,7 +189,20 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
|
||||
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}
|
||||
@@ -240,6 +295,7 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
|
||||
};
|
||||
|
||||
const onLoadStart = () => {
|
||||
pingHandlers.onLoadStart?.();
|
||||
addWebViewBreadcrumb("webview loadStart", {
|
||||
focused: focusedRef.current,
|
||||
currentUrl: urlCacheRef.current || null,
|
||||
@@ -247,6 +303,7 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
|
||||
};
|
||||
|
||||
const onLoadEnd = () => {
|
||||
pingHandlers.onLoadEnd?.();
|
||||
addWebViewBreadcrumb("webview loadEnd", {
|
||||
focused: focusedRef.current,
|
||||
hasStationData: !!stationData,
|
||||
@@ -271,18 +328,23 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
|
||||
if (loadEndTimeoutRef.current) {
|
||||
clearTimeout(loadEndTimeoutRef.current);
|
||||
}
|
||||
addWebViewBreadcrumb("webview initial inject scheduled");
|
||||
addWebViewBreadcrumb("webview initial inject scheduled", {
|
||||
focused: focusedRef.current,
|
||||
});
|
||||
loadEndTimeoutRef.current = setTimeout(() => {
|
||||
if (!focusedRef.current) {
|
||||
addWebViewBreadcrumb("webview initial inject skipped while blurred");
|
||||
loadEndTimeoutRef.current = null;
|
||||
return;
|
||||
}
|
||||
addWebViewBreadcrumb("webview initial inject run");
|
||||
addWebViewBreadcrumb(
|
||||
focusedRef.current
|
||||
? "webview initial inject run"
|
||||
: "webview initial inject run while blurred",
|
||||
{
|
||||
focused: focusedRef.current,
|
||||
}
|
||||
);
|
||||
webview?.current?.injectJavaScript(string);
|
||||
pendingInitialInjectRef.current = null;
|
||||
initialInjectDoneRef.current = true;
|
||||
loadEndTimeoutRef.current = null;
|
||||
}, 500);
|
||||
initialInjectDoneRef.current = true;
|
||||
}, focusedRef.current ? 500 : 700);
|
||||
};
|
||||
|
||||
const handleRenderProcessGone = (event) => {
|
||||
@@ -299,9 +361,9 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
|
||||
|
||||
return (
|
||||
<WebView
|
||||
key={(isDark ? 'dark' : 'light') + (mockApiFeatureEnabled ? '-mock' : '-live') + '-' + remountKey}
|
||||
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",
|
||||
|
||||
@@ -30,6 +30,9 @@ 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,
|
||||
listUpStation,
|
||||
@@ -51,7 +54,6 @@ export const CarouselBox = ({
|
||||
const sortControlsEntering = isAndroid ? undefined : FadeIn.duration(200);
|
||||
const sortControlsExiting = isAndroid ? undefined : FadeOut.duration(150);
|
||||
const carouselRef = useRef<ICarouselInstance>(null);
|
||||
const androidCarouselScrollRef = useRef<ScrollView>(null);
|
||||
const { width } = useWindowDimensions();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const { fontScale } = useResponsive();
|
||||
@@ -116,6 +118,14 @@ 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);
|
||||
@@ -165,15 +175,8 @@ export const CarouselBox = ({
|
||||
if (listIndex < 0) return;
|
||||
const animated = !justExitedSortRef.current;
|
||||
justExitedSortRef.current = false;
|
||||
if (Platform.OS === "android") {
|
||||
androidCarouselScrollRef.current?.scrollTo({
|
||||
x: width * listIndex,
|
||||
animated,
|
||||
});
|
||||
return;
|
||||
}
|
||||
carouselRef.current?.scrollTo({ index: listIndex, animated });
|
||||
}, [listIndex, width]);
|
||||
}, [listIndex]);
|
||||
|
||||
// ドットのスクロール追従
|
||||
useEffect(() => {
|
||||
@@ -204,6 +207,10 @@ export const CarouselBox = ({
|
||||
setDotButton(data === "true");
|
||||
})
|
||||
.catch((error) => {
|
||||
setDotButton(false);
|
||||
if (isMissingStorageKeyError(error)) {
|
||||
return;
|
||||
}
|
||||
Sentry.addBreadcrumb({
|
||||
category: "menu.carousel",
|
||||
level: "error",
|
||||
@@ -336,53 +343,27 @@ export const CarouselBox = ({
|
||||
style={[{ position: "absolute", width }, isAndroid ? { opacity: isSortMode ? 0 : 1 } : carouselAnimStyle]}
|
||||
pointerEvents={isSortMode ? "none" : "auto"}
|
||||
>
|
||||
{Platform.OS === "android" ? (
|
||||
<ScrollView
|
||||
ref={androidCarouselScrollRef}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
overScrollMode="never"
|
||||
onMomentumScrollEnd={({ nativeEvent }) => {
|
||||
const nextIndex = Math.round(nativeEvent.contentOffset.x / width);
|
||||
if (nextIndex !== listIndex) {
|
||||
setListIndex(nextIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(listUpStation.length > 0 ? listUpStation : [[{ StationNumber: "null" }]]).map((item, index) => (
|
||||
<View key={item[0].StationNumber ?? item[0].Station_JP ?? index} style={{ width }}>
|
||||
<RenderItem item={item} />
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<Carousel
|
||||
ref={carouselRef}
|
||||
data={listUpStation.length > 0 ? listUpStation : [[{ StationNumber: "null" }]]}
|
||||
height={carouselHeight}
|
||||
pagingEnabled={true}
|
||||
snapEnabled={true}
|
||||
loop={false}
|
||||
width={width}
|
||||
style={{ width, alignContent: "center" }}
|
||||
mode="parallax"
|
||||
modeConfig={{
|
||||
parallaxScrollingScale: 1,
|
||||
parallaxScrollingOffset: 100,
|
||||
parallaxAdjacentItemScale: 0.8,
|
||||
}}
|
||||
scrollAnimationDuration={600}
|
||||
onSnapToItem={setListIndex}
|
||||
renderItem={RenderItem}
|
||||
overscrollEnabled={false}
|
||||
defaultIndex={
|
||||
lastValidListIndexRef.current >= listUpStation.length
|
||||
? 0
|
||||
: lastValidListIndexRef.current
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Carousel
|
||||
ref={carouselRef}
|
||||
data={listUpStation.length > 0 ? listUpStation : [[{ StationNumber: "null" }]]}
|
||||
height={carouselHeight}
|
||||
pagingEnabled={true}
|
||||
snapEnabled={true}
|
||||
loop={false}
|
||||
width={width}
|
||||
style={{ width, alignContent: "center" }}
|
||||
scrollAnimationDuration={isAndroid ? 450 : 600}
|
||||
onSnapToItem={setListIndex}
|
||||
renderItem={RenderItem}
|
||||
overscrollEnabled={false}
|
||||
defaultIndex={
|
||||
lastValidListIndexRef.current >= listUpStation.length
|
||||
? 0
|
||||
: lastValidListIndexRef.current
|
||||
}
|
||||
enabled={!isSortMode}
|
||||
{...carouselModeProps}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* グリッド:ソートモード中のみマウント */}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
|
||||
import { useThemeColors, type ColorThemePref } from "@/lib/theme/useThemeColors";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
|
||||
const versionCode = "7.0.7"; // Update this version code as needed
|
||||
const versionCode = "7.1";
|
||||
|
||||
export const SettingTopPage = ({
|
||||
testNFC,
|
||||
|
||||
@@ -32,6 +32,19 @@ import {
|
||||
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 {
|
||||
@@ -50,23 +63,51 @@ export default function Setting(props) {
|
||||
const [operationLandscapeEnabled, setOperationLandscapeEnabled] = useState(false);
|
||||
const [operationCaptureEnabled, setOperationCaptureEnabled] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
AS.getItem(STORAGE_KEYS.ICON_SWITCH).then((value) =>
|
||||
setIconSetting(normalizeIconDisplayMode(value)),
|
||||
);
|
||||
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);
|
||||
AS.getItem(STORAGE_KEYS.OPERATION_INFO_LANDSCAPE_ENABLED).then((value) =>
|
||||
setOperationLandscapeEnabled(value === true || value === "true"),
|
||||
);
|
||||
AS.getItem(STORAGE_KEYS.OPERATION_INFO_CAPTURE_ENABLED).then((value) =>
|
||||
setOperationCaptureEnabled(value === true || value === "true"),
|
||||
);
|
||||
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...");
|
||||
|
||||
@@ -59,7 +59,7 @@ export const ListViewItem: FC<{
|
||||
const [
|
||||
typeString,
|
||||
trainName,
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -79,7 +79,7 @@ export const ListViewItem: FC<{
|
||||
to_data_color,
|
||||
train_number_override,
|
||||
} = customTrainDataDetector(d.trainNumber, allCustomTrainData);
|
||||
const [typeString, fontAvailable, isOneMan] = getStringConfig(
|
||||
const [typeString, fontFamily, isOneMan] = getStringConfig(
|
||||
type,
|
||||
d.trainNumber
|
||||
);
|
||||
@@ -111,7 +111,7 @@ export const ListViewItem: FC<{
|
||||
return [
|
||||
typeString,
|
||||
displayName,
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -128,7 +128,7 @@ export const ListViewItem: FC<{
|
||||
return [
|
||||
typeString,
|
||||
"",
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -145,7 +145,7 @@ export const ListViewItem: FC<{
|
||||
migrateTrainName(
|
||||
trainDataArray[trainDataArray.length - 1].split(",")[0] + "行き"
|
||||
),
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -314,15 +314,18 @@ export const ListViewItem: FC<{
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
|
||||
fontWeight: !fontAvailable ? "bold" : undefined,
|
||||
paddingTop: fontAvailable ? 2 : 0,
|
||||
fontFamily,
|
||||
fontWeight: fontFamily ? undefined : "bold",
|
||||
paddingTop: fontFamily ? 2 : 0,
|
||||
paddingLeft: 10,
|
||||
color: isCancelled ? "gray" : color,
|
||||
textDecorationLine: isCancelled ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
{typeString}
|
||||
{fontFamily === "JR-WEST-PLUS" ? (
|
||||
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
{showVehicle
|
||||
? (() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,225 +1,9 @@
|
||||
import React, { useRef, useState, useEffect, useLayoutEffect } from "react";
|
||||
import { View, Text, TouchableOpacity, Linking } from "react-native";
|
||||
//import MapView from "react-native-maps";
|
||||
import { useCurrentTrain } from "../stateBox/useCurrentTrain";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import lineColorList from "../assets/originData/lineColorList";
|
||||
import { lineListPair, stationIDPair } from "../lib/getStationList";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { useTrainMenu } from "../stateBox/useTrainMenu";
|
||||
//import { MapPin } from "./TrainMenu/MapPin";
|
||||
import { UsefulBox } from "./TrainMenu/UsefulBox";
|
||||
import { MapsButton } from "./TrainMenu/MapsButton";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
export default function TrainMenu({ style }) {
|
||||
const { fixed } = useThemeColors();
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
|
||||
type TrainMenuProps = {
|
||||
style?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
export default function TrainMenu(_props: TrainMenuProps) {
|
||||
return null;
|
||||
const { webview } = useCurrentTrain();
|
||||
const mapRef = useRef();
|
||||
const { navigate, goBack } = useNavigation();
|
||||
const [stationPin, setStationPin] = useState([]);
|
||||
const {
|
||||
selectedLine,
|
||||
setSelectedLine,
|
||||
mapsStationData: stationData,
|
||||
} = useTrainMenu();
|
||||
useEffect(() => {
|
||||
const stationPinData = [];
|
||||
Object.keys(stationData).forEach((d, indexBase) => {
|
||||
stationData[d].forEach((D, index) => {
|
||||
if (!D.StationMap) return null;
|
||||
if (selectedLine && selectedLine != d) return;
|
||||
const latlng = D.StationMap.replace(
|
||||
"https://www.google.co.jp/maps/place/",
|
||||
""
|
||||
).split(",");
|
||||
if (latlng.length == 0) return null;
|
||||
stationPinData.push({ D, d, latlng, indexBase: 0, index });
|
||||
});
|
||||
});
|
||||
setStationPin(stationPinData);
|
||||
}, [stationData, selectedLine]);
|
||||
useLayoutEffect(() => {
|
||||
mapRef.current.fitToCoordinates(
|
||||
stationPin.map(({ latlng }) => ({
|
||||
latitude: parseFloat(latlng[0]),
|
||||
longitude: parseFloat(latlng[1]),
|
||||
})),
|
||||
{ edgePadding: { top: 80, bottom: 120, left: 50, right: 50 } } // Add margin values here
|
||||
);
|
||||
}, [stationPin]);
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: fixed.primary, ...style }}>
|
||||
<MapView
|
||||
style={{ flex: 1, width: "100%", height: "100%" }}
|
||||
showsUserLocation={true}
|
||||
loadingEnabled={true}
|
||||
showsMyLocationButton={false}
|
||||
moveOnMarkerPress={false}
|
||||
showsCompass={false}
|
||||
ref={mapRef}
|
||||
//provider={PROVIDER_GOOGLE}
|
||||
initialRegion={{
|
||||
latitude: 33.774519,
|
||||
longitude: 133.533306,
|
||||
latitudeDelta: 1.8, //小さくなるほどズーム
|
||||
longitudeDelta: 1.8,
|
||||
}}
|
||||
>
|
||||
{stationPin.map(({ D, d, latlng, indexBase, index }) => (
|
||||
<MapPin
|
||||
index={index}
|
||||
indexBase={indexBase}
|
||||
latlng={latlng}
|
||||
D={D}
|
||||
d={d}
|
||||
navigate={navigate}
|
||||
webview={webview}
|
||||
key={D.StationNumber + d}
|
||||
/>
|
||||
))}
|
||||
</MapView>
|
||||
<View style={{ position: "relative" }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
position: "absolute",
|
||||
width: "100vw",
|
||||
bottom: 0,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
backgroundColor: selectedLine
|
||||
? lineColorList[stationIDPair[selectedLine]]
|
||||
: fixed.primary,
|
||||
padding: 10,
|
||||
zIndex: 1,
|
||||
alignItems: "center",
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
width: "100%",
|
||||
paddingBottom: 50,
|
||||
}}
|
||||
onPress={() => SheetManager.show("TrainMenuLineSelector")}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: 10,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
▲ ここを押して路線をフィルタリングできます ▲
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: 20,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{selectedLine
|
||||
? lineListPair[stationIDPair[selectedLine]]
|
||||
: "JR四国 対象全駅"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={{ position: "absolute", bottom: 40 }}>
|
||||
路線記号からフィルタリング
|
||||
</Text>
|
||||
{Object.keys(stationData).map((d) => (
|
||||
<TouchableOpacity
|
||||
key={stationIDPair[d]}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: lineColorList[stationIDPair[d]],
|
||||
padding: 5,
|
||||
margin: 2,
|
||||
borderRadius: 10,
|
||||
borderColor: "white",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
alignItems: "center",
|
||||
opacity: selectedLine == d ? 1 : !selectedLine ? 1 : 0.5,
|
||||
zIndex: 10,
|
||||
}}
|
||||
onPress={() => {
|
||||
const s = selectedLine == d ? undefined : d;
|
||||
if(!s) return;
|
||||
setSelectedLine(s);
|
||||
Object.keys(stationData).forEach((data, indexBase) => {
|
||||
stationData[data].forEach((D, index) => {
|
||||
if (!D.StationMap) return null;
|
||||
if (s && s != data) return;
|
||||
const latlng = D.StationMap.replace(
|
||||
"https://www.google.co.jp/maps/place/",
|
||||
""
|
||||
).split(",");
|
||||
if (latlng.length == 0) return null;
|
||||
if (index == 0 && stationPin.length > 0) {
|
||||
webview.current
|
||||
?.injectJavaScript(`MoveDisplayStation('${data}_${D.MyStation}_${D.Station_JP}');
|
||||
document.getElementById("disp").insertAdjacentHTML("afterbegin", "<div />");`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{ color: "white", fontWeight: "bold", fontSize: 20 }}
|
||||
>
|
||||
{stationIDPair[d]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{navigate && (
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<UsefulBox
|
||||
backgroundColor={"#F89038"}
|
||||
icon="train-car"
|
||||
flex={1}
|
||||
onPressButton={() =>
|
||||
navigate("howto", {
|
||||
info: "https://train.jr-shikoku.co.jp/usage.htm",
|
||||
})
|
||||
}
|
||||
>
|
||||
使い方
|
||||
</UsefulBox>
|
||||
<UsefulBox
|
||||
backgroundColor={"#EA4752"}
|
||||
icon="star"
|
||||
flex={1}
|
||||
onPressButton={() => navigate("favoriteList")}
|
||||
>
|
||||
お気に入り
|
||||
</UsefulBox>
|
||||
<UsefulBox
|
||||
backgroundColor={"#91C31F"}
|
||||
icon="clipboard-list-outline"
|
||||
flex={1}
|
||||
onPressButton={() =>
|
||||
Linking.openURL(
|
||||
"https://nexcloud.haruk.in/apps/forms/ZRHjWFF7znr5Xjr2"
|
||||
)
|
||||
}
|
||||
>
|
||||
フィードバック
|
||||
</UsefulBox>
|
||||
</View>
|
||||
)}
|
||||
<MapsButton
|
||||
onPress={() => {
|
||||
goBack();
|
||||
}}
|
||||
top={0}
|
||||
mapSwitch={"flex"}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getCurrentTrainData } from "@/lib/getCurrentTrainData";
|
||||
import type { NavigateFunction } from "@/types";
|
||||
import { PlatformNumber } from "./LED_inside_Component/PlatformNumber";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
|
||||
type Props = {
|
||||
d: eachTrainDiagramType;
|
||||
@@ -48,6 +49,7 @@ export const EachData: FC<Props> = (props) => {
|
||||
} = props;
|
||||
const { currentTrain } = useCurrentTrain();
|
||||
const { stationList } = useStationList();
|
||||
const { playbackCurrentTimeIso } = useTrainMenu();
|
||||
const { allCustomTrainData } = useAllTrainDiagram();
|
||||
const { fixed } = useThemeColors();
|
||||
const openTrainInfo = (d: {
|
||||
@@ -134,7 +136,7 @@ export const EachData: FC<Props> = (props) => {
|
||||
const [h, m] = d.time.split(":");
|
||||
const IntH = parseInt(h);
|
||||
const IntM = parseInt(m);
|
||||
const currentTime = dayjs();
|
||||
const currentTime = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
|
||||
const trainTime = currentTime
|
||||
.set("hour", IntH < 4 ? IntH + 24 : IntH)
|
||||
.set("minute", IntM);
|
||||
@@ -145,7 +147,7 @@ export const EachData: FC<Props> = (props) => {
|
||||
setIsDepartureNow(false);
|
||||
setIsShow(true);
|
||||
};
|
||||
}, [d.time, currentTrainData]);
|
||||
}, [d.time, currentTrainData, playbackCurrentTimeIso]);
|
||||
useInterval(() => {
|
||||
if (isDepartureNow) {
|
||||
setIsShow(!isShow);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,16 @@ import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { stackAwareNavigate } from "@/lib/rootNavigation";
|
||||
import { useStationList } from "@/stateBox/useStationList";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
|
||||
const readBooleanSetting = async (key: string) => {
|
||||
try {
|
||||
return (await AS.getItem(key)) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -52,6 +62,8 @@ export const LED_vision: FC<props> = (props) => {
|
||||
|
||||
const { navigate } = useNavigation();
|
||||
const { currentTrain } = useCurrentTrain();
|
||||
const { stationList } = useStationList();
|
||||
const { playbackCurrentTimeIso } = useTrainMenu();
|
||||
const [stationDiagram, setStationDiagram] = useState<{
|
||||
[key: string]: string;
|
||||
}>({}); //当該駅の全時刻表
|
||||
@@ -64,14 +76,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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -113,10 +125,10 @@ export const LED_vision: FC<props> = (props) => {
|
||||
if (!currentTrain) return () => {};
|
||||
const data = trainTimeAndNumber
|
||||
.filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo]
|
||||
.filter((d) => trainTimeFiltering({ d, currentTrain, station })) //時間フィルター
|
||||
.filter((d) => trainTimeFiltering({ d, currentTrain, station, stationList, now: playbackCurrentTimeIso })) //時間フィルター
|
||||
.filter((d) => !!finalSwitch || d.lastStation != station[0].Station_JP); //最終列車表示設定
|
||||
setSelectedTrain(data);
|
||||
}, [trainTimeAndNumber, currentTrain, finalSwitch]);
|
||||
}, [trainTimeAndNumber, currentTrain, finalSwitch, stationList, playbackCurrentTimeIso]);
|
||||
|
||||
const { width } = useWindowDimensions();
|
||||
const adjustedWidth = width * 0.98;
|
||||
|
||||
@@ -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
@@ -256,3 +256,117 @@
|
||||
- `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` 程度。
|
||||
@@ -0,0 +1,153 @@
|
||||
# X投稿向け運行情報画像セット:実装指示書
|
||||
|
||||
## 依頼
|
||||
|
||||
`ndView.tsx` に、X(旧Twitter)へ複数画像で投稿することを前提とした運行情報画像セット生成機能を実装してください。
|
||||
|
||||
既存の「この項目を切り出す」「この小見出しを切り出す」「表示中の運行情報をすべて切り出す」「項目ごとに複数画像で共有」は残し、新たに「X投稿向け画像を作成」を追加します。
|
||||
|
||||
実装まで行い、型チェックと生成処理の確認をしてください。調査・提案だけで終了しないでください。
|
||||
|
||||
## ユーザー体験
|
||||
|
||||
新しいボタンを押すと、次の順番で最大4枚のPNGを生成し、既存の複数ファイル共有処理へ渡します。
|
||||
|
||||
1. 1枚目:運行状況の表紙(路線図+短い要約)
|
||||
2. 2枚目以降:表示中の運行情報の詳細
|
||||
|
||||
Xで1枚目と2枚目が横並びに表示されたときに自然につながって見える構成にします。ただし、各画像は単体表示されても意味が通じる必要があります。画像の境界をまたいで文章や必須情報を配置しないでください。
|
||||
|
||||
## 出力仕様
|
||||
|
||||
- 各画像は `1080 x 1440 px`(3:4)の固定サイズ
|
||||
- PNG
|
||||
- 画像数は1〜4枚。運行情報が存在する通常ケースでは表紙+詳細で2〜4枚
|
||||
- ファイル名は投稿順を明確にする
|
||||
- `operation-info-x-01-map-<timestamp>.png`
|
||||
- `operation-info-x-02-detail-<timestamp>.png`
|
||||
- 以下同様
|
||||
- ネイティブ側へ送る `batchIndex` はゼロ始まりの表示順と一致させる
|
||||
- 既存の `Share.open({ urls })` に渡る配列順を維持する
|
||||
- 画像生成途中で1枚でも失敗した場合、不完全なセットを共有せず既存形式のエラーを返す
|
||||
|
||||
## 1枚目:表紙
|
||||
|
||||
必須要素:
|
||||
|
||||
- `JR四国 列車運行情報` のタイトル
|
||||
- 現在表示中の状態を示す短い要約
|
||||
- 四国の路線図
|
||||
- 更新日時(取得できる最新のもの)
|
||||
- 詳細ページがある場合は `詳細は次の画像へ →`
|
||||
- 非公式アプリによる生成画像であり、最新情報はJR四国公式を確認する旨
|
||||
- ページ番号(例:`1/4`)
|
||||
|
||||
路線図は既存の `buildMapImage()` と `img/map/*` を再利用します。現在ページ上で有効になっている路線レイヤーを反映してください。既存挙動を壊さない範囲で、影響路線を目立たせ、影響のない部分を弱められるなら実装してください。状態と路線の対応を正確に判定できない場合は、誤った色分けを推測せず、現在表示されているレイヤー表現を使用してください。
|
||||
|
||||
表紙は路線図を主役にし、要約文が長い場合も地図を極端に小さくしないでください。要約は路線名・状態を中心に短く整形し、詳細本文を表紙へ詰め込みません。
|
||||
|
||||
## 2枚目以降:詳細ページ
|
||||
|
||||
必須要素:
|
||||
|
||||
- 路線・項目タイトル
|
||||
- サブタイトル/状態
|
||||
- 更新日時
|
||||
- 小見出しと本文
|
||||
- ページ番号
|
||||
- 非公式画像の注意書き
|
||||
|
||||
既存の `getOperationItems()`、`parseDetailBlocks()`、`splitCaptureBlocksIntoSections()` のデータ構造を再利用します。
|
||||
|
||||
詳細は「1項目=必ず1画像」ではなく、固定高のページへ順に組版してください。
|
||||
|
||||
- 1件が短ければ、同じページに次の項目も配置してよい
|
||||
- 1件が長ければ、ブロックまたは小見出し単位で次ページへ送る
|
||||
- 小見出しだけをページ末尾に残さない
|
||||
- 本文を文字単位で不自然に分断するより、小見出し単位の改ページを優先する
|
||||
- 4枚を超える場合は、詳細3ページへ読みやすく収める
|
||||
- それでも収まらない場合は、文字を極端に縮小したり内容を黙って欠落させたりしない
|
||||
|
||||
4枚で収まらないケースについては、実装上安全な扱いを選び、コードコメントと完了報告で明示してください。推奨は、最小可読サイズを守ったうえで4枚では収まらないことをユーザーへ知らせ、既存の項目別共有を案内することです。
|
||||
|
||||
## レイアウト規則
|
||||
|
||||
- 背景は白を基本とし、既存のJR四国風配色(`#0099CB` など)を継承
|
||||
- 左右80px程度をX側のクロップに備えた安全領域とする
|
||||
- 必須情報は端へ寄せない
|
||||
- 全ページでヘッダー、ページ番号、フッターの位置を統一
|
||||
- 本文の可読性を優先し、既存画像と同程度の文字サイズを維持
|
||||
- 1枚目右側の矢印などで2枚目への視線誘導を作ってよいが、その装飾が切れても意味が失われないようにする
|
||||
- 角丸や画像間の隙間はX側が付けるため、画像自体には外周の角丸を焼き込まない
|
||||
|
||||
## 既存コードの入口
|
||||
|
||||
主な対象は `ndView.tsx` です。
|
||||
|
||||
- `buildOperationPageScript()`:WebViewへ注入する生成処理
|
||||
- `buildMapImage()`:路線図Canvas合成
|
||||
- `appendCaptureItemLayout()` / `drawCaptureLayout()`:既存組版・描画
|
||||
- `getOperationItems()`:表示中の運行情報抽出
|
||||
- `installPageCaptureButton()`:共有ボタン追加
|
||||
- `renderAllItemsToSeparateImages()`:既存バッチ送信の参考
|
||||
- `saveOperationCaptureBatchItem()`:ネイティブ側で順番どおりにファイルを集約
|
||||
- `shareOperationCaptureFiles()`:単一/複数ファイル共有
|
||||
|
||||
必要ならX投稿向けのレイアウト計算・描画関数を独立させてください。既存の可変幅・可変高画像生成ロジックへ多数の条件分岐を足すより、データ抽出と基本描画だけ共有する構成を優先します。
|
||||
|
||||
## 実装上の制約
|
||||
|
||||
- 既存の切り出し/共有機能を壊さない
|
||||
- 横画面サイネージモードでは、従来どおりキャプチャボタンを表示しない
|
||||
- iOS・Android双方で動く既存のWebViewメッセージ方式を維持
|
||||
- 新しい依存パッケージは原則追加しない
|
||||
- WebView内で利用できない新しいJavaScript構文を安易に導入しない
|
||||
- 生成中の二重タップで複数バッチが競合しないよう考慮する
|
||||
- ユーザーの既存変更があるファイルを勝手に巻き戻さない
|
||||
- `scripts/generate-operation-info-userscript.mjs` が `ndView.tsx` の注入コードを元に生成物を作るため、必要なら生成物も更新する。ただし生成物を直接手編集しない
|
||||
|
||||
## UI文言
|
||||
|
||||
新規ボタン:
|
||||
|
||||
`X投稿向け画像を作成`
|
||||
|
||||
生成・共有に失敗した場合は、既存の「切り出し失敗」系ダイアログとトーンをそろえます。4枚へ収まらない場合は、理由と代替手段が分かる日本語を表示してください。
|
||||
|
||||
## 完了条件
|
||||
|
||||
- 新規ボタンから表紙+詳細画像が正しい順番で共有される
|
||||
- 全画像が厳密に1080×1440
|
||||
- 1件、2件、3件、4件以上、長文1件のレイアウトを確認している
|
||||
- 長文がCanvas外へはみ出さない
|
||||
- 空のタイトル、サブタイトル、更新日時でも壊れない
|
||||
- 地図画像のロード失敗時も白紙画像を共有せず、明確に失敗扱いまたは安全なフォールバックになる
|
||||
- 既存4種類の切り出し操作が維持される
|
||||
- TypeScript型チェックが通る
|
||||
- `git diff --check` が通る
|
||||
|
||||
## 検証
|
||||
|
||||
最低限、次を実行してください。
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
git diff --check
|
||||
```
|
||||
|
||||
注入スクリプトを変更した場合は、リポジトリ既存の生成手順も確認します。
|
||||
|
||||
```bash
|
||||
node scripts/generate-operation-info-userscript.mjs
|
||||
```
|
||||
|
||||
可能なら、Canvas描画を開発用に呼び出せる小さなテスト入口または純粋なページ分割関数を用意し、上記の件数・長文パターンを検証してください。本番UIへデバッグ操作を残さないでください。
|
||||
|
||||
## 完了報告に含めるもの
|
||||
|
||||
- 変更したファイル
|
||||
- 表紙・詳細ページの分割ルール
|
||||
- 4枚で収まらない場合の扱い
|
||||
- 実行した検証と結果
|
||||
- 端末で確認が必要な残件
|
||||
|
||||
@@ -89,6 +89,9 @@ export type CustomTrainData = {
|
||||
lng: number;
|
||||
isSpot?: boolean;
|
||||
};
|
||||
|
||||
export type OriginalStationList = Record<string, StationProps[]>;
|
||||
|
||||
export type OperationLogs = {
|
||||
id: number;
|
||||
operation_id?: string;
|
||||
|
||||
+2
-39
@@ -2,6 +2,8 @@
|
||||
// Source: https://github.com/micolous/metrodroid (GPL-3.0)
|
||||
// Keys: stationId = ((areaCode)<<16) | ((lineCode)<<8) | stationCode
|
||||
// areaCode = data[15] >> 6 (from FeliCa history block byte 15)
|
||||
// The source contains historical duplicate IDs; this map intentionally keeps the last
|
||||
// record for each ID to preserve the original object last-record-wins lookup.
|
||||
|
||||
interface FelicaStationEntry { s: string; l: string; c: string }
|
||||
|
||||
@@ -61,7 +63,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x000147: { s: '清水', l: '東海道本', c: '東海旅客鉄道' },
|
||||
0x000149: { s: '草薙', l: '東海道本', c: '東海旅客鉄道' },
|
||||
0x00014b: { s: '東静岡', l: '東海道本', c: '東海旅客鉄道' },
|
||||
0x00014b: { s: '東静岡', l: '東海道本', c: '東海旅客鉄道' },
|
||||
0x00014c: { s: '静岡', l: '東海道本', c: '東海旅客鉄道' },
|
||||
0x00014e: { s: '安倍川', l: '東海道本', c: '東海旅客鉄道' },
|
||||
0x00014f: { s: '用宗', l: '東海道本', c: '東海旅客鉄道' },
|
||||
@@ -267,7 +268,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x000271: { s: '太子堂', l: '東北本', c: '東日本旅客鉄道' },
|
||||
0x000272: { s: '長町', l: '東北本', c: '東日本旅客鉄道' },
|
||||
0x000273: { s: '仙台', l: '東北本', c: '東日本旅客鉄道' },
|
||||
0x000273: { s: '仙台', l: '東北本', c: '東日本旅客鉄道' },
|
||||
0x000275: { s: '東仙台', l: '東北本', c: '東日本旅客鉄道' },
|
||||
0x000276: { s: '岩切', l: '東北本', c: '東日本旅客鉄道' },
|
||||
0x000277: { s: '陸前山王', l: '東北本', c: '東日本旅客鉄道' },
|
||||
@@ -665,9 +665,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00062c: { s: '大野城', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x00062d: { s: '水城', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x00062e: { s: '都府楼南', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x00062f: { s: '二日市', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x00062f: { s: '都府楼南', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x000630: { s: '二日市', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x000630: { s: '天拝山', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x000631: { s: '天拝山', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x000632: { s: '原田', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
@@ -725,7 +723,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00071a: { s: '三毛門', l: '日豊本', c: '九州旅客鉄道' },
|
||||
0x00071b: { s: '吉富', l: '日豊本', c: '九州旅客鉄道' },
|
||||
0x00071c: { s: '中津', l: '日豊本', c: '九州旅客鉄道' },
|
||||
0x000728: { s: '中津', l: '日豊本', c: '九州旅客鉄道' },
|
||||
0x000728: { s: '宇佐', l: '日豊本', c: '九州旅客鉄道' },
|
||||
0x00072a: { s: '西屋敷', l: '日豊本', c: '九州旅客鉄道' },
|
||||
0x00073b: { s: '別府', l: '日豊本', c: '九州旅客鉄道' },
|
||||
@@ -1113,9 +1110,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00104c: { s: '高野川', l: '予讃', c: '四国旅客鉄道' },
|
||||
0x001058: { s: '五郎', l: '予讃', c: '四国旅客鉄道' },
|
||||
0x001059: { s: '伊予大洲', l: '予讃', c: '四国旅客鉄道' },
|
||||
0x001081: { s: '御免', l: 'ごめんなはり', c: '土佐くろしお鉄道' },
|
||||
0x001081: { s: '後免', l: '阿佐', c: '土佐くろしお鉄道' },
|
||||
0x001083: { s: '後免町', l: '阿佐', c: '土佐くろしお鉄道' },
|
||||
0x001083: { s: '御免町', l: 'ごめんなはり', c: '土佐くろしお鉄道' },
|
||||
0x001102: { s: '金蔵寺', l: '土讃', c: '四国旅客鉄道' },
|
||||
0x001103: { s: '善通寺', l: '土讃', c: '四国旅客鉄道' },
|
||||
@@ -1123,7 +1118,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00110c: { s: '佃', l: '土讃', c: '四国旅客鉄道' },
|
||||
0x00110d: { s: '阿波池田', l: '土讃', c: '四国旅客鉄道' },
|
||||
0x001113: { s: '大歩危', l: '土讃', c: '四国旅客鉄道' },
|
||||
0x001124: { s: '御免', l: '土讃', c: '四国旅客鉄道' },
|
||||
0x001124: { s: '後免', l: '土讃', c: '四国旅客鉄道' },
|
||||
0x00112a: { s: '高知', l: '土讃', c: '四国旅客鉄道' },
|
||||
0x00112b: { s: '入明', l: '土讃', c: '四国旅客鉄道' },
|
||||
@@ -1649,7 +1643,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x002984: { s: '岩手和井内', l: '岩泉', c: '東日本旅客鉄道' },
|
||||
0x002986: { s: '押角', l: '岩泉', c: '東日本旅客鉄道' },
|
||||
0x002a0c: { s: '新川崎', l: '横須賀', c: '東日本旅客鉄道' },
|
||||
0x002a0d: { s: '武蔵小杉', l: '横須賀', c: '東日本旅客鉄道' },
|
||||
0x002a0d: { s: '新川崎', l: '横須賀', c: '東日本旅客鉄道' },
|
||||
0x002a0e: { s: '西大井', l: '横須賀', c: '東日本旅客鉄道' },
|
||||
0x002a15: { s: '新日本橋', l: '総武本', c: '東日本旅客鉄道' },
|
||||
@@ -1671,7 +1664,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x002ada: { s: '登別', l: '室蘭本', c: '北海道旅客鉄道' },
|
||||
0x002ae0: { s: '白老', l: '室蘭本', c: '北海道旅客鉄道' },
|
||||
0x002ae3: { s: '錦岡', l: '室蘭本', c: '北海道旅客鉄道' },
|
||||
0x002ae3: { s: '錦岡', l: '室蘭本', c: '北海道旅客鉄道' },
|
||||
0x002ae5: { s: '糸井', l: '室蘭本', c: '北海道旅客鉄道' },
|
||||
0x002ae9: { s: '苫小牧', l: '室蘭本', c: '北海道旅客鉄道' },
|
||||
0x002aeb: { s: '沼ノ端', l: '室蘭本', c: '北海道旅客鉄道' },
|
||||
@@ -2281,7 +2273,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00524a: { s: '三角', l: '三角', c: '九州旅客鉄道' },
|
||||
0x005318: { s: '太田部', l: '小海', c: '東日本旅客鉄道' },
|
||||
0x005319: { s: '中込', l: '小海', c: '東日本旅客鉄道' },
|
||||
0x00531a: { s: '滑津', l: '小海', c: '東日本旅客鉄道' },
|
||||
0x00531a: { s: '中込', l: '小海', c: '東日本旅客鉄道' },
|
||||
0x00531b: { s: '北中込', l: '小海', c: '東日本旅客鉄道' },
|
||||
0x00531c: { s: '岩村田', l: '小海', c: '東日本旅客鉄道' },
|
||||
@@ -2291,7 +2282,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x005352: { s: '人吉', l: '肥薩', c: '九州旅客鉄道' },
|
||||
0x00540c: { s: '戸狩野沢温泉', l: '飯山', c: '東日本旅客鉄道' },
|
||||
0x005415: { s: '森宮野原', l: '飯山', c: '東日本旅客鉄道' },
|
||||
0x005415: { s: '森宮野原', l: '飯山', c: '東日本旅客鉄道' },
|
||||
0x00541f: { s: '十日町', l: '飯山', c: '東日本旅客鉄道' },
|
||||
0x005420: { s: '魚沼中条', l: '飯山', c: '東日本旅客鉄道' },
|
||||
0x005515: { s: '吉田', l: '越後', c: '東日本旅客鉄道' },
|
||||
@@ -2715,7 +2705,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x007403: { s: '播磨高岡', l: '姫新', c: '西日本旅客鉄道' },
|
||||
0x007404: { s: '余部', l: '姫新', c: '西日本旅客鉄道' },
|
||||
0x007407: { s: '本竜野', l: '姫新', c: '西日本旅客鉄道' },
|
||||
0x007408: { s: '東觜崎', l: '姫新線', c: '西日本旅客鉄道' },
|
||||
0x007408: { s: '東觜崎', l: '姫新', c: '西日本旅客鉄道' },
|
||||
0x007409: { s: '播磨新宮', l: '姫新', c: '西日本旅客鉄道' },
|
||||
0x007410: { s: '佐用', l: '姫新', c: '西日本旅客鉄道' },
|
||||
@@ -3453,7 +3442,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00c708: { s: '京王多摩センター', l: '相模原', c: '京王電鉄' },
|
||||
0x00c709: { s: '京王堀之内', l: '相模原', c: '京王電鉄' },
|
||||
0x00c70a: { s: '南大沢', l: '相模原', c: '京王電鉄' },
|
||||
0x00c70a: { s: '南大沢', l: '相模原', c: '京王電鉄' },
|
||||
0x00c70b: { s: '多摩境', l: '相模原', c: '京王電鉄' },
|
||||
0x00c70c: { s: '橋本', l: '相模原', c: '京王電鉄' },
|
||||
0x00c801: { s: '北野', l: '高尾', c: '京王電鉄' },
|
||||
@@ -3596,7 +3584,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00d403: { s: '新高島', l: 'みなとみらい', c: '横浜高速鉄道' },
|
||||
0x00d405: { s: 'みなとみらい', l: 'みなとみらい', c: '横浜高速鉄道' },
|
||||
0x00d407: { s: '馬車道', l: 'みなとみらい', c: '横浜高速鉄道' },
|
||||
0x00d409: { s: '日本大通', l: 'みなとみらい', c: '横浜高速鉄道' },
|
||||
0x00d409: { s: '日本大通り', l: 'みなとみらい', c: '横浜高速鉄道' },
|
||||
0x00d40b: { s: '元町・中華街', l: 'みなとみらい', c: '横浜高速鉄道' },
|
||||
0x00d501: { s: '泉岳寺', l: '本', c: '京浜急行電鉄' },
|
||||
@@ -3654,14 +3641,8 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00d603: { s: '大鳥居', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d604: { s: '穴守稲荷', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d605: { s: '天空橋', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d606: { s: '国際ターミナル駅(仮称)', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d606: { s: '羽田空港国際線ターミナル', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d607: { s: '羽田空港国内線ターミナル', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行' },
|
||||
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d701: { s: '京急川崎', l: '大師', c: '京浜急行電鉄' },
|
||||
0x00d702: { s: '港町', l: '大師', c: '京浜急行電鉄' },
|
||||
0x00d703: { s: '鈴木町', l: '大師', c: '京浜急行電鉄' },
|
||||
@@ -4111,7 +4092,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00fa07: { s: '整備場', l: '東京モノレール羽田', c: '東京モノレール' },
|
||||
0x00fa08: { s: '羽田', l: '東京モノレール羽田', c: '東京モノレール' },
|
||||
0x00fa09: { s: '天空橋', l: '東京モノレール羽田', c: '東京モノレール' },
|
||||
0x00fa0a: { s: '国際線ターミナルビル', l: '東京モノレール羽田', c: '東京モノレール' },
|
||||
0x00fa0a: { s: '羽田空港国際線ビル', l: '東京モノレール羽田', c: '東京モノレール' },
|
||||
0x00fa0b: { s: '新整備場', l: '東京モノレール羽田', c: '東京モノレール' },
|
||||
0x00fa0c: { s: '羽田空港第1ビル', l: '東京モノレール羽田', c: '東京モノレール' },
|
||||
@@ -4823,7 +4803,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x02933e: { s: '元町', l: '本', c: '阪神電気鉄道' },
|
||||
0x029401: { s: '大阪難波', l: '阪神なんば', c: '阪神電気鉄道' },
|
||||
0x029403: { s: '桜川', l: '阪神なんば', c: '阪神電気鉄道' },
|
||||
0x029404: { s: '桜川', l: '阪神なんば', c: '阪神電気鉄道' },
|
||||
0x029404: { s: 'ドーム前', l: '阪神なんば', c: '阪神電気鉄道' },
|
||||
0x029405: { s: '九条', l: '阪神なんば', c: '阪神電気鉄道' },
|
||||
0x029407: { s: '西九条', l: '西大阪', c: '阪神電気鉄道' },
|
||||
@@ -5122,12 +5101,9 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x02c54e: { s: '鳥羽街道', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c550: { s: '東福寺', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c552: { s: '七条', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c554: { s: '五条', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c554: { s: '清水五条', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c556: { s: '祇園四条', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c556: { s: '四条', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c558: { s: '三条', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c55a: { s: '丸太町', l: '鴨東', c: '京阪電気鉄道' },
|
||||
0x02c55a: { s: '神宮丸太町', l: '鴨東', c: '京阪電気鉄道' },
|
||||
0x02c55c: { s: '出町柳', l: '鴨東', c: '京阪電気鉄道' },
|
||||
0x02c5c2: { s: '八幡市', l: '京阪鋼索', c: '京阪電気鉄道' },
|
||||
@@ -5336,12 +5312,8 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x02e603: { s: '近鉄新庄', l: '御所', c: '近畿日本鉄道' },
|
||||
0x02e604: { s: '忍海', l: '御所', c: '近畿日本鉄道' },
|
||||
0x02e605: { s: '近鉄御所', l: '御所', c: '近畿日本鉄道' },
|
||||
0x02e701: { s: '近鉄難波', l: '難波', c: '近畿日本鉄道' },
|
||||
0x02e701: { s: '大阪難波', l: '難波', c: '近畿日本鉄道' },
|
||||
0x02e701: { s: '大阪難波', l: '難波', c: '近畿日本鉄道' },
|
||||
0x02e702: { s: '近鉄日本橋', l: '難波', c: '近畿日本鉄道' },
|
||||
0x02e703: { s: '上本町', l: '大阪', c: '近畿日本鉄道' },
|
||||
0x02e703: { s: '大阪上本町', l: '大阪', c: '近畿日本鉄道' },
|
||||
0x02e703: { s: '大阪上本町', l: '大阪', c: '近畿日本鉄道' },
|
||||
0x02e704: { s: '鶴橋', l: '大阪', c: '近畿日本鉄道' },
|
||||
0x02e706: { s: '今里', l: '大阪', c: '近畿日本鉄道' },
|
||||
@@ -5495,7 +5467,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x02ef16: { s: '桑名', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef17: { s: '益生', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef19: { s: '伊勢朝日', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef1b: { s: '富洲原', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef1b: { s: '川越富洲原', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef1d: { s: '近鉄富田', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef1f: { s: '霞ヶ浦', l: '名古屋', c: '近畿日本鉄道' },
|
||||
@@ -5512,7 +5483,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x02ef2c: { s: '伊勢若松', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef2e: { s: '千代崎', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef30: { s: '白子', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef30: { s: '白子', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef31: { s: '鼓ヶ浦', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef33: { s: '磯山', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef35: { s: '千里', l: '名古屋', c: '近畿日本鉄道' },
|
||||
@@ -5573,7 +5543,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x02f303: { s: '馬道 廃止', l: '北勢', c: '近畿日本鉄道' },
|
||||
0x02f304: { s: '西別所 廃止', l: '北勢', c: '近畿日本鉄道' },
|
||||
0x02f305: { s: '蓮花寺 廃止', l: '北勢', c: '近畿日本鉄道' },
|
||||
0x02f305: { s: '蓮花寺 廃止', l: '北勢', c: '近畿日本鉄道' },
|
||||
0x02f306: { s: '在良 廃止', l: '北勢', c: '近畿日本鉄道' },
|
||||
0x02f307: { s: '坂井橋 廃止', l: '北勢', c: '近畿日本鉄道' },
|
||||
0x02f30b: { s: '六把野 廃止', l: '北勢', c: '近畿日本鉄道' },
|
||||
@@ -5629,15 +5598,12 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x02ffb7: { s: '貿易センター', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffb9: { s: 'ポートターミナル', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffbb: { s: '中公園', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffbd: { s: '市民病院前', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffbd: { s: 'みなとじま', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffbf: { s: '市民広場', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffc1: { s: '南公園', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffc3: { s: '中埠頭', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffc5: { s: '北埠頭', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffc7: { s: '先端医療センター前', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffc7: { s: '医療センター', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffc9: { s: '京コンピュータ前', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffc9: { s: 'ポートアイランド南', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffcb: { s: '神戸空港', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x032841: { s: '川西', l: '錦川清流', c: '錦川鉄道' },
|
||||
@@ -5722,7 +5688,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x03be19: { s: '城北', l: 'アストラムライン', c: '広島高速交通' },
|
||||
0x03be1d: { s: '白島', l: 'アストラムライン', c: '広島高速交通' },
|
||||
0x03be1f: { s: '牛田', l: 'アストラムライン', c: '広島高速交通' },
|
||||
0x03be21: { s: '不動院前', l: 'アストラムライン', c: '広島' },
|
||||
0x03be21: { s: '不動院前', l: 'アストラムライン', c: '広島高速交通' },
|
||||
0x03be23: { s: '祇園新橋北', l: 'アストラムライン', c: '広島高速交通' },
|
||||
0x03be25: { s: '西原', l: 'アストラムライン', c: '広島高速交通' },
|
||||
@@ -5832,7 +5797,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x03d64d: { s: '西鉄千早', l: '貝塚', c: '西日本鉄道' },
|
||||
0x03d64f: { s: '名島', l: '貝塚', c: '西日本鉄道' },
|
||||
0x03d651: { s: '貝塚', l: '貝塚', c: '西日本鉄道' },
|
||||
0x03d765: { s: '西鉄福岡(天神)', l: '天神大牟田線', c: '西日本鉄道' },
|
||||
0x03d765: { s: '西鉄福岡(天神)', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d767: { s: '薬院', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d769: { s: '西鉄平尾', l: '天神大牟田', c: '西日本鉄道' },
|
||||
@@ -5846,7 +5810,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x03d777: { s: '下大利', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d779: { s: '都府楼前', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d77b: { s: '西鉄二日市', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d77c: { s: '二日市南', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d77c: { s: '紫', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d77d: { s: '朝倉街道', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d77f: { s: '桜台', l: '天神大牟田', c: '西日本鉄道' },
|
||||
|
||||
+14
-49
@@ -1,53 +1,18 @@
|
||||
import { trainTypeID } from "@/lib/CommonTypes";
|
||||
import {
|
||||
getTrainType,
|
||||
type TrainTypeFontFamily,
|
||||
} from "@/lib/getTrainType";
|
||||
|
||||
type types = (
|
||||
type: trainTypeID,
|
||||
id: string,
|
||||
) => [string, TrainTypeFontFamily | undefined, boolean];
|
||||
|
||||
type types = (types: trainTypeID, id: string) => [string, boolean, boolean];
|
||||
export const getStringConfig: types = (type, id) => {
|
||||
switch (type) {
|
||||
case "Normal":
|
||||
return ["普通", true, false];
|
||||
case "OneMan":
|
||||
return ["普通", true, true];
|
||||
case "Rapid":
|
||||
return ["快速", true, false];
|
||||
case "OneManRapid":
|
||||
return ["快速", true, true];
|
||||
case "LTDEXP":
|
||||
return ["特急", true, false];
|
||||
case "NightLTDEXP":
|
||||
return ["特急", true, false];
|
||||
case "SPCL":
|
||||
return ["臨時", true, false];
|
||||
case "SPCL_Normal":
|
||||
return ["臨時", true, false];
|
||||
case "SPCL_Rapid":
|
||||
return ["臨時快速", true, false];
|
||||
case "SPCL_EXP":
|
||||
return ["臨時特急", true, false];
|
||||
case "Party":
|
||||
return ["団体", true, false];
|
||||
case "Freight":
|
||||
return ["貨物", false, false];
|
||||
case "Forwarding":
|
||||
return ["回送", false, false];
|
||||
case "Trial":
|
||||
return ["試運転", false, false];
|
||||
case "Construction":
|
||||
return ["工事", false, false];
|
||||
case "FreightForwarding":
|
||||
return ["単機回送", false, false];
|
||||
case "Other":
|
||||
switch (true) {
|
||||
case !!id.includes("T"):
|
||||
return ["単機回送", false, false];
|
||||
case !!id.includes("R"):
|
||||
case !!id.includes("E"):
|
||||
case !!id.includes("L"):
|
||||
case !!id.includes("A"):
|
||||
case !!id.includes("B"):
|
||||
return ["回送", false, false];
|
||||
case !!id.includes("H"):
|
||||
return ["試運転", false, false];
|
||||
}
|
||||
return ["", false, false];
|
||||
}
|
||||
const { shortName, fontFamily, isOneMan } = getTrainType({ type, id });
|
||||
const typeString =
|
||||
type === "Other" && shortName === "その他" ? "" : shortName;
|
||||
|
||||
return [typeString, fontFamily, isOneMan];
|
||||
};
|
||||
|
||||
+21
-20
@@ -29,6 +29,7 @@ type trainTypeString =
|
||||
| "試運転"
|
||||
| "工事"
|
||||
| "その他";
|
||||
export type TrainTypeFontFamily = "JR-Nishi" | "JR-WEST-PLUS";
|
||||
type trainTypeDataString = "rapid" | "express" | "normal" | "notService";
|
||||
type getTrainType = (e: {
|
||||
type: trainTypeID;
|
||||
@@ -38,7 +39,7 @@ type getTrainType = (e: {
|
||||
color: colorString;
|
||||
name: trainTypeString;
|
||||
shortName: string;
|
||||
fontAvailable: boolean;
|
||||
fontFamily: TrainTypeFontFamily | undefined;
|
||||
isOneMan: boolean;
|
||||
data: trainTypeDataString;
|
||||
};
|
||||
@@ -49,7 +50,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: whiteMode ? "#333333ff" : "white",
|
||||
name: "普通列車",
|
||||
shortName: "普通",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "normal",
|
||||
};
|
||||
@@ -58,7 +59,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: whiteMode ? "#333333ff" : "white",
|
||||
name: "普通列車(ワンマン)",
|
||||
shortName: "普通",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: true,
|
||||
data: "normal",
|
||||
};
|
||||
@@ -67,7 +68,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: whiteMode ? "#00a0bdff" : "aqua",
|
||||
name: "快速",
|
||||
shortName: "快速",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "rapid",
|
||||
};
|
||||
@@ -76,7 +77,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: whiteMode ? "#00a0bdff" : "aqua",
|
||||
name: "快速",
|
||||
shortName: "快速",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: true,
|
||||
data: "rapid",
|
||||
};
|
||||
@@ -85,7 +86,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#ee424dff",
|
||||
name: "特急",
|
||||
shortName: "特急",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "express",
|
||||
};
|
||||
@@ -94,7 +95,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: whiteMode ? "#e000b0ff" : "pink",
|
||||
name: "寝台特急",
|
||||
shortName: "特急",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "express",
|
||||
};
|
||||
@@ -104,7 +105,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#297bff",
|
||||
name: "臨時",
|
||||
shortName: "臨時",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "normal",
|
||||
};
|
||||
@@ -113,7 +114,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#297bff",
|
||||
name: "臨時快速",
|
||||
shortName: "臨時快速",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "normal",
|
||||
};
|
||||
@@ -122,7 +123,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#297bff",
|
||||
name: "臨時特急",
|
||||
shortName: "臨時特急",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "normal",
|
||||
};
|
||||
@@ -131,7 +132,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#ff7300ff",
|
||||
name: "団体",
|
||||
shortName: "団体",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "normal",
|
||||
};
|
||||
@@ -140,7 +141,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#007488ff",
|
||||
name: "貨物",
|
||||
shortName: "貨物",
|
||||
fontAvailable: false,
|
||||
fontFamily: undefined,
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -149,7 +150,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#5f5f5fff",
|
||||
name: "回送",
|
||||
shortName: "回送",
|
||||
fontAvailable: false,
|
||||
fontFamily: "JR-WEST-PLUS",
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -158,7 +159,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#5f5f5fff",
|
||||
name: "試運転",
|
||||
shortName: "試運転",
|
||||
fontAvailable: false,
|
||||
fontFamily: "JR-WEST-PLUS",
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -167,7 +168,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#5f5f5fff",
|
||||
name: "工事",
|
||||
shortName: "工事",
|
||||
fontAvailable: false,
|
||||
fontFamily: undefined,
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -176,7 +177,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#5f5f5fff",
|
||||
name: "単機回送",
|
||||
shortName: "単機回送",
|
||||
fontAvailable: false,
|
||||
fontFamily: undefined,
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -189,7 +190,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#5f5f5fff",
|
||||
name: "単機回送",
|
||||
shortName: "単機回送",
|
||||
fontAvailable: false,
|
||||
fontFamily: undefined,
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -202,7 +203,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#5f5f5fff",
|
||||
name: "回送",
|
||||
shortName: "回送",
|
||||
fontAvailable: false,
|
||||
fontFamily: "JR-WEST-PLUS",
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -211,7 +212,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#5f5f5fff",
|
||||
name: "試運転",
|
||||
shortName: "試運転",
|
||||
fontAvailable: false,
|
||||
fontFamily: "JR-WEST-PLUS",
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -221,7 +222,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: whiteMode ? "#333333ff" : "white",
|
||||
name: "その他",
|
||||
shortName: "その他",
|
||||
fontAvailable: false,
|
||||
fontFamily: undefined,
|
||||
isOneMan: false,
|
||||
data: "normal",
|
||||
};
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import { observedFetchJson } from "@/lib/observability/network/observedFetch";
|
||||
|
||||
/**
|
||||
* Position Masters – JR Shikoku mock API
|
||||
*
|
||||
@@ -32,6 +35,8 @@ 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.
|
||||
@@ -39,9 +44,16 @@ let _cache: PositionMaster[] | null = null;
|
||||
*/
|
||||
export const fetchPositionMasters = async (): Promise<PositionMaster[]> => {
|
||||
if (_cache) return _cache;
|
||||
const res = await fetch(POSITION_MASTERS_URL);
|
||||
if (!res.ok) throw new Error(`position-masters fetch failed: ${res.status}`);
|
||||
const data: PositionMaster[] = await res.json();
|
||||
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;
|
||||
};
|
||||
@@ -95,7 +107,35 @@ export const serializePosLookupForJs = (lookup: PositionLookup): string => {
|
||||
* Throws on network error or non-OK response.
|
||||
*/
|
||||
export const fetchMockTrainPositions = async (): Promise<any[]> => {
|
||||
const res = await fetch(MOCK_TRAIN_POSITIONS_URL);
|
||||
if (!res.ok) throw new Error(`mock train-positions fetch failed: ${res.status}`);
|
||||
return res.json();
|
||||
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,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;
|
||||
};
|
||||
@@ -2,6 +2,10 @@ 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 };
|
||||
|
||||
@@ -61,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import dayjs from "dayjs";
|
||||
import { checkDuplicateTrainData } from "@/lib/checkDuplicateTrainData";
|
||||
import { trainDataType, trainPosition } from "@/lib/trainPositionTextArray";
|
||||
import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
|
||||
import betweenData from "@/assets/originData/between";
|
||||
@@ -6,12 +7,15 @@ type trainDataProps = {
|
||||
d: eachTrainDiagramType;
|
||||
currentTrain: trainDataType[];
|
||||
station: StationProps[];
|
||||
stationList: StationProps[][];
|
||||
now?: string | null;
|
||||
};
|
||||
export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
|
||||
const { d, currentTrain, station } = props;
|
||||
const { d, currentTrain, station, stationList, now } = props;
|
||||
const baseTime = 2; // 何時間以内の列車を表示するか
|
||||
if (currentTrain.filter((t) => t.num == d.train).length == 0) {
|
||||
const date = dayjs();
|
||||
const currentTrainMatches = currentTrain.filter((t) => t.num == d.train);
|
||||
if (currentTrainMatches.length == 0) {
|
||||
const date = now ? dayjs(now) : dayjs();
|
||||
const trainTime = date
|
||||
.hour(parseInt(d.time.split(":")[0]))
|
||||
.minute(parseInt(d.time.split(":")[1]));
|
||||
@@ -23,7 +27,10 @@ export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
const Pos = trainPosition(currentTrain.filter((t) => t.num == d.train)[0]);
|
||||
const currentTrainData =
|
||||
checkDuplicateTrainData(currentTrainMatches, stationList) ??
|
||||
currentTrainMatches[0];
|
||||
const Pos = trainPosition(currentTrainData);
|
||||
let nextPos = "";
|
||||
let PrePos = "";
|
||||
//
|
||||
@@ -65,9 +72,9 @@ export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
|
||||
break;
|
||||
}
|
||||
const [h, m] = d.time.split(":");
|
||||
const delayData = currentTrain.filter((t) => t.num == d.train)[0].delay;
|
||||
const delayData = currentTrainData.delay;
|
||||
let delay = delayData === "入線" ? 0 : delayData;
|
||||
const date = dayjs();
|
||||
const date = now ? dayjs(now) : dayjs();
|
||||
const IntH = parseInt(h);
|
||||
const IntM = parseInt(m);
|
||||
const currentHour = date.hour();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const WRAPPABLE_TYPE_LENGTH = 4;
|
||||
const MIN_LONG_TRAIN_NAME_LENGTH = 8;
|
||||
|
||||
const countCharacters = (value: string) => Array.from(value).length;
|
||||
|
||||
const normalizeTrainName = (trainName: string) => trainName.replace(/\s+/g, "");
|
||||
|
||||
export const shouldWrapTrainType = (
|
||||
typeName: string,
|
||||
trainName: string,
|
||||
): boolean =>
|
||||
countCharacters(typeName) === WRAPPABLE_TYPE_LENGTH &&
|
||||
countCharacters(normalizeTrainName(trainName)) >= MIN_LONG_TRAIN_NAME_LENGTH;
|
||||
|
||||
export const formatWrappedTrainType = (typeName: string): string => {
|
||||
const chars = Array.from(typeName);
|
||||
|
||||
if (chars.length !== WRAPPABLE_TYPE_LENGTH) {
|
||||
return typeName;
|
||||
}
|
||||
|
||||
return `${chars.slice(0, 2).join("")}\n${chars.slice(2).join("")}`;
|
||||
};
|
||||
@@ -15,7 +15,7 @@ type WebViewRemountData = Record<string, string | number | boolean | null | unde
|
||||
|
||||
type UseWebViewRemountOptions = {
|
||||
pingEnabled?: boolean;
|
||||
backgroundThresholdMs?: number;
|
||||
backgroundThresholdMs?: number | null;
|
||||
isFocused?: boolean;
|
||||
pauseWatchdogWhenUnfocused?: boolean;
|
||||
ignoreProcessTerminationWhenUnfocused?: boolean;
|
||||
@@ -35,7 +35,7 @@ type UseWebViewRemountOptions = {
|
||||
*/
|
||||
export function useWebViewRemount(options?: UseWebViewRemountOptions) {
|
||||
const pingEnabled = options?.pingEnabled ?? false;
|
||||
const backgroundThresholdMs = options?.backgroundThresholdMs ?? 300_000; // デフォルト5分
|
||||
const backgroundThresholdMs = options?.backgroundThresholdMs ?? null;
|
||||
const isFocused = options?.isFocused ?? true;
|
||||
const pauseWatchdogWhenUnfocused = options?.pauseWatchdogWhenUnfocused ?? false;
|
||||
const ignoreProcessTerminationWhenUnfocused = options?.ignoreProcessTerminationWhenUnfocused ?? false;
|
||||
@@ -44,6 +44,9 @@ export function useWebViewRemount(options?: UseWebViewRemountOptions) {
|
||||
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());
|
||||
@@ -60,8 +63,25 @@ export function useWebViewRemount(options?: UseWebViewRemountOptions) {
|
||||
triggerRemount("manual");
|
||||
}, [triggerRemount]);
|
||||
|
||||
// バックグラウンドから復帰したら再マウント(デフォルト5分超)
|
||||
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();
|
||||
@@ -109,7 +129,7 @@ export function useWebViewRemount(options?: UseWebViewRemountOptions) {
|
||||
|
||||
const processHandlers = {
|
||||
onRenderProcessGone: (event?: any) => {
|
||||
if (ignoreProcessTerminationWhenUnfocused && !isFocused) {
|
||||
if (shouldIgnoreProcessTermination()) {
|
||||
ignoredProcessTerminationRef.current = "render_process_gone";
|
||||
lastPongAt.current = Date.now();
|
||||
return;
|
||||
@@ -117,7 +137,7 @@ export function useWebViewRemount(options?: UseWebViewRemountOptions) {
|
||||
triggerRemount("render_process_gone", { didCrash: event?.nativeEvent?.didCrash ?? null });
|
||||
},
|
||||
onContentProcessDidTerminate: () => {
|
||||
if (ignoreProcessTerminationWhenUnfocused && !isFocused) {
|
||||
if (shouldIgnoreProcessTermination()) {
|
||||
ignoredProcessTerminationRef.current = "content_process_terminated";
|
||||
lastPongAt.current = Date.now();
|
||||
return;
|
||||
|
||||
@@ -150,6 +150,8 @@ export const injectJavascriptData = ({
|
||||
const s = document.createElement('style');
|
||||
s.textContent = '@keyframes _jrs_blink{0%,55%{opacity:1}70%,90%{opacity:0}100%{opacity:1}}' +
|
||||
'@keyframes _jrs_glow{0%,55%{box-shadow:0 0 12px 3px rgba(255,0,0,0.9)}70%,90%{box-shadow:0 0 6px 2px rgba(255,0,0,0.45)}100%{box-shadow:0 0 12px 3px rgba(255,0,0,0.9)}}'
|
||||
+ '@keyframes _jrs_type_primary{0%,49.999%{opacity:1}50%,100%{opacity:0}}'
|
||||
+ '@keyframes _jrs_type_secondary{0%,49.999%{opacity:0}50%,99.999%{opacity:1}100%{opacity:0}}'
|
||||
+ (_isDark ? (
|
||||
'html,body{background-color:#3a3f47!important;}'
|
||||
+ '#main{background-color:#3a3f47!important;}'
|
||||
@@ -190,6 +192,7 @@ export const injectJavascriptData = ({
|
||||
// データ変数の宣言
|
||||
let stationList = {};
|
||||
let trainDataList = [];
|
||||
let trainDataListReady = false;
|
||||
let operationList = [];
|
||||
let trainDiagramData2 = {};
|
||||
let probremsData = [];
|
||||
@@ -198,7 +201,7 @@ export const injectJavascriptData = ({
|
||||
let elesiteData = [];
|
||||
const useEleSiteSetting = ${useElesite === "true"};
|
||||
|
||||
// 列車種別設定 (typeColor / borderColor / bgColor / label / isWanman)
|
||||
// 列車種別設定 (typeColor / borderColor / bgColor / label)
|
||||
const TRAIN_TYPE_CFG = ${JSON.stringify(TRAIN_TYPE_CONFIG)};
|
||||
|
||||
// operationList から列番が一致する運行情報を返すヘルパー
|
||||
@@ -257,11 +260,13 @@ export const injectJavascriptData = ({
|
||||
const DatalistUpdate = () => {
|
||||
fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json())
|
||||
.then(data => {
|
||||
if (hasChanged('trainDataList', data.data)) {
|
||||
trainDataList = data.data;
|
||||
_wcache.set('trainDataList', trainDataList);
|
||||
setReload();
|
||||
}
|
||||
const nextTrainDataList = Array.isArray(data?.data) ? data.data : null;
|
||||
if (!nextTrainDataList) return;
|
||||
trainDataListReady = true;
|
||||
if (!hasChanged('trainDataList', nextTrainDataList)) return;
|
||||
trainDataList = nextTrainDataList;
|
||||
_wcache.set('trainDataList', trainDataList);
|
||||
setReload();
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { setTimeout(DatalistUpdate, ${INTERVALS.DELAY_UPDATE}); });
|
||||
@@ -360,8 +365,12 @@ export const injectJavascriptData = ({
|
||||
Object.keys(_hashes).forEach(k => { _hashes[k] = null; });
|
||||
Promise.allSettled([
|
||||
fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json()).then(data => {
|
||||
trainDataList = data.data ?? trainDataList;
|
||||
_hashes['trainDataList'] = JSON.stringify(trainDataList);
|
||||
if (Array.isArray(data?.data)) {
|
||||
trainDataList = data.data;
|
||||
trainDataListReady = true;
|
||||
_hashes['trainDataList'] = JSON.stringify(trainDataList);
|
||||
_wcache.set('trainDataList', trainDataList);
|
||||
}
|
||||
}).catch(() => {}),
|
||||
fetch("${API_ENDPOINTS.OPERATION_LOGS}").then(r => r.json()).then(data => {
|
||||
const source = Array.isArray(data?.data) ? data.data : [];
|
||||
@@ -404,6 +413,7 @@ export const injectJavascriptData = ({
|
||||
if (_c0.operationList) { operationList = _c0.operationList; _hashes['operationList'] = JSON.stringify(_c0.operationList); anyHit = true; }
|
||||
if (_c0.probremsData) { probremsData = _c0.probremsData; _hashes['probremsData'] = JSON.stringify(_c0.probremsData); anyHit = true; }
|
||||
if (_c0.trainDataList) { trainDataList = _c0.trainDataList; _hashes['trainDataList'] = JSON.stringify(_c0.trainDataList); anyHit = true; }
|
||||
if (_c0.trainDataList) trainDataListReady = true;
|
||||
if (_c0.trainDiagramData2) { trainDiagramData2 = _c0.trainDiagramData2; _hashes['trainDiagramData2'] = JSON.stringify(_c0.trainDiagramData2); anyHit = true; }
|
||||
if (_c0.unyohubData) { unyohubData = _c0.unyohubData; anyHit = true; }
|
||||
if (_c0.elesiteData) { elesiteData = _c0.elesiteData; anyHit = true; }
|
||||
@@ -441,7 +451,9 @@ export const injectJavascriptData = ({
|
||||
// TRAIN_DATA_API: 0.84s/1MB, DIAGRAM_TODAY: 0.27s/522KB
|
||||
Promise.allSettled([
|
||||
fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json()).then(data => {
|
||||
trainDataList = data.data ?? [];
|
||||
if (!Array.isArray(data?.data)) return;
|
||||
trainDataList = data.data;
|
||||
trainDataListReady = true;
|
||||
_hashes['trainDataList'] = JSON.stringify(trainDataList);
|
||||
_wcache.set('trainDataList', trainDataList);
|
||||
}).catch(() => {}),
|
||||
@@ -643,13 +655,19 @@ export const injectJavascriptData = ({
|
||||
let heightData = "22px";
|
||||
let widthData = isHubDataset ? "18px" : "auto";
|
||||
let transformData = isHubDataset ? "scale(1.15)" : "none";
|
||||
let filterData = isHubDataset
|
||||
? "drop-shadow(0 0 0.8px rgba(255,255,255,0.75)) drop-shadow(0 1px 1px rgba(0,0,0,0.38)) drop-shadow(0 1px 1.5px rgba(0,0,0,0.18))"
|
||||
: "drop-shadow(0 1px 1.5px rgba(0,0,0,0.35))";
|
||||
if(backCount == 0){
|
||||
marginData = position ? ${uiSetting === "tokyo" ? `"0px 0px -10px 0px" : "-10px 0px 0px 0px"`: `"0px 2px 0px 0px" : "0px 2px 0px 0px"`};
|
||||
heightData = "16px";
|
||||
widthData = isHubDataset ? "13px" : "auto";
|
||||
if (isHubDataset) {
|
||||
filterData = "drop-shadow(0 0 0.6px rgba(255,255,255,0.7)) drop-shadow(0 1px 0.8px rgba(0,0,0,0.36)) drop-shadow(0 1px 1.2px rgba(0,0,0,0.16))";
|
||||
}
|
||||
}
|
||||
|
||||
setIconElem.insertAdjacentHTML('beforebegin', "<img src="+img+" style='float:"+(position ? 'left' : 'right')+";height:"+heightData+";width:"+widthData+";object-fit:contain;transform:"+transformData+";transform-origin:center center;margin: "+marginData+";background-color: "+backgroundColor+";'>");
|
||||
setIconElem.insertAdjacentHTML('beforebegin', "<img src="+img+" style='float:"+(position ? 'left' : 'right')+";height:"+heightData+";width:"+widthData+";object-fit:contain;transform:"+transformData+";transform-origin:center center;filter:"+filterData+";margin: "+marginData+";background-color: "+backgroundColor+";'>");
|
||||
if (backCount == 0 || backCount == 100) setIconElem.remove();
|
||||
}
|
||||
|
||||
@@ -677,10 +695,10 @@ export const injectJavascriptData = ({
|
||||
|
||||
const normal_train_name = `
|
||||
const nameReplace = (列車名データ,列番データ,行き先情報,hasProblem,isLeft) =>{
|
||||
let isWanman = false;
|
||||
let trainName = "";
|
||||
let trainType = "";
|
||||
let trainTypeColor = "black";
|
||||
let resolvedType = "";
|
||||
let viaData = "";
|
||||
let ToData = "";
|
||||
let TrainNumber = 列番データ;
|
||||
@@ -688,6 +706,21 @@ export const injectJavascriptData = ({
|
||||
let isSeason = false;
|
||||
let TrainNumberOverride;
|
||||
let optionalText = "";
|
||||
const registeredTrainData = trainDataList.find(e => String(e.train_id) === String(列番データ));
|
||||
|
||||
// 列車マスタに未登録でも在線カード自体は残す。
|
||||
// 推測した種別・行先等は表示せず、列番と車両アイコンだけの最小表示にする。
|
||||
if(trainDataListReady && !registeredTrainData){
|
||||
行き先情報.innerText = "";
|
||||
${uiSetting === "tokyo" ? `
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<div data-jrs-train-data='unavailable' style='width:100%;display:flex;flex:1;flex-direction:" + (isLeft ? "column-reverse" : "column") + ";font-weight:bold;-webkit-text-size-adjust:100%;text-size-adjust:100%;'><p style='font-size:6px;padding:0;color:black;text-align:center;'>" + 列番データ + "</p><div style='flex:1;'></div><p style='font-size:8px;font-weight:bold;padding:0;text-align:center;color:" + (hasProblem ? "red" : "black") + ";" + (hasProblem ? "animation:_jrs_blink 3s linear infinite;" : "") + "'>" + (hasProblem ? "‼️停止中‼️" : "") + "</p></div>");
|
||||
` : `
|
||||
var _missingTextColor = _isDark ? 'white' : 'black';
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<p data-jrs-train-data='unavailable' style='font-size:10px;padding:0;color:" + _missingTextColor + ";'>" + 列番データ + "</p>");
|
||||
`}
|
||||
行き先情報.remove();
|
||||
return;
|
||||
}
|
||||
try{
|
||||
const diagram = trainDiagramData2[列番データ] || trainTimeInfo[列番データ];
|
||||
if(diagram){
|
||||
@@ -705,9 +738,6 @@ export const injectJavascriptData = ({
|
||||
trainName = textBase;
|
||||
}
|
||||
|
||||
if(new RegExp(/^4[1-9]\\d\\d[DM]$/).test(列番データ) || new RegExp(/^5[1-7]\\d\\d[DM]$/).test(列番データ) || new RegExp(/^3[2-9]\\d\\d[DM]$/).test(TrainNumber) ){
|
||||
isWanman = true;
|
||||
}
|
||||
if(new RegExp(/^49[0-4]\\dD$/).test(列番データ) || new RegExp(/^9[0-4]\\dD$/).test(列番データ)){
|
||||
viaData = "(海経由)";
|
||||
}
|
||||
@@ -729,7 +759,6 @@ export const injectJavascriptData = ({
|
||||
case "4430D":
|
||||
case "4472D":
|
||||
viaData = "牟岐線直通";
|
||||
isWanman = true;
|
||||
ToData = "牟岐";
|
||||
break;
|
||||
case "434D":
|
||||
@@ -743,12 +772,10 @@ export const injectJavascriptData = ({
|
||||
case "4466D":
|
||||
case "4470D":
|
||||
viaData = "牟岐線直通";
|
||||
isWanman = true;
|
||||
ToData = "阿南";
|
||||
break;
|
||||
case "4456D":
|
||||
viaData = "牟岐線直通";
|
||||
isWanman = true;
|
||||
ToData = "阿波海南"
|
||||
break;
|
||||
//鳴門線発牟岐線行き
|
||||
@@ -768,7 +795,6 @@ export const injectJavascriptData = ({
|
||||
case "4350D":
|
||||
case "4368D":
|
||||
viaData = "高徳線直通";
|
||||
isWanman = true;
|
||||
break;
|
||||
|
||||
//牟岐線発徳島線行き
|
||||
@@ -782,7 +808,6 @@ export const injectJavascriptData = ({
|
||||
case "5471D":
|
||||
case "5479D":
|
||||
viaData = "徳島線直通";
|
||||
isWanman = true;
|
||||
break;
|
||||
//牟岐線発鳴門線行き
|
||||
|
||||
@@ -792,7 +817,6 @@ export const injectJavascriptData = ({
|
||||
case "4954D":
|
||||
case "4978D":
|
||||
viaData = "鳴門線直通";
|
||||
isWanman = true;
|
||||
break;
|
||||
|
||||
//安芸行と併結列車を個別に表示、それ以外をdefaultで下りなら既定の行き先を、上りなら奈半利行を設定
|
||||
@@ -839,11 +863,11 @@ export const injectJavascriptData = ({
|
||||
getThrew(列番データ);
|
||||
if(trainDataList.find(e => e.train_id === 列番データ) !== undefined){
|
||||
const data = trainDataList.find(e => e.train_id === 列番データ);
|
||||
resolvedType = data.type;
|
||||
|
||||
const _tCfg = TRAIN_TYPE_CFG[data.type];
|
||||
if (_tCfg) {
|
||||
trainTypeColor = _tCfg.typeColor;
|
||||
isWanman = _tCfg.isWanman;
|
||||
trainType = _tCfg.label;
|
||||
}
|
||||
isEdit = data.priority == 400;
|
||||
@@ -868,8 +892,9 @@ export const injectJavascriptData = ({
|
||||
optionalText = data.optional_text;
|
||||
}
|
||||
}
|
||||
const isOneManType = resolvedType === "OneMan" || resolvedType === "OneManRapid";
|
||||
//列番付与
|
||||
const returnText1 = (isWanman ? "ワンマン " : "") + trainName + viaData;
|
||||
const returnText1 = trainName + viaData;
|
||||
行き先情報.innerText = "";
|
||||
${uiSetting === "tokyo" ? `
|
||||
let stationIDs = [];
|
||||
@@ -944,6 +969,18 @@ export const injectJavascriptData = ({
|
||||
}
|
||||
|
||||
const optionalTextColor = optionalText.includes("最終") ? "red" : "black";
|
||||
const hasViaData = !!viaData && viaData.trim() !== "";
|
||||
const hasOptionalText = !!optionalText && optionalText.trim() !== "";
|
||||
const hasTrainName = !!trainName && trainName.trim() !== "";
|
||||
const hasToData = !!ToData && ToData.trim() !== "";
|
||||
const trainTypeTextStyle = "font-size:10px;font-weight:bold;font-style:italic;padding:0;margin:0;color:#ffffff;text-align:center;line-height:1;";
|
||||
const trainTypeFrameStyle = "position:relative;height:10px;width:100%;overflow:hidden;display:flex;align-items:center;justify-content:center;";
|
||||
const trainTypeMainStyle = trainTypeTextStyle + "display:flex;align-items:center;justify-content:center;width:100%;height:100%;";
|
||||
const trainTypeWanmanStyle = trainTypeMainStyle.replace("font-size:10px;", "font-size:9px;");
|
||||
const trainTypeHtml = isOneManType
|
||||
? "<div style='" + trainTypeFrameStyle + "'><p style='" + trainTypeMainStyle + "position:absolute;inset:0;animation:_jrs_type_primary 4s linear infinite;'>" + trainType + "</p><p style='" + trainTypeWanmanStyle + "position:absolute;inset:0;animation:_jrs_type_secondary 4s linear infinite;opacity:0;'>ワンマン</p></div>"
|
||||
: "<div style='" + trainTypeFrameStyle + "'><p style='" + trainTypeMainStyle + "'>" + trainType + "</p></div>";
|
||||
const trainTypeContainerPadding = isLeft ? "1px 0 2px 0" : "2px 0 1px 0";
|
||||
const unyohubLookupNum = TrainNumberOverride || 列番データ;
|
||||
const unyohubFormation = getUnyohubFormation(unyohubLookupNum);
|
||||
const hasUnyohub = unyohubFormation !== null;
|
||||
@@ -989,12 +1026,17 @@ export const injectJavascriptData = ({
|
||||
badgeHtml += "<div style='position:absolute;" + badgePosition + ":0;" + offsetStyle + "background-color:#44bb44;border-radius:50%;border:1px solid #228822;width:19px;height:19px;box-sizing:border-box;overflow:hidden;display:flex;align-items:center;justify-content:center;'><img src='https://storage.haruk.in/elesite_logo.jpg' style='width:19px;height:19px;'/></div>";
|
||||
}
|
||||
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<div style='width:100%;display:flex;flex:1;flex-direction:"+(isLeft ? "column-reverse" : "column") + ";font-weight:bold;'>" + badgeHtml + "<p style='font-size:6px;padding:0;color:black;text-align:center;'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p><div style='flex:1;'></div><p style='font-size:8px;font-weight:bold;padding:0;color:black;text-align:center;'>" + (isWanman ? "ワンマン " : "") + "</p><p style='font-size:6px;font-weight:bold;padding:0;color:black;text-align:center;border-style:solid;border-width: "+(!!yosan2Color ? "2px" : "0px")+";border-color:" + yosan2Color + "'>" + viaData + "</p><p style='font-size:8px;font-weight:bold;padding:0;color: " + optionalTextColor + ";text-align:center;'>" + optionalText + "</p><p style='font-size:8px;font-weight:bold;padding:0;color:black;text-align:center;'>" + trainName + "</p><div style='width:100%;background:" + gradient + ";'><p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:white;align-items:center;align-content:center;text-align:center;text-shadow:1px 1px 0px #00000030, -1px -1px 0px #00000030,-1px 1px 0px #00000030, 1px -1px 0px #00000030,1px 0px 0px #00000030, -1px 0px 0px #00000030,0px 1px 0px #00000030, 0px -1px 0px #00000030;'>" + (ToData ? ToData + "行" : ToData) + "</p></div><div style='width:100%;background:" + trainTypeColor + ";border-radius:"+(isLeft ? "4px 4px 0 0" : "0 0 4px 4px")+";'><p style='font-size:10px;font-weight:bold;font-style:italic;padding:0;color: white;text-align:center;'>" + trainType + "</p></div><p style='font-size:8px;font-weight:bold;padding:0;text-align:center;color: "+(hasProblem ? "red": "black")+"; "+(hasProblem ? "animation:_jrs_blink 3s linear infinite;" : "")+"'>" + (hasProblem ? "‼️停止中‼️" : "") + "</p></div>");
|
||||
const viaDataHtml = hasViaData ? "<p style='font-size:6px;font-weight:bold;padding:0;margin:0;color:black;text-align:center;border-style:solid;border-width: " + (!!yosan2Color ? "2px" : "0px") + ";border-color:" + yosan2Color + "'>" + viaData + "</p>" : "";
|
||||
const optionalTextHtml = hasOptionalText ? "<p style='font-size:8px;font-weight:bold;padding:0;margin:0;color: " + optionalTextColor + ";text-align:center;'>" + optionalText + "</p>" : "";
|
||||
const trainNameHtml = hasTrainName ? "<p style='font-size:8px;font-weight:bold;padding:0;margin:0;color:black;text-align:center;'>" + trainName + "</p>" : "";
|
||||
const toDataHtml = hasToData ? "<div style='width:100%;min-height:13px;height:auto;box-sizing:border-box;display:flex;align-items:center;justify-content:center;background:" + gradient + ";padding:1px 0;overflow:visible;'><p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:white;line-height:1.1;text-align:center;text-shadow:1px 1px 0px #00000030, -1px -1px 0px #00000030,-1px 1px 0px #00000030, 1px -1px 0px #00000030,1px 0px 0px #00000030, -1px 0px 0px #00000030,0px 1px 0px #00000030, 0px -1px 0px #00000030;'>" + ToData + "行</p></div>" : "";
|
||||
const problemHtml = hasProblem ? "<p style='font-size:8px;font-weight:bold;padding:0;margin:0;text-align:center;color:red;animation:_jrs_blink 3s linear infinite;'>‼️停止中‼️</p>" : "";
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<div style='width:100%;display:flex;flex:1;flex-direction:"+(isLeft ? "column-reverse" : "column") + ";font-weight:bold;-webkit-text-size-adjust:100%;text-size-adjust:100%;'>" + badgeHtml + "<p style='font-size:6px;padding:0;margin:0;color:black;text-align:center;'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p><div style='flex:1;'></div>" + viaDataHtml + optionalTextHtml + trainNameHtml + toDataHtml + "<div style='width:100%;height:13px;box-sizing:border-box;background:" + trainTypeColor + ";border-radius:"+(isLeft ? "4px 4px 0 0" : "0 0 4px 4px")+";display:flex;align-items:center;justify-content:center;padding:" + trainTypeContainerPadding + ";margin:0;overflow:hidden;'>" + trainTypeHtml + "</div>" + problemHtml + "</div>");
|
||||
`: `
|
||||
var _defTextColor = _isDark ? 'white' : 'black';
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<p style='font-size:10px;font-weight:bold;padding:0;color:" + _defTextColor + ";'>" + returnText1 + "</p>");
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<div style='display:inline-flex;flex-direction:row;'><p style='font-size:10px;font-weight: bold;padding:0;color:" + _defTextColor + ";'>" + (ToData ? ToData + "行 " : ToData) + "</p><p style='font-size:10px;padding:0;color:" + _defTextColor + ";'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p></div>");
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<p style='font-size:10px;font-weight:bold;padding:0;color: "+(hasProblem ? "red": _defTextColor)+"; "+(hasProblem ? "animation:_jrs_blink 3s linear infinite;" : "")+"'>" + (hasProblem ? "‼️停止中‼️" : "") + "</p>");
|
||||
if(returnText1){ 行き先情報.insertAdjacentHTML('beforebegin', "<p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:" + _defTextColor + ";'>" + returnText1 + "</p>"); }
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<div style='display:inline-flex;flex-direction:row;'><p style='font-size:10px;font-weight: bold;padding:0;margin:0;color:" + _defTextColor + ";'>" + (ToData ? ToData + "行 " : "") + "</p><p style='font-size:10px;padding:0;margin:0;color:" + _defTextColor + ";'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p></div>");
|
||||
if(hasProblem){ 行き先情報.insertAdjacentHTML('beforebegin', "<p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:red;animation:_jrs_blink 3s linear infinite;'>‼️停止中‼️</p>"); }
|
||||
`}
|
||||
}
|
||||
`;
|
||||
@@ -1014,13 +1056,7 @@ const setNewTrainItem = (element,hasProblem,type)=>{
|
||||
element.style.backgroundColor = _bgc;
|
||||
}else{
|
||||
element.style.backgroundColor = '#ffffffff';
|
||||
if(element.getAttribute('offclick').includes("express")){
|
||||
element.style.borderColor = '#ff0000ff';
|
||||
}else if(element.getAttribute('offclick').includes("rapid")){
|
||||
element.style.borderColor = '#008cffff';
|
||||
}else{
|
||||
element.style.borderColor = _isDark ? '#30363d' : 'black';
|
||||
}
|
||||
element.style.borderColor = _isDark ? '#30363d' : 'black';
|
||||
}
|
||||
element.style.borderWidth = _isDark ? '1px' : '2px';
|
||||
element.style.borderStyle = 'solid';
|
||||
|
||||
@@ -5,31 +5,29 @@
|
||||
* - borderColor: setNewTrainItem で使う枠線色
|
||||
* - bgColor : setNewTrainItem で使う背景色
|
||||
* - label : 種別ラベル文字列
|
||||
* - isWanman : ワンマン列車かどうか
|
||||
*/
|
||||
export interface TrainTypeConfig {
|
||||
label: string;
|
||||
typeColor: string;
|
||||
borderColor: string;
|
||||
bgColor: string;
|
||||
isWanman: boolean;
|
||||
}
|
||||
|
||||
export const TRAIN_TYPE_CONFIG: Record<string, TrainTypeConfig> = {
|
||||
Normal: { label: "普通", typeColor: "black", borderColor: "black", bgColor: "#ffffffcc", isWanman: false },
|
||||
OneMan: { label: "普通", typeColor: "black", borderColor: "black", bgColor: "#ffffffcc", isWanman: true },
|
||||
Rapid: { label: "快速", typeColor: "rgba(0, 140, 255, 1)", borderColor: "rgba(0, 140, 255, 1)", bgColor: "#ffffffcc", isWanman: false },
|
||||
OneManRapid: { label: "快速", typeColor: "rgba(0, 140, 255, 1)", borderColor: "rgba(0, 140, 255, 1)", bgColor: "#ffffffcc", isWanman: true },
|
||||
LTDEXP: { label: "特急", typeColor: "red", borderColor: "red", bgColor: "#ffffffcc", isWanman: false },
|
||||
NightLTDEXP: { label: "寝台特急", typeColor: "#d300b0ff", borderColor: "#d300b0ff", bgColor: "#ffffffcc", isWanman: false },
|
||||
SPCL: { label: "臨時", typeColor: "#008d07ff", borderColor: "#008d07ff", bgColor: "#ffffffcc", isWanman: false },
|
||||
SPCL_Normal: { label: "臨時", typeColor: "#008d07ff", borderColor: "#008d07ff", bgColor: "#ffffffcc", isWanman: false },
|
||||
SPCL_Rapid: { label: "臨時快速", typeColor: "rgba(0, 81, 255, 1)", borderColor: "#0051ffff", bgColor: "#ffffffcc", isWanman: false },
|
||||
SPCL_EXP: { label: "臨時特急", typeColor: "#a52e2eff", borderColor: "#a52e2eff", bgColor: "#ffffffcc", isWanman: false },
|
||||
Party: { label: "団体", typeColor: "#ff7300ff", borderColor: "#ff7300ff", bgColor: "#ffd0a9ff", isWanman: false },
|
||||
Freight: { label: "貨物", typeColor: "#00869ecc", borderColor: "#00869ecc", bgColor: "#c7c7c7cc", isWanman: false },
|
||||
Forwarding: { label: "回送", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc", isWanman: false },
|
||||
Trial: { label: "試運転", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc", isWanman: false },
|
||||
Construction: { label: "工事", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc", isWanman: false },
|
||||
FreightForwarding: { label: "単機回送", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc", isWanman: false },
|
||||
Normal: { label: "普通", typeColor: "black", borderColor: "black", bgColor: "#ffffffcc" },
|
||||
OneMan: { label: "普通", typeColor: "black", borderColor: "black", bgColor: "#ffffffcc" },
|
||||
Rapid: { label: "快速", typeColor: "rgba(0, 140, 255, 1)", borderColor: "rgba(0, 140, 255, 1)", bgColor: "#ffffffcc" },
|
||||
OneManRapid: { label: "快速", typeColor: "rgba(0, 140, 255, 1)", borderColor: "rgba(0, 140, 255, 1)", bgColor: "#ffffffcc" },
|
||||
LTDEXP: { label: "特急", typeColor: "red", borderColor: "red", bgColor: "#ffffffcc" },
|
||||
NightLTDEXP: { label: "寝台特急", typeColor: "#d300b0ff", borderColor: "#d300b0ff", bgColor: "#ffffffcc" },
|
||||
SPCL: { label: "臨時", typeColor: "#008d07ff", borderColor: "#008d07ff", bgColor: "#ffffffcc" },
|
||||
SPCL_Normal: { label: "臨時", typeColor: "#008d07ff", borderColor: "#008d07ff", bgColor: "#ffffffcc" },
|
||||
SPCL_Rapid: { label: "臨時快速", typeColor: "rgba(0, 81, 255, 1)", borderColor: "#0051ffff", bgColor: "#ffffffcc" },
|
||||
SPCL_EXP: { label: "臨時特急", typeColor: "#a52e2eff", borderColor: "#a52e2eff", bgColor: "#ffffffcc" },
|
||||
Party: { label: "団体", typeColor: "#ff7300ff", borderColor: "#ff7300ff", bgColor: "#ffd0a9ff" },
|
||||
Freight: { label: "貨物", typeColor: "#00869ecc", borderColor: "#00869ecc", bgColor: "#c7c7c7cc" },
|
||||
Forwarding: { label: "回送", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc" },
|
||||
Trial: { label: "試運転", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc" },
|
||||
Construction: { label: "工事", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc" },
|
||||
FreightForwarding: { label: "単機回送", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc" },
|
||||
};
|
||||
|
||||
@@ -44,6 +44,8 @@ type props = {
|
||||
};
|
||||
|
||||
const MAP_PIN_SIZE = Platform.OS === "android" ? 32 : 36;
|
||||
const ANDROID_USER_LOCATION_MARKER_SIZE = 18;
|
||||
const ANDROID_USER_LOCATION_INNER_SIZE = 8;
|
||||
|
||||
export const Menu: FC<props> = (props) => {
|
||||
const { scrollRef, mapHeight, MapFullHeight, mapMode, setMapMode } = props;
|
||||
@@ -381,7 +383,7 @@ export const Menu: FC<props> = (props) => {
|
||||
<MapView
|
||||
ref={mapsRef}
|
||||
style={{ width: "100%", height: mapMode ? MapFullHeight : mapHeight }}
|
||||
showsUserLocation={true}
|
||||
showsUserLocation={Platform.OS !== "android"}
|
||||
loadingEnabled={true}
|
||||
showsMyLocationButton={false}
|
||||
moveOnMarkerPress={false}
|
||||
@@ -410,6 +412,42 @@ export const Menu: FC<props> = (props) => {
|
||||
goToMap();
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
{listUpStation.map(([{ lat, lng, StationNumber }], index) => (
|
||||
<Marker
|
||||
key={index + StationNumber}
|
||||
@@ -479,7 +517,7 @@ export const Menu: FC<props> = (props) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{originalStationList.length != 0 && isHeavyContentReady && (
|
||||
{Object.keys(originalStationList).length !== 0 && isHeavyContentReady && (
|
||||
<>
|
||||
<CarouselBox
|
||||
{...{
|
||||
|
||||
+817
-63
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -6,6 +6,7 @@
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"eject": "expo eject",
|
||||
"postinstall": "patch-package",
|
||||
"pushWeb": "npx expo export -p web && netlify deploy --dir dist --prod",
|
||||
"checkDiagram": "bash ./check.sh"
|
||||
},
|
||||
@@ -106,7 +107,8 @@
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.14",
|
||||
"babel-preset-expo": "~55.0.12",
|
||||
"baseline-browser-mapping": "^2.10.9"
|
||||
"baseline-browser-mapping": "^2.10.9",
|
||||
"patch-package": "^8.0.1"
|
||||
},
|
||||
"private": true,
|
||||
"name": "jrshikoku",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
diff --git a/node_modules/@react-navigation/bottom-tabs/src/views/BottomTabView.tsx b/node_modules/@react-navigation/bottom-tabs/src/views/BottomTabView.tsx
|
||||
index ec0491e..c546b88 100644
|
||||
--- a/node_modules/@react-navigation/bottom-tabs/src/views/BottomTabView.tsx
|
||||
+++ b/node_modules/@react-navigation/bottom-tabs/src/views/BottomTabView.tsx
|
||||
@@ -318,7 +318,12 @@ export function BottomTabView(props: Props) {
|
||||
return (
|
||||
<MaybeScreen
|
||||
key={route.key}
|
||||
- style={[StyleSheet.absoluteFill, { zIndex: isFocused ? 0 : -1 }]}
|
||||
+ style={[
|
||||
+ StyleSheet.absoluteFill,
|
||||
+ Platform.OS === 'android'
|
||||
+ ? { opacity: isFocused ? 1 : 0 }
|
||||
+ : { zIndex: isFocused ? 0 : -1 },
|
||||
+ ]}
|
||||
active={activityState}
|
||||
enabled={detachInactiveScreens}
|
||||
freezeOnBlur={freezeOnBlur}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getDiagramTodayUrl,
|
||||
BACKEND_API_BASE_URLS,
|
||||
} from "@/lib/jrDataSystemEnvironment";
|
||||
import { observedFetchJson } from "@/lib/observability/network/observedFetch";
|
||||
const initialState = {
|
||||
allTrainDiagram: {},
|
||||
setAllTrainDiagram: (e) => {},
|
||||
@@ -51,10 +52,10 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
|
||||
setKeyList(Object.keys(allTrainDiagram));
|
||||
else setKeyList([]);
|
||||
}, [allTrainDiagram]);
|
||||
const [backendApiBaseUrl, setBackendApiBaseUrl] = React.useState(
|
||||
const [backendApiBaseUrl, setBackendApiBaseUrl] = React.useState<string>(
|
||||
BACKEND_API_BASE_URLS.production,
|
||||
);
|
||||
const [diagramTodayUrl, setDiagramTodayUrl] = React.useState(
|
||||
const [diagramTodayUrl, setDiagramTodayUrl] = React.useState<string>(
|
||||
"https://jr-shikoku-api-data-storage.haruk.in/tmp/diagram-today.json",
|
||||
);
|
||||
|
||||
@@ -68,10 +69,16 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
|
||||
}, []);
|
||||
|
||||
const getTrainDiagram = () => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||||
fetch(diagramTodayUrl, { signal: controller.signal })
|
||||
.then((res) => res.json())
|
||||
observedFetchJson<any[]>(diagramTodayUrl, {
|
||||
endpoint: "timetable_today",
|
||||
source: "static_storage",
|
||||
userVisible: false,
|
||||
preload: true,
|
||||
fetchPriority: "medium",
|
||||
timeoutMs: 15000,
|
||||
retry: true,
|
||||
urlPathTemplate: "/tmp/diagram-today.json",
|
||||
})
|
||||
.then((res) => {
|
||||
const data = {};
|
||||
res.forEach((d) => {
|
||||
@@ -101,8 +108,7 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
|
||||
.catch(() => {
|
||||
alert("初回の路線情報の取得に失敗しました。");
|
||||
});
|
||||
})
|
||||
.finally(() => clearTimeout(timeoutId));
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -111,15 +117,20 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
|
||||
useInterval(getTrainDiagram, 30000, true, true); //30秒毎に全在線列車取得(バックグラウンドでも継続)
|
||||
|
||||
const getCustomTrainData = () => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||||
fetch(`${backendApiBaseUrl}/train-data`, { signal: controller.signal })
|
||||
.then((res) => res.json())
|
||||
observedFetchJson<{ data: CustomTrainData[] }>(`${backendApiBaseUrl}/train-data`, {
|
||||
endpoint: "train_operation_data",
|
||||
source: "backend_api",
|
||||
userVisible: false,
|
||||
preload: true,
|
||||
fetchPriority: "medium",
|
||||
timeoutMs: 15000,
|
||||
retry: true,
|
||||
urlPathTemplate: "/train-data",
|
||||
})
|
||||
.then((res) => {
|
||||
setAllCustomTrainData(res.data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => clearTimeout(timeoutId));
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -130,10 +141,16 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
|
||||
|
||||
const [todayOperation, setTodayOperation] = useState<OperationLogs[]>([]); // 本日の運行情報
|
||||
const getTodayOperation = () => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||||
fetch(`${backendApiBaseUrl}/operation-logs`, { signal: controller.signal })
|
||||
.then((res) => res.json())
|
||||
observedFetchJson<{ data: OperationLogs[] | null }>(`${backendApiBaseUrl}/operation-logs`, {
|
||||
endpoint: "operation_logs",
|
||||
source: "backend_api",
|
||||
userVisible: false,
|
||||
preload: true,
|
||||
fetchPriority: "medium",
|
||||
timeoutMs: 15000,
|
||||
retry: true,
|
||||
urlPathTemplate: "/operation-logs",
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data === null) {
|
||||
setTodayOperation([]);
|
||||
@@ -141,8 +158,7 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
|
||||
}
|
||||
setTodayOperation(res.data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => clearTimeout(timeoutId));
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+77
-21
@@ -3,9 +3,12 @@ import React, {
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
FC,
|
||||
} from "react";
|
||||
import { InteractionManager } from "react-native";
|
||||
import useInterval from "../lib/useInterval";
|
||||
import { observedFetchJson, observedFetchText } from "@/lib/observability/network/observedFetch";
|
||||
|
||||
const setoStationID = [
|
||||
"Y00",
|
||||
@@ -361,41 +364,94 @@ export const AreaInfoProvider: FC<props> = ({ children }) => {
|
||||
const [areaIconBadgeText, setAreaIconBadgeText] = useState("");
|
||||
const [areaStationID, setAreaStationID] = useState([]);
|
||||
const [isInfo, setIsInfo] = useState(false);
|
||||
const getAreaData = () => {
|
||||
fetch(
|
||||
"https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec"
|
||||
const areaDescriptionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initialFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fetchAreaDescription = () => {
|
||||
observedFetchText(
|
||||
"https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec",
|
||||
{
|
||||
endpoint: "operation_info_text",
|
||||
source: "gas",
|
||||
userVisible: true,
|
||||
preload: false,
|
||||
fetchPriority: "medium",
|
||||
expectedContentType: "text",
|
||||
timeoutMs: 15000,
|
||||
retry: false,
|
||||
urlPathTemplate: "/macros/s/AKfy.../exec",
|
||||
}
|
||||
)
|
||||
.then((d) => d.text())
|
||||
.then((d) => setAreaInfo(d));
|
||||
fetch("https://n8n.haruk.in/webhook/jr-shikoku-trainfo-flag")
|
||||
.then((d) => d.json())
|
||||
.then((d) => setAreaInfo(d))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const scheduleAreaDescriptionFetch = () => {
|
||||
if (areaDescriptionTimeoutRef.current) {
|
||||
clearTimeout(areaDescriptionTimeoutRef.current);
|
||||
}
|
||||
areaDescriptionTimeoutRef.current = setTimeout(() => {
|
||||
areaDescriptionTimeoutRef.current = null;
|
||||
fetchAreaDescription();
|
||||
}, 800);
|
||||
};
|
||||
|
||||
const getAreaData = () => {
|
||||
observedFetchJson<any>("https://n8n.haruk.in/webhook/jr-shikoku-trainfo-flag", {
|
||||
endpoint: "operation_info_flag",
|
||||
source: "n8n",
|
||||
userVisible: true,
|
||||
preload: true,
|
||||
fetchPriority: "medium",
|
||||
timeoutMs: 10000,
|
||||
retry: true,
|
||||
urlPathTemplate: "/webhook/jr-shikoku-trainfo-flag",
|
||||
})
|
||||
.then((d) => {
|
||||
if (!d.data) return;
|
||||
const lineInfo = d.data.filter((e) => e.area != "genelic");
|
||||
const genelicInfo = d.data.filter((e) => e.area == "genelic");
|
||||
const text = lineInfo
|
||||
.filter((e) => e.status)
|
||||
.map((e) => {
|
||||
return `${areaStationPair[e.area].id}`;
|
||||
});
|
||||
const activeLineInfo = lineInfo.filter((e) => e.status);
|
||||
const text = activeLineInfo.map((e) => {
|
||||
return `${areaStationPair[e.area].id}`;
|
||||
});
|
||||
let stationIDList = [];
|
||||
lineInfo
|
||||
.filter((e) => e.status)
|
||||
.forEach((e) => {
|
||||
stationIDList = stationIDList.concat(
|
||||
areaStationPair[e.area].stationID
|
||||
);
|
||||
});
|
||||
activeLineInfo.forEach((e) => {
|
||||
stationIDList = stationIDList.concat(
|
||||
areaStationPair[e.area].stationID
|
||||
);
|
||||
});
|
||||
const info = genelicInfo[0].status.includes("nodelay") ? true : false;
|
||||
setIsInfo(info);
|
||||
setAreaStationID(stationIDList);
|
||||
setAreaIconBadgeText(
|
||||
text.length == 0 ? (info ? "i" : "!") : text.join(",")
|
||||
);
|
||||
});
|
||||
if (stationIDList.length > 0) {
|
||||
scheduleAreaDescriptionFetch();
|
||||
} else {
|
||||
setAreaInfo("");
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
useEffect(() => {
|
||||
getAreaData();
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
initialFetchTimeoutRef.current = setTimeout(() => {
|
||||
initialFetchTimeoutRef.current = null;
|
||||
getAreaData();
|
||||
}, 1200);
|
||||
});
|
||||
return () => {
|
||||
task.cancel?.();
|
||||
if (initialFetchTimeoutRef.current) {
|
||||
clearTimeout(initialFetchTimeoutRef.current);
|
||||
initialFetchTimeoutRef.current = null;
|
||||
}
|
||||
if (areaDescriptionTimeoutRef.current) {
|
||||
clearTimeout(areaDescriptionTimeoutRef.current);
|
||||
areaDescriptionTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
useInterval(getAreaData, 60000); //60秒毎に全在線列車取得
|
||||
return (
|
||||
|
||||
@@ -19,6 +19,7 @@ import { MOCK_TRAIN_POSITIONS } from "@/lib/mockApi";
|
||||
import { fetchMockTrainPositions } from "@/lib/mockApi/positionMasters";
|
||||
import WebView from "react-native-webview";
|
||||
import { StationProps } from "@/lib/CommonTypes";
|
||||
import { observedFetchJson } from "@/lib/observability/network/observedFetch";
|
||||
type loading = "loading" | "success" | "error";
|
||||
const initialState = {
|
||||
webview: undefined,
|
||||
@@ -129,7 +130,7 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
|
||||
let nearest: StationProps | null = null;
|
||||
let nearestDist = Infinity;
|
||||
Object.keys(originalStationList).forEach((lineName) => {
|
||||
(originalStationList as any)[lineName]?.forEach((station: StationProps) => {
|
||||
originalStationList[lineName]?.forEach((station) => {
|
||||
const d = _calcDistance({ lat: station.lat, lng: station.lng }, userPos);
|
||||
if (d < nearestDist) {
|
||||
nearestDist = d;
|
||||
@@ -305,15 +306,21 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
|
||||
setCurrentTrainLoading("success");
|
||||
})
|
||||
.catch(() => {
|
||||
setCurrentTrainLoading("error");
|
||||
setCurrentTrainLoading(currentTrain.length > 0 ? "success" : "error");
|
||||
})
|
||||
.finally(() => clearTimeout(timeoutId));
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
fetch("https://n8n.haruk.in/webhook/c501550c-7d1b-4e50-927b-4429fe18931a", { signal: controller.signal })
|
||||
.then((response) => response.json())
|
||||
observedFetchJson<any>("https://n8n.haruk.in/webhook/c501550c-7d1b-4e50-927b-4429fe18931a", {
|
||||
endpoint: "positions_current",
|
||||
source: "n8n",
|
||||
userVisible: true,
|
||||
preload: false,
|
||||
fetchPriority: "high",
|
||||
timeoutMs: 8000,
|
||||
retry: true,
|
||||
urlPathTemplate: "/webhook/c501550c-7d1b-4e50-927b-4429fe18931a",
|
||||
})
|
||||
.then((d) => d.data)
|
||||
.then((d) =>
|
||||
d.map((x) => ({
|
||||
@@ -343,13 +350,20 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
|
||||
})));
|
||||
})
|
||||
.catch(() => {
|
||||
const fallbackController = new AbortController();
|
||||
const fallbackTimeoutId = setTimeout(() => fallbackController.abort(), 8000);
|
||||
fetch(
|
||||
observedFetchJson<any[]>(
|
||||
"https://script.google.com/macros/s/AKfycby9Y2-Bm75J_WkbZimi7iS8v5r9wMa9wtzpdwES9sOGF4i6HIYEJOM60W6gM1gXzt1o/exec",
|
||||
{ ...HeaderConfig, signal: fallbackController.signal }
|
||||
{
|
||||
...HeaderConfig,
|
||||
endpoint: "positions_gas_fallback",
|
||||
source: "gas",
|
||||
userVisible: true,
|
||||
preload: false,
|
||||
fetchPriority: "high",
|
||||
timeoutMs: 8000,
|
||||
retry: true,
|
||||
urlPathTemplate: "/macros/s/AKfyc.../exec",
|
||||
}
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((d) =>
|
||||
d.map((x) => ({ num: x.TrainNum, delay: x.delay, Pos: x.Pos }))
|
||||
)
|
||||
@@ -367,12 +381,9 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
|
||||
setCurrentTrainLoading("success");
|
||||
})
|
||||
.catch(() => {
|
||||
// エラー時の処理
|
||||
setCurrentTrainLoading("error");
|
||||
})
|
||||
.finally(() => clearTimeout(fallbackTimeoutId));
|
||||
})
|
||||
.finally(() => clearTimeout(timeoutId));
|
||||
setCurrentTrainLoading(currentTrain.length > 0 ? "success" : "error");
|
||||
});
|
||||
});
|
||||
};
|
||||
const inject = (i) => {
|
||||
webview.current?.injectJavaScript(i);
|
||||
|
||||
@@ -11,7 +11,12 @@ import * as Notifications from "expo-notifications";
|
||||
import * as Device from "expo-device";
|
||||
import Constants from "expo-constants";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { rootNavigationRef, stackAwareNavigate } from "@/lib/rootNavigation";
|
||||
import {
|
||||
markStartupExplicitTarget,
|
||||
resolveStartupNavigationSource,
|
||||
rootNavigationRef,
|
||||
stackAwareNavigate,
|
||||
} from "@/lib/rootNavigation";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { AS } from "@/storageControl";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
@@ -209,6 +214,8 @@ export const NotificationProvider: FC<Props> = ({ children }) => {
|
||||
const action = resolveNotificationRoute(response);
|
||||
if (!action) return;
|
||||
|
||||
markStartupExplicitTarget();
|
||||
|
||||
if (!rootNavigationRef.isReady()) {
|
||||
if (retryCount < 8) {
|
||||
setTimeout(() => routeFromNotification(response, retryCount + 1), 300);
|
||||
@@ -263,11 +270,15 @@ export const NotificationProvider: FC<Props> = ({ children }) => {
|
||||
// キーが存在しない場合は初回起動なので何もしない
|
||||
})
|
||||
.finally(() => {
|
||||
Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||
if (response) {
|
||||
routeFromNotification(response);
|
||||
}
|
||||
});
|
||||
Notifications.getLastNotificationResponseAsync()
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
routeFromNotification(response);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
resolveStartupNavigationSource();
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
getStationList,
|
||||
lineList_LineWebID,
|
||||
} from "@/lib/getStationList";
|
||||
import { StationProps } from "@/lib/CommonTypes";
|
||||
import { OriginalStationList, StationProps } from "@/lib/CommonTypes";
|
||||
|
||||
type initialStateType = {
|
||||
originalStationList: StationProps[][];
|
||||
setOriginalStationList: React.Dispatch<React.SetStateAction<StationProps[][]>>;
|
||||
originalStationList: OriginalStationList;
|
||||
setOriginalStationList: React.Dispatch<
|
||||
React.SetStateAction<OriginalStationList>
|
||||
>;
|
||||
getStationDataFromName: (id: string) => StationProps[];
|
||||
getStationDataFromId: (id: string) => StationProps[];
|
||||
getStationDataFromNameBase: (name: string) => StationProps[];
|
||||
@@ -27,7 +29,7 @@ type initialStateType = {
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
originalStationList: [[]],
|
||||
originalStationList: {},
|
||||
setOriginalStationList: () => {},
|
||||
getStationDataFromName: () => [],
|
||||
getStationDataFromId: () => [],
|
||||
@@ -51,7 +53,8 @@ export const useStationList = () => {
|
||||
};
|
||||
|
||||
export const StationListProvider: FC<Props> = ({ children }) => {
|
||||
const [originalStationList, setOriginalStationList] = useState<StationProps[][]>([]);
|
||||
const [originalStationList, setOriginalStationList] =
|
||||
useState<OriginalStationList>({});
|
||||
useEffect(() => {
|
||||
getStationList().then(setOriginalStationList);
|
||||
}, []);
|
||||
@@ -105,9 +108,10 @@ export const StationListProvider: FC<Props> = ({ children }) => {
|
||||
|
||||
const [stationList, setStationList] = useState<StationProps[][]>([[]]);
|
||||
useEffect(() => {
|
||||
if (originalStationList.length === 0) return;
|
||||
if (Object.keys(originalStationList).length === 0) return;
|
||||
const stationList = lineList.map((d) =>
|
||||
originalStationList[d].map((a) => ({
|
||||
...a,
|
||||
StationNumber: a.StationNumber,
|
||||
StationName: a.Station_JP,
|
||||
}))
|
||||
|
||||
@@ -6,16 +6,18 @@ import React, {
|
||||
FC,
|
||||
} from "react";
|
||||
import { lineList, getStationList } from "../lib/getStationList";
|
||||
import { StationProps } from "@/lib/CommonTypes";
|
||||
import { OriginalStationList, StationProps } from "@/lib/CommonTypes";
|
||||
|
||||
type initialStateType = {
|
||||
originalStationList: StationProps[][];
|
||||
setOriginalStationList: React.Dispatch<React.SetStateAction<StationProps[][]>>;
|
||||
originalStationList: OriginalStationList;
|
||||
setOriginalStationList: React.Dispatch<
|
||||
React.SetStateAction<OriginalStationList>
|
||||
>;
|
||||
getStationData: (id: string) => StationProps[];
|
||||
stationList: { StationNumber: string; StationName: string }[][];
|
||||
};
|
||||
const initialState = {
|
||||
originalStationList: [[]],
|
||||
originalStationList: {},
|
||||
setOriginalStationList: () => {},
|
||||
getStationData: () => [],
|
||||
stationList: [],
|
||||
@@ -30,7 +32,8 @@ export const useTopMenu = () => {
|
||||
};
|
||||
|
||||
export const TopMenuProvider: FC<Props> = ({ children }) => {
|
||||
const [originalStationList, setOriginalStationList] = useState<StationProps[][]>([]);
|
||||
const [originalStationList, setOriginalStationList] =
|
||||
useState<OriginalStationList>({});
|
||||
useEffect(() => {
|
||||
getStationList().then(setOriginalStationList);
|
||||
}, []);
|
||||
@@ -47,7 +50,7 @@ export const TopMenuProvider: FC<Props> = ({ children }) => {
|
||||
};
|
||||
const [stationList, setStationList] = useState<{ StationNumber: string; StationName: string }[][]>([[]]);
|
||||
useEffect(()=>{
|
||||
if(originalStationList.length === 0) return;
|
||||
if (Object.keys(originalStationList).length === 0) return;
|
||||
const stationList =
|
||||
originalStationList &&
|
||||
lineList.map((d) =>
|
||||
|
||||
@@ -107,6 +107,8 @@ const initialState = {
|
||||
activeRecording: null as TrainRecording | null,
|
||||
/** 現在再生中のスナップショットインデックス */
|
||||
playbackIndex: 0,
|
||||
/** 再生中スナップショットに対応する基準時刻(通常時は null) */
|
||||
playbackCurrentTimeIso: null as string | null,
|
||||
/** 再生一時停止中か */
|
||||
playbackPaused: false,
|
||||
startRecording: () => {},
|
||||
@@ -229,6 +231,13 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
const [recordingList, setRecordingList] = useState<RecordingMeta[]>([]);
|
||||
const [activeRecording, setActiveRecording] = useState<TrainRecording | null>(null);
|
||||
const [playbackIndex, setPlaybackIndex] = useState(0);
|
||||
const playbackCurrentTimeIso =
|
||||
recorderState === 'playing' && activeRecording && activeRecording.snapshots.length > 0
|
||||
? new Date(
|
||||
new Date(activeRecording.recordedAt).getTime() +
|
||||
(activeRecording.snapshots[playbackIndex]?.t ?? 0)
|
||||
).toISOString()
|
||||
: null;
|
||||
const [playbackPaused, setPlaybackPaused] = useState(false);
|
||||
const recordingStartTimeRef = useRef<number>(0);
|
||||
const recordingSnapshotsRef = useRef<Array<{ t: number; trains: TrainEntry[] }>>([]);
|
||||
@@ -563,6 +572,7 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
recordingList,
|
||||
activeRecording,
|
||||
playbackIndex,
|
||||
playbackCurrentTimeIso,
|
||||
playbackPaused,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "expo-location";
|
||||
import { Platform } from "react-native";
|
||||
import useInterval from "@/lib/useInterval";
|
||||
import { logger } from "@/utils/logger";
|
||||
|
||||
type initialStateType = {
|
||||
position: LocationObject | undefined;
|
||||
@@ -42,17 +43,30 @@ export const UserPositionProvider: FC<Props> = ({ children }) => {
|
||||
);
|
||||
|
||||
const getLocationPermission = async () => {
|
||||
return requestForegroundPermissionsAsync().then((data) => {
|
||||
setLocationStatus(
|
||||
Platform.OS == "ios"
|
||||
? data.status == "granted"
|
||||
: data.android.accuracy == "fine"
|
||||
);
|
||||
});
|
||||
return requestForegroundPermissionsAsync()
|
||||
.then((data) => {
|
||||
setLocationStatus(
|
||||
Platform.OS == "ios"
|
||||
? data.status == "granted"
|
||||
: data.android.accuracy == "fine"
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setLocationStatus(false);
|
||||
if (__DEV__) {
|
||||
logger.warn("Location permission request failed:", error);
|
||||
}
|
||||
});
|
||||
};
|
||||
const getCurrentPosition = () => {
|
||||
if (!locationStatus) return () => {};
|
||||
getCurrentPositionAsync({}).then((location) => setPosition(location));
|
||||
getCurrentPositionAsync({})
|
||||
.then((location) => setPosition(location))
|
||||
.catch((error) => {
|
||||
if (__DEV__) {
|
||||
logger.warn("Current location fetch failed:", error);
|
||||
}
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (Platform.OS == "web") return;
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./*"],
|
||||
"expo-live-activity": ["./modules/expo-live-activity/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"extends": "expo/tsconfig.base"
|
||||
|
||||
@@ -2084,6 +2084,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.13.tgz#ff34942667a4e19a9f4a0996a76814daac364cf3"
|
||||
integrity sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==
|
||||
|
||||
"@yarnpkg/lockfile@^1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
|
||||
integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==
|
||||
|
||||
abort-controller@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
|
||||
@@ -2539,6 +2544,32 @@ cacheable-request@^7.0.2:
|
||||
normalize-url "^6.0.1"
|
||||
responselike "^2.0.0"
|
||||
|
||||
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
|
||||
integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
|
||||
dependencies:
|
||||
es-errors "^1.3.0"
|
||||
function-bind "^1.1.2"
|
||||
|
||||
call-bind@^1.0.8:
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.9.tgz#39a644700c80bc7d0ca9102fc6d1d43b2fd7eee7"
|
||||
integrity sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==
|
||||
dependencies:
|
||||
call-bind-apply-helpers "^1.0.2"
|
||||
es-define-property "^1.0.1"
|
||||
get-intrinsic "^1.3.0"
|
||||
set-function-length "^1.2.2"
|
||||
|
||||
call-bound@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a"
|
||||
integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==
|
||||
dependencies:
|
||||
call-bind-apply-helpers "^1.0.2"
|
||||
get-intrinsic "^1.3.0"
|
||||
|
||||
camelcase@^5.3.1:
|
||||
version "5.3.1"
|
||||
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
|
||||
@@ -2614,7 +2645,7 @@ ci-info@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
|
||||
integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==
|
||||
|
||||
ci-info@^3.2.0, ci-info@^3.3.0:
|
||||
ci-info@^3.2.0, ci-info@^3.3.0, ci-info@^3.7.0:
|
||||
version "3.9.0"
|
||||
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4"
|
||||
integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==
|
||||
@@ -2788,7 +2819,7 @@ cross-fetch@^3.1.5:
|
||||
dependencies:
|
||||
node-fetch "^2.7.0"
|
||||
|
||||
cross-spawn@^7.0.6:
|
||||
cross-spawn@^7.0.3, cross-spawn@^7.0.6:
|
||||
version "7.0.6"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
|
||||
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
|
||||
@@ -2895,6 +2926,15 @@ defer-to-connect@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587"
|
||||
integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==
|
||||
|
||||
define-data-property@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e"
|
||||
integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==
|
||||
dependencies:
|
||||
es-define-property "^1.0.0"
|
||||
es-errors "^1.3.0"
|
||||
gopd "^1.0.1"
|
||||
|
||||
define-lazy-prop@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
|
||||
@@ -2959,6 +2999,15 @@ domutils@^3.0.1:
|
||||
domelementtype "^2.3.0"
|
||||
domhandler "^5.0.3"
|
||||
|
||||
dunder-proto@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
|
||||
integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
|
||||
dependencies:
|
||||
call-bind-apply-helpers "^1.0.1"
|
||||
es-errors "^1.3.0"
|
||||
gopd "^1.2.0"
|
||||
|
||||
eastasianwidth@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
|
||||
@@ -3020,11 +3069,23 @@ error-stack-parser@^2.0.6:
|
||||
dependencies:
|
||||
stackframe "^1.3.4"
|
||||
|
||||
es-define-property@^1.0.0, es-define-property@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
|
||||
integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
|
||||
|
||||
es-errors@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
|
||||
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
|
||||
|
||||
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b"
|
||||
integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==
|
||||
dependencies:
|
||||
es-errors "^1.3.0"
|
||||
|
||||
escalade@^3.1.1, escalade@^3.2.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
|
||||
@@ -3463,6 +3524,13 @@ find-up@^7.0.0:
|
||||
path-exists "^5.0.0"
|
||||
unicorn-magic "^0.1.0"
|
||||
|
||||
find-yarn-workspace-root@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd"
|
||||
integrity sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==
|
||||
dependencies:
|
||||
micromatch "^4.0.2"
|
||||
|
||||
flow-enums-runtime@^0.0.6:
|
||||
version "0.0.6"
|
||||
resolved "https://registry.yarnpkg.com/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz#5bb0cd1b0a3e471330f4d109039b7eba5cb3e787"
|
||||
@@ -3486,6 +3554,15 @@ fresh@~0.5.2:
|
||||
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
|
||||
integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
|
||||
|
||||
fs-extra@^10.0.0:
|
||||
version "10.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf"
|
||||
integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.0"
|
||||
jsonfile "^6.0.1"
|
||||
universalify "^2.0.0"
|
||||
|
||||
fs.realpath@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
||||
@@ -3511,11 +3588,35 @@ get-caller-file@^2.0.5:
|
||||
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
|
||||
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
|
||||
|
||||
get-intrinsic@^1.2.4, get-intrinsic@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
|
||||
integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
|
||||
dependencies:
|
||||
call-bind-apply-helpers "^1.0.2"
|
||||
es-define-property "^1.0.1"
|
||||
es-errors "^1.3.0"
|
||||
es-object-atoms "^1.1.1"
|
||||
function-bind "^1.1.2"
|
||||
get-proto "^1.0.1"
|
||||
gopd "^1.2.0"
|
||||
has-symbols "^1.1.0"
|
||||
hasown "^2.0.2"
|
||||
math-intrinsics "^1.1.0"
|
||||
|
||||
get-package-type@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
|
||||
integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==
|
||||
|
||||
get-proto@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
|
||||
integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
|
||||
dependencies:
|
||||
dunder-proto "^1.0.1"
|
||||
es-object-atoms "^1.0.0"
|
||||
|
||||
get-stream@^5.1.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
|
||||
@@ -3561,6 +3662,11 @@ glob@^7.1.1, glob@^7.1.3, glob@^7.1.4:
|
||||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
|
||||
gopd@^1.0.1, gopd@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
|
||||
integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
|
||||
|
||||
got@^11.5.1:
|
||||
version "11.8.6"
|
||||
resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a"
|
||||
@@ -3578,7 +3684,7 @@ got@^11.5.1:
|
||||
p-cancelable "^2.0.0"
|
||||
responselike "^2.0.0"
|
||||
|
||||
graceful-fs@^4.2.4, graceful-fs@^4.2.9:
|
||||
graceful-fs@^4.1.11, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9:
|
||||
version "4.2.11"
|
||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
|
||||
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
|
||||
@@ -3600,7 +3706,19 @@ has-flag@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
|
||||
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
|
||||
|
||||
hasown@^2.0.3:
|
||||
has-property-descriptors@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854"
|
||||
integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==
|
||||
dependencies:
|
||||
es-define-property "^1.0.0"
|
||||
|
||||
has-symbols@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
|
||||
integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
|
||||
|
||||
hasown@^2.0.2, hasown@^2.0.3:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003"
|
||||
integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==
|
||||
@@ -3841,6 +3959,11 @@ is-wsl@^2.1.1, is-wsl@^2.2.0:
|
||||
dependencies:
|
||||
is-docker "^2.0.0"
|
||||
|
||||
isarray@^2.0.5:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
|
||||
integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
|
||||
|
||||
isexe@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||
@@ -4010,11 +4133,36 @@ json-buffer@3.0.1:
|
||||
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
|
||||
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
|
||||
|
||||
json-stable-stringify@^1.0.2:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz#8903cfac42ea1a0f97f35d63a4ce0518f0cc6a70"
|
||||
integrity sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==
|
||||
dependencies:
|
||||
call-bind "^1.0.8"
|
||||
call-bound "^1.0.4"
|
||||
isarray "^2.0.5"
|
||||
jsonify "^0.0.1"
|
||||
object-keys "^1.1.1"
|
||||
|
||||
json5@^2.2.3:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
|
||||
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
|
||||
|
||||
jsonfile@^6.0.1:
|
||||
version "6.2.1"
|
||||
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.2.1.tgz#b6e31717f22cc37330b081ce0051ed5de53af2f6"
|
||||
integrity sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==
|
||||
dependencies:
|
||||
universalify "^2.0.0"
|
||||
optionalDependencies:
|
||||
graceful-fs "^4.1.6"
|
||||
|
||||
jsonify@^0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978"
|
||||
integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==
|
||||
|
||||
keyv@^4.0.0:
|
||||
version "4.5.4"
|
||||
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
|
||||
@@ -4022,6 +4170,13 @@ keyv@^4.0.0:
|
||||
dependencies:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
klaw-sync@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c"
|
||||
integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==
|
||||
dependencies:
|
||||
graceful-fs "^4.1.11"
|
||||
|
||||
kleur@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
|
||||
@@ -4201,6 +4356,11 @@ marky@^1.2.2:
|
||||
resolved "https://registry.yarnpkg.com/marky/-/marky-1.3.0.tgz#422b63b0baf65022f02eda61a238eccdbbc14997"
|
||||
integrity sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==
|
||||
|
||||
math-intrinsics@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
||||
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
|
||||
|
||||
mdn-data@2.0.14:
|
||||
version "2.0.14"
|
||||
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
|
||||
@@ -4420,7 +4580,7 @@ metro@0.83.7, metro@^0.83.6:
|
||||
ws "^7.5.10"
|
||||
yargs "^17.6.2"
|
||||
|
||||
micromatch@^4.0.4:
|
||||
micromatch@^4.0.2, micromatch@^4.0.4:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
|
||||
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
|
||||
@@ -4498,6 +4658,11 @@ minimist@1.2.0:
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
|
||||
integrity sha512-7Wl+Jz+IGWuSdgsQEJ4JunV0si/iMhg42MnQQG6h1R6TNeVenp4U9x5CC5v/gYqz/fENLQITAWXidNtVL0NNbw==
|
||||
|
||||
minimist@^1.2.6:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
|
||||
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
||||
|
||||
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2, minipass@^7.1.3:
|
||||
version "7.1.3"
|
||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b"
|
||||
@@ -4622,6 +4787,11 @@ object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
|
||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
|
||||
|
||||
object-keys@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
|
||||
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
|
||||
|
||||
on-finished@~2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
|
||||
@@ -4655,7 +4825,7 @@ onetime@^2.0.0:
|
||||
dependencies:
|
||||
mimic-fn "^1.0.0"
|
||||
|
||||
open@^7.0.3:
|
||||
open@^7.0.3, open@^7.4.2:
|
||||
version "7.4.2"
|
||||
resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321"
|
||||
integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==
|
||||
@@ -4769,6 +4939,26 @@ parseurl@~1.3.3:
|
||||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
||||
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
|
||||
|
||||
patch-package@^8.0.1:
|
||||
version "8.0.1"
|
||||
resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.1.tgz#79d02f953f711e06d1f8949c8a13e5d3d7ba1a60"
|
||||
integrity sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==
|
||||
dependencies:
|
||||
"@yarnpkg/lockfile" "^1.1.0"
|
||||
chalk "^4.1.2"
|
||||
ci-info "^3.7.0"
|
||||
cross-spawn "^7.0.3"
|
||||
find-yarn-workspace-root "^2.0.0"
|
||||
fs-extra "^10.0.0"
|
||||
json-stable-stringify "^1.0.2"
|
||||
klaw-sync "^6.0.0"
|
||||
minimist "^1.2.6"
|
||||
open "^7.4.2"
|
||||
semver "^7.5.3"
|
||||
slash "^2.0.0"
|
||||
tmp "^0.2.4"
|
||||
yaml "^2.2.2"
|
||||
|
||||
path-exists@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
|
||||
@@ -5364,7 +5554,7 @@ semver@^6.3.0, semver@^6.3.1:
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
semver@^7.1.3, semver@^7.3.5, semver@^7.5.4, semver@^7.6.0:
|
||||
semver@^7.1.3, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0:
|
||||
version "7.8.5"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69"
|
||||
integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==
|
||||
@@ -5403,6 +5593,18 @@ serve-static@^1.16.2:
|
||||
parseurl "~1.3.3"
|
||||
send "~0.19.1"
|
||||
|
||||
set-function-length@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
|
||||
integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==
|
||||
dependencies:
|
||||
define-data-property "^1.1.4"
|
||||
es-errors "^1.3.0"
|
||||
function-bind "^1.1.2"
|
||||
get-intrinsic "^1.2.4"
|
||||
gopd "^1.0.1"
|
||||
has-property-descriptors "^1.0.2"
|
||||
|
||||
setimmediate@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
|
||||
@@ -5466,6 +5668,11 @@ sisteransi@^1.0.5:
|
||||
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
|
||||
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
|
||||
|
||||
slash@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"
|
||||
integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==
|
||||
|
||||
slash@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
|
||||
@@ -5730,6 +5937,11 @@ tmp@^0.0.33:
|
||||
dependencies:
|
||||
os-tmpdir "~1.0.2"
|
||||
|
||||
tmp@^0.2.4:
|
||||
version "0.2.7"
|
||||
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.7.tgz#26f4db11d1601ce8012dcb8a798ece1c06a99059"
|
||||
integrity sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==
|
||||
|
||||
tmpl@1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"
|
||||
@@ -5820,6 +6032,11 @@ unicorn-magic@^0.1.0:
|
||||
resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4"
|
||||
integrity sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==
|
||||
|
||||
universalify@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
|
||||
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==
|
||||
|
||||
unpipe@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
|
||||
@@ -6030,7 +6247,7 @@ yaml@^1.10.0:
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3"
|
||||
integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==
|
||||
|
||||
yaml@^2.6.1:
|
||||
yaml@^2.2.2, yaml@^2.6.1:
|
||||
version "2.9.0"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4"
|
||||
integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==
|
||||
|
||||
Reference in New Issue
Block a user