443 lines
12 KiB
TypeScript
443 lines
12 KiB
TypeScript
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 { Stop } from './types';
|
|
import { PositionedPanel, renderLineTransportMode, renderLinePicto, TransportModeWeights } from './utils';
|
|
|
|
import { AppContextContext, AppContextStore } from "./appContext";
|
|
import { BusinessDataContext, BusinessDataStore } from "./businessData";
|
|
|
|
import "leaflet/dist/leaflet.css";
|
|
import "./stopsSearchMenu.scss";
|
|
|
|
|
|
type ByStopIdMarkers = Record<number, LeafletMarker[] | undefined>;
|
|
|
|
interface SearchStore {
|
|
|
|
getSearchText: () => string;
|
|
setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise<void>;
|
|
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<SearchStore>();
|
|
|
|
function SearchProvider(props: { children: JSX.Element }) {
|
|
|
|
type Store = {
|
|
searchText: string;
|
|
searchInProgress: boolean;
|
|
foundStops: Stop[];
|
|
markers: ByStopIdMarkers;
|
|
displayedPanelId: number;
|
|
panels: PositionedPanel[];
|
|
};
|
|
|
|
const [store, setStore] = createStore<Store>({
|
|
searchText: "", searchInProgress: false, foundStops: [], markers: {}, displayedPanelId: 0, panels: []
|
|
});
|
|
|
|
const getSearchText = (): string => {
|
|
return store.searchText;
|
|
}
|
|
|
|
const setSearchText = async (text: string, businessDataStore: BusinessDataStore): Promise<void> => {
|
|
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 (
|
|
<SearchContext.Provider value={{
|
|
getSearchText, setSearchText, isSearchInProgress,
|
|
getFoundStops, setFoundStops,
|
|
getDisplayedPanelId, setDisplayedPanelId,
|
|
addMarkers,
|
|
getPanels, setPanels
|
|
}}>
|
|
{props.children}
|
|
</SearchContext.Provider>
|
|
);
|
|
}
|
|
|
|
|
|
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 <div />;
|
|
|
|
const { isSearchInProgress, setSearchText } = searchStore;
|
|
|
|
const onStopNameInput: JSX.EventHandler<HTMLInputElement, InputEvent> = async (event): Promise<void> => {
|
|
/* 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 (
|
|
<div class="header">
|
|
<div class="title">
|
|
<svg viewBox="0 0 1260 50">
|
|
<text x="0" y="50%" dominant-baseline="middle" font-size="50" style="fill: #ffffff">
|
|
{props.title}
|
|
</text>
|
|
</svg>
|
|
</div>
|
|
<div class="inputGroup">
|
|
<InputGroup >
|
|
<InputLeftAddon>🚉 🚏</InputLeftAddon>
|
|
<Input onInput={onStopNameInput} readOnly={isSearchInProgress()} placeholder="Stop name..." />
|
|
</InputGroup>
|
|
</div>
|
|
</div >
|
|
);
|
|
};
|
|
|
|
|
|
const StopRepr: VoidComponent<{ stop: Stop }> = (props) => {
|
|
|
|
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
|
|
if (businessDataStore === undefined)
|
|
return <div />;
|
|
|
|
const { getLine } = businessDataStore;
|
|
|
|
const fetchLinesRepr = async (lineIds: string[]): Promise<JSX.Element[]> => {
|
|
const reprs = [];
|
|
for (const lineId of lineIds) {
|
|
const line = await getLine(lineId);
|
|
if (line !== undefined) {
|
|
reprs.push(<div class="transportMode">{renderLineTransportMode(line)}</div>);
|
|
reprs.push(renderLinePicto(line));
|
|
}
|
|
}
|
|
return reprs;
|
|
}
|
|
|
|
const [lineReprs] = createResource<JSX.Element[], string[]>(props.stop.lines, fetchLinesRepr);
|
|
|
|
return (
|
|
<div class="stop">
|
|
<div class="name">{props.stop.name}</div>
|
|
<For each={lineReprs()}>{(line: JSX.Element) => line}</For>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
type ByTransportModeReprs = {
|
|
mode: JSX.Element | undefined;
|
|
lines: Record<string, JSX.Element | JSX.Element[] | undefined>;
|
|
};
|
|
|
|
|
|
const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
|
|
|
|
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
|
|
const appContextStore: AppContextStore | undefined = useContext(AppContextContext);
|
|
if (businessDataStore === undefined || appContextStore === undefined)
|
|
return <div />;
|
|
|
|
const { getLine } = businessDataStore;
|
|
const { setDisplayedStops } = appContextStore;
|
|
|
|
const fetchLinesRepr = async (stop: Stop): Promise<JSX.Element> => {
|
|
const lineIds = new Set(stop.lines);
|
|
const stops = stop.stops;
|
|
for (const stop of stops) {
|
|
stop.lines.forEach(lineIds.add, lineIds);
|
|
}
|
|
|
|
const byModeReprs: Record<string, ByTransportModeReprs> = {};
|
|
for (const lineId of lineIds) {
|
|
const line = await getLine(lineId);
|
|
if (line !== undefined) {
|
|
if (!(line.transportMode in byModeReprs)) {
|
|
byModeReprs[line.transportMode] = {
|
|
mode: <div class="transportMode">{renderLineTransportMode(line)}</div>,
|
|
lines: {}
|
|
};
|
|
}
|
|
byModeReprs[line.transportMode].lines[line.shortName] = renderLinePicto(line);
|
|
}
|
|
}
|
|
|
|
const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] <
|
|
TransportModeWeights[y] ? 1 : -1);
|
|
|
|
return (
|
|
<div class="lineRepr">
|
|
<For each={sortedTransportModes}>{(transportMode) => {
|
|
const reprs = byModeReprs[transportMode];
|
|
const lineNames = Object.keys(reprs.lines).sort((x, y) => x.localeCompare(y));
|
|
return <>
|
|
{reprs.mode}
|
|
<div class="linesRepresentationMatrix">
|
|
<For each={lineNames}>{(lineName) => reprs.lines[lineName]}</For>
|
|
</div>
|
|
</>
|
|
}}
|
|
</For>
|
|
</div >
|
|
);
|
|
}
|
|
const [lineReprs] = createResource(props.stop, fetchLinesRepr);
|
|
|
|
return (
|
|
<div class="stop" onClick={() => setDisplayedStops([props.stop])}>
|
|
<div class="name">{props.stop.name}</div>
|
|
{lineReprs()}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => {
|
|
return (
|
|
<div classList={{ ["stopPanel"]: true, ["displayed"]: props.show }}>
|
|
<For each={props.stops.sort((x, y) => x.name.localeCompare(y.name))}>
|
|
{(stop) => {
|
|
return <Show when={stop.stops !== undefined} fallback={<StopRepr stop={stop} />}>
|
|
<StopAreaRepr stop={stop} />
|
|
</Show>;
|
|
}}
|
|
</For>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => {
|
|
const searchStore: SearchStore | undefined = useContext(SearchContext);
|
|
|
|
if (searchStore === undefined) {
|
|
return <div />;
|
|
}
|
|
|
|
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 (
|
|
<div ref={stopsPanelsRef} class="stopsPanels">
|
|
{() => {
|
|
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 = <StopsPanel stops={stops} show={panelId == getDisplayedPanelId()} />;
|
|
newPanels.push(panel);
|
|
positioneds.push({ position: panelId, panel: panel });
|
|
stops = [stop];
|
|
}
|
|
}
|
|
if (stops.length) {
|
|
const panelId = newPanels.length;
|
|
const panel = <StopsPanel stops={stops} show={panelId == getDisplayedPanelId()} />;
|
|
newPanels.push(panel);
|
|
positioneds.push({ position: panelId, panel: panel });
|
|
}
|
|
|
|
setPanels(positioneds);
|
|
|
|
return newPanels;
|
|
}}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const Map: VoidComponent<{}> = () => {
|
|
|
|
const mapCenter: LeafletLatLngLiteral = { lat: 48.853, lng: 2.35 };
|
|
|
|
const searchStore: SearchStore | undefined = useContext(SearchContext);
|
|
if (searchStore === undefined)
|
|
return <div />;
|
|
|
|
const { addMarkers, getFoundStops } = searchStore;
|
|
|
|
|
|
let mapDiv: any;
|
|
let map: LeafletMap | undefined = undefined;
|
|
const stopsLayerGroup = leafletFeatureGroup();
|
|
|
|
const buildMap = (div: HTMLDivElement) => {
|
|
map = new LeafletMap(div).setView(mapCenter, 11);
|
|
leafletTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
}).addTo(map);
|
|
stopsLayerGroup.addTo(map);
|
|
}
|
|
|
|
const setMarker = (stop: Stop): L.Marker[] => {
|
|
const markers = [];
|
|
if (stop.lat !== undefined && stop.lon !== undefined) {
|
|
/* TODO: Add stop lines representation to popup. */
|
|
markers.push(new LeafletMarker([stop.lat, stop.lon]).bindPopup(`${stop.name}`).openPopup());
|
|
}
|
|
else {
|
|
for (const _stop of stop.stops) {
|
|
markers.push(...setMarker(_stop));
|
|
}
|
|
}
|
|
return markers;
|
|
}
|
|
|
|
onMount(() => buildMap(mapDiv));
|
|
|
|
createEffect(() => {
|
|
/* TODO: Avoid to clear all layers... */
|
|
stopsLayerGroup.clearLayers();
|
|
|
|
for (const stop of getFoundStops()) {
|
|
const markers = setMarker(stop);
|
|
addMarkers(stop.id, markers);
|
|
for (const marker of markers) {
|
|
stopsLayerGroup.addLayer(marker);
|
|
}
|
|
}
|
|
|
|
const stopsBound = stopsLayerGroup.getBounds();
|
|
if (map !== undefined && Object.keys(stopsBound).length) {
|
|
map.fitBounds(stopsBound);
|
|
}
|
|
});
|
|
|
|
return <div ref={mapDiv} class="map" id="main-map" />;
|
|
}
|
|
|
|
const Footer: VoidComponent<{}> = () => {
|
|
|
|
const searchStore: SearchStore | undefined = useContext(SearchContext);
|
|
|
|
if (searchStore === undefined) {
|
|
return <div />;
|
|
}
|
|
|
|
const { getDisplayedPanelId, getPanels } = searchStore;
|
|
|
|
return (
|
|
<div class="footer">
|
|
<For each={getPanels()}>
|
|
{(panel) => {
|
|
const position = panel.position;
|
|
return (
|
|
<div>
|
|
<svg viewBox="0 0 29 29">
|
|
<circle cx="50%" cy="50%" r="13" stroke="#ffffff" stroke-width="3"
|
|
style={{ fill: `var(--idfm-${position == getDisplayedPanelId() ? "white" : "black"})` }}
|
|
/>
|
|
</svg>
|
|
</div>
|
|
);
|
|
}}
|
|
</For>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const StopsSearchMenu: VoidComponent = () => {
|
|
|
|
const MAX_STOPS_PER_PANEL = 5;
|
|
|
|
return (
|
|
<div class="stopSearchMenu">
|
|
<SearchProvider>
|
|
<Header title="Recherche de l'arrêt..." minCharsNb={4} />
|
|
<div class="body">
|
|
<StopsPanels maxStopsPerPanel={MAX_STOPS_PER_PANEL} />
|
|
<Map />
|
|
</div>
|
|
<Footer />
|
|
</SearchProvider>
|
|
</div>
|
|
);
|
|
};
|