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 (
); };