216 lines
6.3 KiB
TypeScript
216 lines
6.3 KiB
TypeScript
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);
|
|
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 <>
|
|
<div ref={mapDiv} class="map">
|
|
<StopPopup ref={popup} stop={selectedMapStop()} show={isPopupDisplayed()} />
|
|
</div>
|
|
<For each={getFoundStops()}>{(stop) => <MapStop stop={stop} selected={selectedMapStop()} />}</For>
|
|
</>;
|
|
}
|