💄 Redesign StopSearchMenu (map panel)

- Replace leaflet with openlayers
- Add stop areas shape to map
- Display stop destinations sub-panel on click
This commit is contained in:
2023-04-13 21:27:07 +02:00
parent 0a7d74a215
commit a2728cfc0c
4 changed files with 479 additions and 76 deletions

View File

@@ -12,9 +12,11 @@
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/leaflet": "^1.9.0", "@types/leaflet": "^1.9.0",
"@types/proj4": "^2.5.2",
"@vitejs/plugin-basic-ssl": "^1.0.1", "@vitejs/plugin-basic-ssl": "^1.0.1",
"eslint": "^8.32.0", "eslint": "^8.32.0",
"eslint-plugin-solid": "^0.9.3", "eslint-plugin-solid": "^0.9.3",
"sass": "^1.62.0",
"typescript": "^4.9.4", "typescript": "^4.9.4",
"typescript-eslint-language-service": "^5.0.0", "typescript-eslint-language-service": "^5.0.0",
"vite": "^4.0.3", "vite": "^4.0.3",
@@ -27,9 +29,9 @@
"@solid-primitives/scroll": "^2.0.10", "@solid-primitives/scroll": "^2.0.10",
"@stitches/core": "^1.2.8", "@stitches/core": "^1.2.8",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"leaflet": "^1.9.3",
"matrix-widget-api": "^1.1.1", "matrix-widget-api": "^1.1.1",
"sass": "^1.58.3", "ol": "^7.3.0",
"proj4": "^2.9.0",
"solid-js": "^1.6.6", "solid-js": "^1.6.6",
"solid-transition-group": "^0.0.10" "solid-transition-group": "^0.0.10"
} }

View File

@@ -10,7 +10,7 @@
} }
/* Idfm: 1800x100px (margin: 17px 60px) */ /* Idfm: 1800x100px (margin: 17px 60px) */
.header { %header {
width: calc(1800/1920*100%); width: calc(1800/1920*100%);
height: calc(100/1080*100%); height: calc(100/1080*100%);
/*Percentage margin are computed relatively to the nearest block container's width, not height */ /*Percentage margin are computed relatively to the nearest block container's width, not height */
@@ -23,7 +23,10 @@
font-family: IDFVoyageur-bold; font-family: IDFVoyageur-bold;
} }
// .header .title { .header {
@extend %header;
}
%title { %title {
height: 50%; height: 50%;
width: 70%; width: 70%;
@@ -31,8 +34,6 @@
margin-right: auto; margin-right: auto;
} }
/* Idfm: 1860x892px (margin: 0px 30px) */ /* Idfm: 1860x892px (margin: 0px 30px) */
%body { %body {
width: calc(1860/1920*100%); width: calc(1860/1920*100%);
@@ -50,10 +51,8 @@
} }
/* Idfm: 1800x54px (margin: 0px 50px) */ /* Idfm: 1800x54px (margin: 0px 50px) */
.footer { %footer {
width: calc(1820/1920*100%); width: calc(1820/1920*100%);
height: calc(54/1080*100%); height: calc(54/1080*100%);
margin: 0 calc(50/1920*100%); margin: 0 calc(50/1920*100%);
@@ -63,6 +62,10 @@
justify-content: right; justify-content: right;
} }
.footer {
@extend %footer;
}
.footer div { .footer div {
aspect-ratio: 1; aspect-ratio: 1;
height: 50%; height: 50%;

View File

@@ -51,6 +51,8 @@
/* TODO: Disable border-bottom for the last .line */ /* TODO: Disable border-bottom for the last .line */
border-bottom: solid calc(2px); border-bottom: solid calc(2px);
cursor: default;
.name { .name {
margin-left: calc(40/1920*100%); margin-left: calc(40/1920*100%);
width: 60%; width: 60%;
@@ -134,8 +136,87 @@
} }
.map { .map {
position: relative;
height: 100%; height: 100%;
width: 50%; 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;
}
} }
} }
} }

View File

@@ -1,24 +1,43 @@
import { createContext, createEffect, createResource, For, JSX, onMount, ParentComponent, Show, useContext, VoidComponent } from 'solid-js'; import { createContext, createEffect, createResource, createSignal, For, JSX, onMount, ParentComponent, Show, useContext, VoidComponent } from 'solid-js';
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { createScrollPosition } from "@solid-primitives/scroll"; import { createScrollPosition } from "@solid-primitives/scroll";
import { Input, InputLeftAddon, InputGroup } from "@hope-ui/solid"; 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 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 { fromLonLat, toLonLat } from 'ol/proj';
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 { register } from 'ol/proj/proj4';
import proj4 from 'proj4';
import { Stop, StopShape } from './types';
import { PositionedPanel, renderLineTransportMode, renderLinePicto, ScrollingText, TransportModeWeights } from './utils'; import { PositionedPanel, renderLineTransportMode, renderLinePicto, ScrollingText, TransportModeWeights } from './utils';
import { AppContextContext, AppContextStore } from "./appContext"; import { AppContextContext, AppContextStore } from "./appContext";
import { BusinessDataContext, BusinessDataStore } from "./businessData"; import { BusinessDataContext, BusinessDataStore } from "./businessData";
import "leaflet/dist/leaflet.css";
import "./stopsSearchMenu.scss"; import "./stopsSearchMenu.scss";
type ByStopIdMarkers = Record<number, LeafletMarker[] | undefined>; proj4.defs("EPSG:2154", "+proj=lcc +lat_1=49 +lat_2=44 +lat_0=46.5 +lon_0=3 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs");
register(proj4);
type ByStopIdMapFeatures = Record<number, OlFeature>;
interface SearchStore { interface SearchStore {
@@ -32,10 +51,16 @@ interface SearchStore {
getDisplayedPanelId: () => number; getDisplayedPanelId: () => number;
setDisplayedPanelId: (panelId: number) => void; setDisplayedPanelId: (panelId: number) => void;
addMarkers: (stopId: number, markers: LeafletMarker[]) => void;
getPanels: () => PositionedPanel[]; getPanels: () => PositionedPanel[];
setPanels: (panels: PositionedPanel[]) => void; 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<SearchStore>(); const SearchContext = createContext<SearchStore>();
@@ -46,13 +71,20 @@ function SearchProvider(props: { children: JSX.Element }) {
searchText: string; searchText: string;
searchInProgress: boolean; searchInProgress: boolean;
foundStops: Stop[]; foundStops: Stop[];
markers: ByStopIdMarkers;
displayedPanelId: number; displayedPanelId: number;
panels: PositionedPanel[]; panels: PositionedPanel[];
highlightedStop: Stop | undefined;
mapFeatures: ByStopIdMapFeatures;
}; };
const [store, setStore] = createStore<Store>({ const [store, setStore] = createStore<Store>({
searchText: "", searchInProgress: false, foundStops: [], markers: {}, displayedPanelId: 0, panels: [] searchText: "",
searchInProgress: false,
foundStops: [],
displayedPanelId: 0,
panels: [],
highlightedStop: undefined,
mapFeatures: {},
}); });
const getSearchText = (): string => { const getSearchText = (): string => {
@@ -93,24 +125,48 @@ function SearchProvider(props: { children: JSX.Element }) {
setStore('displayedPanelId', panelId); setStore('displayedPanelId', panelId);
} }
const addMarkers = (stopId: number, markers: L.Marker[]): void => {
setStore('markers', stopId, markers);
}
const getPanels = (): PositionedPanel[] => { const getPanels = (): PositionedPanel[] => {
return store.panels; return store.panels;
} }
const setPanels = (panels: PositionedPanel[]): void => { const setPanels = (panels: PositionedPanel[]): void => {
setStore('panels', panels); 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 ( return (
<SearchContext.Provider value={{ <SearchContext.Provider value={{
getSearchText, setSearchText, isSearchInProgress, getSearchText, setSearchText, isSearchInProgress,
getFoundStops, setFoundStops, getFoundStops, setFoundStops,
getDisplayedPanelId, setDisplayedPanelId, getDisplayedPanelId, setDisplayedPanelId,
addMarkers, getPanels, setPanels,
getPanels, setPanels getHighlightedStop, setHighlightedStop, resetHighlightedStop,
getMapFeature, getAllMapFeatures, setMapFeature,
}}> }}>
{props.children} {props.children}
</SearchContext.Provider> </SearchContext.Provider>
@@ -158,6 +214,7 @@ const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) =>
const StopRepr: VoidComponent<{ stop: Stop }> = (props) => { const StopRepr: VoidComponent<{ stop: Stop }> = (props) => {
const fontSize: number = 40;
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataStore === undefined) if (businessDataStore === undefined)
@@ -181,7 +238,14 @@ const StopRepr: VoidComponent<{ stop: Stop }> = (props) => {
return ( return (
<div class="stop"> <div class="stop">
<div class="name">{props.stop.name}</div> <svg class="name" viewBox={`0 0 215 ${fontSize}`}>
<text
x="100%" y="55%"
dominant-baseline="middle" text-anchor="end"
font-size={fontSize}>
{props.stop.name}
</text>
</svg>
<For each={lineReprs()}>{(line: JSX.Element) => line}</For> <For each={lineReprs()}>{(line: JSX.Element) => line}</For>
</div> </div>
); );
@@ -198,11 +262,15 @@ const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const appContextStore: AppContextStore | undefined = useContext(AppContextContext); const appContextStore: AppContextStore | undefined = useContext(AppContextContext);
if (businessDataStore === undefined || appContextStore === undefined) const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || appContextStore === undefined || searchStore === undefined)
return <div />; return <div />;
const { getLine } = businessDataStore; const { getLine } = businessDataStore;
const { setDisplayedStops } = appContextStore; const { setDisplayedStops } = appContextStore;
const { setHighlightedStop, resetHighlightedStop } = searchStore;
const fetchLinesRepr = async (stop: Stop): Promise<JSX.Element> => { const fetchLinesRepr = async (stop: Stop): Promise<JSX.Element> => {
const lineIds = new Set(stop.lines); const lineIds = new Set(stop.lines);
@@ -247,7 +315,12 @@ const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
const [lineReprs] = createResource(props.stop, fetchLinesRepr); const [lineReprs] = createResource(props.stop, fetchLinesRepr);
return ( return (
<div class="stop" onClick={() => setDisplayedStops([props.stop])}> <div
class="stop"
onClick={() => setDisplayedStops([props.stop])}
onMouseEnter={() => setHighlightedStop(props.stop)}
onMouseLeave={resetHighlightedStop}
>
<div class="name" > <div class="name" >
<ScrollingText height={fontSize} width={100} content={props.stop.name} /> <ScrollingText height={fontSize} width={100} content={props.stop.name} />
</div> </div>
@@ -257,8 +330,9 @@ const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
} }
const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => { const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => {
return ( return (
<div classList={{ ["stopPanel"]: true, ["displayed"]: props.show }}> <div classList={{ "stopPanel": true, "displayed": props.show }}>
<For each={props.stops.sort((x, y) => x.name.localeCompare(y.name))}> <For each={props.stops.sort((x, y) => x.name.localeCompare(y.name))}>
{(stop) => { {(stop) => {
return <Show when={stop.stops !== undefined} fallback={<StopRepr stop={stop} />}> return <Show when={stop.stops !== undefined} fallback={<StopRepr stop={stop} />}>
@@ -286,14 +360,12 @@ const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => {
createEffect(() => { createEffect(() => {
yStopsPanelsScroll(); yStopsPanelsScroll();
for (const panel of Object.values(getPanels())) { for (const panel of getPanels()) {
if (panel.panel) { const panelDiv = panel.panel();
const panelDiv = panel.panel(); const panelDivClientRect = panelDiv.getBoundingClientRect();
const panelDivClientRect = panelDiv.getBoundingClientRect(); if (panelDivClientRect.y > 0) {
if (panelDivClientRect.y > 0) { setDisplayedPanelId(panel.position);
setDisplayedPanelId(panel.position); break;
break;
}
} }
} }
}); });
@@ -335,64 +407,309 @@ const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => {
); );
} }
const Map: VoidComponent<{}> = () => { // TODO: Use boolean to set MapStop selected
const MapStop: VoidComponent<{ stop: Stop, selected: Stop | undefined }> = (props) => {
const mapCenter: LeafletLatLngLiteral = { lat: 48.853, lng: 2.35 }; const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext); const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) if (businessDataStore === undefined || searchStore === undefined)
return <div />; return <div />;
const { addMarkers, getFoundStops } = searchStore; 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,
}),
});
let mapDiv: any; const selectedStopStyle = new Style({
let map: LeafletMap | undefined = undefined; image: new Circle({
const stopsLayerGroup = leafletFeatureGroup(); fill: undefined,
stroke: new Stroke({ color: 'purple', width: 2 }),
radius: 10,
}),
});
const buildMap = (div: HTMLDivElement) => { const stopAreaStyle = new Style({
map = new LeafletMap(div).setView(mapCenter, 11); stroke: new Stroke({ color: 'red' }),
leafletTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { fill: new Fill({ color: 'rgba(255,255,255,0.2)' }),
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' });
}).addTo(map);
stopsLayerGroup.addTo(map);
}
const setMarker = (stop: Stop): L.Marker[] => { const getShape = async (stopId: number): Promise<StopShape | undefined> => {
const markers = []; return await getStopShape(stopId);
if (stop.lat !== undefined && stop.lon !== undefined) { };
/* TODO: Add stop lines representation to popup. */ const [shape] = createResource<StopShape | undefined, number>(props.stop.id, getShape);
markers.push(new LeafletMarker([stop.lat, stop.lon]).bindPopup(`${stop.name}`).openPopup());
createEffect(() => {
const shape_ = shape();
if (shape_ === undefined) {
return;
} }
let feature = undefined;
if (props.stop.lat !== undefined && props.stop.lon !== undefined) {
const selectStopStyle = () => {
return (props.selected?.id === props.stop.id ? selectedStopStyle : stopStyle);
}
feature = new OlFeature({
geometry: new OlPoint(fromLonLat([props.stop.lon, props.stop.lat])),
});
feature.setStyle(selectStopStyle);
}
else { else {
for (const _stop of stop.stops) { let geometry = undefined;
markers.push(...setMarker(_stop)); const areaShape = shape();
if (areaShape !== undefined) {
const transformed = areaShape.points.map(point => fromLonLat(toLonLat(point, 'EPSG:2154')));
geometry = new OlPolygon([transformed.slice(0, -1)]);
}
else {
geometry = new OlPoint(fromLonLat([props.stop.lon, props.stop.lat]));
}
feature = new OlFeature({ geometry: geometry });
feature.setStyle(stopAreaStyle);
}
feature.setId(props.stop.id);
setMapFeature(props.stop.id, feature);
});
<For each={props.stop.stops}>{stop => <MapStop stop={stop} selected={props.selected} />}</For>
}
const StopPopup: ParentComponent<{ stop: Stop, show: boolean }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataStore === undefined)
return <div />;
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 markers;
return ret;
}
const [destinations] = createResource(() => props.stop, getDestinations);
return (
<div ref={popupDiv} classList={{ "popup": true, "displayed": props.show }}>
<div class="header">{props.stop?.name}</div>
<div class="body">
<For each={destinations()}>
{(dst) => {
return <div class='line'>
{dst.lineId}
<div class="name">
<ScrollingText height={10} width={130} content={dst.destinations.join('/')} />
</div>
</div>;
}}
</For>
</div>
</div >
);
}
const Map: ParentComponent<{}> = () => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined)
return <div />;
const { getStop } = businessDataStore;
const { getAllMapFeatures, getFoundStops, getHighlightedStop } = searchStore;
const [selectedMapStop, setSelectedMapStop] = createSignal<Stop | undefined>(undefined);
const [isPopupDisplayed, setPopupDisplayed] = createSignal<boolean>(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<number, OlFeature> = {};
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<void> => {
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<void> => {
const stopId: number = feature.getId();
const stop = getStop(stopId);
// TODO: Handle StopArea (use center given by the backend)
if (stop?.lat !== undefined && stop?.lon !== undefined) {
setSelectedMapStop(stop);
map?.getView().animate(
{
center: fromLonLat([stop.lon, stop.lat]),
duration: 1000
},
// Display the popup once the animation finished
() => setPopupDisplayed(true));
}
} }
onMount(() => buildMap(mapDiv)); onMount(() => buildMap(mapDiv));
// Filling the map with stops shape
createEffect(() => { createEffect(() => {
/* TODO: Avoid to clear all layers... */ const features = getAllMapFeatures();
stopsLayerGroup.clearLayers();
for (const stop of getFoundStops()) { for (const [stopId, feature] of Object.entries(features)) {
const markers = setMarker(stop); if (!(stopId in displayedFeatures)) {
addMarkers(stop.id, markers); const stop = getStop(parseInt(stopId));
for (const marker of markers) { stopVectorSource.addFeature(feature);
stopsLayerGroup.addLayer(marker); displayedFeatures[stopId] = feature;
} }
} }
const stopsBound = stopsLayerGroup.getBounds(); for (const [stopId, feature] of Object.entries(displayedFeatures)) {
if (map !== undefined && Object.keys(stopsBound).length) { if (!(stopId in features)) {
map.fitBounds(stopsBound); stopVectorSource.removeFeature(feature);
delete displayedFeatures[stopId];
}
}
const extend = stopVectorSource.getExtent();
if (map !== undefined && !isEmptyExtend(extend)) {
map.getView().fit(extend, { duration: fitDurationMs, padding: fitPointsPadding });
} }
}); });
return <div ref={mapDiv} class="map" id="main-map" />; // 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 <>
<div ref={mapDiv} class="map">
<StopPopup ref={popup} stop={selectedMapStop()} show={isPopupDisplayed()} />
</div>
<For each={getFoundStops()}>{(stop) => <MapStop stop={stop} selected={selectedMapStop()} />}</For>
</>;
} }
const Footer: VoidComponent<{}> = () => { const Footer: VoidComponent<{}> = () => {
@@ -427,14 +744,14 @@ const Footer: VoidComponent<{}> = () => {
export const StopsSearchMenu: VoidComponent = () => { export const StopsSearchMenu: VoidComponent = () => {
const MAX_STOPS_PER_PANEL = 5; const maxStopsPerPanel = 5;
return ( return (
<div class="stopSearchMenu"> <div class="stopSearchMenu">
<SearchProvider> <SearchProvider>
<Header title="Recherche de l'arrêt..." minCharsNb={4} /> <Header title="Recherche de l'arrêt..." minCharsNb={4} />
<div class="body"> <div class="body">
<StopsPanels maxStopsPerPanel={MAX_STOPS_PER_PANEL} /> <StopsPanels maxStopsPerPanel={maxStopsPerPanel} />
<Map /> <Map />
</div> </div>
<Footer /> <Footer />