From e9a651e47e50af7b244feebb22b8e00eae3c02f9 Mon Sep 17 00:00:00 2001 From: Adrien Date: Tue, 25 Apr 2023 21:47:10 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20StopsSearchMenu?= =?UTF-8?q?=20+=20load=20Map=20lazily=20+=20placeholder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/passagesDisplay.scss | 1 + frontend/src/stopsSearchMenu.scss | 246 ------ frontend/src/stopsSearchMenu.tsx | 764 ------------------ frontend/src/stopsSearchMenu/map.scss | 87 ++ frontend/src/stopsSearchMenu/map.tsx | 213 +++++ frontend/src/stopsSearchMenu/mapStop.tsx | 85 ++ frontend/src/stopsSearchMenu/searchStore.tsx | 165 ++++ frontend/src/stopsSearchMenu/stopPanel.scss | 100 +++ frontend/src/stopsSearchMenu/stopPanel.tsx | 143 ++++ frontend/src/stopsSearchMenu/stopPopup.tsx | 49 ++ .../src/stopsSearchMenu/stopsSearchMenu.scss | 85 ++ .../src/stopsSearchMenu/stopsSearchMenu.tsx | 200 +++++ 12 files changed, 1128 insertions(+), 1010 deletions(-) delete mode 100644 frontend/src/stopsSearchMenu.scss delete mode 100644 frontend/src/stopsSearchMenu.tsx create mode 100644 frontend/src/stopsSearchMenu/map.scss create mode 100644 frontend/src/stopsSearchMenu/map.tsx create mode 100644 frontend/src/stopsSearchMenu/mapStop.tsx create mode 100644 frontend/src/stopsSearchMenu/searchStore.tsx create mode 100644 frontend/src/stopsSearchMenu/stopPanel.scss create mode 100644 frontend/src/stopsSearchMenu/stopPanel.tsx create mode 100644 frontend/src/stopsSearchMenu/stopPopup.tsx create mode 100644 frontend/src/stopsSearchMenu/stopsSearchMenu.scss create mode 100644 frontend/src/stopsSearchMenu/stopsSearchMenu.tsx diff --git a/frontend/src/passagesDisplay.scss b/frontend/src/passagesDisplay.scss index e36d3a7..c523cfa 100644 --- a/frontend/src/passagesDisplay.scss +++ b/frontend/src/passagesDisplay.scss @@ -1,6 +1,7 @@ @use "_common"; @use "_utils"; + .passagesDisplay { @extend %widget; diff --git a/frontend/src/stopsSearchMenu.scss b/frontend/src/stopsSearchMenu.scss deleted file mode 100644 index fa7667b..0000000 --- a/frontend/src/stopsSearchMenu.scss +++ /dev/null @@ -1,246 +0,0 @@ -@use "_common"; -@use "_utils"; - -.stopSearchMenu { - @extend %widget; - - .stopNameInput { - width: 50%; - height: 60%; - - display: flex; - flex-flow: row; - - border: solid var(--neutral-color) calc(0.01*1vh); - border-radius: var(--border-radius); - - background-color: transparent; - - .leftAddon { - width: 17%; - - display: flex; - align-items: center; - justify-content: center; - - background-color: var(--idfm-white); - } - - input { - width: 83%; - - padding-left: 3%; - padding-right: 3%; - - color: var(--idfm-white); - font-family: IDFVoyageur-regular; - background-color: transparent; - } - } - - - .title { - @extend %title; - - display: flex; - justify-content: center; - } - - .body { - @extend %body; - - flex-direction: row; - - .stopsPanels { - width: 50%; - height: 100%; - - scroll-snap-type: y mandatory; - overflow-y: scroll; - - .stopPanel { - scroll-snap-align: center; - - .stop { - width: calc(1880/1920*100%); - height: calc(100% / 5); - // margin: 0 calc(20/1920*100%); - margin: 0 calc(10/1920*100%); - - display: flex; - align-items: center; - flex-direction: row; - - /* TODO: compute the border weight according to the parent height */ - /* TODO: Disable border-bottom for the last .line */ - border-bottom: solid calc(2px); - - cursor: default; - - .name { - margin-left: calc(40/1920*100%); - width: 60%; - aspect-ratio: 2.5; - - display: flex; - align-items: center; - - font-family: IDFVoyageur-bold; - } - - .lineRepr { - width: 40%; - aspect-ratio: 2.5; - - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: center; - - .transportMode { - @extend %transportMode; - - height: 50%; - } - - .linesRepresentationMatrix { - @extend %busLinePicto; // Use the larger picto aspect-ratio - width: 75%; - aspect-ratio: 3; - - display: flex; - flex-flow: row; - flex-wrap: wrap; - - %picto { - margin-left: 1%; - align-self: center; - justify-self: center; - } - - %singleLinePicto { - @extend %picto; - - height: 80%; - } - - .transportMode { - @extend %transportMode; - @extend %picto; - } - - .tramLinePicto { - @extendnd %tramLinePicto; - @extend %singleLinePicto; - } - - .trainLinePicto { - @extend %trainLinePicto; - @extend %singleLinePicto; - } - - .metroLinePicto { - @extend %metroLinePicto; - @extend %singleLinePicto; - } - - .busLinePicto { - @extend %busLinePicto; - @extend %picto; - - height: 40%; - } - } - } - } - } - .displayed { - display: block; - } - } - - .map { - position: relative; - - height: 100%; - width: 50%; - - .ol-viewport { - @extend %body; - position: absolute; - margin: 0; - } - - .popup { - @extend %body; - margin: 0; - - position: absolute; - width: 100%; - height: 35%; - - border: solid var(--idfm-white) calc(0.2*1vh); - - background-color: var(--idfm-black); - - z-index: 1; - visibility: hidden; - - .header { - @extend %header; - - color: var(--idfm-white); - } - - .body { - @extend %body; - - scroll-snap-type: y mandatory; - overflow-y: scroll; - - .line { - scroll-snap-align: center; - - height: calc(100% / 3); - margin: 0 calc(10/1920*100%); - - display: flex; - flex-direction: row; - align-items: center; - - font-family: IDFVoyageur-bold; - - .busLinePicto { - @extend %busLinePicto; - - height: 80%; - width: 30%; - } - - .name { - width: 100%; - height: 60%; - } - - div { - height: 100%; - - svg { - max-width: 100%; - max-height: 100%; - } - } - } - } - - .footer { - @extend %footer; - } - } - - .displayed { - visibility: visible; - } - } - } -} diff --git a/frontend/src/stopsSearchMenu.tsx b/frontend/src/stopsSearchMenu.tsx deleted file mode 100644 index d6fd3e2..0000000 --- a/frontend/src/stopsSearchMenu.tsx +++ /dev/null @@ -1,764 +0,0 @@ -import { createContext, createEffect, createResource, createSignal, For, JSX, onMount, ParentComponent, Show, useContext, VoidComponent } from 'solid-js'; -import { createStore } from "solid-js/store"; -import { createScrollPosition } from "@solid-primitives/scroll"; - -import OlFeature from 'ol/Feature'; -import OlMap from 'ol/Map'; -import OlView from 'ol/View'; -import { isEmpty as isEmptyExtend } from 'ol/extent'; -import { FeatureLike as OlFeatureLike } from 'ol/Feature'; -import OlOSM from 'ol/source/OSM'; -import OlOverlay from 'ol/Overlay'; -import OlPoint from 'ol/geom/Point'; -import OlPolygon from 'ol/geom/Polygon'; -import OlVectorSource from 'ol/source/Vector'; -import { Tile as OlTileLayer, Vector as OlVectorLayer } from 'ol/layer'; -import { Circle, Fill, Stroke, Style } from 'ol/style'; -import { easeOut } from 'ol/easing'; -import { getVectorContext } from 'ol/render'; -import { unByKey } from 'ol/Observable'; - -import { Stop, StopShape } from './types'; -import { PositionedPanel, renderLineTransportMode, renderLinePicto, ScrollingText, TransportModeWeights } from './utils'; -import { AppContextContext, AppContextStore } from "./appContext"; -import { BusinessDataContext, BusinessDataStore } from "./businessData"; - -import "./stopsSearchMenu.scss"; - - -type ByStopIdMapFeatures = Record; - -interface SearchStore { - - getSearchText: () => string; - setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise; - - getFoundStops: () => Stop[]; - setFoundStops: (stops: Stop[]) => void; - - getDisplayedPanelId: () => number; - setDisplayedPanelId: (panelId: number) => void; - - getPanels: () => PositionedPanel[]; - setPanels: (panels: PositionedPanel[]) => void; - - getHighlightedStop: () => Stop | undefined; - setHighlightedStop: (stop: Stop) => void; - resetHighlightedStop: () => void; - - getMapFeature: (stopId: number) => OlFeature | undefined; - getAllMapFeatures: () => ByStopIdMapFeatures; - setMapFeature: (stopId: number, feature: OlFeature) => void; -}; - -const SearchContext = createContext(); - - -function SearchProvider(props: { children: JSX.Element }) { - - const searchTextDelayMs = 1500; - - type Store = { - searchText: string; - searchPromise: Promise | undefined; - foundStops: Stop[]; - displayedPanelId: number; - panels: PositionedPanel[]; - highlightedStop: Stop | undefined; - mapFeatures: ByStopIdMapFeatures; - }; - - const [store, setStore] = createStore({ - searchText: "", - searchPromise: undefined, - foundStops: [], - displayedPanelId: 0, - panels: [], - highlightedStop: undefined, - mapFeatures: {}, - }); - - const getSearchText = (): string => { - return store.searchText; - } - - const debounce = async (fn: (...args: any[]) => Promise, delayMs: number) => { - let timerId: number; - return new Promise((...args) => { - clearTimeout(timerId); - timerId = setTimeout(fn, delayMs, ...args); - }); - } - - const setSearchText = async (text: string, businessDataStore: BusinessDataStore): Promise => { - setStore('searchText', text); - - if (store.searchPromise === undefined) { - const { searchStopByName } = businessDataStore; - const promise: Promise = debounce(async (onSuccess: () => void) => { - console.log(`Fetching data for "${store.searchText}" stop name`); - const stopsById = await searchStopByName(store.searchText); - console.log("stopsById=", stopsById); - setFoundStops(Object.values(stopsById)); - onSuccess(); - }, searchTextDelayMs).then(() => { - setStore('searchPromise', undefined); - }); - setStore('searchPromise', promise); - } - } - - 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 getPanels = (): PositionedPanel[] => { - return store.panels; - } - - const setPanels = (panels: PositionedPanel[]): void => { - setStore('panels', panels); - } - - const getHighlightedStop = (): Stop | undefined => { - return store.highlightedStop; - } - - const setHighlightedStop = (stop: Stop): void => { - setStore('highlightedStop', stop); - } - - const resetHighlightedStop = (): void => { - setStore('highlightedStop', undefined); - } - - - const getAllMapFeatures = (): ByStopIdMapFeatures => { - return store.mapFeatures; - } - - const getMapFeature = (stopId: number): OlFeature | undefined => { - return store.mapFeatures[stopId]; - } - - const setMapFeature = (stopId: number, feature: OlFeature): void => { - setStore('mapFeatures', stopId, feature); - }; - - return ( - - {props.children} - - ); -} - -const StopNameInput: VoidComponent<{ onInput: JSX.EventHandler, leftAddon: string, placeholder: string }> = (props) => { - return ( -
-
{props.leftAddon}
- -
); -}; - -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 { setSearchText } = searchStore; - - const onStopNameInput: JSX.EventHandler = async (event): Promise => { - const stopName = event.currentTarget.value; - if (stopName.length >= props.minCharsNb) { - await setSearchText(stopName, businessDataStore); - } - } - - return ( -
-
- - - {props.title} - - -
- -
- ); -}; - - -const StopRepr: VoidComponent<{ stop: Stop }> = (props) => { - const fontSize: number = 40; - - const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); - if (businessDataStore === undefined) - return
; - - const { getLine } = businessDataStore; - - const fetchLinesRepr = async (lineIds: string[]): Promise => { - const reprs = []; - for (const lineId of lineIds) { - const line = await getLine(lineId); - if (line !== undefined) { - reprs.push(
{renderLineTransportMode(line)}
); - reprs.push(renderLinePicto(line)); - } - } - return reprs; - } - - const [lineReprs] = createResource(props.stop.lines, fetchLinesRepr); - - return ( -
- - - {props.stop.name} - - - {(line: JSX.Element) => line} -
- ); -} - - -type ByTransportModeReprs = { - mode: JSX.Element | undefined; - lines: Record; -} - -const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => { - const fontSize: number = 10; - - const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); - const appContextStore: AppContextStore | undefined = useContext(AppContextContext); - const searchStore: SearchStore | undefined = useContext(SearchContext); - - if (businessDataStore === undefined || appContextStore === undefined || searchStore === undefined) - return
; - - const { getLine } = businessDataStore; - const { setDisplayedStops } = appContextStore; - const { setHighlightedStop, resetHighlightedStop } = searchStore; - - - const fetchLinesRepr = async (stop: Stop): Promise => { - const lineIds = new Set(stop.lines); - const stops = stop.stops; - for (const stop of stops) { - stop.lines.forEach(lineIds.add, lineIds); - } - - const byModeReprs: Record = {}; - for (const lineId of lineIds) { - const line = await getLine(lineId); - if (line !== undefined) { - if (!(line.transportMode in byModeReprs)) { - byModeReprs[line.transportMode] = { - mode:
{renderLineTransportMode(line)}
, - lines: {} - }; - } - byModeReprs[line.transportMode].lines[line.shortName] = renderLinePicto(line); - } - } - - 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 ( -
setDisplayedStops([props.stop])} - onMouseEnter={() => setHighlightedStop(props.stop)} - onMouseLeave={resetHighlightedStop} - > -
- -
- {lineReprs()} -
- ); -} - -const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => { - - return ( -
- x.name.localeCompare(y.name))}> - {(stop) => { - return }> - - ; - }} - -
- ); -} - -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 getPanels()) { - 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 StopPopup: ParentComponent<{ stop: Stop, show: boolean }> = (props) => { - const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); - if (businessDataStore === undefined) - return
; - - const { getLine, getStopDestinations } = businessDataStore; - - let popupDiv: HTMLDivElement | undefined = undefined; - - const getDestinations = async (stop: Stop): Promise<{ lineId: string, destinations: string[] }[]> => { - let ret = []; - - if (stop !== undefined) { - const result = await getStopDestinations(stop.id); - for (const [lineId, destinations] of Object.entries(result)) { - const line = await getLine(lineId); - const linePicto = renderLinePicto(line); - ret.push({ lineId: linePicto, destinations: destinations }); - } - } - - return ret; - } - const [destinations] = createResource(() => props.stop, getDestinations); - - return ( -
-
{props.stop?.name}
-
- - {(dst) => { - return
- {dst.lineId} -
- -
-
; - }} -
-
-
- ); -} - - -// TODO: Use boolean to set MapStop selected -const MapStop: VoidComponent<{ stop: Stop, selected: Stop | undefined }> = (props) => { - const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); - const searchStore: SearchStore | undefined = useContext(SearchContext); - if (businessDataStore === undefined || searchStore === undefined) - return
; - - const { getStopShape } = businessDataStore; - const { setMapFeature } = searchStore; - - const stopStyle = new Style({ - image: new Circle({ - fill: undefined, - stroke: new Stroke({ color: '#3399CC', width: 1.5 }), - radius: 10, - }), - }); - - const selectedStopStyle = new Style({ - image: new Circle({ - fill: undefined, - stroke: new Stroke({ color: 'purple', width: 2 }), - radius: 10, - }), - }); - - const stopAreaStyle = new Style({ - stroke: new Stroke({ color: 'red' }), - fill: new Fill({ color: 'rgba(255,255,255,0.2)' }), - }); - - const getShape = async (stopId: number): Promise => { - return await getStopShape(stopId); - }; - const [shape] = createResource(props.stop.id, getShape); - - createEffect(() => { - const shape_ = shape(); - - if (shape_ === undefined) { - return; - } - - let feature = undefined; - - if (props.stop.epsg3857_x !== undefined && props.stop.epsg3857_y !== undefined) { - const selectStopStyle = () => { - return (props.selected?.id === props.stop.id ? selectedStopStyle : stopStyle); - } - feature = new OlFeature({ - geometry: new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]), - }); - feature.setStyle(selectStopStyle); - } - - else { - let geometry = undefined; - const areaShape = shape(); - if (areaShape !== undefined) { - geometry = new OlPolygon([areaShape.epsg3857_points.slice(0, -1)]); - } - else { - geometry = new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]); - } - feature = new OlFeature({ geometry: geometry }); - feature.setStyle(stopAreaStyle); - } - feature.setId(props.stop.id); - - setMapFeature(props.stop.id, feature); - }); - - return {stop => }; -} - - -const Map: ParentComponent<{}> = () => { - const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); - const searchStore: SearchStore | undefined = useContext(SearchContext); - if (businessDataStore === undefined || searchStore === undefined) - return
; - - const { getStop } = businessDataStore; - const { getAllMapFeatures, getFoundStops, getHighlightedStop } = searchStore; - - const [selectedMapStop, setSelectedMapStop] = createSignal(undefined); - const [isPopupDisplayed, setPopupDisplayed] = createSignal(false); - - const mapCenter = [260769.80336542107, 6250587.867330259]; // EPSG:3857 - const fitDurationMs = 1500; - const flashDurationMs = 2000; - // TODO: Set padding according to the marker design. - const fitPointsPadding = [50, 50, 50, 50]; - - let mapDiv: HTMLDivElement | undefined = undefined; - let popup: StopPopup | undefined = undefined; - - const stopVectorSource = new OlVectorSource({ features: [] }); - const stopVectorLayer = new OlVectorLayer({ source: stopVectorSource }); - - let overlay: OlOverlay | undefined = undefined; - let map: OlMap | undefined = undefined; - - const displayedFeatures: Record = {}; - - const buildMap = (div: HTMLDivElement): void => { - overlay = new OlOverlay({ - element: popup, - autoPan: { - animation: { - duration: 250, - }, - }, - }); - map = new OlMap({ - target: div, - controls: [], // remove controls - view: new OlView({ - center: mapCenter, - zoom: 10, - }), - layers: [ - new OlTileLayer({ - source: new OlOSM(), - }), - stopVectorLayer, - ], - overlays: [overlay], - }); - map.on('singleclick', onClickedMap); - } - - const onClickedMap = async (event): Promise => { - const features = await stopVectorLayer.getFeatures(event.pixel); - // Handle only the first feature - if (features.length > 0) { - await onClickedFeature(features[0]); - } - else { - setPopupDisplayed(false); - setSelectedMapStop(undefined); - } - } - - const onClickedFeature = async (feature: OlFeatureLike): Promise => { - const stopId: number = feature.getId(); - const stop = getStop(stopId); - // TODO: Handle StopArea (use center given by the backend) - if (stop?.epsg3857_x !== undefined && stop?.epsg3857_y !== undefined) { - setSelectedMapStop(stop); - map?.getView().animate( - { - center: [stop.epsg3857_x, stop.epsg3857_y], - duration: 1000 - }, - // Display the popup once the animation finished - () => setPopupDisplayed(true) - ); - } - } - - onMount(() => buildMap(mapDiv)); - - // Filling the map with stops shape - createEffect(() => { - const stops = getFoundStops(); - const foundStopIds = new Set(); - for (const foundStop of stops) { - foundStopIds.add(foundStop.id); - foundStop.stops.forEach(s => foundStopIds.add(s.id)); - } - - for (const [stopIdStr, feature] of Object.entries(displayedFeatures)) { - const stopId = parseInt(stopIdStr); - if (!foundStopIds.has(stopId)) { - console.log(`Remove feature for ${stopId}`); - stopVectorSource.removeFeature(feature); - delete displayedFeatures[stopId]; - } - } - - const features = getAllMapFeatures(); - for (const [stopIdStr, feature] of Object.entries(features)) { - const stopId = parseInt(stopIdStr); - if (foundStopIds.has(stopId) && !(stopId in displayedFeatures)) { - console.log(`Add feature for ${stopId}`); - stopVectorSource.addFeature(feature); - displayedFeatures[stopId] = feature; - } - } - - const extend = stopVectorSource.getExtent(); - if (map !== undefined && !isEmptyExtend(extend)) { - map.getView().fit(extend, { duration: fitDurationMs, padding: fitPointsPadding }); - } - }); - - // Flashing effect - createEffect(() => { - const highlightedStopId = getHighlightedStop()?.id; - if (highlightedStopId !== undefined) { - const stop = getStop(highlightedStopId); - if (stop !== undefined) { - const stops = stop.stops ? stop.stops : [stop]; - stops.forEach((s) => { - const feature = displayedFeatures[s.id]; - if (feature !== undefined) { - flash(feature); - } - }); - } - } - }); - - const flash = (feature: OlFeature) => { - const start = Date.now(); - const flashGeom = feature.getGeometry()?.clone(); - const listenerKey = stopVectorLayer.on('postrender', animate); - - // Force postrender raising. - feature.changed(); - - function animate(event) { - const frameState = event.frameState; - const elapsed = frameState.time - start; - const vectorContext = getVectorContext(event); - - if (elapsed >= flashDurationMs) { - unByKey(listenerKey); - return; - } - - if (flashGeom !== undefined && map !== undefined) { - const elapsedRatio = elapsed / flashDurationMs; - // radius will be 5 at start and 30 at end. - const radius = easeOut(elapsedRatio) * 25 + 5; - const opacity = easeOut(1 - elapsedRatio); - - const style = new Style({ - image: new Circle({ - radius: radius, - stroke: new Stroke({ - color: `rgba(255, 0, 0, ${opacity})`, - width: 0.25 + opacity, - }), - }), - }); - - vectorContext.setStyle(style); - vectorContext.drawGeometry(flashGeom); - - // tell OpenLayers to continue postrender animation - map.render(); - } - } - } - - return <> -
- -
- {(stop) => } - ; -} - -const Footer: VoidComponent<{}> = () => { - - const searchStore: SearchStore | undefined = useContext(SearchContext); - - if (searchStore === undefined) { - return
; - } - - const { getDisplayedPanelId, getPanels } = searchStore; - - return ( - - ); -} - -export const StopsSearchMenu: VoidComponent = () => { - - const maxStopsPerPanel = 5; - - return ( -
- -
-
- - -
-
- -
- ); -}; diff --git a/frontend/src/stopsSearchMenu/map.scss b/frontend/src/stopsSearchMenu/map.scss new file mode 100644 index 0000000..d744b2a --- /dev/null +++ b/frontend/src/stopsSearchMenu/map.scss @@ -0,0 +1,87 @@ +@use "../_common"; +@use "../_utils"; + + +.map { + position: relative; + + height: 100%; + width: 50%; + + .ol-viewport { + @extend %body; + position: absolute; + margin: 0; + } + + .popup { + @extend %body; + margin: 0; + + position: absolute; + width: 100%; + height: 35%; + + border: solid var(--idfm-white) calc(0.2*1vh); + + background-color: var(--idfm-black); + + z-index: 1; + visibility: hidden; + + .header { + @extend %header; + + color: var(--idfm-white); + } + + .body { + @extend %body; + + scroll-snap-type: y mandatory; + overflow-y: scroll; + + .line { + scroll-snap-align: center; + + height: calc(100% / 3); + margin: 0 calc(10/1920*100%); + + display: flex; + flex-direction: row; + align-items: center; + + font-family: IDFVoyageur-bold; + + .busLinePicto { + @extend %busLinePicto; + + height: 80%; + width: 30%; + } + + .name { + width: 100%; + height: 60%; + } + + div { + height: 100%; + + svg { + max-width: 100%; + max-height: 100%; + } + } + } + } + + .footer { + @extend %footer; + } + } + + .displayed { + visibility: visible; + } +} diff --git a/frontend/src/stopsSearchMenu/map.tsx b/frontend/src/stopsSearchMenu/map.tsx new file mode 100644 index 0000000..e20b5cb --- /dev/null +++ b/frontend/src/stopsSearchMenu/map.tsx @@ -0,0 +1,213 @@ +import { createEffect, createSignal, For, onMount, ParentComponent, useContext } from 'solid-js'; +import OlFeature from 'ol/Feature'; +import OlMap from 'ol/Map'; +import OlView from 'ol/View'; +import { isEmpty as isEmptyExtend } from 'ol/extent'; +import { FeatureLike as OlFeatureLike } from 'ol/Feature'; +import OlOSM from 'ol/source/OSM'; +import OlOverlay from 'ol/Overlay'; +import OlVectorSource from 'ol/source/Vector'; +import { Tile as OlTileLayer, Vector as OlVectorLayer } from 'ol/layer'; +import { Circle, Stroke, Style } from 'ol/style'; +import { easeOut } from 'ol/easing'; +import { getVectorContext } from 'ol/render'; +import { unByKey } from 'ol/Observable'; + +import { Stop } from '../types'; +import { BusinessDataContext, BusinessDataStore } from "../businessData"; +import { SearchContext, SearchStore } from "./searchStore"; +import { MapStop } from "./mapStop"; +import { StopPopup } from "./stopPopup"; + + +import "./map.scss"; + + +export const Map: ParentComponent<{}> = () => { + const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); + const searchStore: SearchStore | undefined = useContext(SearchContext); + if (businessDataStore === undefined || searchStore === undefined) + return
; + + const { getStop } = businessDataStore; + const { getAllMapFeatures, getFoundStops, getHighlightedStop } = searchStore; + + const [selectedMapStop, setSelectedMapStop] = createSignal(undefined); + const [isPopupDisplayed, setPopupDisplayed] = createSignal(false); + + const mapCenter = [260769.80336542107, 6250587.867330259]; // EPSG:3857 + const fitDurationMs = 1500; + const flashDurationMs = 2000; + // TODO: Set padding according to the marker design. + const fitPointsPadding = [50, 50, 50, 50]; + + let mapDiv: HTMLDivElement | undefined = undefined; + let popup: StopPopup | undefined = undefined; + + const stopVectorSource = new OlVectorSource({ features: [] }); + const stopVectorLayer = new OlVectorLayer({ source: stopVectorSource }); + + let overlay: OlOverlay | undefined = undefined; + let map: OlMap | undefined = undefined; + + const displayedFeatures: Record = {}; + + const buildMap = (div: HTMLDivElement): void => { + overlay = new OlOverlay({ + element: popup, + autoPan: { + animation: { + duration: 250, + }, + }, + }); + map = new OlMap({ + target: div, + controls: [], // remove controls + view: new OlView({ + center: mapCenter, + zoom: 10, + }), + layers: [ + new OlTileLayer({ + source: new OlOSM(), + }), + stopVectorLayer, + ], + overlays: [overlay], + }); + map.on('singleclick', onClickedMap); + } + + const onClickedMap = async (event): Promise => { + const features = await stopVectorLayer.getFeatures(event.pixel); + // Handle only the first feature + if (features.length > 0) { + await onClickedFeature(features[0]); + } + else { + setPopupDisplayed(false); + setSelectedMapStop(undefined); + } + } + + const onClickedFeature = async (feature: OlFeatureLike): Promise => { + const stopId: number = feature.getId(); + const stop = getStop(stopId); + // TODO: Handle StopArea (use center given by the backend) + if (stop?.epsg3857_x !== undefined && stop?.epsg3857_y !== undefined) { + setSelectedMapStop(stop); + map?.getView().animate( + { + center: [stop.epsg3857_x, stop.epsg3857_y], + duration: 1000 + }, + // Display the popup once the animation finished + () => setPopupDisplayed(true) + ); + } + } + + onMount(() => buildMap(mapDiv)); + + // Filling the map with stops shape + createEffect(() => { + const stops = getFoundStops(); + const foundStopIds = new Set(); + for (const foundStop of stops) { + foundStopIds.add(foundStop.id); + foundStop.stops.forEach(s => foundStopIds.add(s.id)); + } + + for (const [stopIdStr, feature] of Object.entries(displayedFeatures)) { + const stopId = parseInt(stopIdStr); + if (!foundStopIds.has(stopId)) { + console.log(`Remove feature for ${stopId}`); + stopVectorSource.removeFeature(feature); + delete displayedFeatures[stopId]; + } + } + + const features = getAllMapFeatures(); + for (const [stopIdStr, feature] of Object.entries(features)) { + const stopId = parseInt(stopIdStr); + if (foundStopIds.has(stopId) && !(stopId in displayedFeatures)) { + console.log(`Add feature for ${stopId}`); + stopVectorSource.addFeature(feature); + displayedFeatures[stopId] = feature; + } + } + + const extend = stopVectorSource.getExtent(); + if (map !== undefined && !isEmptyExtend(extend)) { + map.getView().fit(extend, { duration: fitDurationMs, padding: fitPointsPadding }); + } + }); + + // Flashing effect + createEffect(() => { + const highlightedStopId = getHighlightedStop()?.id; + if (highlightedStopId !== undefined) { + const stop = getStop(highlightedStopId); + if (stop !== undefined) { + const stops = stop.stops ? stop.stops : [stop]; + stops.forEach((s) => { + const feature = displayedFeatures[s.id]; + if (feature !== undefined) { + flash(feature); + } + }); + } + } + }); + + const flash = (feature: OlFeature) => { + const start = Date.now(); + const flashGeom = feature.getGeometry()?.clone(); + const listenerKey = stopVectorLayer.on('postrender', animate); + + // Force postrender raising. + feature.changed(); + + function animate(event) { + const frameState = event.frameState; + const elapsed = frameState.time - start; + const vectorContext = getVectorContext(event); + + if (elapsed >= flashDurationMs) { + unByKey(listenerKey); + return; + } + + if (flashGeom !== undefined && map !== undefined) { + const elapsedRatio = elapsed / flashDurationMs; + // radius will be 5 at start and 30 at end. + const radius = easeOut(elapsedRatio) * 25 + 5; + const opacity = easeOut(1 - elapsedRatio); + + const style = new Style({ + image: new Circle({ + radius: radius, + stroke: new Stroke({ + color: `rgba(255, 0, 0, ${opacity})`, + width: 0.25 + opacity, + }), + }), + }); + + vectorContext.setStyle(style); + vectorContext.drawGeometry(flashGeom); + + // tell OpenLayers to continue postrender animation + map.render(); + } + } + } + + return <> +
+ +
+ {(stop) => } + ; +} diff --git a/frontend/src/stopsSearchMenu/mapStop.tsx b/frontend/src/stopsSearchMenu/mapStop.tsx new file mode 100644 index 0000000..e2dc744 --- /dev/null +++ b/frontend/src/stopsSearchMenu/mapStop.tsx @@ -0,0 +1,85 @@ +import { createEffect, createResource, For, useContext, VoidComponent } from 'solid-js'; +import { Circle, Fill, Stroke, Style } from 'ol/style'; +import OlFeature from 'ol/Feature'; +import OlPoint from 'ol/geom/Point'; +import OlPolygon from 'ol/geom/Polygon'; + +import { Stop, StopShape } from '../types'; +import { BusinessDataContext, BusinessDataStore } from "../businessData"; +import { SearchContext, SearchStore } from "./searchStore"; + + +// TODO: Use boolean to set MapStop selected +export const MapStop: VoidComponent<{ stop: Stop, selected: Stop | undefined }> = (props) => { + const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); + const searchStore: SearchStore | undefined = useContext(SearchContext); + if (businessDataStore === undefined || searchStore === undefined) + return
; + + const { getStopShape } = businessDataStore; + const { setMapFeature } = searchStore; + + const stopStyle = new Style({ + image: new Circle({ + fill: undefined, + stroke: new Stroke({ color: '#3399CC', width: 1.5 }), + radius: 10, + }), + }); + + const selectedStopStyle = new Style({ + image: new Circle({ + fill: undefined, + stroke: new Stroke({ color: 'purple', width: 2 }), + radius: 10, + }), + }); + + const stopAreaStyle = new Style({ + stroke: new Stroke({ color: 'red' }), + fill: new Fill({ color: 'rgba(255,255,255,0.2)' }), + }); + + const getShape = async (stopId: number): Promise => { + return await getStopShape(stopId); + }; + const [shape] = createResource(props.stop.id, getShape); + + createEffect(() => { + const shape_ = shape(); + + if (shape_ === undefined) { + return; + } + + let feature = undefined; + + if (props.stop.epsg3857_x !== undefined && props.stop.epsg3857_y !== undefined) { + const selectStopStyle = () => { + return (props.selected?.id === props.stop.id ? selectedStopStyle : stopStyle); + } + feature = new OlFeature({ + geometry: new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]), + }); + feature.setStyle(selectStopStyle); + } + + else { + let geometry = undefined; + const areaShape = shape(); + if (areaShape !== undefined) { + geometry = new OlPolygon([areaShape.epsg3857_points.slice(0, -1)]); + } + else { + geometry = new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]); + } + feature = new OlFeature({ geometry: geometry }); + feature.setStyle(stopAreaStyle); + } + feature.setId(props.stop.id); + + setMapFeature(props.stop.id, feature); + }); + + return {stop => }; +} diff --git a/frontend/src/stopsSearchMenu/searchStore.tsx b/frontend/src/stopsSearchMenu/searchStore.tsx new file mode 100644 index 0000000..e666eed --- /dev/null +++ b/frontend/src/stopsSearchMenu/searchStore.tsx @@ -0,0 +1,165 @@ +import { createContext, JSX } from 'solid-js'; +import { createStore } from "solid-js/store"; +import OlFeature from 'ol/Feature'; +import { BusinessDataStore } from "../businessData"; + +import { Stop } from '../types'; +import { PositionedPanel } from '../utils'; + +type ByStopIdMapFeatures = Record; + +export interface SearchStore { + + getSearchText: () => string; + setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise; + + getFoundStops: () => Stop[]; + setFoundStops: (stops: Stop[]) => void; + + getDisplayedPanelId: () => number; + setDisplayedPanelId: (panelId: number) => void; + + getPanels: () => PositionedPanel[]; + setPanels: (panels: PositionedPanel[]) => void; + + getHighlightedStop: () => Stop | undefined; + setHighlightedStop: (stop: Stop) => void; + resetHighlightedStop: () => void; + + enableMap: (enable: boolean) => void; + isMapEnabled: () => boolean; + getMapFeature: (stopId: number) => OlFeature | undefined; + getAllMapFeatures: () => ByStopIdMapFeatures; + setMapFeature: (stopId: number, feature: OlFeature) => void; +}; + +export const SearchContext = createContext(); + + +export function SearchProvider(props: { children: JSX.Element }) { + + const searchTextDelayMs = 1500; + + type Store = { + searchText: string; + searchPromise: Promise | undefined; + foundStops: Stop[]; + displayedPanelId: number; + panels: PositionedPanel[]; + highlightedStop: Stop | undefined; + mapEnabled: boolean; + mapFeatures: ByStopIdMapFeatures; + }; + + const [store, setStore] = createStore({ + searchText: "", + searchPromise: undefined, + foundStops: [], + displayedPanelId: 0, + panels: [], + highlightedStop: undefined, + // mapEnabled: false, + mapFeatures: {}, + }); + + const getSearchText = (): string => { + return store.searchText; + } + + const debounce = async (fn: (...args: any[]) => Promise, delayMs: number) => { + let timerId: number; + return new Promise((...args) => { + clearTimeout(timerId); + timerId = setTimeout(fn, delayMs, ...args); + }); + } + + const setSearchText = async (text: string, businessDataStore: BusinessDataStore): Promise => { + setStore('searchText', text); + + if (store.searchPromise === undefined) { + const { searchStopByName } = businessDataStore; + const promise: Promise = debounce(async (onSuccess: () => void) => { + console.log(`Fetching data for "${store.searchText}" stop name`); + const stopsById = await searchStopByName(store.searchText); + console.log("stopsById=", stopsById); + setFoundStops(Object.values(stopsById)); + onSuccess(); + }, searchTextDelayMs).then(() => { + setStore('searchPromise', undefined); + }); + setStore('searchPromise', promise); + } + } + + 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 getPanels = (): PositionedPanel[] => { + return store.panels; + } + + const setPanels = (panels: PositionedPanel[]): void => { + setStore('panels', panels); + } + + const getHighlightedStop = (): Stop | undefined => { + return store.highlightedStop; + } + + const setHighlightedStop = (stop: Stop): void => { + setStore('highlightedStop', stop); + } + + const resetHighlightedStop = (): void => { + setStore('highlightedStop', undefined); + } + + + const enableMap = (enable: boolean): void => { + setStore("mapEnabled", enable); + } + + const isMapEnabled = (): boolean => { + return store.mapEnabled; + } + + const getAllMapFeatures = (): ByStopIdMapFeatures => { + return store.mapFeatures; + } + + const getMapFeature = (stopId: number): OlFeature | undefined => { + return store.mapFeatures[stopId]; + } + + const setMapFeature = (stopId: number, feature: OlFeature): void => { + setStore('mapFeatures', stopId, feature); + }; + + return ( + + {props.children} + + ); +} diff --git a/frontend/src/stopsSearchMenu/stopPanel.scss b/frontend/src/stopsSearchMenu/stopPanel.scss new file mode 100644 index 0000000..0f3986e --- /dev/null +++ b/frontend/src/stopsSearchMenu/stopPanel.scss @@ -0,0 +1,100 @@ +@use "../_common"; +@use "../_utils"; + + +.stopPanel { + scroll-snap-align: center; + + .stop { + width: calc(1880/1920*100%); + height: calc(100% / 5); + // margin: 0 calc(20/1920*100%); + margin: 0 calc(10/1920*100%); + + display: flex; + align-items: center; + flex-direction: row; + + /* TODO: compute the border weight according to the parent height */ + /* TODO: Disable border-bottom for the last .line */ + border-bottom: solid calc(2px); + + cursor: default; + + .name { + margin-left: calc(40/1920*100%); + width: 60%; + aspect-ratio: 2.5; + + display: flex; + align-items: center; + + font-family: IDFVoyageur-bold; + } + + .lineRepr { + width: 40%; + aspect-ratio: 2.5; + + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + + .transportMode { + @extend %transportMode; + + height: 50%; + } + + .linesRepresentationMatrix { + @extend %busLinePicto; // Use the larger picto aspect-ratio + width: 75%; + aspect-ratio: 3; + + display: flex; + flex-flow: row; + flex-wrap: wrap; + + %picto { + margin-left: 1%; + align-self: center; + justify-self: center; + } + + %singleLinePicto { + @extend %picto; + + height: 80%; + } + + .transportMode { + @extend %transportMode; + @extend %picto; + } + + .tramLinePicto { + @extend %tramLinePicto; + @extend %singleLinePicto; + } + + .trainLinePicto { + @extend %trainLinePicto; + @extend %singleLinePicto; + } + + .metroLinePicto { + @extend %metroLinePicto; + @extend %singleLinePicto; + } + + .busLinePicto { + @extend %busLinePicto; + @extend %picto; + + height: 40%; + } + } + } + } +} diff --git a/frontend/src/stopsSearchMenu/stopPanel.tsx b/frontend/src/stopsSearchMenu/stopPanel.tsx new file mode 100644 index 0000000..2c0d903 --- /dev/null +++ b/frontend/src/stopsSearchMenu/stopPanel.tsx @@ -0,0 +1,143 @@ +import { createResource, For, JSX, ParentComponent, Show, useContext, VoidComponent } from 'solid-js'; + +import { Stop } from '../types'; +import { renderLineTransportMode, renderLinePicto, ScrollingText, TransportModeWeights } from '../utils'; +import { AppContextContext, AppContextStore } from "../appContext"; +import { BusinessDataContext, BusinessDataStore } from "../businessData"; +import { SearchContext, SearchStore } from "./searchStore"; + + +import "./stopPanel.scss"; + + +const StopRepr: VoidComponent<{ stop: Stop }> = (props) => { + const fontSize: number = 40; + + const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); + if (businessDataStore === undefined) + return
; + + const { getLine } = businessDataStore; + + const fetchLinesRepr = async (lineIds: string[]): Promise => { + const reprs = []; + for (const lineId of lineIds) { + const line = await getLine(lineId); + if (line !== undefined) { + reprs.push(
{renderLineTransportMode(line)}
); + reprs.push(renderLinePicto(line)); + } + } + return reprs; + } + + const [lineReprs] = createResource(props.stop.lines, fetchLinesRepr); + + return ( +
+ + + {props.stop.name} + + + {(line: JSX.Element) => line} +
+ ); +} + + +type ByTransportModeReprs = { + mode: JSX.Element | undefined; + lines: Record; +} + + +const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => { + const fontSize: number = 10; + + const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); + const appContextStore: AppContextStore | undefined = useContext(AppContextContext); + const searchStore: SearchStore | undefined = useContext(SearchContext); + + if (businessDataStore === undefined || appContextStore === undefined || searchStore === undefined) + return
; + + const { getLine } = businessDataStore; + const { setDisplayedStops } = appContextStore; + const { setHighlightedStop, resetHighlightedStop } = searchStore; + + + const fetchLinesRepr = async (stop: Stop): Promise => { + const lineIds = new Set(stop.lines); + const stops = stop.stops; + for (const stop of stops) { + stop.lines.forEach(lineIds.add, lineIds); + } + + const byModeReprs: Record = {}; + for (const lineId of lineIds) { + const line = await getLine(lineId); + if (line !== undefined) { + if (!(line.transportMode in byModeReprs)) { + byModeReprs[line.transportMode] = { + mode:
{renderLineTransportMode(line)}
, + lines: {} + }; + } + byModeReprs[line.transportMode].lines[line.shortName] = renderLinePicto(line); + } + } + + 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 ( +
setDisplayedStops([props.stop])} + onMouseEnter={() => setHighlightedStop(props.stop)} + onMouseLeave={resetHighlightedStop} + > +
+ +
+ {lineReprs()} +
+ ); +} + +export const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => { + + return ( +
+ x.name.localeCompare(y.name))}> + {(stop) => { + return }> + + ; + }} + +
+ ); +} diff --git a/frontend/src/stopsSearchMenu/stopPopup.tsx b/frontend/src/stopsSearchMenu/stopPopup.tsx new file mode 100644 index 0000000..6c0f278 --- /dev/null +++ b/frontend/src/stopsSearchMenu/stopPopup.tsx @@ -0,0 +1,49 @@ +import { createResource, For, ParentComponent, useContext } from 'solid-js'; + +import { Stop } from '../types'; +import { BusinessDataContext, BusinessDataStore } from "../businessData"; +import { renderLinePicto, ScrollingText } from '../utils'; + +export const StopPopup: ParentComponent<{ stop: Stop, show: boolean }> = (props) => { + const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); + if (businessDataStore === undefined) + return
; + + const { getLine, getStopDestinations } = businessDataStore; + + let popupDiv: HTMLDivElement | undefined = undefined; + + const getDestinations = async (stop: Stop): Promise<{ lineId: string, destinations: string[] }[]> => { + let ret = []; + + if (stop !== undefined) { + const result = await getStopDestinations(stop.id); + for (const [lineId, destinations] of Object.entries(result)) { + const line = await getLine(lineId); + const linePicto = renderLinePicto(line); + ret.push({ lineId: linePicto, destinations: destinations }); + } + } + + return ret; + } + const [destinations] = createResource(() => props.stop, getDestinations); + + return ( +
+
{props.stop?.name}
+
+ + {(dst) => { + return
+ {dst.lineId} +
+ +
+
; + }} +
+
+
+ ); +} diff --git a/frontend/src/stopsSearchMenu/stopsSearchMenu.scss b/frontend/src/stopsSearchMenu/stopsSearchMenu.scss new file mode 100644 index 0000000..3a439d0 --- /dev/null +++ b/frontend/src/stopsSearchMenu/stopsSearchMenu.scss @@ -0,0 +1,85 @@ +@use "../_common"; +@use "../_utils"; + + +.mapPlaceholder { + --border-width: 0.1vh; + + height: calc(100% - 2*var(--border-width)); + width: 50%; + + display: flex; + align-items: center; + justify-content: center; + + border: solid var(--neutral-color) var(--border-width); + border-radius: var(--border-radius); + + background-color: var(--idfm-black); + font-family: IDFVoyageur-bold; + font-size: 2vh; + color: var(--idfm-white); +} + +.stopNameInput { + width: 50%; + height: 60%; + + display: flex; + flex-flow: row; + + border: solid var(--neutral-color) calc(0.01vh); + border-radius: var(--border-radius); + + background-color: transparent; + + .leftAddon { + width: 17%; + + display: flex; + align-items: center; + justify-content: center; + + background-color: var(--idfm-white); + } + + input { + width: 83%; + + padding-left: 3%; + padding-right: 3%; + + color: var(--idfm-white); + font-family: IDFVoyageur-regular; + background-color: transparent; + } +} + +.title { + @extend %title; + + display: flex; + justify-content: center; +} + +.stopSearchMenu { + @extend %widget; + + .body { + @extend %body; + + flex-direction: row; + + .stopsPanels { + width: 50%; + height: 100%; + + scroll-snap-type: y mandatory; + overflow-y: scroll; + + .displayed { + display: block; + } + } + } +} diff --git a/frontend/src/stopsSearchMenu/stopsSearchMenu.tsx b/frontend/src/stopsSearchMenu/stopsSearchMenu.tsx new file mode 100644 index 0000000..bb5fcdf --- /dev/null +++ b/frontend/src/stopsSearchMenu/stopsSearchMenu.tsx @@ -0,0 +1,200 @@ +import { createEffect, For, JSX, lazy, ParentComponent, useContext, Show, VoidComponent } from 'solid-js'; +import { lazily } from 'solidjs-lazily'; +import { createScrollPosition } from "@solid-primitives/scroll"; + +import { Stop } from '../types'; +import { PositionedPanel } from '../utils'; + +import { BusinessDataContext, BusinessDataStore } from "../businessData"; +import { SearchContext, SearchProvider, SearchStore } from "./searchStore"; +import { StopsPanel } from "./stopPanel"; + +const { Map } = lazily(() => import("./map")); + + +import "./stopsSearchMenu.scss"; + + +const StopNameInput: VoidComponent<{ onInput: JSX.EventHandler, leftAddon: string, placeholder: string }> = (props) => { + return ( +
+
{props.leftAddon}
+ +
); +}; + + +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 { setSearchText } = searchStore; + + const onStopNameInput: JSX.EventHandler = async (event): Promise => { + const stopName = event.currentTarget.value; + if (stopName.length >= props.minCharsNb) { + await setSearchText(stopName, businessDataStore); + } + } + + return ( +
+
+ + + {props.title} + + +
+ +
+ ); +}; + + +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 getPanels()) { + 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 MapPlaceholder: VoidComponent<{}> = () => { + const searchStore: SearchStore | undefined = useContext(SearchContext); + + if (searchStore === undefined) { + return
; + } + + const { enableMap } = searchStore; + + const onDoubleClick = (): void => { + console.log('!!! ON DOUBLE CLICK'); + enableMap(true); + } + + return
onDoubleClick()}> + Double-clic pour activer la carte +
; +}; + + +const Body: VoidComponent<{}> = () => { + const searchStore: SearchStore | undefined = useContext(SearchContext); + if (searchStore === undefined) { + return
; + } + + const { isMapEnabled } = searchStore; + + const maxStopsPerPanel = 5; + + return
+ + }> + + +
; +}; + + +const Footer: VoidComponent<{}> = () => { + const searchStore: SearchStore | undefined = useContext(SearchContext); + if (searchStore === undefined) { + return
; + } + + const { getDisplayedPanelId, getPanels } = searchStore; + + return ( + + ); +}; + + +export const StopsSearchMenu: VoidComponent = () => { + return ( +
+ +
+ +
+ ); +};