From 4a2fadb5b31f8dafb0f1c05480079770d0cc05ee Mon Sep 17 00:00:00 2001 From: Adrien Date: Sun, 5 Mar 2023 13:46:25 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20Redesign=20stop=20search=20menu?= =?UTF-8?q?=20to=20follow=20the=20passage=20display=20style?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/appContext.tsx | 43 ++++ frontend/src/passagesDisplay.tsx | 31 +-- frontend/src/search.tsx | 21 +- frontend/src/stopsSearchMenu.tsx | 400 ++++++++++++++++++++++++------- frontend/src/utils.tsx | 13 + 5 files changed, 390 insertions(+), 118 deletions(-) create mode 100644 frontend/src/appContext.tsx diff --git a/frontend/src/appContext.tsx b/frontend/src/appContext.tsx new file mode 100644 index 0000000..ae58c0e --- /dev/null +++ b/frontend/src/appContext.tsx @@ -0,0 +1,43 @@ +import { createContext, JSX } from 'solid-js'; +import { createStore } from "solid-js/store"; + +import { Stop } from './types'; + +export interface AppContextStore { + getDisplayedStops: () => Stop[]; + setDisplayedStops: (stops: Stop[]) => void; +}; + +export const AppContextContext = createContext(); + +export function AppContextProvider(props: { children: JSX.Element }) { + + type Store = { + displayedStops: Stop[]; + }; + + const [store, setStore] = createStore({ + displayedStops: [], + }); + + const getDisplayedStops = (): Stop[] => { + return store.displayedStops; + } + + const setDisplayedStops = (stops: Stop[]): void => { + console.log("setDisplayedStops=", stops); + // setStore((s: Store) => { + setStore('displayedStops', stops); + // return s; + // }); + } + + return ( + + {props.children} + + ); + +}; diff --git a/frontend/src/passagesDisplay.tsx b/frontend/src/passagesDisplay.tsx index a900ad8..f2f312f 100644 --- a/frontend/src/passagesDisplay.tsx +++ b/frontend/src/passagesDisplay.tsx @@ -5,19 +5,12 @@ import { IconButton, Menu, MenuTrigger, MenuContent, MenuItem } from "@hope-ui/s import { format } from "date-fns"; import { BusinessDataContext, BusinessDataStore } from "./businessData"; -import { SearchContext, SearchStore } from "./search"; +import { AppContextContext, AppContextStore } from "./appContext"; -import { getTransportModeSrc } from "./utils"; +import { getTransportModeSrc, PositionedPanel } from "./utils"; import { PassagesPanel } from "./passagesPanel"; import { IconHamburgerMenu } from './extra/iconHamburgerMenu'; - - -type PositionnedPanel = { - position: number; - // TODO: Should be PassagesPanelComponent ? - panel: JSX.Element; -}; import "./passagesDisplay.scss"; @@ -27,8 +20,8 @@ interface PassagesDisplayStore { disablePassagesRefresh: () => void; togglePassagesRefresh: () => void; - getPanels: () => Array; - setPanels: (panels: Array) => void; + getPanels: () => PositionedPanel[]; + setPanels: (panels: PositionedPanel[]) => void; getDisplayedPanelId: () => number; setDisplayedPanelId: (panelId: number) => void; @@ -40,7 +33,7 @@ function PassagesDisplayProvider(props: { children: JSX.Element }) { type Store = { refreshEnabled: boolean; - panels: Array; + panels: PositionedPanel[]; displayedPanelId: number; }; @@ -62,10 +55,10 @@ function PassagesDisplayProvider(props: { children: JSX.Element }) { setStore('refreshEnabled', !store.refreshEnabled); } - const getPanels = (): Array => { + const getPanels = (): PositionedPanel[] => { return store.panels; } - const setPanels = (panels: Array): void => { + const setPanels = (panels: PositionedPanel[]): void => { setStore('panels', panels); } @@ -88,7 +81,6 @@ function PassagesDisplayProvider(props: { children: JSX.Element }) { ); } - // TODO: Sort transport modes by weight const Header: VoidComponent<{ title: string }> = (props) => { @@ -193,17 +185,16 @@ const Footer: VoidComponent<{}> = () => { const Body: ParentComponent<{ maxPassagesPerPanel: number, syncPeriodMsec: number, panelSwitchPeriodMsec: number }> = (props) => { const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); + const appContextStore: AppContextStore | undefined = useContext(AppContextContext); const passagesDisplayStore: PassagesDisplayStore | undefined = useContext(PassagesDisplayContext); - // TODO: Use props instead - const searchStore: SearchStore | undefined = useContext(SearchContext); - if (businessDataStore === undefined || passagesDisplayStore === undefined || searchStore === undefined) { + if (businessDataStore === undefined || appContextStore === undefined || passagesDisplayStore === undefined) { return
; } const { getLineDestinations, passages, getPassagesLineIds, clearPassages, refreshPassages } = businessDataStore; const { isPassagesRefreshEnabled, getDisplayedPanelId, setDisplayedPanelId, getPanels, setPanels } = passagesDisplayStore; - const { getDisplayedStops } = searchStore; + const { getDisplayedStops } = appContextStore; setInterval(() => { let nextPanelId = getDisplayedPanelId() + 1; @@ -249,7 +240,7 @@ const Body: ParentComponent<{ maxPassagesPerPanel: number, syncPeriodMsec: numbe setPanels([]); let newPanels = []; - let positioneds: PositionnedPanel[] = []; + let positioneds: PositionedPanel[] = []; let index = 0; let lineIds: string[] = []; diff --git a/frontend/src/search.tsx b/frontend/src/search.tsx index fe78f99..3a49e07 100644 --- a/frontend/src/search.tsx +++ b/frontend/src/search.tsx @@ -1,13 +1,16 @@ -import { createContext, JSX } from 'solid-js'; +import { batch, createContext, JSX } from 'solid-js'; import { createStore } from 'solid-js/store'; import { Marker as LeafletMarker } from 'leaflet'; -import { Stop } from './types'; +import { Stop, Stops } from './types'; export type ByStopIdMarkers = Record; export interface SearchStore { + getFoundStops: () => Stop[]; + setFoundStops: (stops: Stop[]) => void; + getDisplayedStops: () => Stop[]; setDisplayedStops: (stops: Stop[]) => void; @@ -19,12 +22,20 @@ export const SearchContext = createContext(); export function SearchProvider(props: { children: JSX.Element }) { type Store = { - stops: Record; + foundStops: Stop[]; markers: ByStopIdMarkers; displayedStops: Stop[]; }; - const [store, setStore] = createStore({ stops: {}, markers: {}, displayedStops: [] }); + const [store, setStore] = createStore({ foundStops: [], markers: {}, displayedStops: [] }); + + const getFoundStops = (): Stop[] => { + return store.foundStops; + } + + const setFoundStops = (stops: Stop[]): void => { + setStore('foundStops', stops); + } const getDisplayedStops = (): Stop[] => { return store.displayedStops; @@ -42,7 +53,7 @@ export function SearchProvider(props: { children: JSX.Element }) { } return ( - + {props.children} ); diff --git a/frontend/src/stopsSearchMenu.tsx b/frontend/src/stopsSearchMenu.tsx index fe5b6bd..b30b802 100644 --- a/frontend/src/stopsSearchMenu.tsx +++ b/frontend/src/stopsSearchMenu.tsx @@ -1,20 +1,160 @@ -import { createEffect, createResource, createSignal, For, JSX, onMount, Show, useContext, VoidComponent } from 'solid-js'; - -import { Box, Button, Input, InputLeftAddon, InputGroup, HStack, List, ListItem, Progress, ProgressIndicator, VStack } from "@hope-ui/solid"; -import 'leaflet/dist/leaflet.css'; +import { createContext, createEffect, createResource, For, JSX, onMount, ParentComponent, Show, useContext, VoidComponent } from 'solid-js'; +import { createStore } from "solid-js/store"; +import { createScrollPosition } from "@solid-primitives/scroll"; +import { Input, InputLeftAddon, InputGroup } from "@hope-ui/solid"; import { featureGroup as leafletFeatureGroup, LatLngLiteral as LeafletLatLngLiteral, Map as LeafletMap, Marker as LeafletMarker, tileLayer as leafletTileLayer } from 'leaflet'; -import { BusinessDataContext, BusinessDataStore } from "./businessData"; -import { SearchContext, SearchStore } from './search'; - import { Stop } from './types'; -import { renderLineTransportMode, renderLinePicto, TransportModeWeights } from './utils'; +import { PositionedPanel, renderLineTransportMode, renderLinePicto, TransportModeWeights } from './utils'; -import styles from './stopManager.module.css'; +import { AppContextContext, AppContextStore } from "./appContext"; +import { BusinessDataContext, BusinessDataStore } from "./businessData"; + +import "leaflet/dist/leaflet.css"; +import "./stopsSearchMenu.scss"; + + +type ByStopIdMarkers = Record; + +interface SearchStore { + + getSearchText: () => string; + setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise; + isSearchInProgress: () => boolean; + + getFoundStops: () => Stop[]; + setFoundStops: (stops: Stop[]) => void; + + getDisplayedPanelId: () => number; + setDisplayedPanelId: (panelId: number) => void; + + addMarkers: (stopId: number, markers: LeafletMarker[]) => void; + + getPanels: () => PositionedPanel[]; + setPanels: (panels: PositionedPanel[]) => void; +}; + +const SearchContext = createContext(); + +function SearchProvider(props: { children: JSX.Element }) { + + type Store = { + searchText: string; + searchInProgress: boolean; + foundStops: Stop[]; + markers: ByStopIdMarkers; + displayedPanelId: number; + panels: PositionedPanel[]; + }; + + const [store, setStore] = createStore({ + searchText: "", searchInProgress: false, foundStops: [], markers: {}, displayedPanelId: 0, panels: [] + }); + + const getSearchText = (): string => { + return store.searchText; + } + + const setSearchText = async (text: string, businessDataStore: BusinessDataStore): Promise => { + setStore('searchInProgress', true); + + setStore('searchText', text); + + const { searchStopByName } = businessDataStore; + console.log("store.searchText=", store.searchText); + const stopsById = await searchStopByName(store.searchText); + console.log("stopsById=", stopsById); + setFoundStops(Object.values(stopsById)); + + setStore('searchInProgress', false); + } + + const isSearchInProgress = (): boolean => { + return store.searchInProgress; + } + + const getFoundStops = (): Stop[] => { + return store.foundStops; + } + + const setFoundStops = (stops: Stop[]): void => { + setStore('foundStops', stops); + } + + const getDisplayedPanelId = (): number => { + return store.displayedPanelId; + } + + const setDisplayedPanelId = (panelId: number): void => { + setStore('displayedPanelId', panelId); + } + + const addMarkers = (stopId: number, markers: L.Marker[]): void => { + setStore('markers', stopId, markers); + } + + const getPanels = (): PositionedPanel[] => { + return store.panels; + } + const setPanels = (panels: PositionedPanel[]): void => { + setStore('panels', panels); + } + + return ( + + {props.children} + + ); +} + + +const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) => { + + const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); + const searchStore: SearchStore | undefined = useContext(SearchContext); + + if (businessDataStore === undefined || searchStore === undefined) + return
; + + const { isSearchInProgress, setSearchText } = searchStore; + + const onStopNameInput: JSX.EventHandler = async (event): Promise => { + /* TODO: Add a tempo before fetching stop for giving time to user to finish his request */ + const stopName = event.currentTarget.value; + if (stopName.length >= props.minCharsNb) { + console.log(`Fetching data for "${stopName}" stop name`); + await setSearchText(stopName, businessDataStore); + } + } + + return ( +
+
+ + + {props.title} + + +
+
+ + πŸš‰ 🚏 + + +
+
+ ); +}; const StopRepr: VoidComponent<{ stop: Stop }> = (props) => { @@ -40,27 +180,31 @@ const StopRepr: VoidComponent<{ stop: Stop }> = (props) => { const [lineReprs] = createResource(props.stop.lines, fetchLinesRepr); return ( - - {props.stop.name} +
+
{props.stop.name}
{(line: JSX.Element) => line} - +
); } + +type ByTransportModeReprs = { + mode: JSX.Element | undefined; + lines: Record; +}; + + const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => { const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); - if (businessDataStore === undefined) + const appContextStore: AppContextStore | undefined = useContext(AppContextContext); + if (businessDataStore === undefined || appContextStore === undefined) return
; const { getLine } = businessDataStore; + const { setDisplayedStops } = appContextStore; - type ByTransportModeReprs = { - mode: JSX.Element | undefined; - [key: string]: JSX.Element | JSX.Element[] | undefined; - }; - - const fetchLinesRepr = async (stop: Stop): Promise => { + const fetchLinesRepr = async (stop: Stop): Promise => { const lineIds = new Set(stop.lines); const stops = stop.stops; for (const stop of stops) { @@ -74,38 +218,122 @@ const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => { if (!(line.transportMode in byModeReprs)) { byModeReprs[line.transportMode] = { mode:
{renderLineTransportMode(line)}
, + lines: {} }; } - byModeReprs[line.transportMode][line.shortName] = renderLinePicto(line, styles); + byModeReprs[line.transportMode].lines[line.shortName] = renderLinePicto(line); } } - const reprs = []; - const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] < TransportModeWeights[y] ? 1 : -1); - for (const transportMode of sortedTransportModes) { - const lines = byModeReprs[transportMode]; - const repr = [lines.mode]; - delete lines.mode; - for (const lineId of Object.keys(lines).sort((x, y) => x.localeCompare(y))) { - repr.push(lines[lineId]); - } - reprs.push(repr); - } - return reprs; + const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] < + TransportModeWeights[y] ? 1 : -1); + + return ( +
+ {(transportMode) => { + const reprs = byModeReprs[transportMode]; + const lineNames = Object.keys(reprs.lines).sort((x, y) => x.localeCompare(y)); + return <> + {reprs.mode} +
+ {(lineName) => reprs.lines[lineName]} +
+ + }} +
+
+ ); } - const [lineReprs] = createResource(props.stop, fetchLinesRepr); return ( - - {props.stop.name} - {(line) => line} - +
setDisplayedStops([props.stop])}> +
{props.stop.name}
+ {lineReprs()} +
); } +const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => { + return ( +
+ x.name.localeCompare(y.name))}> + {(stop) => { + return }> + + ; + }} + +
+ ); +} -const Map: VoidComponent<{ stops: Stop[] }> = (props) => { +const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => { + const searchStore: SearchStore | undefined = useContext(SearchContext); + + if (searchStore === undefined) { + return
; + } + + const { getDisplayedPanelId, getFoundStops, getPanels, setDisplayedPanelId, setPanels } = searchStore; + + let stopsPanelsRef: HTMLDivElement | undefined = undefined + const stopsPanelsScroll = createScrollPosition(() => stopsPanelsRef); + const yStopsPanelsScroll = () => stopsPanelsScroll.y; + + createEffect(() => { + yStopsPanelsScroll(); + + for (const panel of Object.values(getPanels())) { + if (panel.panel) { + const panelDiv = panel.panel(); + const panelDivClientRect = panelDiv.getBoundingClientRect(); + if (panelDivClientRect.y > 0) { + setDisplayedPanelId(panel.position); + break; + } + } + } + }); + + return ( +
+ {() => { + setPanels([]); + + let newPanels = []; + let positioneds: PositionedPanel[] = []; + + let stops: Stop[] = []; + + for (const stop of getFoundStops()) { + if (stops.length < props.maxStopsPerPanel) { + stops.push(stop); + } + else { + const panelId = newPanels.length; + const panel = ; + newPanels.push(panel); + positioneds.push({ position: panelId, panel: panel }); + stops = [stop]; + } + } + if (stops.length) { + const panelId = newPanels.length; + const panel = ; + newPanels.push(panel); + positioneds.push({ position: panelId, panel: panel }); + } + + setPanels(positioneds); + + return newPanels; + }} +
+ ); +} + +const Map: VoidComponent<{}> = () => { const mapCenter: LeafletLatLngLiteral = { lat: 48.853, lng: 2.35 }; @@ -113,7 +341,7 @@ const Map: VoidComponent<{ stops: Stop[] }> = (props) => { if (searchStore === undefined) return
; - const { addMarkers } = searchStore; + const { addMarkers, getFoundStops } = searchStore; let mapDiv: any; @@ -148,7 +376,7 @@ const Map: VoidComponent<{ stops: Stop[] }> = (props) => { /* TODO: Avoid to clear all layers... */ stopsLayerGroup.clearLayers(); - for (const stop of props.stops) { + for (const stop of getFoundStops()) { const markers = setMarker(stop); addMarkers(stop.id, markers); for (const marker of markers) { @@ -162,67 +390,53 @@ const Map: VoidComponent<{ stops: Stop[] }> = (props) => { } }); - return
; + return
; } -export const StopsManager: VoidComponent = () => { +const Footer: VoidComponent<{}> = () => { - const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); const searchStore: SearchStore | undefined = useContext(SearchContext); - if (businessDataStore === undefined || searchStore === undefined) + if (searchStore === undefined) { return
; - - const { searchStopByName } = businessDataStore; - const { setDisplayedStops } = searchStore; - - // TODO: Use props instead - const [minCharactersNb] = createSignal(4); - - const [inProgress, setInProgress] = createSignal(false); - const [foundStops, setFoundStops] = createSignal([]); - - const onStopNameInput: JSX.EventHandler = async (event): Promise => { - /* TODO: Add a tempo before fetching stop for giving time to user to finish his request */ - const stopName = event.currentTarget.value; - if (stopName.length >= minCharactersNb()) { - console.log(`Fetching data for ${stopName}`); - setInProgress(true); - const stopsById = await searchStopByName(stopName); - setFoundStops(Object.values(stopsById)); - setInProgress(false); - } } + const { getDisplayedPanelId, getPanels } = searchStore; + return ( - - - πŸš‰ 🚏 - - - - - - - - x.name.localeCompare(y.name))}> - {(stop) => - - - - } - - - - - - - + + ); +} + +export const StopsSearchMenu: VoidComponent = () => { + + const MAX_STOPS_PER_PANEL = 5; + + return ( +
+ +
+
+ + +
+
+ +
); }; diff --git a/frontend/src/utils.tsx b/frontend/src/utils.tsx index 01ef21b..739e6ff 100644 --- a/frontend/src/utils.tsx +++ b/frontend/src/utils.tsx @@ -2,6 +2,13 @@ import { JSX } from 'solid-js'; import { Line } from './types'; +// Thanks to https://dev.to/ycmjason/how-to-create-range-in-javascript-539i +export function* range(start: number, end: number): Generator { + for (let i = start; i <= end; i++) { + yield i; + } +} + const validTransportModes = ["bus", "tram", "metro", "rer", "transilien", "funicular", "ter", "unknown"]; export const TransportModeWeights: Record = { @@ -116,3 +123,9 @@ export function renderLinePicto(line: Line): JSX.Element { return renderTrainLinePicto(line); } } + +export type PositionedPanel = { + position: number; + // TODO: Should be PassagesPanelComponent ? + panel: JSX.Element; +};