♻️ Refactor StopsSearchMenu + load Map lazily + placeholder
This commit is contained in:
213
frontend/src/stopsSearchMenu/map.tsx
Normal file
213
frontend/src/stopsSearchMenu/map.tsx
Normal file
@@ -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 <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?.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 <>
|
||||
<div ref={mapDiv} class="map">
|
||||
<StopPopup ref={popup} stop={selectedMapStop()} show={isPopupDisplayed()} />
|
||||
</div>
|
||||
<For each={getFoundStops()}>{(stop) => <MapStop stop={stop} selected={selectedMapStop()} />}</For>
|
||||
</>;
|
||||
}
|
Reference in New Issue
Block a user