diff --git a/package.json b/package.json index aecd94ea..0b9d170b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "jspdf": "^3.0.3", "lodash.debounce": "^4.0.8", "maplibre-gl": "^5.10.0", - "mobility-toolbox-js": "3.4.6", + "mobility-toolbox-js": "3.5.0", "ol": "^10.6.1", "preact": "^10.27.2", "preact-custom-element": "^4.5.1", diff --git a/src/Departure/Departure.tsx b/src/Departure/Departure.tsx index a7669b8f..ddbc49d6 100644 --- a/src/Departure/Departure.tsx +++ b/src/Departure/Departure.tsx @@ -14,7 +14,8 @@ export interface DepartureProps { } function Departure({ departure, index, ...props }: DepartureProps) { - const { setStationId, setTrainId } = useMapContext(); + const { realtimeLayer, setFeaturesInfos, setStationId, setTrainId } = + useMapContext(); const departureState = useMemo(() => { return { departure, index }; @@ -23,10 +24,12 @@ function Departure({ departure, index, ...props }: DepartureProps) { return ( + ); +} + +export default memo(SearchLinesResult); diff --git a/src/SearchLinesResult/index.tsx b/src/SearchLinesResult/index.tsx new file mode 100644 index 00000000..34dcdb71 --- /dev/null +++ b/src/SearchLinesResult/index.tsx @@ -0,0 +1 @@ +export { default } from "./SearchLinesResult"; diff --git a/src/SearchLinesResults/SearchLinesResults.tsx b/src/SearchLinesResults/SearchLinesResults.tsx new file mode 100644 index 00000000..fa7fa8a7 --- /dev/null +++ b/src/SearchLinesResults/SearchLinesResults.tsx @@ -0,0 +1,106 @@ +import { cloneElement, toChildArray } from "preact"; +import { memo } from "preact/compat"; +import { useCallback, useContext, useMemo } from "preact/hooks"; + +import { SearchContext } from "../Search/Search2"; +import SearchResult from "../SearchResult"; +import SearchResults from "../SearchResults"; +import SearchResultsHeader from "../SearchResultsHeader"; +import useI18n from "../utils/hooks/useI18n"; +import useSearchLines from "../utils/hooks/useSearchLines"; + +import type { ReactElement } from "preact/compat"; + +import type { + SearchResultsChildProps, + SearchResultsProps, +} from "../SearchResults/SearchResults"; +import type { LnpLineInfo } from "../utils/hooks/useLnp"; + +const defaultSort = (a: LnpLineInfo, b: LnpLineInfo) => { + if (a.long_name === b.long_name) { + return a.long_name < b.long_name ? 1 : -1; + } + return a.short_name < b.short_name ? 1 : -1; +}; + +function SearchLinesResults({ + children, + filter, + resultClassName, + resultsClassName, + resultsContainerClassName, + sort = defaultSort, +}: SearchResultsProps) { + const { open, query, setOpen, setSelectedQuery } = useContext(SearchContext); + const searchResponse = useSearchLines(query); + + const { t } = useI18n(); + + const onSelectResult = useCallback( + (item: LnpLineInfo) => { + setSelectedQuery(item.short_name || item.long_name); + setOpen(false); + }, + [setOpen, setSelectedQuery], + ); + + const results = useMemo(() => { + let rs = [...(searchResponse?.results || [])]; + + if (filter) { + rs = rs.filter(filter); + } + + if (sort) { + rs = rs.sort(sort); + } + return rs; + }, [searchResponse, filter, sort]); + + const searchResponseFiltered = useMemo(() => { + return { + ...searchResponse, + results, + }; + }, [results, searchResponse]); + + const showResults = useMemo(() => { + return open && !!results?.length; + }, [open, results]); + + if (!showResults) { + return null; + } + + return ( + <> + {t("search_lines_results")} + + {results.map((item: LnpLineInfo) => { + return ( + + {toChildArray(children).map( + (child: ReactElement>) => { + const onSelectItem = (itemm: LnpLineInfo, evt: Event) => { + onSelectResult(itemm); + child.props?.onSelectItem?.(itemm, evt); + }; + return cloneElement(child, { + item: item, + onSelectItem, + }); + }, + )} + + ); + })} + + + ); +} +export default memo(SearchLinesResults); diff --git a/src/SearchLinesResults/index.tsx b/src/SearchLinesResults/index.tsx new file mode 100644 index 00000000..7ef1e842 --- /dev/null +++ b/src/SearchLinesResults/index.tsx @@ -0,0 +1 @@ +export { default } from "./SearchLinesResults"; diff --git a/src/SearchResult/SearchResult.tsx b/src/SearchResult/SearchResult.tsx new file mode 100644 index 00000000..d6d02603 --- /dev/null +++ b/src/SearchResult/SearchResult.tsx @@ -0,0 +1,25 @@ +import { memo } from "preact/compat"; +import { twMerge } from "tailwind-merge"; + +import type { HTMLAttributes, PreactDOMAttributes } from "preact"; + +export type SearchResultProps = { + className?: string; +} & HTMLAttributes & + PreactDOMAttributes; + +function SearchResult({ children, className, ...props }: SearchResultProps) { + return ( +
  • + {children} +
  • + ); +} + +export default memo(SearchResult); diff --git a/src/SearchResult/index.tsx b/src/SearchResult/index.tsx new file mode 100644 index 00000000..89d49d4d --- /dev/null +++ b/src/SearchResult/index.tsx @@ -0,0 +1 @@ +export { default } from "./SearchResult"; diff --git a/src/SearchResults/SearchResults.tsx b/src/SearchResults/SearchResults.tsx new file mode 100644 index 00000000..5ad54172 --- /dev/null +++ b/src/SearchResults/SearchResults.tsx @@ -0,0 +1,77 @@ +import { memo } from "preact/compat"; +import { twMerge } from "tailwind-merge"; + +import useI18n from "../utils/hooks/useI18n"; + +import type { HTMLAttributes, PreactDOMAttributes } from "preact"; + +import type { SearchResponse } from "../utils/hooks/useSearchStops"; + +export type SearchResultsProps = { + className?: string; + filter?: (item: T) => boolean; + resultClassName?: string; + resultsClassName?: string; + resultsContainerClassName?: string; + searchResponse?: SearchResponse; + sort?: (a: T, b: T) => number; +} & HTMLAttributes & + PreactDOMAttributes; + +export interface SearchResultsChildProps { + item: T; + onSelectItem?: (item: T, evt: Event) => void; +} + +/** + * Results list of search. + */ +function SearchResults({ + children, + className, + resultsClassName, + searchResponse, + ...props +}: SearchResultsProps) { + const { t } = useI18n(); + + if (!(searchResponse?.results?.length >= 0)) { + return null; + } + + return ( +
    + {searchResponse.results.length === 0 && ( +
    +
    +
    {t("search_no_results")}
    +
    + )} + {searchResponse.results.length > 0 && ( +
      + {children} +
    + )} +
    + ); +} + +export default memo(SearchResults); diff --git a/src/SearchResults/index.tsx b/src/SearchResults/index.tsx new file mode 100644 index 00000000..e185c4d5 --- /dev/null +++ b/src/SearchResults/index.tsx @@ -0,0 +1 @@ +export { default } from "./SearchResults"; diff --git a/src/SearchResultsHeader/SearchResultsHeader.tsx b/src/SearchResultsHeader/SearchResultsHeader.tsx new file mode 100644 index 00000000..28eef485 --- /dev/null +++ b/src/SearchResultsHeader/SearchResultsHeader.tsx @@ -0,0 +1,33 @@ +import { memo } from "preact/compat"; +import { twMerge } from "tailwind-merge"; + +import type { HTMLAttributes, PreactDOMAttributes } from "preact"; + +export type SearchResultsHeaderProps = { + className?: string; + resultsClassName?: string; +} & HTMLAttributes & + PreactDOMAttributes; + +/** + * Header of list of search results. + */ +function SearchResultsHeader({ + children, + className, + ...props +}: SearchResultsHeaderProps) { + return ( +
    + {children} +
    + ); +} + +export default memo(SearchResultsHeader); diff --git a/src/SearchResultsHeader/index.tsx b/src/SearchResultsHeader/index.tsx new file mode 100644 index 00000000..37acbdf2 --- /dev/null +++ b/src/SearchResultsHeader/index.tsx @@ -0,0 +1 @@ +export { default } from "./SearchResultsHeader"; diff --git a/src/SearchStopsResult/SearchStopsResult.tsx b/src/SearchStopsResult/SearchStopsResult.tsx new file mode 100644 index 00000000..68a6cb20 --- /dev/null +++ b/src/SearchStopsResult/SearchStopsResult.tsx @@ -0,0 +1,43 @@ +import { memo } from "preact/compat"; +import { twMerge } from "tailwind-merge"; + +import useMapContext from "../utils/hooks/useMapContext"; + +import type { ButtonHTMLAttributes, PreactDOMAttributes } from "preact"; + +import type { StopsFeature } from "../utils/hooks/useSearchStops"; + +export type SearchStopsResultProps = { + className?: string; + item?: StopsFeature; + onSelectItem?: (stop: StopsFeature, evt: MouseEvent) => void; +} & ButtonHTMLAttributes & + PreactDOMAttributes; + +function SearchStopsResult({ + className, + item, + onSelectItem, + ...props +}: SearchStopsResultProps) { + const { stationsLayer } = useMapContext(); + + return ( + + ); +} + +export default memo(SearchStopsResult); diff --git a/src/SearchStopsResult/index.tsx b/src/SearchStopsResult/index.tsx new file mode 100644 index 00000000..6546650f --- /dev/null +++ b/src/SearchStopsResult/index.tsx @@ -0,0 +1 @@ +export { default } from "./SearchStopsResult"; diff --git a/src/SearchStopsResults/SearchStopsResults.tsx b/src/SearchStopsResults/SearchStopsResults.tsx new file mode 100644 index 00000000..e156f067 --- /dev/null +++ b/src/SearchStopsResults/SearchStopsResults.tsx @@ -0,0 +1,101 @@ +import { cloneElement, toChildArray } from "preact"; +import { memo } from "preact/compat"; +import { useCallback, useContext, useMemo } from "preact/hooks"; + +import { SearchContext } from "../Search/Search2"; +import SearchResult from "../SearchResult"; +import SearchResults from "../SearchResults"; +import SearchResultsHeader from "../SearchResultsHeader"; +import useI18n from "../utils/hooks/useI18n"; +import useSearchStops from "../utils/hooks/useSearchStops"; + +import type { ReactElement } from "preact/compat"; + +import type { + SearchResultsChildProps, + SearchResultsProps, +} from "../SearchResults/SearchResults"; +import type { StopsFeature } from "../utils/hooks/useSearchStops"; + +function SearchStopsResults({ + children, + filter, + resultClassName, + resultsClassName, + resultsContainerClassName, + sort, +}: SearchResultsProps) { + const { open, query, setOpen, setSelectedQuery } = useContext(SearchContext); + const searchResponse = useSearchStops(query); + + const { t } = useI18n(); + + const onSelectResult = useCallback( + (item: StopsFeature) => { + setSelectedQuery(item.properties.name); + setOpen(false); + }, + [setOpen, setSelectedQuery], + ); + + const results = useMemo(() => { + let rs = [...(searchResponse?.results || [])]; + + if (filter) { + rs = rs.filter(filter); + } + + if (sort) { + rs = rs.sort(sort); + } + return rs; + }, [searchResponse, filter, sort]); + + const searchResponseFiltered = useMemo(() => { + return { + ...searchResponse, + results, + }; + }, [results, searchResponse]); + + const showResults = useMemo(() => { + return open && !!results?.length; + }, [open, results]); + + if (!showResults) { + return null; + } + + return ( + <> + {t("search_stops_results")} + + {results.map((item: StopsFeature) => { + return ( + + {toChildArray(children).map( + ( + child: ReactElement>, + ) => { + const onSelectItem = (itemm: StopsFeature, evt: Event) => { + onSelectResult(itemm); + child.props?.onSelectItem?.(itemm, evt); + }; + return cloneElement(child, { + item: item, + onSelectItem, + }); + }, + )} + + ); + })} + + + ); +} +export default memo(SearchStopsResults); diff --git a/src/SearchStopsResults/index.tsx b/src/SearchStopsResults/index.tsx new file mode 100644 index 00000000..ea7c0f34 --- /dev/null +++ b/src/SearchStopsResults/index.tsx @@ -0,0 +1 @@ +export { default } from "./SearchStopsResults"; diff --git a/src/SearchTrajectoriesResult/SearchTrajectoriesResult.tsx b/src/SearchTrajectoriesResult/SearchTrajectoriesResult.tsx new file mode 100644 index 00000000..75213c0b --- /dev/null +++ b/src/SearchTrajectoriesResult/SearchTrajectoriesResult.tsx @@ -0,0 +1,43 @@ +import { memo } from "preact/compat"; +import { twMerge } from "tailwind-merge"; + +import RouteIcon from "../RouteIcon"; +import useMapContext from "../utils/hooks/useMapContext"; + +import type { RealtimeTrajectory } from "mobility-toolbox-js/types"; +import type { ButtonHTMLAttributes, PreactDOMAttributes } from "preact"; + +export type SearchTrajectoriesResultProps = { + className?: string; + onSelect?: (line: RealtimeTrajectory) => void; + trajectory: RealtimeTrajectory; +} & ButtonHTMLAttributes & + PreactDOMAttributes; + +function SearchTrajectoriesResult({ + className, + onSelect, + trajectory, + ...props +}: SearchTrajectoriesResultProps) { + const { realtimeLayer, setTrainId } = useMapContext(); + return ( + + ); +} + +export default memo(SearchTrajectoriesResult); diff --git a/src/SearchTrajectoriesResult/index.tsx b/src/SearchTrajectoriesResult/index.tsx new file mode 100644 index 00000000..cba46250 --- /dev/null +++ b/src/SearchTrajectoriesResult/index.tsx @@ -0,0 +1 @@ +export { default } from "./SearchTrajectoriesResult"; diff --git a/src/SingleClickListener/SingleClickListener.tsx b/src/SingleClickListener/SingleClickListener.tsx index 2b67f3c9..c353a41d 100644 --- a/src/SingleClickListener/SingleClickListener.tsx +++ b/src/SingleClickListener/SingleClickListener.tsx @@ -25,6 +25,10 @@ function SingleClickListener({ queryablelayers, setFeaturesInfos, setFeaturesInfosHovered, + setLinesIds, + setNotificationId, + setStationId, + setTrainId, stationsLayer, tenant, } = useMapContext(); @@ -50,7 +54,19 @@ function SingleClickListener({ const stationsFeatures = featuresInfoStations?.features || []; const [stationFeature] = stationsFeatures.filter((feat) => { - return feat.get("tralis_network")?.includes(tenant); + // TODO: think how to do better. LNP stations should have a tralis_network property? + // travic stations has a tralis_network property + if (feat.get("tralis_network")) { + return feat.get("tralis_network").includes(tenant); + } + + // We move the external_id to uid to be consistent across all stations (lnp and others) + if (!feat.get("uid") && feat.get("external_id")) { + feat.set("uid", feat.get("external_id")); + } + + // LNP stations have no tralis_network property + return true; }); // Replace the features clicked in the stations layer by the filtered one @@ -85,8 +101,28 @@ function SingleClickListener({ async (evt: MapBrowserEvent) => { const featuresInfos = await getFeaturesInfosAtEvt(evt); setFeaturesInfos(featuresInfos); + // When user click we close the overlay + if ( + featuresInfos?.flatMap((fi) => { + return fi.features; + }).length === 0 + ) { + // It means no feature selectable were clicked so we set all ids to null + // to close the overlay + setTrainId(null); + setStationId(null); + setNotificationId(null); + setLinesIds(null); + } }, - [getFeaturesInfosAtEvt, setFeaturesInfos], + [ + getFeaturesInfosAtEvt, + setFeaturesInfos, + setLinesIds, + setNotificationId, + setStationId, + setTrainId, + ], ); useEffect(() => { diff --git a/src/Station/Station.tsx b/src/Station/Station.tsx index 929933e3..f209add5 100644 --- a/src/Station/Station.tsx +++ b/src/Station/Station.tsx @@ -1,11 +1,12 @@ -import { debounceDeparturesMessages } from "mobility-toolbox-js/ol"; import { memo } from "preact/compat"; -import { useEffect, useRef, useState } from "preact/hooks"; import { twMerge } from "tailwind-merge"; import Departure from "../Departure"; +import ShadowOverflow from "../ShadowOverflow"; import StationHeader from "../StationHeader"; import useMapContext from "../utils/hooks/useMapContext"; +import useRealtimeDepartures from "../utils/hooks/useRealtimeDepartures"; +import useRealtimeStation from "../utils/hooks/useRealtimeStation"; import type { RealtimeDeparture } from "mobility-toolbox-js/types"; import type { HTMLAttributes, PreactDOMAttributes } from "preact"; @@ -15,34 +16,10 @@ export type StationProps = { } & HTMLAttributes & PreactDOMAttributes; -function Station(props: StationProps) { - const { realtimeLayer, station } = useMapContext(); - const [departures, setDepartures] = useState(); - const ref = useRef(); - const { className } = props; - - useEffect(() => { - if (!station || !realtimeLayer?.api) { - return; - } - - const onMessage = debounceDeparturesMessages( - (newDepartures: RealtimeDeparture[]) => { - setDepartures(newDepartures); - return null; - }, - false, - 180, - ); - // @ts-expect-error bad type definition - realtimeLayer.api.subscribeDepartures(station?.properties?.uid, onMessage); - - return () => { - setDepartures(null); - // @ts-expect-error bad type definition - realtimeLayer?.api?.unsubscribeDepartures(station?.properties.uid); - }; - }, [station, realtimeLayer?.api]); +function Station({ className, ...props }: StationProps) { + const { stationId } = useMapContext(); + const station = useRealtimeStation(stationId); + const departures = useRealtimeDepartures(stationId); if (!station) { return null; @@ -50,24 +27,22 @@ function Station(props: StationProps) { return ( <> - -
    - {(departures || []) - // .filter(hideDepartures) - .map((departure: RealtimeDeparture, index: number) => { - return ( - - ); - })} -
    + + +
    + {(departures || []) + // .filter(hideDepartures) + .map((departure: RealtimeDeparture, index: number) => { + return ( + + ); + })} +
    +
    ); } diff --git a/src/StationHeader/StationHeader.tsx b/src/StationHeader/StationHeader.tsx index a7c90d86..2719913b 100644 --- a/src/StationHeader/StationHeader.tsx +++ b/src/StationHeader/StationHeader.tsx @@ -2,10 +2,10 @@ import { memo } from "preact/compat"; import StationName from "../StationName"; import StationServices from "../StationServices"; -import useMapContext from "../utils/hooks/useMapContext"; -function StationHeader() { - const { station } = useMapContext(); +import type { RealtimeStation } from "mobility-toolbox-js/types"; + +function StationHeader({ station }: { station: RealtimeStation }) { return (
    diff --git a/src/StationsLayer/StationsLayer.tsx b/src/StationsLayer/StationsLayer.tsx index ee88ff80..f4c73161 100644 --- a/src/StationsLayer/StationsLayer.tsx +++ b/src/StationsLayer/StationsLayer.tsx @@ -18,11 +18,19 @@ function StationsLayer(props: Partial) { layersFilter: ({ metadata }) => { return ( metadata?.["tralis.variable"] === "station" || - metadata?.["general.filter"] === "stations" + metadata?.["general.filter"] === "stations" || + metadata?.["geops.filter"] === "netzplan_stops" ); }, maplibreLayer: baseLayer, name: LAYER_NAME_STATIONS, + // queryRenderedLayersFilter: ({ metadata }) => { + // return ( + // metadata?.["tralis.variable"] === "station" || + // metadata?.["general.filter"] === "stations" || + // metadata?.["geops.filter"] === "netzplan_stops" // we include lnp stations only on click + // ); + // }, ...(props || {}), }); }, [baseLayer, props]); diff --git a/src/StopsSearch/StopsSearch.tsx b/src/StopsSearch/StopsSearch.tsx index 4b6ccace..f2847782 100644 --- a/src/StopsSearch/StopsSearch.tsx +++ b/src/StopsSearch/StopsSearch.tsx @@ -24,7 +24,7 @@ import type { TargetedKeyboardEvent, } from "preact"; -export type StopsFeature = StopsResponse["features"][0]; +import type { StopsFeature } from "../utils/hooks/useSearchStops"; export type StopsSearchProps = { apikey: string; diff --git a/src/StopsSearch/index.tsx b/src/StopsSearch/index.tsx index f209f47f..d2be640e 100644 --- a/src/StopsSearch/index.tsx +++ b/src/StopsSearch/index.tsx @@ -1,2 +1 @@ export { default } from "./StopsSearch"; -export type { StopsFeature, StopsSearchProps } from "./StopsSearch"; diff --git a/src/ui/InputSearch/InputSearch.tsx b/src/ui/InputSearch/InputSearch.tsx new file mode 100644 index 00000000..b51a1990 --- /dev/null +++ b/src/ui/InputSearch/InputSearch.tsx @@ -0,0 +1,105 @@ +import { memo } from "preact/compat"; +import { useRef } from "preact/hooks"; +import { twMerge } from "tailwind-merge"; + +import Cancel from "../../icons/Cancel"; +import Search from "../../icons/Search"; +import useI18n from "../../utils/hooks/useI18n"; +import IconButton from "../IconButton"; +import Input from "../Input"; + +import type { PreactDOMAttributes } from "preact"; +import type { HTMLAttributes, ReactNode } from "preact/compat"; + +import type { IconButtonProps } from "../IconButton/IconButton"; +import type { InputProps } from "../Input/Input"; + +export type InputSearchProps = { + cancelButtonClassName?: string; + cancelButtonProps?: IconButtonProps; + cancelIcon?: ReactNode; + className?: string; + inputClassName?: string; + inputContainerClassName?: string; + inputProps?: InputProps; + resultClassName?: string; + resultsClassName?: string; + resultsContainerClassName?: string; + searchIcon?: ReactNode; + searchIconContainerClassName?: string; + withResultsClassName?: string; +} & HTMLAttributes & + PreactDOMAttributes; + +/** + * Rich search input component. + */ +function InputSearch({ + cancelButtonClassName, + cancelButtonProps, + cancelIcon, + children, + className, + inputClassName, + inputContainerClassName, + inputProps, + searchIcon, + searchIconContainerClassName, + withResultsClassName, +}: InputSearchProps) { + const { t } = useI18n(); + const myRef = useRef(); + + return ( + <> +
    +
    + {searchIcon || } +
    +
    + + {!!inputProps.value && ( + + {cancelIcon || } + + )} +
    +
    + {children} + + ); +} + +export default memo(InputSearch); diff --git a/src/ui/InputSearch/index.tsx b/src/ui/InputSearch/index.tsx new file mode 100644 index 00000000..a7a285ad --- /dev/null +++ b/src/ui/InputSearch/index.tsx @@ -0,0 +1 @@ +export { default } from "./InputSearch"; diff --git a/src/utils/centerOnStation.ts b/src/utils/centerOnStation.ts index 85c47422..dd3c133a 100644 --- a/src/utils/centerOnStation.ts +++ b/src/utils/centerOnStation.ts @@ -2,9 +2,9 @@ import { fromLonLat } from "ol/proj"; import type { Map } from "ol"; -import type { StationFeature } from "../StopsSearch"; +import type { StopsFeature } from "./hooks/useSearchStops"; -const centerOnStation = (selectedStation: StationFeature, map: Map) => { +const centerOnStation = (selectedStation: StopsFeature, map: Map) => { const center = selectedStation?.geometry?.coordinates; if (center) { map?.getView()?.animate({ diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 50b15279..4391199d 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -34,6 +34,7 @@ export const LAYER_PROP_IS_EXPORTING = "isExporting"; export const LAYER_PROP_IS_LOADING = "isLoading"; export const MAX_EXTENT = undefined; +export const MAX_EXTENT_4326 = undefined; export const EXPORT_PREFIX = "mwc"; @@ -46,7 +47,7 @@ export const LNP_LINE_ID_PROP = "original_line_id"; // LNP data source id in the style export const LNP_SOURCE_ID = "network_plans"; -// Metadata key in the lnp data source +// LNP metadata key in the lnp data source export const LNP_MD_LINES = "geops.lnp.lines"; export const LNP_MD_STOPS = "geops.lnp.stops"; @@ -55,3 +56,10 @@ export const LNP_GEOPS_FILTER_HIGHLIGHT = "highlightnetzplan"; // LNP style layer id where the dynamic filtering will apply export const LNP_LAYER_ID_HIGHLIGHT = "netzplan_highlight_trip"; + +// Layer props used by layer and/or layerConfig +export const LAYER_TREE_HIDE_PROP = "layerTreeHidden"; +export const LAYER_TREE_TITLE_FUNC_PROP = "layerTreeTitleRenderFunc"; + +/** FIT ON FEATURES */ +export const FIT_ON_FEATURES_MAX_ZOOM_POINT = 16; diff --git a/src/utils/getBgColor.ts b/src/utils/getBgColor.ts deleted file mode 100644 index ee35621c..00000000 --- a/src/utils/getBgColor.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { realtimeConfig } from "mobility-toolbox-js/ol"; - -export default realtimeConfig.getBgColor; diff --git a/src/utils/getDelayColorForVehicle.test.ts b/src/utils/getDelayColorForVehicle.test.ts index b906b83e..94391b03 100644 --- a/src/utils/getDelayColorForVehicle.test.ts +++ b/src/utils/getDelayColorForVehicle.test.ts @@ -2,31 +2,39 @@ import getDelayColorForVehicle from "./getDelayColorForVehicle"; describe("getDelayColorForVehicle", () => { it("returns cancelled color", () => { - expect(getDelayColorForVehicle(0, true, true)).toBe("#dc2626"); - expect(getDelayColorForVehicle(0, true, false)).toBe("#a0a0a0"); + expect(getDelayColorForVehicle(null, null, 0, true, true)).toBe("#dc2626"); + expect(getDelayColorForVehicle(null, null, 0, true, false)).toBe("#a0a0a0"); }); it("returns null delay (no realtime train) color", () => { - expect(getDelayColorForVehicle(null)).toBe("#a0a0a0"); + expect(getDelayColorForVehicle(null, null, null)).toBe("#a0a0a0"); }); it("returns green", () => { - expect(getDelayColorForVehicle(0)).toBe("#16a34a"); - expect(getDelayColorForVehicle(2.49 * 60 * 1000)).toBe("#16a34a"); + expect(getDelayColorForVehicle(null, null, 0)).toBe("#16a34a"); + expect(getDelayColorForVehicle(null, null, 2.49 * 60 * 1000)).toBe( + "#16a34a", + ); }); it("returns yellow", () => { - expect(getDelayColorForVehicle(3 * 60 * 1000)).toBe("#ca8a04"); - expect(getDelayColorForVehicle(4.49 * 60 * 1000 - 1)).toBe("#ca8a04"); + expect(getDelayColorForVehicle(null, null, 3 * 60 * 1000)).toBe("#ca8a04"); + expect(getDelayColorForVehicle(null, null, 4.49 * 60 * 1000 - 1)).toBe( + "#ca8a04", + ); }); it("returns orange", () => { - expect(getDelayColorForVehicle(5 * 60 * 1000)).toBe("#ea580c"); - expect(getDelayColorForVehicle(9.49 * 60 * 1000 - 1)).toBe("#ea580c"); + expect(getDelayColorForVehicle(null, null, 5 * 60 * 1000)).toBe("#ea580c"); + expect(getDelayColorForVehicle(null, null, 9.49 * 60 * 1000 - 1)).toBe( + "#ea580c", + ); }); it("returns red", () => { - expect(getDelayColorForVehicle(10 * 60 * 1000)).toBe("#dc2626"); - expect(getDelayColorForVehicle(180 * 60 * 1000)).toBe("#dc2626"); + expect(getDelayColorForVehicle(null, null, 10 * 60 * 1000)).toBe("#dc2626"); + expect(getDelayColorForVehicle(null, null, 180 * 60 * 1000)).toBe( + "#dc2626", + ); }); }); diff --git a/src/utils/getDelayColorForVehicle.ts b/src/utils/getDelayColorForVehicle.ts index 751a1e5f..a23ff40d 100644 --- a/src/utils/getDelayColorForVehicle.ts +++ b/src/utils/getDelayColorForVehicle.ts @@ -1,13 +1,15 @@ import getDelayColor from "./getDelayColor"; +import type { ViewState } from "mobility-toolbox-js/types"; + /** - * @private - * @param {number} delayInMs Delay in milliseconds. - * @param {boolean} cancelled true if the journey is cancelled. - * @param {boolean} isDelayText true if the color is used for delay text of the symbol. + * Return the delay color depending on an object representing a vehicle or a line. + * This function is used to have the same color on the map and on other components. */ const getDelayColorForVehicle = ( - delayInMs: null | number, + object?: unknown, + viewState?: ViewState, + delayInMs?: number, cancelled?: boolean, isDelayText?: boolean, ): string => { diff --git a/src/utils/getDelayFontForVehicle.test.ts b/src/utils/getDelayFontForVehicle.test.ts deleted file mode 100644 index d51c467b..00000000 --- a/src/utils/getDelayFontForVehicle.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import getDelayFontForVehicle from "./getDelayFontForVehicle"; - -describe("getDelayFontForVehicle", () => { - it("returns font that inherit", () => { - expect(getDelayFontForVehicle(12)).toBe("bold 12px arial"); - }); -}); diff --git a/src/utils/getDelayFontForVehicle.tsx b/src/utils/getDelayFontForVehicle.tsx deleted file mode 100644 index 801fba0a..00000000 --- a/src/utils/getDelayFontForVehicle.tsx +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Return the font for the delay text in the map. - */ -const getDelayFontForVehicle = (fontSize: number) => { - return `bold ${fontSize}px arial`; -}; - -export default getDelayFontForVehicle; diff --git a/src/utils/getDelayTextForVehicle.test.ts b/src/utils/getDelayTextForVehicle.test.ts index 5ebdd86c..2f545505 100644 --- a/src/utils/getDelayTextForVehicle.test.ts +++ b/src/utils/getDelayTextForVehicle.test.ts @@ -2,31 +2,31 @@ import getDelayTextForVehicle from "./getDelayTextForVehicle"; describe("getDelayTextForVehicle", () => { it("returns cancelled character", () => { - expect(getDelayTextForVehicle(7200000, true)).toBe( + expect(getDelayTextForVehicle(null, null, 7200000, true)).toBe( String.fromCodePoint(0x00d7), ); }); it("returns hours (floor)", () => { - expect(getDelayTextForVehicle(7200000)).toBe("+2h"); - expect(getDelayTextForVehicle(7255555)).toBe("+2h1m"); + expect(getDelayTextForVehicle(null, null, 7200000)).toBe("+2h"); + expect(getDelayTextForVehicle(null, null, 7255555)).toBe("+2h1m"); }); it("returns minutes (round)", () => { - expect(getDelayTextForVehicle(120000)).toBe("+2m"); - expect(getDelayTextForVehicle(151000)).toBe("+3m"); + expect(getDelayTextForVehicle(null, null, 120000)).toBe("+2m"); + expect(getDelayTextForVehicle(null, null, 151000)).toBe("+3m"); }); it("doesn't display seconds", () => { - expect(getDelayTextForVehicle(1000)).toBe(""); - expect(getDelayTextForVehicle(30000)).toBe("+1m"); - expect(getDelayTextForVehicle(7255555)).toBe("+2h1m"); + expect(getDelayTextForVehicle(null, null, 1000)).toBe(""); + expect(getDelayTextForVehicle(null, null, 30000)).toBe("+1m"); + expect(getDelayTextForVehicle(null, null, 7255555)).toBe("+2h1m"); }); it("returns empty value", () => { - expect(getDelayTextForVehicle(1000)).toBe(""); - expect(getDelayTextForVehicle(null)).toBe(""); - expect(getDelayTextForVehicle(undefined)).toBe(""); - expect(getDelayTextForVehicle(0)).toBe(""); + expect(getDelayTextForVehicle(null, null, 1000)).toBe(""); + expect(getDelayTextForVehicle(null, null, null)).toBe(""); + expect(getDelayTextForVehicle(null, null, undefined)).toBe(""); + expect(getDelayTextForVehicle(null, null, 0)).toBe(""); }); }); diff --git a/src/utils/getDelayTextForVehicle.ts b/src/utils/getDelayTextForVehicle.ts index cd493425..4b588f5a 100644 --- a/src/utils/getDelayTextForVehicle.ts +++ b/src/utils/getDelayTextForVehicle.ts @@ -1,11 +1,15 @@ import getDelayString from "./getDelayString"; +import type { RealtimeTrajectory, ViewState } from "mobility-toolbox-js/types"; + /** * This function returns the text displays near the vehicle. * We use getDelayString inside it to make sure that RouteSchedule and * the map have the same values. */ const getDelayTextForVehicle = ( + trajectory: RealtimeTrajectory, + viewState: ViewState, delayInMs: number, cancelled = false, ): string => { diff --git a/src/utils/getMainColorForVehicle.ts b/src/utils/getMainColorForVehicle.ts index c842e810..44dea282 100644 --- a/src/utils/getMainColorForVehicle.ts +++ b/src/utils/getMainColorForVehicle.ts @@ -1,4 +1,4 @@ -import getBgColor from "./getBgColor"; +import { realtimeStyleUtils } from "mobility-toolbox-js/ol"; import type { RealtimeDeparture, @@ -8,7 +8,10 @@ import type { RealtimeTrajectory, } from "mobility-toolbox-js/types"; -// This function returns the main color of a line using a line, trajectory, stopsequence or departure object. +/** + * Return the color depending on an object representing a vehicle or a line. + * This function is used to have the same color on the map and on other components. + */ const getMainColorForVehicle = (object: unknown = null): string => { let color = (object as RealtimeTrajectory)?.properties?.line?.color || @@ -34,10 +37,12 @@ const getMainColorForVehicle = (object: unknown = null): string => { type = "rail"; } } - color = getBgColor(type) || getBgColor("rail"); + color = + realtimeStyleUtils.getColorForType(type) || + realtimeStyleUtils.getColorForType("rail"); } - if (color && color[0] !== "#") { + if (color && !color.startsWith("#")) { color = `#${color}`; } diff --git a/src/utils/getTextColor.ts b/src/utils/getTextColor.ts deleted file mode 100644 index b4fcd0d8..00000000 --- a/src/utils/getTextColor.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { realtimeConfig } from "mobility-toolbox-js/ol"; - -const getTextColor = (type) => { - return realtimeConfig.getTextColor(type); -}; - -export default getTextColor; diff --git a/src/utils/getTextColorForVehicle.ts b/src/utils/getTextColorForVehicle.ts new file mode 100644 index 00000000..eb7c4e84 --- /dev/null +++ b/src/utils/getTextColorForVehicle.ts @@ -0,0 +1,29 @@ +import { realtimeConfig } from "mobility-toolbox-js/ol"; + +import type { + RealtimeDeparture, + RealtimeLine, + RealtimeStopSequence, + RealtimeTrajectory, +} from "mobility-toolbox-js/types"; + +import type { LnpLineInfo } from "./hooks/useLnp"; + +const getTextColorForVehicle = (object: unknown) => { + const textColor = + (object as LnpLineInfo | RealtimeLine).text_color || + (object as RealtimeDeparture | RealtimeStopSequence).line?.text_color || + (object as RealtimeTrajectory).properties?.line?.text_color; + + if (textColor) { + return textColor; + } + + const type = + (object as RealtimeStopSequence).type || + (object as RealtimeTrajectory).properties?.type; + + return realtimeConfig.getTextColorForType(type); +}; + +export default getTextColorForVehicle; diff --git a/src/utils/getTextFontForVehicle.test.ts b/src/utils/getTextFontForVehicle.test.ts index 4926f59e..40898417 100644 --- a/src/utils/getTextFontForVehicle.test.ts +++ b/src/utils/getTextFontForVehicle.test.ts @@ -2,6 +2,6 @@ import getTextFontForVehicle from "./getTextFontForVehicle"; describe("getTextFontForVehicle", () => { it("returns font that inherit", () => { - expect(getTextFontForVehicle(12)).toBe("bold 12px arial"); + expect(getTextFontForVehicle(null, null, 12)).toBe("bold 12px arial"); }); }); diff --git a/src/utils/getTextFontForVehicle.tsx b/src/utils/getTextFontForVehicle.tsx index a1b52824..a9cdefd5 100644 --- a/src/utils/getTextFontForVehicle.tsx +++ b/src/utils/getTextFontForVehicle.tsx @@ -1,8 +1,16 @@ +import type { ViewState } from "mobility-toolbox-js/types"; + /** - * Return the font for the delay text in the map. + * Return the font depending on an object representing a vehicle or a line. + * This function is used to have the same font on the map and on other components. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const getTextFontForVehicle = (fontSize: number, text?: string) => { +const getTextFontForVehicle = ( + object?: unknown, + viewState?: ViewState, + fontSize?: number, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + text?: string, +) => { return `bold ${fontSize}px arial`; }; diff --git a/src/utils/getTextForVehicle.ts b/src/utils/getTextForVehicle.ts index ec6715cb..ff427c47 100644 --- a/src/utils/getTextForVehicle.ts +++ b/src/utils/getTextForVehicle.ts @@ -4,12 +4,18 @@ import type { RealtimeTrajectory, } from "mobility-toolbox-js/types"; +import type { LnpLineInfo } from "./hooks/useLnp"; + +/** + * Return the text depending on an object representing a vehicle or a line. + * This function is used to have the same text on the map and on other components. + */ const getTextForVehicle = (object: unknown = ""): string => { const name = (object as RealtimeTrajectory)?.properties?.line?.name || - // @ts-expect-error bad type definition (object as RealtimeStopSequence)?.line?.name || (object as RealtimeLine)?.name || + (object as LnpLineInfo)?.short_name || (object as string) || ""; diff --git a/src/utils/hooks/useFitOnFeatures.tsx b/src/utils/hooks/useFitOnFeatures.tsx new file mode 100644 index 00000000..b9a857c3 --- /dev/null +++ b/src/utils/hooks/useFitOnFeatures.tsx @@ -0,0 +1,61 @@ +import { GeoJSON } from "ol/format"; +import { Vector } from "ol/source"; +import { useCallback, useEffect, useRef } from "preact/hooks"; + +import { FIT_ON_FEATURES_MAX_ZOOM_POINT } from "../constants"; + +import useMapContext from "./useMapContext"; + +import type { Feature, Map } from "ol"; +import type { GeoJSONFeature } from "ol/format/GeoJSON"; +const geojson = new GeoJSON(); + +const useFitOnFeatures = () => { + const { isOverlayOpen, map: contextMap } = useMapContext(); + + const isOverlayOpenRef = useRef(isOverlayOpen); + + useEffect(() => { + isOverlayOpenRef.current = isOverlayOpen; + }, [isOverlayOpen]); + + const fitOnFeatures = useCallback( + (features: (Feature | GeoJSONFeature)[], map?: Map) => { + if ((!map && !contextMap) || !features?.length) { + return; + } + let feats = features as Feature[]; + + // Convert to ol features if GeoJSON + const geoJSONFeature = features?.[0] as GeoJSONFeature; + if (geoJSONFeature?.properties && geoJSONFeature?.type === "Feature") { + // Single feature case + feats = geojson.readFeatures( + { + features, + type: "FeatureCollection", + }, + { featureProjection: "EPSG:3857" }, + ); + } + + const mapToUse = map || contextMap; + const extent = new Vector({ features: feats }).getExtent(); + mapToUse.getView().fit(extent, { + duration: 500, + maxZoom: + extent[0] === extent[2] || extent[1] === extent[3] + ? FIT_ON_FEATURES_MAX_ZOOM_POINT + : undefined, + padding: [100, 100, 100, isOverlayOpenRef.current ? 400 : 100], + }); + return () => { + mapToUse?.getView().cancelAnimations(); + }; + }, + [contextMap], + ); + return fitOnFeatures; +}; + +export default useFitOnFeatures; diff --git a/src/utils/hooks/useLayersConfig.tsx b/src/utils/hooks/useLayersConfig.tsx index 67ae1054..aef1daef 100644 --- a/src/utils/hooks/useLayersConfig.tsx +++ b/src/utils/hooks/useLayersConfig.tsx @@ -4,10 +4,13 @@ import { LAYERS_NAMES } from "../constants"; import useMapContext from "./useMapContext"; +import type { LAYER_TREE_HIDE_PROP } from "../constants"; + export interface LayerConfig { featurelink?: { href?: string; }; + [LAYER_TREE_HIDE_PROP]?: boolean; link?: { href?: string; show?: boolean; diff --git a/src/utils/hooks/useLnp.tsx b/src/utils/hooks/useLnp.tsx index d9f20249..ab8f8a89 100644 --- a/src/utils/hooks/useLnp.tsx +++ b/src/utils/hooks/useLnp.tsx @@ -6,7 +6,7 @@ import useMapContext from "./useMapContext"; import type { VectorTileSource } from "maplibre-gl"; -export interface LineInfo { +export interface LnpLineInfo { color: string; external_id: string; id: string; @@ -18,7 +18,7 @@ export interface LineInfo { text_color: string; } -export interface StopInfo { +export interface LnpStopInfo { external_id: string; importance: number; long_name: string; @@ -26,8 +26,8 @@ export interface StopInfo { visibility_level: number; } -export type LinesInfos = Record; -export type StopsInfos = Record; +export type LinesInfos = Record; +export type StopsInfos = Record; let cacheLnpSourceInfo: { [LNP_MD_LINES]: LinesInfos; @@ -91,7 +91,7 @@ export function useLnpStopsInfos(): StopsInfos { * This hook search line informations from lnp data. It takes a string in * parameter then it will search if there is a property that exactly match this value. */ -function useLnpLineInfo(text: string): LineInfo { +function useLnpLineInfo(text: string): LnpLineInfo { const linesInfos = useLnpLinesInfos(); if (!linesInfos || !text) { @@ -109,4 +109,26 @@ function useLnpLineInfo(text: string): LineInfo { }); } +/** + * This hook search line informations from lnp data. It takes a string in + * parameter then it will search if there is a property that exactly match this value. + */ +export function useLnpStopInfo(text: string): LnpStopInfo { + const stationsInfos = useLnpStopsInfos(); + + if (!stationsInfos || !text) { + return null; + } + + if (stationsInfos[text]) { + return stationsInfos[text]; + } + + return Object.values(stationsInfos).find((info) => { + return ["id", "external_id", "short_name", "long_name"].find((key) => { + return !!info[key] && info[key].toLowerCase() === text.toLowerCase(); + }); + }); +} + export default useLnpLineInfo; diff --git a/src/utils/hooks/useMapContext.tsx b/src/utils/hooks/useMapContext.tsx index 95fd7a7e..4f79759c 100644 --- a/src/utils/hooks/useMapContext.tsx +++ b/src/utils/hooks/useMapContext.tsx @@ -97,12 +97,12 @@ export type MapContextType = { setSelectedFeature: (feature: Feature) => void; setSelectedFeatures: (features: Feature[]) => void; setStation: (station?: RealtimeStation) => void; - setStationId: (stationId?: RealtimeStationId) => void; + setStationId: (stationId?: RealtimeStationId | string) => void; setStationsLayer: (stationsLayer?: MaplibreStyleLayer) => void; setStopSequence: (stopSequence?: RealtimeStopSequence) => void; setTrainId: (trainId?: RealtimeTrainId) => void; station: RealtimeStation; - stationId: RealtimeStationId; + stationId: RealtimeStationId | string; stationsLayer: MaplibreStyleLayer; stopSequence: RealtimeStopSequence; trainId: RealtimeTrainId; diff --git a/src/utils/hooks/useRealtimeDepartures.tsx b/src/utils/hooks/useRealtimeDepartures.tsx new file mode 100644 index 00000000..1d911e36 --- /dev/null +++ b/src/utils/hooks/useRealtimeDepartures.tsx @@ -0,0 +1,45 @@ +import { debounceDeparturesMessages } from "mobility-toolbox-js/ol"; +import { useEffect, useMemo, useState } from "preact/hooks"; + +import useMapContext from "./useMapContext"; + +import type { RealtimeDeparture } from "mobility-toolbox-js/types"; + +function useRealtimeDepartures(stationId: number | string) { + const { realtimeLayer } = useMapContext(); + const [departures, setDepartures] = useState(); + + const api = useMemo(() => { + return realtimeLayer?.api; + }, [realtimeLayer?.api]); + + useEffect(() => { + if (!stationId || !api) { + return; + } + + if (!api.wsApi.open) { + api.open(); + } + + const onMessage = debounceDeparturesMessages( + (newDepartures: RealtimeDeparture[]) => { + setDepartures(newDepartures); + return null; + }, + false, + 180, + ); + // @ts-expect-error bad type definition + api.subscribeTimetable(stationId, onMessage); + + return () => { + setDepartures(null); + api.unsubscribeTimetable(stationId as number); + }; + }, [stationId, api]); + + return departures; +} + +export default useRealtimeDepartures; diff --git a/src/utils/hooks/useRealtimeRenderedTrajectory.tsx b/src/utils/hooks/useRealtimeRenderedTrajectory.tsx new file mode 100644 index 00000000..aca5ab24 --- /dev/null +++ b/src/utils/hooks/useRealtimeRenderedTrajectory.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from "preact/hooks"; + +import useMapContext from "./useMapContext"; + +import type { RealtimeTrajectory } from "mobility-toolbox-js/types"; + +function useRealtimeRenderedTrajectories(trainId: number | string) { + const { realtimeLayer } = useMapContext(); + const [trajectory, setTrajectory] = useState(); + const [trainIdFound, setTrainIdFound] = useState(); + + // We try to find the trainId every second until we have it + // TODO: find a efficient way to find the trajectory without polling + useEffect(() => { + if (trainIdFound || !trainId || !realtimeLayer) { + return; + } + const timeout = setInterval(() => { + let traj = realtimeLayer?.trajectories?.[trainId]; + + if (!traj) { + traj = Object.values(realtimeLayer?.trajectories)?.find((item) => { + return item.properties.route_identifier === trainId; + }); + } + + if (traj) { + setTrajectory(traj); + setTrainIdFound(traj.properties.train_id); + } + }, 1000); + + return () => { + clearInterval(timeout); + setTrainIdFound(undefined); + }; + }, [trainId, realtimeLayer, trainIdFound]); + + return trajectory; +} + +export default useRealtimeRenderedTrajectories; diff --git a/src/utils/hooks/useRealtimeStation.tsx b/src/utils/hooks/useRealtimeStation.tsx new file mode 100644 index 00000000..5ba68449 --- /dev/null +++ b/src/utils/hooks/useRealtimeStation.tsx @@ -0,0 +1,39 @@ +import { useEffect, useMemo, useState } from "preact/hooks"; + +import useMapContext from "./useMapContext"; + +import type { RealtimeStation } from "mobility-toolbox-js/types"; + +function useRealtimeStation(stationId: number | string) { + const { realtimeLayer } = useMapContext(); + const [station, setStation] = useState(); + const api = useMemo(() => { + return realtimeLayer?.api; + }, [realtimeLayer?.api]); + + useEffect(() => { + if (!stationId || !api) { + return; + } + + if (!api.wsApi.open) { + api.wsApi.connect(api.url); + } + + api.subscribe(`station ${stationId}`, ({ content }) => { + if (content) { + setStation(content as RealtimeStation); + } + }); + + return () => { + setStation(undefined); + if (stationId) { + api?.unsubscribe(`station ${stationId}`); + } + }; + }, [stationId, api]); + return station; +} + +export default useRealtimeStation; diff --git a/src/utils/hooks/useRealtimeStopSequences.tsx b/src/utils/hooks/useRealtimeStopSequences.tsx new file mode 100644 index 00000000..3dec5a6f --- /dev/null +++ b/src/utils/hooks/useRealtimeStopSequences.tsx @@ -0,0 +1,43 @@ +import { useEffect, useMemo, useState } from "preact/hooks"; + +import useMapContext from "./useMapContext"; + +import type { RealtimeStopSequence } from "mobility-toolbox-js/types"; + +function useRealtimeStopSequences(trainId: string) { + const { realtimeLayer } = useMapContext(); + const [stopSequences, setStopSequences] = useState(); + + const api = useMemo(() => { + return realtimeLayer?.api; + }, [realtimeLayer?.api]); + + useEffect(() => { + let trainIdSubscribed = null; + if (!trainId || !api) { + return; + } + + if (!api.wsApi.open) { + api.open(); + } + + trainIdSubscribed = trainId; + + const onMessage = ({ content }) => { + if (content) { + setStopSequences([...content]); + } + }; + api.subscribeStopSequence(trainId, onMessage); + + return () => { + setStopSequences([]); + api.unsubscribeStopSequence(trainIdSubscribed); + }; + }, [trainId, api]); + + return stopSequences; +} + +export default useRealtimeStopSequences; diff --git a/src/utils/hooks/useRouteStop.tsx b/src/utils/hooks/useRouteStop.tsx index e0f45bfd..59bab55b 100644 --- a/src/utils/hooks/useRouteStop.tsx +++ b/src/utils/hooks/useRouteStop.tsx @@ -1,7 +1,11 @@ import { createContext } from "preact"; import { useContext } from "preact/hooks"; -import type { RealtimeStation, RealtimeStop } from "mobility-toolbox-js/types"; +import type { + RealtimeStation, + RealtimeStop, + RealtimeStopSequence, +} from "mobility-toolbox-js/types"; import type { StopStatus } from "../getStopStatus"; @@ -13,6 +17,7 @@ export interface RouteStopContextType { stop?: { platform?: string; } & RealtimeStop; + stopSequence?: RealtimeStopSequence; } export const RouteStopContext = createContext({ @@ -21,6 +26,7 @@ export const RouteStopContext = createContext({ station: null, status: null, stop: null, + stopSequence: null, } as RouteStopContextType); const useRouteStop = (): RouteStopContextType => { diff --git a/src/utils/hooks/useSearchLines.tsx b/src/utils/hooks/useSearchLines.tsx new file mode 100644 index 00000000..f799bd61 --- /dev/null +++ b/src/utils/hooks/useSearchLines.tsx @@ -0,0 +1,34 @@ +import { useMemo } from "preact/hooks"; + +import { useLnpLinesInfos } from "./useLnp"; +import useMapContext from "./useMapContext"; + +import type { LnpLineInfo } from "./useLnp"; +import type { SearchResponse } from "./useSearchStops"; + +function useSearchLines(query: string): SearchResponse { + const { hasLnp } = useMapContext(); + const linesInfos = useLnpLinesInfos(); + + const results = useMemo(() => { + if (!query || !linesInfos || !hasLnp) { + return []; + } + return Object.values(linesInfos || {}).filter((line: LnpLineInfo) => { + return ( + line?.short_name?.toLowerCase().includes(query.toLowerCase()) || + line?.long_name?.toLowerCase().includes(query.toLowerCase()) || + line?.id?.toLowerCase().includes(query.toLowerCase()) || + line?.external_id?.toLowerCase().includes(query.toLowerCase()) || + line?.mot?.toLowerCase().includes(query.toLowerCase()) + ); + }); + }, [hasLnp, linesInfos, query]); + + return { + isLoading: false, + results: results || [], + }; +} + +export default useSearchLines; diff --git a/src/utils/hooks/useSearchStops.tsx b/src/utils/hooks/useSearchStops.tsx new file mode 100644 index 00000000..c2a77477 --- /dev/null +++ b/src/utils/hooks/useSearchStops.tsx @@ -0,0 +1,85 @@ +import debounce from "lodash.debounce"; +import { StopsAPI } from "mobility-toolbox-js/ol"; +import { useEffect, useMemo, useState } from "preact/hooks"; + +import { MAX_EXTENT_4326 } from "../constants"; + +import useMapContext from "./useMapContext"; + +import type { StopsParameters, StopsResponse } from "mobility-toolbox-js/types"; + +export type StopsFeature = StopsResponse["features"][0]; + +export interface SearchResponse { + isLoading: boolean; + results: T[] | undefined; +} + +/** + * This hook launch a request to the Stops API. + * + * @param query + */ +function useSearchStops( + query: string, + params?: Partial, +): SearchResponse { + const { apikey, mots, stopsurl } = useMapContext(); + const [results, setResults] = useState(); + const [isLoading, setIsLoading] = useState(false); + + const api: StopsAPI = useMemo(() => { + if (!apikey || !stopsurl) { + return null; + } + return new StopsAPI({ apiKey: apikey, url: stopsurl }); + }, [apikey, stopsurl]); + + const debouncedSearch = useMemo(() => { + let abortCtrl: AbortController | undefined; + + return debounce((q) => { + abortCtrl?.abort(); + abortCtrl = new AbortController(); + + const reqParams = { + bbox: MAX_EXTENT_4326?.join(","), + mots, + q, + ...(params ?? {}), + } as StopsParameters; + + setIsLoading(true); + api + .search(reqParams, { signal: abortCtrl.signal }) + .then((res: StopsResponse) => { + setResults(res.features); + setIsLoading(false); + }) + .catch((e) => { + // AbortError is expected + if (e.code !== 20) { + // eslint-disable-next-line no-console + console.error("Failed to fetch stations", e); + return; + } + setIsLoading(false); + }); + }, 150); + }, [api, mots, params]); + + useEffect(() => { + if (!query || !api) { + setResults([]); + return; + } + debouncedSearch(query); + }, [api, debouncedSearch, query]); + + return { + isLoading, + results: results || [], + }; +} + +export default useSearchStops; diff --git a/src/utils/hooks/useSearchTrajectories.tsx b/src/utils/hooks/useSearchTrajectories.tsx new file mode 100644 index 00000000..b8e548ea --- /dev/null +++ b/src/utils/hooks/useSearchTrajectories.tsx @@ -0,0 +1,33 @@ +import { useMemo } from "preact/hooks"; + +import useMapContext from "./useMapContext"; + +import type { RealtimeTrajectory } from "mobility-toolbox-js/types"; + +import type { SearchResponse } from "./useSearchStops"; + +function useSearchTrajectories( + query: string, +): SearchResponse { + const { hasRealtime, realtimeLayer } = useMapContext(); + + const results = useMemo(() => { + if (!query || !realtimeLayer?.trajectories || !hasRealtime) { + return []; + } + return Object.values(realtimeLayer.trajectories || {}).filter( + (trajectory) => { + return trajectory?.properties.route_identifier + ?.toLowerCase() + .includes(query.toLowerCase()); + }, + ); + }, [query, realtimeLayer?.trajectories, hasRealtime]); + + return { + isLoading: false, + results: results || [], + }; +} + +export default useSearchTrajectories; diff --git a/src/utils/hooks/useStation.tsx b/src/utils/hooks/useStation.tsx deleted file mode 100644 index 8d548831..00000000 --- a/src/utils/hooks/useStation.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { createContext } from "preact"; -import { useContext } from "preact/hooks"; - -import type { RealtimeStation } from "mobility-toolbox-js/types"; - -export interface StationContextType { - station?: RealtimeStation; -} - -export const StationContext = createContext({ - station: null, -} as StationContextType); - -const useRouteStop = (): StationContextType => { - const context = useContext(StationContext); - if (!context) { - throw new Error("useRouteStop must be used within a ContextProvider"); - } - return context; -}; - -export default useRouteStop; diff --git a/src/utils/translations.ts b/src/utils/translations.ts index 701607ad..43cf97f8 100644 --- a/src/utils/translations.ts +++ b/src/utils/translations.ts @@ -34,7 +34,12 @@ const translations: Translations = { platform_other: "Kan.", platform_rail: "Gl.", print_menu_title: "Drucken", + search_input_cancel: "Eingabe löschen", + search_lines_results: "Linien", search_menu_title: "Suchen", + search_placeholder: "Haltestelle, Linie, Fährte suchen", + search_stops_results: "Haltestellen", + search_trajectories_results: "Fährte", share_email_send: "E-Mail senden", share_image_save: "Bild speichern", share_menu_title: "Teilen", @@ -70,7 +75,11 @@ const translations: Translations = { platform_other: "Std.", // stand platform_rail: "Pl.", print_menu_title: "Print", + search_lines_results: "Lines", search_menu_title: "Search", + search_placeholder: "Search stop, line, route", + search_stops_results: "Stops", + search_trajectories_results: "Routes", share_email_send: "Send email", share_image_save: "Save image", share_menu_title: "Share", @@ -106,7 +115,11 @@ const translations: Translations = { platform_other: "Quai", platform_rail: "Voie", print_menu_title: "Imprimer", + search_lines_results: "Lignes", search_menu_title: "Rechercher", + search_placeholder: "Rechercher un arrêt, une ligne, une route", + search_stops_results: "Arrêts", + search_trajectories_results: "Trajectoires", share_email_send: "Envoyer un email", share_image_save: "Enregistrer l'image", share_menu_title: "Partager", @@ -142,7 +155,11 @@ const translations: Translations = { platform_other: "Cor.", // corsia platform_rail: "Bin.", print_menu_title: "Stampa", + search_lines_results: "Linee", search_menu_title: "Cerca", + search_placeholder: "Cerca fermata, linea, percorso", + search_stops_results: "Fermate", + search_trajectories_results: "Percorsi", share_email_send: "Invia email", share_image_save: "Salva immagine", share_menu_title: "Condividi", diff --git a/yarn.lock b/yarn.lock index 8ca6805c..16c56375 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8710,10 +8710,10 @@ mlly@^1.7.4: pkg-types "^1.3.1" ufo "^1.6.1" -mobility-toolbox-js@3.4.6: - version "3.4.6" - resolved "https://registry.yarnpkg.com/mobility-toolbox-js/-/mobility-toolbox-js-3.4.6.tgz#f50d8d6ccf5fd1737300ab3c9a344583f04aa38a" - integrity sha512-d7dZlk41mBEpqf7IxL+DnzEd54XOi3ZbGaU0Bx8rNJteXKOkYPEdbuX0izti1jjHmjVWGplME6MFfzZXoOsYaQ== +mobility-toolbox-js@3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/mobility-toolbox-js/-/mobility-toolbox-js-3.5.0.tgz#12600bfd647b67b690801d20957ada565a59b518" + integrity sha512-D4SH+RfL0DHWHnedrs04ZuXsHYisU3AHb+S1EjXrZmLU2kAnbaeGwpwQQb3GKV159GoO6JUCiQuIyfHsaGFI9w== dependencies: "@geoblocks/ol-maplibre-layer" "^1.0.3" "@geops/geops-ui" "0.3.6"