From 3b1cdde1051fe359eea97fe49d3419af396d219a Mon Sep 17 00:00:00 2001 From: Law Wai Chun Date: Thu, 4 Jan 2024 12:00:28 +0800 Subject: [PATCH] Code refactoring for reducing data usage --- src/AppContext.tsx | 5 +- src/DbContext.tsx | 21 +- src/components/home/RangeMapDialog.tsx | 12 +- src/components/home/SwipeableList.tsx | 438 ++---------------- .../home/lists/CollectionRouteList.tsx | 105 +++++ .../HomeRouteListDropDown.tsx} | 2 +- src/components/home/lists/NearbyRouteList.tsx | 167 +++++++ src/components/home/lists/SavedRouteList.tsx | 145 ++++++ .../home/lists/SmartCollectionRouteList.tsx | 142 ++++++ src/pages/DataImport.tsx | 8 +- src/pages/Home.tsx | 23 +- src/utils.ts | 56 ++- 12 files changed, 681 insertions(+), 443 deletions(-) create mode 100644 src/components/home/lists/CollectionRouteList.tsx rename src/components/home/{HomeRouteList.tsx => lists/HomeRouteListDropDown.tsx} (96%) create mode 100644 src/components/home/lists/NearbyRouteList.tsx create mode 100644 src/components/home/lists/SavedRouteList.tsx create mode 100644 src/components/home/lists/SmartCollectionRouteList.tsx diff --git a/src/AppContext.tsx b/src/AppContext.tsx index f3158e53d7fc..3c8ac3d6fad9 100644 --- a/src/AppContext.tsx +++ b/src/AppContext.tsx @@ -9,7 +9,7 @@ import React, { import type { ReactNode } from "react"; import { DEFAULT_GEOLOCATION, - DEFAULT_SEARCH_RANGE_OPTIONS, + DEFAULT_SEARCH_RANGE, iOSRNWebView, iOSTracking, isStrings, @@ -260,8 +260,7 @@ export const AppContextProvider = ({ JSON.parse(localStorage.getItem("annotateScheduled")) ?? false, fontSize: JSON.parse(localStorage.getItem("fontSize")) ?? 14, searchRange: - JSON.parse(localStorage.getItem("searchRange")) ?? - DEFAULT_SEARCH_RANGE_OPTIONS.slice(-1)[0], + JSON.parse(localStorage.getItem("searchRange")) ?? DEFAULT_SEARCH_RANGE, isRangeController: false, // always hide the controller by default }; }; diff --git a/src/DbContext.tsx b/src/DbContext.tsx index 520063b00328..cba2f8c2dc05 100644 --- a/src/DbContext.tsx +++ b/src/DbContext.tsx @@ -4,6 +4,7 @@ import { isEmptyObj } from "./utils"; import { fetchDbFunc } from "./db"; import { compress as compressJson } from "lzutf8-light"; import type { DatabaseType } from "./db"; +import { isHoliday } from "./timetable"; interface DatabaseContextState { db: DatabaseType; @@ -12,6 +13,7 @@ interface DatabaseContextState { interface DatabaseContextValue extends DatabaseContextState { AppTitle: string; + isTodayHoliday: boolean; renewDb: () => Promise; toggleAutoDbRenew: () => void; } @@ -72,13 +74,24 @@ export const DbProvider = ({ initialDb, children }: DbProviderProps) => { } }, [db]); - const contextValue = useMemo( - () => ({ AppTitle, db, autoRenew, renewDb, toggleAutoDbRenew }), - [db, autoRenew, renewDb, toggleAutoDbRenew] + const isTodayHoliday = useMemo( + () => isHoliday(db.holidays, new Date()), + [db.holidays] ); return ( - {children} + + {children} + ); }; diff --git a/src/components/home/RangeMapDialog.tsx b/src/components/home/RangeMapDialog.tsx index 70ba4301db49..369e981a357f 100644 --- a/src/components/home/RangeMapDialog.tsx +++ b/src/components/home/RangeMapDialog.tsx @@ -3,16 +3,17 @@ import { Dialog, DialogContent, DialogTitle, + IconButton, Slider, SxProps, Theme, } from "@mui/material"; - import RangeMap from "./RangeMap"; import { useTranslation } from "react-i18next"; import { useCallback, useContext, useState } from "react"; import AppContext from "../../AppContext"; import { Location } from "hk-bus-eta"; +import { Close as CloseIcon } from "@mui/icons-material"; interface RangeMapDialogProps { open: boolean; @@ -62,7 +63,12 @@ const RangeMapDialog = ({ open, onClose }: RangeMapDialogProps) => { return ( - {t("自訂搜尋範圍(米)")} + + {t("自訂搜尋範圍(米)")} + + + + = { theme.palette.mode === "dark" ? theme.palette.primary.main : theme.palette.text.primary, + display: "flex", + justifyContent: "space-between", }; const sliderSx: SxProps = { diff --git a/src/components/home/SwipeableList.tsx b/src/components/home/SwipeableList.tsx index 480b98b85405..908b6191efdd 100644 --- a/src/components/home/SwipeableList.tsx +++ b/src/components/home/SwipeableList.tsx @@ -1,35 +1,19 @@ -import { Box, List, Typography } from "@mui/material"; -import { - EtaDb, - Location, - RouteList, - StopList, - StopListEntry, -} from "hk-bus-eta"; import React, { useCallback, useContext, - useEffect, useImperativeHandle, - useMemo, useRef, - useState, } from "react"; -import { useTranslation } from "react-i18next"; import SwipeableViews from "react-swipeable-views"; import AppContext from "../../AppContext"; -import { isHoliday, isRouteAvaliable } from "../../timetable"; -import { RouteCollection, TransportType } from "../../typing"; -import { coToType, getDistance } from "../../utils"; -import { CircularProgress } from "../Progress"; -import HomeRouteListDropDown from "./HomeRouteList"; import type { HomeTabType } from "./HomeTabbar"; import SearchRangeController from "./SearchRangeController"; -import SuccinctTimeReport from "./SuccinctTimeReport"; +import NearbyRouteList from "./lists/NearbyRouteList"; +import SavedRouteList from "./lists/SavedRouteList"; +import SmartCollectionRouteList from "./lists/SmartCollectionRouteList"; +import CollectionRouteList from "./lists/CollectionRouteList"; interface SwipeableListProps { - geolocation: Location; - setGeolocation: React.Dispatch>; homeTab: HomeTabType; onChangeTab: (v: string) => void; } @@ -38,37 +22,11 @@ interface SwipeableListRef { changeTab: (v: HomeTabType) => void; } -interface SelectedRoutes { - saved: string; - nearby: Partial>; - smartCollections: Array<{ - name: string; - routes: string; - defaultExpanded: boolean; - }>; - collections: string[]; -} - const SwipeableList = React.forwardRef( - ({ geolocation, homeTab, onChangeTab }, ref) => { - const { - savedEtas, - db: { holidays, routeList, stopList, serviceDayMap }, - isRouteFilter, - collections, - searchRange, - } = useContext(AppContext); - const isTodayHoliday = useMemo( - () => isHoliday(holidays, new Date()), - [holidays] - ); - const defaultHometab = useRef(homeTab); - const { t } = useTranslation(); - const [selectedRoutes, setSelectedRoutes] = useState( - null - ); + ({ homeTab, onChangeTab }, ref) => { + const { collections } = useContext(AppContext); - const [hasNoNearbyRoutes, setHasNoNearbyRoutes] = useState(true); + const defaultHometab = useRef(homeTab); useImperativeHandle(ref, () => ({ changeTab: (v) => { @@ -76,160 +34,6 @@ const SwipeableList = React.forwardRef( }, })); - useEffect(() => { - setSelectedRoutes( - getSelectedRoutes({ - geolocation, - savedEtas, - collections, - routeList, - stopList, - isRouteFilter, - isTodayHoliday, - serviceDayMap, - searchRange, - }) - ); - }, [ - geolocation, - savedEtas, - collections, - routeList, - stopList, - isRouteFilter, - isTodayHoliday, - serviceDayMap, - searchRange, - ]); - - const SavedRouteList = useMemo(() => { - if (selectedRoutes === null) { - return ; - } - const savedRoutes = selectedRoutes["saved"].split("|"); - const noRoutes = savedRoutes.every((routeId) => !routeId); - - return ( - - {noRoutes ? ( - - {t("未有收藏路線")} - - ) : ( - - {savedRoutes.map( - (selectedRoute, idx) => - Boolean(selectedRoute) && ( - - ) - )} - - )} - - ); - }, [selectedRoutes, t]); - - const NearbyRouteList = useMemo(() => { - if (selectedRoutes?.nearby) { - setHasNoNearbyRoutes(() => { - return Object.values(selectedRoutes.nearby) - .map((nearbyRoutes) => - nearbyRoutes.split("|").every((item) => item === "") - ) - .every(Boolean); - }); - } - return selectedRoutes?.nearby ? ( - <> - {hasNoNearbyRoutes ? ( - - {t("附近未有任何路線")} - - ) : ( - - {Object.entries(selectedRoutes.nearby).map( - ([type, nearbyRoutes]) => ( - - ) - )} - - )} - - ) : ( - - ); - }, [selectedRoutes?.nearby, hasNoNearbyRoutes, t]); - - const SmartCollectionRouteList = useMemo( - () => - selectedRoutes?.smartCollections.length > 0 ? ( - - {selectedRoutes.smartCollections.map( - ({ name, routes, defaultExpanded }, idx) => ( - - ) - )} - {!selectedRoutes.smartCollections.reduce( - (acc, { routes }) => - acc || routes.split("|").filter((v) => Boolean(v)).length > 0, - false - ) && ( - - {t("未有收藏路線")} - - )} - - ) : ( - - {t("未有收藏路線")} - - ), - [t, selectedRoutes] - ); - - const collectionRouteLists = useMemo( - () => - collections.map((_, idx) => { - const routes = selectedRoutes?.collections[idx].split("|") ?? []; - const noRoutes = routes.every((routeId) => !routeId); - - return ( - - {noRoutes ? ( - - {t("收藏中未有路線")} - - ) : ( - - {routes.map( - (selectedRoute, idx) => - Boolean(selectedRoute) && ( - - ) - )} - - )} - - ); - }), - [t, collections, selectedRoutes] - ); - const getViewIdx = useCallback(() => { let ret = HOME_TAB.indexOf(defaultHometab.current); if (ret !== -1) return ret; @@ -241,38 +45,32 @@ const SwipeableList = React.forwardRef( return -1; }, [collections]); - return useMemo( - () => ( - <> - {/* SwipeableViews has overflow attribute child div and this preventing fixed on top using `position: sticky` */} - {homeTab === "nearby" ? : null} - { - onChangeTab( - idx < HOME_TAB.length - ? HOME_TAB[idx] - : collections[idx - HOME_TAB.length].name - ); - }} - > - {NearbyRouteList} - {SavedRouteList} - {SmartCollectionRouteList} - {collectionRouteLists?.map((Collection) => Collection)} - - - ), - [ - homeTab, - getViewIdx, - NearbyRouteList, - SavedRouteList, - SmartCollectionRouteList, - collectionRouteLists, - onChangeTab, - collections, - ] + return ( + <> + {/* SwipeableViews has overflow attribute child div and this preventing fixed on top using `position: sticky` */} + {homeTab === "nearby" ? : null} + { + onChangeTab( + idx < HOME_TAB.length + ? HOME_TAB[idx] + : collections[idx - HOME_TAB.length].name + ); + }} + > + + + + {collections.map((collection) => ( + + ))} + + ); } ); @@ -280,173 +78,3 @@ const SwipeableList = React.forwardRef( export default SwipeableList; const HOME_TAB = ["nearby", "saved", "collections"]; - -const getSelectedRoutes = ({ - savedEtas, - collections, - geolocation, - stopList, - routeList, - isRouteFilter, - isTodayHoliday, - serviceDayMap, - searchRange, -}: { - savedEtas: string[]; - collections: RouteCollection[]; - geolocation: Location; - stopList: StopList; - routeList: RouteList; - isRouteFilter: boolean; - isTodayHoliday: boolean; - serviceDayMap: EtaDb["serviceDayMap"]; - searchRange: number; -}): SelectedRoutes => { - const selectedRoutes = savedEtas - .filter((routeUrl, index, self) => { - return ( - self.indexOf(routeUrl) === index && routeUrl.split("/")[0] in routeList - ); - }) - .map((routeUrl, idx, self): [string, number, number] => { - const [routeId, stopIdx] = routeUrl.split("/"); - // TODO: taking the longest stop array to avoid error, should be fixed in the database - const _stops = Object.values(routeList[routeId].stops).sort( - (a, b) => b.length - a.length - )[0]; - if (stopIdx !== undefined) { - // if specified which stop - return [ - routeUrl, - getDistance(geolocation, stopList[_stops[stopIdx]].location), - self.length - idx, - ]; - } else { - // else find the nearest stop - const stop = _stops - .map((stop) => [ - stop, - getDistance(geolocation, stopList[stop].location), - ]) - .sort(([, a], [, b]) => (a < b ? -1 : 1))[0][0]; - return [ - routeUrl, - getDistance(geolocation, stopList[stop].location), - self.length - idx, - ]; - } - }); - - const nearbyRoutes = Object.entries(stopList) - .map((stop: [string, StopListEntry]): [string, StopListEntry, number] => - // potentially could be optimized by other distance function - [...stop, getDistance(stop[1].location, geolocation)] - ) - .filter( - (stop) => - // keep only nearby 1000m stops - stop[2] < searchRange - ) - .sort((a, b) => a[2] - b[2]) - .slice(0, 20) - .reduce( - (acc, [stopId]) => { - Object.entries(routeList).forEach(([key, route]) => { - ["kmb", "lrtfeeder", "lightRail", "gmb", "ctb", "nlb"].forEach( - (co) => { - if (route.stops[co] && route.stops[co].includes(stopId)) { - if (acc[coToType[co]] === undefined) acc[coToType[co]] = []; - acc[coToType[co]].push( - key + "/" + route.stops[co].indexOf(stopId) - ); - } - } - ); - }); - return acc; - }, - { bus: [], mtr: [], lightRail: [], minibus: [] } - ); - - const collectionRoutes = collections.reduce( - (acc, { name, list, schedules }) => { - acc.push({ - name: name, - routes: list, - defaultExpanded: schedules.reduce((acc, { day, start, end }) => { - if (acc) return acc; - const curDate = new Date(); - curDate.setUTCHours(curDate.getUTCHours() + 8); - const _day = curDate.getUTCDay(); - // skip handling timezone here - if ((isTodayHoliday && day === 0) || day === _day) { - let sTs = start.hour * 60 + start.minute; - let eTs = end.hour * 60 + end.minute; - let curTs = - (curDate.getUTCHours() * 60 + curDate.getUTCMinutes()) % 1440; - return sTs <= curTs && curTs <= eTs; - } - return false; - }, false), - }); - return acc; - }, - [] - ); - - const formatHandling = (routes) => { - return routes - .filter((v, i, s) => s.indexOf(v) === i) // uniqify - .filter((routeUrl) => { - const [routeId] = routeUrl.split("/"); - return ( - routeList[routeId] && - (!isRouteFilter || - isRouteAvaliable( - routeId, - routeList[routeId].freq, - isTodayHoliday, - serviceDayMap - )) - ); - }) - .map((routeUrl) => { - // handling for saved route without specified stop, use the nearest one - const [routeId, stopIdx] = routeUrl.split("/"); - if (stopIdx !== undefined) return routeUrl; - const _stops = Object.values(routeList[routeId].stops).sort( - (a, b) => b.length - a.length - )[0]; - const stop = _stops - .map((stop) => [ - stop, - getDistance(geolocation, stopList[stop].location), - ]) - .sort(([, a], [, b]) => (a < b ? -1 : 1))[0][0]; - return `${routeUrl}/${_stops.indexOf(stop as string)}`; - }) - .concat(Array(40).fill("")) // padding - .slice(0, 40) - .join("|"); - }; - - return { - saved: formatHandling( - selectedRoutes - .sort((a, b) => a[2] - b[2]) - .map((v) => v[0]) - .slice(0, 40) - ), - nearby: Object.entries(nearbyRoutes).reduce((acc, [type, nearbyRoutes]) => { - acc[type] = formatHandling(nearbyRoutes); - return acc; - }, {}), - smartCollections: collectionRoutes.map((v) => ({ - ...v, - routes: formatHandling(v.routes), - })), - collections: collections.map((colleciton) => - formatHandling(colleciton.list) - ), - }; -}; diff --git a/src/components/home/lists/CollectionRouteList.tsx b/src/components/home/lists/CollectionRouteList.tsx new file mode 100644 index 000000000000..665352ebb007 --- /dev/null +++ b/src/components/home/lists/CollectionRouteList.tsx @@ -0,0 +1,105 @@ +import { List, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import SuccinctTimeReport from "../SuccinctTimeReport"; +import { EtaDb, Location, RouteList, StopList } from "hk-bus-eta"; +import { formatHandling } from "../../../utils"; +import { useContext, useMemo } from "react"; +import AppContext from "../../../AppContext"; +import { RouteCollection } from "../../../typing"; + +interface CollectionRouteListProps { + collection: RouteCollection; + isFocus: boolean; +} + +const CollectionRouteList = ({ + collection, + isFocus, +}: CollectionRouteListProps) => { + const { t } = useTranslation(); + const { + geolocation, + db: { routeList, stopList, serviceDayMap }, + isRouteFilter, + isTodayHoliday, + } = useContext(AppContext); + + const routes = useMemo( + () => + getRoutes({ + savedEtas: collection.list, + geolocation, + stopList, + routeList, + isRouteFilter, + isTodayHoliday, + serviceDayMap, + }), + [ + collection, + geolocation, + stopList, + routeList, + isRouteFilter, + isTodayHoliday, + serviceDayMap, + ] + ); + + const noRoutes = useMemo(() => routes.every((routeId) => !routeId), [routes]); + + if (!isFocus) { + return <>; + } + + if (noRoutes) { + return ( + + {t("收藏中未有路線")} + + ); + } + + return ( + + {routes.map( + (selectedRoute, idx) => + Boolean(selectedRoute) && ( + + ) + )} + + ); +}; + +export default CollectionRouteList; + +const getRoutes = ({ + savedEtas, + geolocation, + stopList, + routeList, + isRouteFilter, + isTodayHoliday, + serviceDayMap, +}: { + savedEtas: string[]; + geolocation: Location; + stopList: StopList; + routeList: RouteList; + isRouteFilter: boolean; + isTodayHoliday: boolean; + serviceDayMap: EtaDb["serviceDayMap"]; +}): string[] => + formatHandling( + savedEtas, + isTodayHoliday, + isRouteFilter, + routeList, + stopList, + serviceDayMap, + geolocation + ).split("|"); diff --git a/src/components/home/HomeRouteList.tsx b/src/components/home/lists/HomeRouteListDropDown.tsx similarity index 96% rename from src/components/home/HomeRouteList.tsx rename to src/components/home/lists/HomeRouteListDropDown.tsx index ab041674ac69..9f52ccbad373 100644 --- a/src/components/home/HomeRouteList.tsx +++ b/src/components/home/lists/HomeRouteListDropDown.tsx @@ -1,5 +1,5 @@ import { Box, Divider, List, SxProps, Theme, Typography } from "@mui/material"; -import SuccinctTimeReport from "./SuccinctTimeReport"; +import SuccinctTimeReport from "../SuccinctTimeReport"; import { useMemo, useState } from "react"; import { ExpandMore as ExpandMoreIcon, diff --git a/src/components/home/lists/NearbyRouteList.tsx b/src/components/home/lists/NearbyRouteList.tsx new file mode 100644 index 000000000000..adf6c38390c3 --- /dev/null +++ b/src/components/home/lists/NearbyRouteList.tsx @@ -0,0 +1,167 @@ +import { Box, Typography } from "@mui/material"; +import HomeRouteListDropDown from "./HomeRouteListDropDown"; +import { useTranslation } from "react-i18next"; +import { useContext, useEffect, useMemo, useState } from "react"; +import { TransportType } from "../../../typing"; +import { + EtaDb, + Location, + RouteList, + StopList, + StopListEntry, +} from "hk-bus-eta"; +import { coToType, formatHandling, getDistance } from "../../../utils"; +import AppContext from "../../../AppContext"; +import throttle from "lodash.throttle"; + +interface NearbyRouteListProps { + isFocus: boolean; +} + +const NearbyRouteList = ({ isFocus }: NearbyRouteListProps) => { + const { t } = useTranslation(); + const { + manualGeolocation, + geolocation, + db: { routeList, stopList, serviceDayMap }, + isRouteFilter, + isTodayHoliday, + searchRange, + } = useContext(AppContext); + + // throttle to avoid rapidly UI changes due to geolocation changes + const [state, setState] = useState( + manualGeolocation ?? geolocation + ); + const throttleUpdateGeolocation = useMemo( + () => + throttle((geolocation: Location) => { + setState(geolocation); + }, 1000), + [] + ); + + useEffect(() => { + throttleUpdateGeolocation(manualGeolocation ?? geolocation); + }, [manualGeolocation, geolocation, throttleUpdateGeolocation]); + + const routes = useMemo( + () => + getRoutes({ + geolocation: state, + stopList, + routeList, + isRouteFilter, + isTodayHoliday, + serviceDayMap, + searchRange, + }), + [ + state, + stopList, + routeList, + isRouteFilter, + isTodayHoliday, + serviceDayMap, + searchRange, + ] + ); + + const noNearbyRoutes = useMemo( + () => + Object.values(routes) + .map((nearbyRoutes) => + nearbyRoutes.split("|").every((item) => item === "") + ) + .every(Boolean), + [routes] + ); + + if (!isFocus) { + return <>; + } + + if (noNearbyRoutes) { + return ( + + {t("附近未有任何路線")} + + ); + } + + return ( + + {Object.entries(routes).map(([type, nearbyRoutes]) => ( + + ))} + + ); +}; + +export default NearbyRouteList; + +const getRoutes = ({ + geolocation, + stopList, + routeList, + isRouteFilter, + isTodayHoliday, + serviceDayMap, + searchRange, +}: { + geolocation: Location; + stopList: StopList; + routeList: RouteList; + isRouteFilter: boolean; + isTodayHoliday: boolean; + serviceDayMap: EtaDb["serviceDayMap"]; + searchRange: number; +}): Partial> => { + const nearbyRoutes = Object.entries(stopList) + .map((stop: [string, StopListEntry]): [string, StopListEntry, number] => + // potentially could be optimized by other distance function + [...stop, getDistance(stop[1].location, geolocation)] + ) + .filter( + (stop) => + // keep only nearby 1000m stops + stop[2] < searchRange + ) + .sort((a, b) => a[2] - b[2]) + .slice(0, 20) + .reduce( + (acc, [stopId]) => { + Object.entries(routeList).forEach(([key, route]) => { + ["kmb", "lrtfeeder", "lightRail", "gmb", "ctb", "nlb"].forEach( + (co) => { + if (route.stops[co] && route.stops[co].includes(stopId)) { + if (acc[coToType[co]] === undefined) acc[coToType[co]] = []; + acc[coToType[co]].push( + key + "/" + route.stops[co].indexOf(stopId) + ); + } + } + ); + }); + return acc; + }, + { bus: [], mtr: [], lightRail: [], minibus: [] } + ); + + return Object.entries(nearbyRoutes).reduce((acc, [type, nearbyRoutes]) => { + acc[type] = formatHandling( + nearbyRoutes, + isTodayHoliday, + isRouteFilter, + routeList, + stopList, + serviceDayMap, + geolocation + ); + return acc; + }, {}); +}; diff --git a/src/components/home/lists/SavedRouteList.tsx b/src/components/home/lists/SavedRouteList.tsx new file mode 100644 index 000000000000..43170707d529 --- /dev/null +++ b/src/components/home/lists/SavedRouteList.tsx @@ -0,0 +1,145 @@ +import { List, Typography } from "@mui/material"; +import { useContext, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import SuccinctTimeReport from "../SuccinctTimeReport"; +import { + type EtaDb, + type Location, + type RouteList, + type StopList, +} from "hk-bus-eta"; +import { formatHandling, getDistance } from "../../../utils"; +import AppContext from "../../../AppContext"; + +interface SavedRouteListProps { + isFocus: boolean; +} + +const SavedRouteList = ({ isFocus }: SavedRouteListProps) => { + const { t } = useTranslation(); + const { + savedEtas, + geolocation, + db: { routeList, stopList, serviceDayMap }, + isRouteFilter, + isTodayHoliday, + } = useContext(AppContext); + + const savedRoutes = useMemo( + () => + getRoutes({ + savedEtas, + geolocation, + stopList, + routeList, + isRouteFilter, + isTodayHoliday, + serviceDayMap, + }), + [ + savedEtas, + geolocation, + routeList, + stopList, + isRouteFilter, + isTodayHoliday, + serviceDayMap, + ] + ); + const noRoutes = useMemo( + () => savedRoutes.every((routeId) => !routeId), + [savedRoutes] + ); + + if (!isFocus) { + return <>; + } + + if (noRoutes) { + return ( + + {t("未有收藏路線")} + + ); + } + + return ( + + {savedRoutes.map( + (selectedRoute, idx) => + Boolean(selectedRoute) && ( + + ) + )} + + ); +}; + +export default SavedRouteList; + +const getRoutes = ({ + savedEtas, + geolocation, + stopList, + routeList, + isRouteFilter, + isTodayHoliday, + serviceDayMap, +}: { + savedEtas: string[]; + geolocation: Location; + stopList: StopList; + routeList: RouteList; + isRouteFilter: boolean; + isTodayHoliday: boolean; + serviceDayMap: EtaDb["serviceDayMap"]; +}): string[] => + formatHandling( + savedEtas + .filter((routeUrl, index, self) => { + return ( + self.indexOf(routeUrl) === index && + routeUrl.split("/")[0] in routeList + ); + }) + .map((routeUrl, idx, self): [string, number, number] => { + const [routeId, stopIdx] = routeUrl.split("/"); + // TODO: taking the longest stop array to avoid error, should be fixed in the database + const _stops = Object.values(routeList[routeId].stops).sort( + (a, b) => b.length - a.length + )[0]; + if (stopIdx !== undefined) { + // if specified which stop + return [ + routeUrl, + getDistance(geolocation, stopList[_stops[stopIdx]].location), + self.length - idx, + ]; + } else { + // else find the nearest stop + const stop = _stops + .map((stop) => [ + stop, + getDistance(geolocation, stopList[stop].location), + ]) + .sort(([, a], [, b]) => (a < b ? -1 : 1))[0][0]; + return [ + routeUrl, + getDistance(geolocation, stopList[stop].location), + self.length - idx, + ]; + } + }) + .sort((a, b) => a[2] - b[2]) + .map((v) => v[0]) + .slice(0, 40), + isTodayHoliday, + isRouteFilter, + routeList, + stopList, + serviceDayMap, + geolocation + ).split("|"); diff --git a/src/components/home/lists/SmartCollectionRouteList.tsx b/src/components/home/lists/SmartCollectionRouteList.tsx new file mode 100644 index 000000000000..05966be3accb --- /dev/null +++ b/src/components/home/lists/SmartCollectionRouteList.tsx @@ -0,0 +1,142 @@ +import { Box, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import HomeRouteListDropDown from "./HomeRouteListDropDown"; +import { EtaDb, Location, RouteList, StopList } from "hk-bus-eta"; +import { RouteCollection } from "../../../typing"; +import { formatHandling } from "../../../utils"; +import { useContext, useMemo } from "react"; +import AppContext from "../../../AppContext"; + +interface SmartCollectionRouteListProps { + isFocus: boolean; +} + +const SmartCollectionRouteList = ({ + isFocus, +}: SmartCollectionRouteListProps) => { + const { t } = useTranslation(); + + const { + collections: _collections, + geolocation, + db: { routeList, stopList, serviceDayMap }, + isRouteFilter, + isTodayHoliday, + } = useContext(AppContext); + + const collections = useMemo( + () => + getCollections({ + collections: _collections, + geolocation, + stopList, + routeList, + isRouteFilter, + isTodayHoliday, + serviceDayMap, + }), + [ + _collections, + geolocation, + stopList, + routeList, + isRouteFilter, + isTodayHoliday, + serviceDayMap, + ] + ); + + if (!isFocus) { + return <>; + } + + if (collections.length === 0) { + return ( + + {t("未有收藏路線")} + + ); + } + + return ( + + {collections.map(({ name, routes, defaultExpanded }, idx) => ( + + ))} + {!collections.reduce( + (acc, { routes }) => + acc || routes.split("|").filter((v) => Boolean(v)).length > 0, + false + ) && ( + + {t("未有收藏路線")} + + )} + + ); +}; + +export default SmartCollectionRouteList; + +const getCollections = ({ + collections, + geolocation, + stopList, + routeList, + isRouteFilter, + isTodayHoliday, + serviceDayMap, +}: { + collections: RouteCollection[]; + geolocation: Location; + stopList: StopList; + routeList: RouteList; + isRouteFilter: boolean; + isTodayHoliday: boolean; + serviceDayMap: EtaDb["serviceDayMap"]; +}): Array<{ + name: string; + routes: string; + defaultExpanded: boolean; +}> => { + return collections + .reduce((acc, { name, list, schedules }) => { + acc.push({ + name: name, + routes: list, + defaultExpanded: schedules.reduce((acc, { day, start, end }) => { + if (acc) return acc; + const curDate = new Date(); + curDate.setUTCHours(curDate.getUTCHours() + 8); + const _day = curDate.getUTCDay(); + // skip handling timezone here + if ((isTodayHoliday && day === 0) || day === _day) { + let sTs = start.hour * 60 + start.minute; + let eTs = end.hour * 60 + end.minute; + let curTs = + (curDate.getUTCHours() * 60 + curDate.getUTCMinutes()) % 1440; + return sTs <= curTs && curTs <= eTs; + } + return false; + }, false), + }); + return acc; + }, []) + .map((v) => ({ + ...v, + routes: formatHandling( + v.routes, + isTodayHoliday, + isRouteFilter, + routeList, + stopList, + serviceDayMap, + geolocation + ), + })); +}; diff --git a/src/pages/DataImport.tsx b/src/pages/DataImport.tsx index 8f9d1021031d..c48b30e0953f 100644 --- a/src/pages/DataImport.tsx +++ b/src/pages/DataImport.tsx @@ -13,11 +13,7 @@ import { decompress } from "lzutf8-light"; import { Check as CheckIcon } from "@mui/icons-material"; import throttle from "lodash.throttle"; import AppContext, { AppState } from "../AppContext"; -import { - DEFAULT_GEOLOCATION, - DEFAULT_SEARCH_RANGE_OPTIONS, - isStrings, -} from "../utils"; +import { DEFAULT_GEOLOCATION, DEFAULT_SEARCH_RANGE, isStrings } from "../utils"; import { CollectionState } from "../CollectionContext"; const DataImport = () => { @@ -123,7 +119,7 @@ const DataImport = () => { annotateScheduled: obj.annotateScheduled ?? true, isRecentSearchShown: obj.isRecentSearchShown ?? true, fontSize: obj.fontSize ?? 16, - searchRange: obj.searchRange ?? DEFAULT_SEARCH_RANGE_OPTIONS.slice(-1)[0], + searchRange: obj.searchRange ?? DEFAULT_SEARCH_RANGE, isRangeController: false, }); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index ad84800f9945..a0504add4775 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,12 +1,10 @@ -import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; +import React, { useContext, useEffect, useRef, useState } from "react"; import { Paper, SxProps, Theme, Typography } from "@mui/material"; import { visuallyHidden } from "@mui/utils"; import { useTranslation } from "react-i18next"; import AppContext from "../AppContext"; import { setSeoHeader } from "../utils"; -import throttle from "lodash.throttle"; -import { Location } from "hk-bus-eta"; import HomeTabbar, { isHomeTab } from "../components/home/HomeTabbar"; import type { HomeTabType } from "../components/home/HomeTabbar"; import BadWeatherCard from "../components/layout/BadWeatherCard"; @@ -15,8 +13,7 @@ import DbRenewReminder from "../components/layout/DbRenewReminder"; import { useParams } from "react-router-dom"; const Home = () => { - const { AppTitle, manualGeolocation, geolocation, collections } = - useContext(AppContext); + const { AppTitle, collections } = useContext(AppContext); const { t, i18n } = useTranslation(); const { collectionName } = useParams(); @@ -35,20 +32,6 @@ const Home = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [i18n.language]); - // throttle to avoid rapidly UI changes due to geolocation changes - const [_geolocation, set_geolocation] = useState(geolocation); - const throttleUpdateGeolocation = useMemo( - () => - throttle((geolocation: Location) => { - set_geolocation(geolocation); - }, 1000), - [] - ); - - useEffect(() => { - throttleUpdateGeolocation(manualGeolocation ?? geolocation); - }, [manualGeolocation, geolocation, throttleUpdateGeolocation]); - const handleTabChange = (v: HomeTabType, rerenderList = false) => { setHomeTab(v); localStorage.setItem("homeTab", v); @@ -71,8 +54,6 @@ const Home = () => { diff --git a/src/utils.ts b/src/utils.ts index 8ec7b4a738ac..5dc0b0116559 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,14 @@ -import type { Company, StopListEntry } from "hk-bus-eta"; +import type { + Company, + EtaDb, + Location, + RouteList, + StopList, + StopListEntry, +} from "hk-bus-eta"; import type { Location as GeoLocation } from "hk-bus-eta"; import type { TransportType, WarnUpMessageData } from "./typing"; +import { isRouteAvaliable } from "./timetable"; export const getDistance = (a: GeoLocation, b: GeoLocation) => { const R = 6371e3; // metres const φ1 = (a.lat * Math.PI) / 180; // φ, λ in radians @@ -29,6 +37,8 @@ export const DEFAULT_GEOLOCATION: GeoLocation = { lng: 114.177216, }; +export const DEFAULT_SEARCH_RANGE = 100; + export const DEFAULT_SEARCH_RANGE_OPTIONS: number[] = [100, 200, 500]; // HK location if no valid value @@ -397,3 +407,47 @@ export const PLATFORM = [ "⑧", "⑨", ] as const; + +export const formatHandling = ( + routes: string[], + isTodayHoliday: boolean, + isRouteFilter: boolean, + routeList: RouteList, + stopList: StopList, + serviceDayMap: EtaDb["serviceDayMap"], + geolocation: Location +) => { + return routes + .filter((v, i, s) => s.indexOf(v) === i) // uniqify + .filter((routeUrl) => { + const [routeId] = routeUrl.split("/"); + return ( + routeList[routeId] && + (!isRouteFilter || + isRouteAvaliable( + routeId, + routeList[routeId].freq, + isTodayHoliday, + serviceDayMap + )) + ); + }) + .map((routeUrl) => { + // handling for saved route without specified stop, use the nearest one + const [routeId, stopIdx] = routeUrl.split("/"); + if (stopIdx !== undefined) return routeUrl; + const _stops = Object.values(routeList[routeId].stops).sort( + (a, b) => b.length - a.length + )[0]; + const stop = _stops + .map((stop) => [ + stop, + getDistance(geolocation, stopList[stop].location), + ]) + .sort(([, a], [, b]) => (a < b ? -1 : 1))[0][0]; + return `${routeUrl}/${_stops.indexOf(stop as string)}`; + }) + .concat(Array(40).fill("")) // padding + .slice(0, 40) + .join("|"); +};