diff --git a/frontend/package.json b/frontend/package.json index 076d60c..54540a1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,9 +12,11 @@ "license": "MIT", "devDependencies": { "@types/leaflet": "^1.9.0", + "@types/proj4": "^2.5.2", "@vitejs/plugin-basic-ssl": "^1.0.1", "eslint": "^8.32.0", "eslint-plugin-solid": "^0.9.3", + "sass": "^1.62.0", "typescript": "^4.9.4", "typescript-eslint-language-service": "^5.0.0", "vite": "^4.0.3", @@ -27,9 +29,9 @@ "@solid-primitives/scroll": "^2.0.10", "@stitches/core": "^1.2.8", "date-fns": "^2.29.3", - "leaflet": "^1.9.3", "matrix-widget-api": "^1.1.1", - "sass": "^1.58.3", + "ol": "^7.3.0", + "proj4": "^2.9.0", "solid-js": "^1.6.6", "solid-transition-group": "^0.0.10" } diff --git a/frontend/src/_common.scss b/frontend/src/_common.scss index c77e86b..cb2c37b 100644 --- a/frontend/src/_common.scss +++ b/frontend/src/_common.scss @@ -10,7 +10,7 @@ } /* Idfm: 1800x100px (margin: 17px 60px) */ -.header { +%header { width: calc(1800/1920*100%); height: calc(100/1080*100%); /*Percentage margin are computed relatively to the nearest block container's width, not height */ @@ -23,7 +23,10 @@ font-family: IDFVoyageur-bold; } -// .header .title { +.header { + @extend %header; +} + %title { height: 50%; width: 70%; @@ -31,8 +34,6 @@ margin-right: auto; } - - /* Idfm: 1860x892px (margin: 0px 30px) */ %body { width: calc(1860/1920*100%); @@ -50,10 +51,8 @@ } - - /* Idfm: 1800x54px (margin: 0px 50px) */ -.footer { +%footer { width: calc(1820/1920*100%); height: calc(54/1080*100%); margin: 0 calc(50/1920*100%); @@ -63,6 +62,10 @@ justify-content: right; } +.footer { + @extend %footer; +} + .footer div { aspect-ratio: 1; height: 50%; diff --git a/frontend/src/stopsSearchMenu.scss b/frontend/src/stopsSearchMenu.scss index b1c1bf4..6c2cecb 100644 --- a/frontend/src/stopsSearchMenu.scss +++ b/frontend/src/stopsSearchMenu.scss @@ -51,6 +51,8 @@ /* TODO: Disable border-bottom for the last .line */ border-bottom: solid calc(2px); + cursor: default; + .name { margin-left: calc(40/1920*100%); width: 60%; @@ -134,8 +136,87 @@ } .map { + position: relative; + height: 100%; 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; + } } } } diff --git a/frontend/src/stopsSearchMenu.tsx b/frontend/src/stopsSearchMenu.tsx index c0ab089..7b8222a 100644 --- a/frontend/src/stopsSearchMenu.tsx +++ b/frontend/src/stopsSearchMenu.tsx @@ -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 { 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 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 { AppContextContext, AppContextStore } from "./appContext"; import { BusinessDataContext, BusinessDataStore } from "./businessData"; -import "leaflet/dist/leaflet.css"; import "./stopsSearchMenu.scss"; -type ByStopIdMarkers = Record; +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; interface SearchStore { @@ -32,10 +51,16 @@ interface SearchStore { getDisplayedPanelId: () => number; setDisplayedPanelId: (panelId: number) => void; - addMarkers: (stopId: number, markers: LeafletMarker[]) => void; - getPanels: () => PositionedPanel[]; 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(); @@ -46,13 +71,20 @@ function SearchProvider(props: { children: JSX.Element }) { searchText: string; searchInProgress: boolean; foundStops: Stop[]; - markers: ByStopIdMarkers; displayedPanelId: number; panels: PositionedPanel[]; + highlightedStop: Stop | undefined; + mapFeatures: ByStopIdMapFeatures; }; const [store, setStore] = createStore({ - searchText: "", searchInProgress: false, foundStops: [], markers: {}, displayedPanelId: 0, panels: [] + searchText: "", + searchInProgress: false, + foundStops: [], + displayedPanelId: 0, + panels: [], + highlightedStop: undefined, + mapFeatures: {}, }); const getSearchText = (): string => { @@ -93,24 +125,48 @@ function SearchProvider(props: { children: JSX.Element }) { 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); } + 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 ( {props.children} @@ -158,6 +214,7 @@ const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) => const StopRepr: VoidComponent<{ stop: Stop }> = (props) => { + const fontSize: number = 40; const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); if (businessDataStore === undefined) @@ -181,7 +238,14 @@ const StopRepr: VoidComponent<{ stop: Stop }> = (props) => { return (
-
{props.stop.name}
+ + + {props.stop.name} + + {(line: JSX.Element) => line}
); @@ -198,11 +262,15 @@ const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => { const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); 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
; const { getLine } = businessDataStore; const { setDisplayedStops } = appContextStore; + const { setHighlightedStop, resetHighlightedStop } = searchStore; + const fetchLinesRepr = async (stop: Stop): Promise => { const lineIds = new Set(stop.lines); @@ -247,7 +315,12 @@ const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => { const [lineReprs] = createResource(props.stop, fetchLinesRepr); return ( -
setDisplayedStops([props.stop])}> +
setDisplayedStops([props.stop])} + onMouseEnter={() => setHighlightedStop(props.stop)} + onMouseLeave={resetHighlightedStop} + >
@@ -257,8 +330,9 @@ const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => { } const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => { + return ( -
+
x.name.localeCompare(y.name))}> {(stop) => { return }> @@ -286,14 +360,12 @@ const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => { 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; - } + for (const panel of getPanels()) { + const panelDiv = panel.panel(); + const panelDivClientRect = panelDiv.getBoundingClientRect(); + if (panelDivClientRect.y > 0) { + setDisplayedPanelId(panel.position); + break; } } }); @@ -335,64 +407,309 @@ const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => { ); } -const Map: VoidComponent<{}> = () => { - - const mapCenter: LeafletLatLngLiteral = { lat: 48.853, lng: 2.35 }; - +// TODO: Use boolean to set MapStop selected +const MapStop: VoidComponent<{ stop: Stop, selected: Stop | undefined }> = (props) => { + const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); const searchStore: SearchStore | undefined = useContext(SearchContext); - if (searchStore === undefined) + if (businessDataStore === undefined || searchStore === undefined) return
; - 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; - let map: LeafletMap | undefined = undefined; - const stopsLayerGroup = leafletFeatureGroup(); + const selectedStopStyle = new Style({ + image: new Circle({ + fill: undefined, + stroke: new Stroke({ color: 'purple', width: 2 }), + radius: 10, + }), + }); - const buildMap = (div: HTMLDivElement) => { - map = new LeafletMap(div).setView(mapCenter, 11); - leafletTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors' - }).addTo(map); - stopsLayerGroup.addTo(map); - } + const stopAreaStyle = new Style({ + stroke: new Stroke({ color: 'red' }), + fill: new Fill({ color: 'rgba(255,255,255,0.2)' }), + }); - 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()); + const getShape = async (stopId: number): Promise => { + return await getStopShape(stopId); + }; + const [shape] = createResource(props.stop.id, getShape); + + 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 { - for (const _stop of stop.stops) { - markers.push(...setMarker(_stop)); + let geometry = undefined; + 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); + }); + + {stop => } +} + + +const StopPopup: ParentComponent<{ stop: Stop, show: boolean }> = (props) => { + const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); + if (businessDataStore === undefined) + return
; + + 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 ( +
+
{props.stop?.name}
+
+ + {(dst) => { + return
+ {dst.lineId} +
+ +
+
; + }} +
+
+
+ ); +} + + +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?.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)); + // Filling the map with stops shape createEffect(() => { - /* TODO: Avoid to clear all layers... */ - stopsLayerGroup.clearLayers(); + const features = getAllMapFeatures(); - for (const stop of getFoundStops()) { - const markers = setMarker(stop); - addMarkers(stop.id, markers); - for (const marker of markers) { - stopsLayerGroup.addLayer(marker); + for (const [stopId, feature] of Object.entries(features)) { + if (!(stopId in displayedFeatures)) { + const stop = getStop(parseInt(stopId)); + stopVectorSource.addFeature(feature); + displayedFeatures[stopId] = feature; } } - const stopsBound = stopsLayerGroup.getBounds(); - if (map !== undefined && Object.keys(stopsBound).length) { - map.fitBounds(stopsBound); + for (const [stopId, feature] of Object.entries(displayedFeatures)) { + if (!(stopId in features)) { + stopVectorSource.removeFeature(feature); + delete displayedFeatures[stopId]; + } + } + + const extend = stopVectorSource.getExtent(); + if (map !== undefined && !isEmptyExtend(extend)) { + map.getView().fit(extend, { duration: fitDurationMs, padding: fitPointsPadding }); } }); - return
; + // 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) => } + ; } const Footer: VoidComponent<{}> = () => { @@ -427,14 +744,14 @@ const Footer: VoidComponent<{}> = () => { export const StopsSearchMenu: VoidComponent = () => { - const MAX_STOPS_PER_PANEL = 5; + const maxStopsPerPanel = 5; return (
- +