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); if (foundStop.stops !== undefined) { 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) => } ; }