Files
jrshikoku/menu.tsx

448 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};