diff --git a/src/components/bookmarked-stop/StopRouteList.tsx b/src/components/bookmarked-stop/StopRouteList.tsx index 6f0f0957fd10..5441dd42bec8 100644 --- a/src/components/bookmarked-stop/StopRouteList.tsx +++ b/src/components/bookmarked-stop/StopRouteList.tsx @@ -1,15 +1,24 @@ import { Box, CircularProgress, List, SxProps, Theme } from "@mui/material"; import SuccinctTimeReport from "../home/SuccinctTimeReport"; +import { getStopGroup } from "../../stopGroup"; import { useStopEtas } from "../../hooks/useStopEtas"; import { Company } from "hk-bus-eta"; +import DbContext from "../../context/DbContext"; +import { useContext, useMemo } from "react"; interface StopRouteListProps { stops: Array<[Company, string]>; // [[co, stopId]] + routeId : string | undefined; isFocus: boolean; } -const StopRouteList = ({ stops, isFocus }: StopRouteListProps) => { - const stopEtas = useStopEtas({ stopKeys: stops, disabled: !isFocus }); +const StopRouteList = ({ stops, routeId = undefined, isFocus }: StopRouteListProps) => { + const { db: { routeList, stopList } } = useContext(DbContext); + const stopGroup = useMemo( + () => getStopGroup({ routeList, stopList, stopKeys: stops, routeId }), + [routeList, stopList, stops, routeId] + ); + const stopEtas = useStopEtas({ stopKeys: stopGroup, disabled: !isFocus }); if (stopEtas.length === 0) { return ( diff --git a/src/components/bookmarked-stop/SwipeableStopList.tsx b/src/components/bookmarked-stop/SwipeableStopList.tsx index d40bd8298c2e..6d82abc54ac0 100644 --- a/src/components/bookmarked-stop/SwipeableStopList.tsx +++ b/src/components/bookmarked-stop/SwipeableStopList.tsx @@ -72,6 +72,7 @@ const SwipeableStopList = React.forwardRef< ))} diff --git a/src/components/route-eta/StopDialog.tsx b/src/components/route-eta/StopDialog.tsx index 5ac165c7d6cf..37efd40aa293 100644 --- a/src/components/route-eta/StopDialog.tsx +++ b/src/components/route-eta/StopDialog.tsx @@ -23,11 +23,12 @@ import CollectionContext from "../../CollectionContext"; interface StopDialogProps { open: boolean; + routeId : string; stops: Array<[Company, string]>; onClose: () => void; } -const StopDialog = ({ open, stops, onClose }: StopDialogProps) => { +const StopDialog = ({ open, routeId, stops, onClose }: StopDialogProps) => { const { db: { stopList }, } = useContext(DbContext); @@ -83,7 +84,7 @@ const StopDialog = ({ open, stops, onClose }: StopDialogProps) => { - + ); diff --git a/src/pages/RouteEta.tsx b/src/pages/RouteEta.tsx index 71ce826999f4..63ecffe6e47f 100644 --- a/src/pages/RouteEta.tsx +++ b/src/pages/RouteEta.tsx @@ -253,6 +253,7 @@ const RouteEta = () => { /> diff --git a/src/stopGroup.ts b/src/stopGroup.ts new file mode 100644 index 000000000000..fc4f5f85464e --- /dev/null +++ b/src/stopGroup.ts @@ -0,0 +1,175 @@ +import { Company } from "hk-bus-eta"; +import type { RouteList, StopList, StopListEntry } from "hk-bus-eta"; +import { getDistance, getBearing } from "./utils"; + +interface useStopGroupProps { + routeList : RouteList; + stopList : StopList; + stopKeys: Array<[Company, string]>; + routeId : string | undefined; +} +interface StopListEntryExtended extends StopListEntry { + id : string; + distance : number; + routeStops : RouteStopCoSeq[]; +} +interface RouteStopCoSeq { + routeKey : string; + co : Company; + seq : number; +} + +// stopKey in format "|", e.g., "lightRail|LR140" +export const getStopGroup = ({ + routeList, stopList, stopKeys, routeId +}: useStopGroupProps) => { + + // TODO: put these constants in AppContext user preference + const DISTANCE_THRESHOLD = 50; // in metres, will recursively find stops within this number of metres, so keep it as small as possible. Never choose larger than 300m. + const BEARING_THRESHOLD = 45; // in degrees (°), acceptable deviation to the left or right of current bearing + const STOP_LIST_LIMIT = 50; // max number of stops in a group, if more than that, the ETA list will be too long and meaningless + + const getDistanceStops = (a : StopListEntry, b : StopListEntry) => { + return getDistance(a.location, b.location); + }; + const getBearingStops = (a : StopListEntry, b : StopListEntry) => { + return getBearing(a.location, b.location); + }; + + const getAllRouteStopsBearings = (routeStops : RouteStopCoSeq[]) => { + // routeStop example: {"routeKey":"101+1+KENNEDY TOWN+KWUN TONG (YUE MAN SQUARE)","co":"ctb","seq":12} + return routeStops.map(routeStop => { + const { routeKey, co, seq } = routeStop; + const stopLength = routeList[routeKey].stops[co].length; + if(seq == stopLength - 1) { // last stop + // no next stop, hence no forward bearing, just use -1 as dummy value then discard it later + return -1; + } + const stopA = stopList[routeList[routeKey].stops[co][seq]]; + const stopB = stopList[routeList[routeKey].stops[co][seq + 1]]; + return getBearingStops(stopA, stopB); + }).filter(brng => brng !== -1); + }; + + const routeStops : RouteStopCoSeq[] = []; + if(routeId !== undefined) { + // StopDialog + let targetRouteStops = routeList[routeId].stops; + stopKeys.forEach(([co, stopId]) => { + let seq = targetRouteStops[co]?.indexOf(stopId) ?? -1; + if(seq != -1) { + routeStops.push({ + routeKey : routeId, + co : co, + seq : seq + }); + } + }); + } else { + // SwipableStopList (saved stop list) + stopKeys.forEach(([co, stopId]) => { + Object.keys(routeList).forEach(routeKey => { + let seq = routeList[routeKey]?.stops[co]?.indexOf(stopId) ?? -1; + if(seq != -1) { + routeStops.push({ + routeKey : routeKey, + co : co, + seq : seq + }); + } + }) + }); + } + + const bearingTargets = getAllRouteStopsBearings(routeStops); + const isBearingInRange = (bearing : number) => { + if(BEARING_THRESHOLD >= 180 || bearingTargets.length == 0) { + return true; + } + for(let i = 0; i < bearingTargets.length; ++i) { + let bearingMin = bearingTargets[i] - BEARING_THRESHOLD; + let bearingMax = bearingTargets[i] + BEARING_THRESHOLD; + if(bearingMin < 0) + bearingMin += 360; + if(bearingMax > 360) + bearingMax -= 360; + if((bearingMin <= bearingMax && bearingMin <= bearing && bearing <= bearingMax) + || (bearingMin > bearingMax && (bearingMin <= bearing || bearing <= bearingMax))) // crossing 0/360° mark, eg min=340°,max=020° + return true; + } + return false; + }; + + const searchNearbyStops = (targetStopId : string, excludedStopIdList : string[]) => { + const targetStop = stopList[targetStopId]; + + return Object.keys(stopList).filter((stopId) => { + // find stops that are within DISTANCE_THRESHOLD metres of target stop and along similar direction + return getDistanceStops(targetStop, stopList[stopId]) <= DISTANCE_THRESHOLD && + // filter out stops that have not been added into excludeList before + !excludedStopIdList.includes(stopId); + }) + .reduce( (acc, stopId) => { + // get all the routes that has stop with this stopId + const _routeStops = Object.entries(routeList).map(([routeKey, routeListEntry]) => { + const stops = routeListEntry.stops ?? {}; + const companies = Object.keys(stops) as Company[]; + for(let co of companies) { + let stopPos = stops[co]?.indexOf(stopId) ?? -1; + if(stopPos > -1) + return { routeKey : routeKey, co : co, seq : stopPos } as RouteStopCoSeq; + } + return { routeKey : routeKey, co : 'ctb', seq : -1 } as RouteStopCoSeq; // use ctb as dummy value and seq = -1, will be discarded in next filter + }) + .filter((_rs) => _rs.seq != -1); + // if any of the routes passing this stop is facing same direction (+/- BEARING_THRESHOLD), add the stop to the list + // Note: once the stop is added, other routes not facing same direction but passing this stop will also be shown in ETA (most commonly seen in railway lines) + const bearings = getAllRouteStopsBearings(_routeStops); + if(bearings.find(b => isBearingInRange(b)) !== undefined) { + const thisStop : StopListEntryExtended = { + ...stopList[stopId], + id : stopId, + routeStops : _routeStops, // _routeStops.length must be > 0 here, as bearings.length must be > 0 to reach into this if-condition + distance : 0 // dummy value + }; + acc.push(thisStop); + } + return acc + }, [] as Array); + }; + + const stopGroup : Array<[Company, string]> = []; + + let stopListEntries : StopListEntryExtended[] = []; + + // recursively search for nearby stops within thresholds (distance and bearing) + // stop searching when no new stops are found within range, or when stop list is getting too large + stopKeys.forEach((stopKey) => { + const [, stopId] = stopKey; // [co, stopId] + stopListEntries = stopListEntries.concat(searchNearbyStops(stopId, stopListEntries.map((stop) => stop.id))); + for(let i = 0; i < stopListEntries.length; ++i) { // use traditional for-loop as the length keeps expanding + stopListEntries = stopListEntries.concat(searchNearbyStops(stopListEntries[i].id, stopListEntries.map((stop) => stop.id))); + if(stopListEntries.length >= STOP_LIST_LIMIT) + break; + } + }); + + // sort by distance from first stop in stopMap (stopKeys[0]) + if(stopKeys.length > 0) { + let [, stopId] = stopKeys[0]; // [co, stopId] but don't use this co + stopListEntries = stopListEntries.map(stop => { + return { + ... stop, + distance : getDistanceStops(stopList[stopId], stop) + }; + }).sort((stopA, stopB) => { + return stopA.distance - stopB.distance + }); + stopListEntries.forEach((stopListEntry) => { + if(stopListEntry.routeStops.length > 0) + stopGroup.push([stopListEntry.routeStops[0].co, stopListEntry.id]); + }); + } + + return stopGroup; +}; diff --git a/src/utils.ts b/src/utils.ts index 3ac44629645c..f2a519c342ee 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -34,6 +34,19 @@ export const getDistanceWithUnit = (distanceInMetre: number) => { }; }; +export const getBearing = (a: GeoLocation, b: GeoLocation) => { + // Reference: https://www.movable-type.co.uk/scripts/latlong.html + const φ1 = a.lat * Math.PI / 180; // φ, λ = lat, lon in radians + const φ2 = b.lat * Math.PI / 180; + const λ1 = a.lng * Math.PI / 180; // φ, λ = lat, lon in radians + const λ2 = b.lng * Math.PI / 180; + const y = Math.sin(λ2 - λ1) * Math.cos(φ2); + const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(λ2-λ1); + const θ = Math.atan2(y, x); + const brng = (θ * 180 / Math.PI + 360) % 360; // in degrees + return brng; +}; + export const DEFAULT_GEOLOCATION: GeoLocation = { lat: 22.302711, lng: 114.177216,