448 lines
15 KiB
TypeScript
448 lines
15 KiB
TypeScript
import React, { useRef, useState, useEffect, useCallback, useMemo, FC } from "react";
|
||
import { Platform, View, ScrollView, LayoutAnimation, Text, InteractionManager, useWindowDimensions, Image, StatusBar } from "react-native";
|
||
import Constants from "expo-constants";
|
||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||
import {
|
||
configureReanimatedLogger,
|
||
ReanimatedLogLevel,
|
||
} from "react-native-reanimated";
|
||
import { useThemeColors } from "@/lib/theme";
|
||
import { useResponsive } from "@/lib/responsive";
|
||
|
||
import { LED_vision } from "@/components/発車時刻表/LED_vidion";
|
||
import { TitleBar } from "@/components/Menu/TitleBar";
|
||
import { FixedContentBottom } from "@/components/Menu/FixedContentBottom";
|
||
|
||
import { lineList, stationIDPair } from "@/lib/getStationList";
|
||
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
|
||
import { useNavigation, useIsFocused } from "@react-navigation/native";
|
||
import { useStationList } from "@/stateBox/useStationList";
|
||
import { TopMenuButton } from "@/components/Menu/TopMenuButton";
|
||
import { JRSTraInfoBox } from "@/components/Menu/JRSTraInfoBox";
|
||
import MapView, { Marker } from "react-native-maps";
|
||
import { CarouselBox } from "@/components/Menu/Carousel/CarouselBox";
|
||
import { CarouselTypeChanger } from "@/components/Menu/Carousel/CarouselTypeChanger";
|
||
import { useUserPosition } from "@/stateBox/useUserPosition";
|
||
import { AS } from "@/storageControl";
|
||
import { STORAGE_KEYS } from "@/constants";
|
||
import { lineList_LineWebID } from "@/lib/getStationList";
|
||
import { StationProps } from "@/lib/CommonTypes";
|
||
import { LocationObject } from "expo-location";
|
||
import { StationSource } from "@/types";
|
||
configureReanimatedLogger({
|
||
level: ReanimatedLogLevel.error, // Set the log level to error
|
||
strict: true, // Reanimated runs in strict mode by default
|
||
});
|
||
|
||
type props = {
|
||
scrollRef: React.RefObject<ScrollView>;
|
||
mapHeight: number;
|
||
MapFullHeight: number;
|
||
mapMode: boolean;
|
||
setMapMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||
};
|
||
|
||
const MAP_PIN_SIZE = Platform.OS === "android" ? 32 : 36;
|
||
|
||
export const Menu: FC<props> = (props) => {
|
||
const { scrollRef, mapHeight, MapFullHeight, mapMode, setMapMode } = props;
|
||
const { navigate } = useNavigation();
|
||
const { colors, isDark } = useThemeColors();
|
||
const isMenuFocused = useIsFocused();
|
||
const { verticalScale } = useResponsive();
|
||
const insets = useSafeAreaInsets();
|
||
const { favoriteStation } = useFavoriteStation();
|
||
const { originalStationList, getStationDataFromNameBase } = useStationList();
|
||
const [stationSource, _setStationSource] = useState<StationSource>({ type: "position" });
|
||
// 時刻表βの遅延レンダリング(画面遷移アニメーション完了後に描画)
|
||
const [ledReady, setLedReady] = useState(false);
|
||
useEffect(() => {
|
||
const handle = InteractionManager.runAfterInteractions(() => setLedReady(true));
|
||
return () => handle.cancel();
|
||
}, []);
|
||
// 検索モードを閉じたときに戻るソースを記憶
|
||
const prevNonSearchTypeRef = useRef<"position" | "favorite">("position");
|
||
|
||
// stationSource を更新するラッパー(position/favorite に切り替えた際に戻り先を記録)
|
||
const setStationSource = useCallback((source: StationSource) => {
|
||
if (source.type !== "search") prevNonSearchTypeRef.current = source.type;
|
||
_setStationSource(source);
|
||
}, []);
|
||
|
||
// 検索モードを閉じて直前の position/favorite に戻る
|
||
const closeSearch = useCallback(() => {
|
||
_setStationSource({ type: prevNonSearchTypeRef.current });
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
AS.getItem(STORAGE_KEYS.STATION_LIST_MODE)
|
||
.then((res) => {
|
||
if (res === "position" || res === "favorite") {
|
||
setStationSource({ type: res });
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}, []);
|
||
const mapsRef = useRef(null);
|
||
const returnToTop = (bool = true) => {
|
||
scrollRef?.current.scrollTo({
|
||
y: mapHeight > verticalScale(80) ? mapHeight - verticalScale(80) : 0,
|
||
animated: bool,
|
||
});
|
||
};
|
||
const goToMap = () => {
|
||
scrollRef?.current.scrollTo({
|
||
y: 0,
|
||
animated: true,
|
||
});
|
||
};
|
||
useEffect(() => {
|
||
setTimeout(() => {
|
||
returnToTop(false);
|
||
}, 10);
|
||
}, [mapHeight]);
|
||
const [scrollStartPosition, setScrollStartPosition] = useState(0);
|
||
const onScrollBeginDrag = (e) => {
|
||
LayoutAnimation.configureNext({
|
||
duration: 300,
|
||
create: {
|
||
type: LayoutAnimation.Types.easeInEaseOut,
|
||
property: LayoutAnimation.Properties.opacity,
|
||
},
|
||
update: {
|
||
type: LayoutAnimation.Types.easeInEaseOut,
|
||
property: LayoutAnimation.Properties.opacity,
|
||
},
|
||
});
|
||
setScrollStartPosition(e.nativeEvent.contentOffset.y);
|
||
setMapMode(false);
|
||
};
|
||
//現在地基準の駅名標リストアップ機能
|
||
const { position, locationStatus } = useUserPosition();
|
||
|
||
const [nearPositionStation, setNearPositionStation] = useState<
|
||
StationProps[][]
|
||
>([]); //第三要素
|
||
|
||
useEffect(() => {
|
||
if (!position) return () => {};
|
||
makeCurrentStation(position);
|
||
}, [position, stationSource.type]);
|
||
|
||
const makeCurrentStation = (location: LocationObject) => {
|
||
if (!originalStationList) return () => {};
|
||
const findStationEachLine = (selectLine) => {
|
||
const searchArea = 0.055; //検索範囲
|
||
const _calcDistance = (from, to) => {
|
||
let lat = Math.abs(from.lat - to.lat);
|
||
let lng = Math.abs(from.lng - to.lng);
|
||
return Math.sqrt(lat * lat + lng * lng);
|
||
};
|
||
let NearStation = selectLine.filter(
|
||
(d) =>
|
||
_calcDistance(d, {
|
||
lat: location.coords.latitude,
|
||
lng: location.coords.longitude,
|
||
}) < searchArea
|
||
);
|
||
//NearStationを距離の近い順にソート
|
||
NearStation.sort((a, b) => {
|
||
return (
|
||
_calcDistance(a, {
|
||
lat: location.coords.latitude,
|
||
lng: location.coords.longitude,
|
||
}) -
|
||
_calcDistance(b, {
|
||
lat: location.coords.latitude,
|
||
lng: location.coords.longitude,
|
||
})
|
||
);
|
||
});
|
||
return NearStation;
|
||
};
|
||
|
||
let _stList: StationProps[] = lineList
|
||
.map((d) => findStationEachLine(originalStationList[d]))
|
||
.filter((d) => d.length > 0)
|
||
.reduce((pre, current) => {
|
||
pre.push(...current);
|
||
return pre;
|
||
}, []);
|
||
// 観光スポットも位置情報検索の対象に含める
|
||
const nearSpots = findStationEachLine(originalStationList["観光スポット"] ?? []);
|
||
nearSpots.forEach((d) => _stList.push(d));
|
||
if (_stList.length == 0) setNearPositionStation([]);
|
||
else {
|
||
let returnData: StationProps[][] = [];
|
||
_stList.forEach((d, index, array) => {
|
||
const stationName = d.Station_JP;
|
||
if (returnData.findIndex((d) => d[0].Station_JP == stationName) != -1)
|
||
return;
|
||
returnData.push(array.filter((d2) => d2.Station_JP == stationName));
|
||
});
|
||
//returnDataを距離の近い順にソート
|
||
returnData.sort((a, b) => {
|
||
const _calcDistance = (from, to) => {
|
||
let lat = Math.abs(from.lat - to.lat);
|
||
let lng = Math.abs(from.lng - to.lng);
|
||
return Math.sqrt(lat * lat + lng * lng);
|
||
};
|
||
return (
|
||
_calcDistance(a[0], {
|
||
lat: location.coords.latitude,
|
||
lng: location.coords.longitude,
|
||
}) -
|
||
_calcDistance(b[0], {
|
||
lat: location.coords.latitude,
|
||
lng: location.coords.longitude,
|
||
})
|
||
);
|
||
});
|
||
setNearPositionStation(returnData);
|
||
}
|
||
};
|
||
|
||
const [listIndex, setListIndex] = useState(0);
|
||
|
||
// listUpStation を useMemo で派生(useLayoutEffect + setState を排除)
|
||
// stationSource が変わるたびに再計算。input も query として統合済みなので
|
||
// テキスト入力のたびに正しくリストが更新される(旧来のバグを修正)。
|
||
const listUpStation = useMemo<StationProps[][]>(() => {
|
||
if (stationSource.type === "search") {
|
||
const { query, lineId } = stationSource;
|
||
const returnData: StationProps[][] = [];
|
||
// query も lineId も未入力の初期状態では空リストを返す
|
||
if (!query && !lineId) return returnData;
|
||
if (!query) {
|
||
// lineId のみ指定:その路線の全駅
|
||
Object.keys(lineList_LineWebID).forEach((d) => {
|
||
originalStationList[d]?.forEach((D) => {
|
||
if (lineId !== stationIDPair[lineList_LineWebID[d]]) return;
|
||
if (!D.StationNumber) return;
|
||
returnData.push([D]);
|
||
});
|
||
});
|
||
} else {
|
||
const found = getStationDataFromNameBase(query);
|
||
found.forEach((d, _, array) => {
|
||
const name = d.Station_JP;
|
||
if (returnData.findIndex((r) => r[0].Station_JP === name) !== -1) return;
|
||
returnData.push(array.filter((d2) => d2.Station_JP === name));
|
||
});
|
||
}
|
||
return returnData;
|
||
} else if (stationSource.type === "position") {
|
||
return nearPositionStation.filter((d) => d != undefined);
|
||
} else {
|
||
return favoriteStation.filter((d) => d != undefined);
|
||
}
|
||
}, [stationSource, nearPositionStation, favoriteStation, originalStationList, getStationDataFromNameBase]);
|
||
|
||
// ソース種別が切り替わったら listIndex を即座にリセット(ループ防止)
|
||
useEffect(() => {
|
||
setListIndex(0);
|
||
}, [stationSource.type]);
|
||
|
||
// listUpStation が縮小した場合、有効な範囲に1ステップでクランプ(漸減ループを防ぐ)
|
||
useEffect(() => {
|
||
if (listIndex < 0) return; // ソートモード中(未選択状態)は無視
|
||
if (listUpStation.length === 0) {
|
||
setListIndex(0);
|
||
return;
|
||
}
|
||
if (listIndex >= listUpStation.length) {
|
||
setMapMode(false);
|
||
setListIndex(listUpStation.length - 1);
|
||
}
|
||
// listIndex を依存に入れると再発火ループになるため除外
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [listUpStation]);
|
||
useEffect(() => {
|
||
if (originalStationList == undefined) return;
|
||
if (listUpStation.length == 0) return;
|
||
if (listUpStation[listIndex] == undefined) return;
|
||
const { lat, lng } = listUpStation[listIndex][0];
|
||
const mapRegion = {
|
||
latitude: lat,
|
||
longitude: lng,
|
||
latitudeDelta: 0.05,
|
||
longitudeDelta: 0.05,
|
||
};
|
||
if (mapMode) {
|
||
mapsRef?.current.fitToCoordinates(
|
||
listUpStation.map((d) => ({
|
||
latitude: d[0].lat,
|
||
longitude: d[0].lng,
|
||
})),
|
||
{ edgePadding: { top: verticalScale(80), bottom: verticalScale(120), left: 50, right: 50 } } // Add margin values here
|
||
);
|
||
} else {
|
||
mapsRef.current.animateToRegion(mapRegion, 1000);
|
||
}
|
||
}, [listIndex, listUpStation]);
|
||
|
||
return (
|
||
<View
|
||
style={{
|
||
height: "100%",
|
||
backgroundColor: colors.background,
|
||
paddingTop: Platform.OS === "web" ? 0 : insets.top,
|
||
}}
|
||
>
|
||
{isMenuFocused && (
|
||
<StatusBar
|
||
barStyle={isDark ? "light-content" : "dark-content"}
|
||
translucent
|
||
/>
|
||
)}
|
||
{!mapMode ? <TitleBar /> : <></>}
|
||
<ScrollView
|
||
ref={scrollRef}
|
||
snapToStart={false}
|
||
snapToEnd={false}
|
||
decelerationRate={"normal"}
|
||
snapToOffsets={[mapHeight - verticalScale(80)]}
|
||
onScrollBeginDrag={onScrollBeginDrag}
|
||
onScrollEndDrag={(e) => {
|
||
if (e.nativeEvent.contentOffset.y < mapHeight - verticalScale(80)) {
|
||
if (scrollStartPosition > e.nativeEvent.contentOffset.y) {
|
||
goToMap();
|
||
} else {
|
||
returnToTop();
|
||
}
|
||
}
|
||
}}
|
||
>
|
||
<MapView
|
||
ref={mapsRef}
|
||
style={{ width: "100%", height: mapMode ? MapFullHeight : mapHeight }}
|
||
showsUserLocation={true}
|
||
loadingEnabled={true}
|
||
showsMyLocationButton={false}
|
||
moveOnMarkerPress={false}
|
||
showsCompass={false}
|
||
userInterfaceStyle={isDark ? "dark" : "light"}
|
||
//provider={PROVIDER_GOOGLE}
|
||
initialRegion={{
|
||
latitude: 33.774519,
|
||
longitude: 133.533306,
|
||
latitudeDelta: 1.8, //小さくなるほどズーム
|
||
longitudeDelta: 1.8,
|
||
}}
|
||
onTouchStart={() => {
|
||
LayoutAnimation.configureNext({
|
||
duration: 300,
|
||
create: {
|
||
type: LayoutAnimation.Types.easeInEaseOut,
|
||
property: LayoutAnimation.Properties.opacity,
|
||
},
|
||
update: {
|
||
type: LayoutAnimation.Types.easeInEaseOut,
|
||
property: LayoutAnimation.Properties.opacity,
|
||
},
|
||
});
|
||
setMapMode(true);
|
||
goToMap();
|
||
}}
|
||
>
|
||
{listUpStation.map(([{ lat, lng, StationNumber }], index) => (
|
||
<Marker
|
||
key={index + StationNumber}
|
||
coordinate={{
|
||
latitude: lat,
|
||
longitude: lng,
|
||
}}
|
||
anchor={{ x: 0.5, y: 1 }}
|
||
tracksViewChanges={false}
|
||
onPress={() => {
|
||
setMapMode(false);
|
||
setListIndex(index);
|
||
if (mapsRef.current) {
|
||
mapsRef.current.animateToRegion(
|
||
{
|
||
latitude: lat,
|
||
longitude: lng,
|
||
latitudeDelta: 0.05,
|
||
longitudeDelta: 0.05,
|
||
},
|
||
1000
|
||
);
|
||
}
|
||
LayoutAnimation.configureNext({
|
||
duration: 300,
|
||
create: {
|
||
type: LayoutAnimation.Types.easeInEaseOut,
|
||
property: LayoutAnimation.Properties.opacity,
|
||
},
|
||
update: {
|
||
type: LayoutAnimation.Types.easeInEaseOut,
|
||
property: LayoutAnimation.Properties.opacity,
|
||
},
|
||
});
|
||
returnToTop();
|
||
}}
|
||
>
|
||
<Image
|
||
source={require("@/assets/reccha-small.png")}
|
||
style={{ width: MAP_PIN_SIZE, height: MAP_PIN_SIZE }}
|
||
resizeMode="contain"
|
||
/>
|
||
</Marker>
|
||
))}
|
||
</MapView>
|
||
{!mapMode && (
|
||
<CarouselTypeChanger
|
||
{...{
|
||
locationStatus,
|
||
position,
|
||
stationSource,
|
||
setStationSource,
|
||
closeSearch,
|
||
setSelectedCurrentStation: setListIndex,
|
||
mapMode,
|
||
setMapMode,
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{originalStationList.length != 0 && (
|
||
<>
|
||
<CarouselBox
|
||
{...{
|
||
originalStationList,
|
||
listUpStation,
|
||
nearPositionStation,
|
||
setListIndex,
|
||
listIndex,
|
||
navigate,
|
||
stationSource,
|
||
}}
|
||
/>
|
||
{listUpStation[listIndex] && ledReady && (
|
||
<LED_vision station={listUpStation[listIndex]} />
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
<TopMenuButton />
|
||
<JRSTraInfoBox />
|
||
<FixedContentBottom navigate={navigate} />
|
||
</ScrollView>
|
||
{mapMode && (
|
||
<CarouselTypeChanger
|
||
{...{
|
||
locationStatus,
|
||
position,
|
||
stationSource,
|
||
setStationSource,
|
||
closeSearch,
|
||
setSelectedCurrentStation: setListIndex,
|
||
mapMode,
|
||
setMapMode,
|
||
}}
|
||
/>
|
||
)}
|
||
</View>
|
||
);
|
||
};
|