diff --git a/backend/backend/idfm_interface/idfm_interface.py b/backend/backend/idfm_interface/idfm_interface.py index c6cf33e..2c04e99 100644 --- a/backend/backend/idfm_interface/idfm_interface.py +++ b/backend/backend/idfm_interface/idfm_interface.py @@ -12,10 +12,9 @@ from typing import ( from aiofiles import open as async_open from aiohttp import ClientSession - from msgspec import ValidationError from msgspec.json import Decoder -from rich import print +from pyproj import Transformer from shapefile import Reader as ShapeFileReader, ShapeRecord from ..db import Database @@ -64,6 +63,8 @@ class IdfmInterface: self._http_headers = {"Accept": "application/json", "apikey": self._api_key} + self._epsg2154_epsg3857_transformer = Transformer.from_crs(2154, 3857) + self._json_stops_decoder = Decoder(type=List[IdfmStop]) self._json_stop_areas_decoder = Decoder(type=List[IdfmStopArea]) self._json_connection_areas_decoder = Decoder(type=List[IdfmConnectionArea]) @@ -89,19 +90,19 @@ class IdfmInterface: ( StopShape, self._request_stop_shapes, - IdfmInterface._format_idfm_stop_shapes, + self._format_idfm_stop_shapes, ), ( ConnectionArea, self._request_idfm_connection_areas, - IdfmInterface._format_idfm_connection_areas, + self._format_idfm_connection_areas, ), ( StopArea, self._request_idfm_stop_areas, - IdfmInterface._format_idfm_stop_areas, + self._format_idfm_stop_areas, ), - (Stop, self._request_idfm_stops, IdfmInterface._format_idfm_stops), + (Stop, self._request_idfm_stops, self._format_idfm_stops), ) for model, get_method, format_method in STEPS: @@ -391,23 +392,26 @@ class IdfmInterface: return ret - @staticmethod - def _format_idfm_stops(*stops: IdfmStop) -> Iterable[Stop]: + def _format_idfm_stops(self, *stops: IdfmStop) -> Iterable[Stop]: for stop in stops: fields = stop.fields + try: created_ts = int(fields.arrcreated.timestamp()) # type: ignore except AttributeError: created_ts = None + + epsg3857_point = self._epsg2154_epsg3857_transformer.transform( + fields.arrxepsg2154, fields.arryepsg2154 + ) + yield Stop( id=int(fields.arrid), name=fields.arrname, - latitude=fields.arrgeopoint.lat, - longitude=fields.arrgeopoint.lon, + epsg3857_x=epsg3857_point[0], + epsg3857_y=epsg3857_point[1], town_name=fields.arrtown, postal_region=fields.arrpostalregion, - xepsg2154=fields.arrxepsg2154, - yepsg2154=fields.arryepsg2154, transport_mode=TransportMode(fields.arrtype.value), version=fields.arrversion, created_ts=created_ts, @@ -419,53 +423,76 @@ class IdfmInterface: record_ts=int(stop.record_timestamp.timestamp()), ) - @staticmethod - def _format_idfm_stop_areas(*stop_areas: IdfmStopArea) -> Iterable[StopArea]: + def _format_idfm_stop_areas(self, *stop_areas: IdfmStopArea) -> Iterable[StopArea]: for stop_area in stop_areas: fields = stop_area.fields + try: created_ts = int(fields.zdacreated.timestamp()) # type: ignore except AttributeError: created_ts = None + + epsg3857_point = self._epsg2154_epsg3857_transformer.transform( + fields.zdaxepsg2154, fields.zdayepsg2154 + ) + yield StopArea( id=int(fields.zdaid), name=fields.zdaname, town_name=fields.zdatown, postal_region=fields.zdapostalregion, - xepsg2154=fields.zdaxepsg2154, - yepsg2154=fields.zdayepsg2154, + epsg3857_x=epsg3857_point[0], + epsg3857_y=epsg3857_point[1], type=StopAreaType(fields.zdatype.value), version=fields.zdaversion, created_ts=created_ts, changed_ts=int(fields.zdachanged.timestamp()), ) - @staticmethod def _format_idfm_connection_areas( + self, *connection_areas: IdfmConnectionArea, ) -> Iterable[ConnectionArea]: for connection_area in connection_areas: + + epsg3857_point = self._epsg2154_epsg3857_transformer.transform( + connection_area.zdcxepsg2154, connection_area.zdcyepsg2154 + ) + yield ConnectionArea( id=int(connection_area.zdcid), name=connection_area.zdcname, town_name=connection_area.zdctown, postal_region=connection_area.zdcpostalregion, - xepsg2154=connection_area.zdcxepsg2154, - yepsg2154=connection_area.zdcyepsg2154, + epsg3857_x=epsg3857_point[0], + epsg3857_y=epsg3857_point[1], transport_mode=StopAreaType(connection_area.zdctype.value), version=connection_area.zdcversion, created_ts=int(connection_area.zdccreated.timestamp()), changed_ts=int(connection_area.zdcchanged.timestamp()), ) - @staticmethod - def _format_idfm_stop_shapes(*shape_records: ShapeRecord) -> Iterable[StopShape]: + def _format_idfm_stop_shapes( + self, *shape_records: ShapeRecord + ) -> Iterable[StopShape]: for shape_record in shape_records: + + epsg3857_points = [ + self._epsg2154_epsg3857_transformer.transform(*point) + for point in shape_record.shape.points + ] + + bbox_it = iter(shape_record.shape.bbox) + epsg3857_bbox = [ + self._epsg2154_epsg3857_transformer.transform(*point) + for point in zip(bbox_it, bbox_it) + ] + yield StopShape( id=shape_record.record[1], type=shape_record.shape.shapeType, - bounding_box=list(shape_record.shape.bbox), - points=shape_record.shape.points, + epsg3857_bbox=epsg3857_bbox, + epsg3857_points=epsg3857_points, ) async def render_line_picto(self, line: Line) -> tuple[None | str, None | str]: @@ -508,7 +535,7 @@ class IdfmInterface: print("---------------------------------------------------------------------") return data - async def get_next_passages(self, stop_point_id: str) -> IdfmResponse | None: + async def get_next_passages(self, stop_point_id: int) -> IdfmResponse | None: ret = None params = {"MonitoringRef": f"STIF:StopPoint:Q:{stop_point_id}:"} async with ClientSession(headers=self._http_headers) as session: diff --git a/backend/backend/models/stop.py b/backend/backend/models/stop.py index d286878..b686741 100644 --- a/backend/backend/models/stop.py +++ b/backend/backend/models/stop.py @@ -48,8 +48,8 @@ class _Stop(Base): name = mapped_column(String, nullable=False, index=True) town_name = mapped_column(String, nullable=False) postal_region = mapped_column(String, nullable=False) - xepsg2154 = mapped_column(BigInteger, nullable=False) - yepsg2154 = mapped_column(BigInteger, nullable=False) + epsg3857_x = mapped_column(Float, nullable=False) + epsg3857_y = mapped_column(Float, nullable=False) version = mapped_column(String, nullable=False) created_ts = mapped_column(BigInteger) @@ -111,8 +111,6 @@ class Stop(_Stop): id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True) - latitude = mapped_column(Float, nullable=False) - longitude = mapped_column(Float, nullable=False) transport_mode = mapped_column(Enum(TransportMode), nullable=False) accessibility = mapped_column(Enum(IdfmState), nullable=False) visual_signs_available = mapped_column(Enum(IdfmState), nullable=False) @@ -191,8 +189,8 @@ class StopShape(Base): id = mapped_column(BigInteger, primary_key=True) # Same id than ConnectionArea type = mapped_column(Integer, nullable=False) - bounding_box = mapped_column(JSON) - points = mapped_column(JSON) + epsg3857_bbox = mapped_column(JSON) + epsg3857_points = mapped_column(JSON) __tablename__ = "stop_shapes" @@ -206,8 +204,8 @@ class ConnectionArea(Base): name = mapped_column(String, nullable=False) town_name = mapped_column(String, nullable=False) postal_region = mapped_column(String, nullable=False) - xepsg2154 = mapped_column(BigInteger, nullable=False) - yepsg2154 = mapped_column(BigInteger, nullable=False) + epsg3857_x = mapped_column(Float, nullable=False) + epsg3857_y = mapped_column(Float, nullable=False) transport_mode = mapped_column(Enum(StopAreaType), nullable=False) version = mapped_column(String, nullable=False) diff --git a/backend/backend/schemas/stop.py b/backend/backend/schemas/stop.py index 39b8fc0..2bd6f6e 100644 --- a/backend/backend/schemas/stop.py +++ b/backend/backend/schemas/stop.py @@ -7,10 +7,8 @@ class Stop(BaseModel): id: int name: str town: str - lat: float - lon: float - # xepsg2154: int - # yepsg2154: int + epsg3857_x: float + epsg3857_y: float lines: list[str] @@ -18,15 +16,16 @@ class StopArea(BaseModel): id: int name: str town: str - # xepsg2154: int - # yepsg2154: int type: StopAreaType lines: list[str] # SNCF lines are linked to stop areas and not stops. stops: list[Stop] +Point = tuple[float, float] + + class StopShape(BaseModel): id: int type: int - bbox: list[float] - points: list[tuple[float, float]] + epsg3857_bbox: list[Point] + epsg3857_points: list[Point] diff --git a/backend/main.py b/backend/main.py index 7387c1f..9eeef97 100644 --- a/backend/main.py +++ b/backend/main.py @@ -58,8 +58,6 @@ async def shutdown(): await db.disconnect() -# /addwidget https://localhost:4443/static/#?widgetId=$matrix_widget_id&userId=$matrix_user_id -# /addwidget https://localhost:3000/widget?widgetId=$matrix_widget_id&userId=$matrix_user_id STATIC_ROOT = "../frontend/" app.mount("/widget", StaticFiles(directory=STATIC_ROOT, html=True), name="widget") @@ -94,22 +92,16 @@ async def get_line(line_id: str) -> LineSchema: def _format_stop(stop: Stop) -> StopSchema: - # print(stop.__dict__) return StopSchema( id=stop.id, name=stop.name, town=stop.town_name, - # xepsg2154=stop.xepsg2154, - # yepsg2154=stop.yepsg2154, - lat=stop.latitude, - lon=stop.longitude, + epsg3857_x=stop.epsg3857_x, + epsg3857_y=stop.epsg3857_y, lines=[line.id for line in stop.lines], ) -# châtelet - - @app.get("/stop/") async def get_stop( name: str = "", limit: int = 10 @@ -142,8 +134,6 @@ async def get_stop( id=stop_area.id, name=stop_area.name, town=stop_area.town_name, - # xepsg2154=stop_area.xepsg2154, - # yepsg2154=stop_area.yepsg2154, type=stop_area.type, lines=[line.id for line in stop_area.lines], stops=formatted_stops, @@ -187,8 +177,9 @@ async def get_next_passages(stop_id: str) -> NextPassagesSchema | None: dst_names = call.DestinationDisplay dsts = [dst.value for dst in dst_names] if dst_names else [] - - print(f"{call.ArrivalPlatformName = }") + arrivalPlatformName = ( + call.ArrivalPlatformName.value if call.ArrivalPlatformName else None + ) next_passage = NextPassageSchema( line=line_id, @@ -197,9 +188,7 @@ async def get_next_passages(stop_id: str) -> NextPassagesSchema | None: atStop=call.VehicleAtStop, aimedArrivalTs=optional_datetime_to_ts(call.AimedArrivalTime), expectedArrivalTs=optional_datetime_to_ts(call.ExpectedArrivalTime), - arrivalPlatformName=call.ArrivalPlatformName.value - if call.ArrivalPlatformName - else None, + arrivalPlatformName=arrivalPlatformName, aimedDepartTs=optional_datetime_to_ts(call.AimedDepartureTime), expectedDepartTs=optional_datetime_to_ts(call.ExpectedDepartureTime), arrivalStatus=call.ArrivalStatus.value, @@ -250,7 +239,10 @@ async def get_stop_shape(stop_id: int) -> StopShapeSchema | None: and (shape := await StopShape.get_by_id(connection_area.id)) is not None ): return StopShapeSchema( - id=shape.id, type=shape.type, bbox=shape.bounding_box, points=shape.points + id=shape.id, + type=shape.type, + epsg3857_bbox=shape.epsg3857_bbox, + epsg3857_points=shape.epsg3857_points, ) msg = f"No shape found for stop {stop_id}" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6d16503..a593d1b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,7 @@ uvicorn = "^0.20.0" asyncpg = "^0.27.0" msgspec = "^0.12.0" pyshp = "^2.3.1" +pyproj = "^3.5.0" [build-system] requires = ["poetry-core"] diff --git a/frontend/package.json b/frontend/package.json index 54540a1..1049d89 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,38 +1,37 @@ { - "name": "vite-template-solid", - "version": "0.0.0", - "engine": "19.3.0", - "description": "", - "scripts": { - "start": "vite", - "dev": "vite --debug", - "build": "vite build", - "serve": "vite preview" - }, - "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", - "vite-plugin-solid": "^2.5.0" - }, - "dependencies": { - "@hope-ui/solid": "^0.6.7", - "@motionone/solid": "^10.15.5", - "@solid-primitives/date": "^2.0.5", - "@solid-primitives/scroll": "^2.0.10", - "@stitches/core": "^1.2.8", - "date-fns": "^2.29.3", - "matrix-widget-api": "^1.1.1", - "ol": "^7.3.0", - "proj4": "^2.9.0", - "solid-js": "^1.6.6", - "solid-transition-group": "^0.0.10" - } + "name": "vite-template-solid", + "version": "0.0.0", + "engine": "19.3.0", + "description": "", + "scripts": { + "start": "vite", + "dev": "vite --debug", + "build": "vite build", + "serve": "vite preview", + "bundle-visualizer": "npx vite-bundle-visualizer" + }, + "license": "MIT", + "devDependencies": { + "@types/leaflet": "^1.9.0", + "@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", + "vite-bundle-visualizer": "^0.6.0", + "vite-plugin-solid": "^2.5.0" + }, + "dependencies": { + "@motionone/solid": "^10.15.5", + "@solid-primitives/date": "^2.0.5", + "@solid-primitives/scroll": "^2.0.10", + "@stitches/core": "^1.2.8", + "date-fns": "^2.29.3", + "matrix-widget-api": "^1.1.1", + "ol": "^7.3.0", + "solid-js": "^1.6.6", + "solid-transition-group": "^0.0.10" + } } diff --git a/frontend/src/App.scss b/frontend/src/App.scss index 9506539..0d0fbef 100644 --- a/frontend/src/App.scss +++ b/frontend/src/App.scss @@ -2,6 +2,10 @@ --idfm-black: #2c2e35; --idfm-white: #ffffff; + --neutral-color: #d7dbdf; + + --border-radius: calc(15/1920*100%); + height: inherit; width: inherit; diff --git a/frontend/src/extra/iconHamburgerMenu.tsx b/frontend/src/extra/iconHamburgerMenu.tsx index ee51b6c..a00a97f 100644 --- a/frontend/src/extra/iconHamburgerMenu.tsx +++ b/frontend/src/extra/iconHamburgerMenu.tsx @@ -1,19 +1,16 @@ -import { createIcon } from "@hope-ui/solid"; +import { VoidComponent } from "solid-js"; - -// From https://github.com/hope-ui/hope-ui/blob/main/apps/docs/src/icons/IconHamburgerMenu.tsx - -export const IconHamburgerMenu = createIcon({ - viewBox: "0 0 15 15", - path: () => ( - = () => { + return ( + + - ), -}); + ); +} diff --git a/frontend/src/passagesDisplay.scss b/frontend/src/passagesDisplay.scss index 2fc8d35..c523cfa 100644 --- a/frontend/src/passagesDisplay.scss +++ b/frontend/src/passagesDisplay.scss @@ -1,6 +1,7 @@ @use "_common"; @use "_utils"; + .passagesDisplay { @extend %widget; @@ -21,18 +22,24 @@ } .menu { + aspect-ratio: 0.75; height: $header-element-height; - aspect-ratio: 1; + margin-right: calc(30/1920*100%); margin-left: auto; + border: $component-border; + border-radius: $component-border-radius; button { height: 100%; - aspect-ratio: 1; - background-color: var(--idfm-black); - border: $component-border; - border-radius: $component-border-radius; + border: 0; + color: var(--idfm-white); + background-color: transparent; + + .iconHamburgerMenu { + width: 75%; + } } } diff --git a/frontend/src/stopsSearchMenu.scss b/frontend/src/stopsSearchMenu.scss deleted file mode 100644 index 6c2cecb..0000000 --- a/frontend/src/stopsSearchMenu.scss +++ /dev/null @@ -1,222 +0,0 @@ -@use "_common"; -@use "_utils"; - -.stopSearchMenu { - @extend %widget; - - .inputGroup { - width: 50%; - // height: 5%; - - // TODO: Setup hop-ui to avoid to have to overrride rules. - input { - color: var(--idfm-white); - font-family: IDFVoyageur-regular; - } - } - - .title { - @extend %title; - - display: flex; - justify-content: center; - } - - .body { - @extend %body; - - flex-direction: row; - - .stopsPanels { - width: 50%; - height: 100%; - - scroll-snap-type: y mandatory; - overflow-y: scroll; - - .stopPanel { - scroll-snap-align: center; - - .stop { - width: calc(1880/1920*100%); - height: calc(100% / 5); - // margin: 0 calc(20/1920*100%); - margin: 0 calc(10/1920*100%); - - display: flex; - align-items: center; - flex-direction: row; - - /* TODO: compute the border weight according to the parent height */ - /* TODO: Disable border-bottom for the last .line */ - border-bottom: solid calc(2px); - - cursor: default; - - .name { - margin-left: calc(40/1920*100%); - width: 60%; - aspect-ratio: 2.5; - - display: flex; - align-items: center; - - font-family: IDFVoyageur-bold; - } - - .lineRepr { - width: 40%; - aspect-ratio: 2.5; - - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: center; - - .transportMode { - @extend %transportMode; - - height: 50%; - } - - .linesRepresentationMatrix { - @extend %busLinePicto; // Use the larger picto aspect-ratio - width: 75%; - aspect-ratio: 3; - - display: flex; - flex-flow: row; - flex-wrap: wrap; - - %picto { - margin-left: 1%; - align-self: center; - justify-self: center; - } - - %singleLinePicto { - @extend %picto; - - height: 80%; - } - - .transportMode { - @extend %transportMode; - @extend %picto; - } - - .tramLinePicto { - @extendnd %tramLinePicto; - @extend %singleLinePicto; - } - - .trainLinePicto { - @extend %trainLinePicto; - @extend %singleLinePicto; - } - - .metroLinePicto { - @extend %metroLinePicto; - @extend %singleLinePicto; - } - - .busLinePicto { - @extend %busLinePicto; - @extend %picto; - - height: 40%; - } - } - } - } - } - .displayed { - display: block; - } - } - - .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 deleted file mode 100644 index e978cf7..0000000 --- a/frontend/src/stopsSearchMenu.tsx +++ /dev/null @@ -1,779 +0,0 @@ -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 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 "./stopsSearchMenu.scss"; - - -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 { - - getSearchText: () => string; - setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise; - - getFoundStops: () => Stop[]; - setFoundStops: (stops: Stop[]) => void; - - getDisplayedPanelId: () => number; - setDisplayedPanelId: (panelId: number) => 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(); - - -function SearchProvider(props: { children: JSX.Element }) { - - const searchTextDelayMs = 1500; - - type Store = { - searchText: string; - searchPromise: Promise | undefined; - foundStops: Stop[]; - displayedPanelId: number; - panels: PositionedPanel[]; - highlightedStop: Stop | undefined; - mapFeatures: ByStopIdMapFeatures; - }; - - const [store, setStore] = createStore({ - searchText: "", - searchPromise: undefined, - foundStops: [], - displayedPanelId: 0, - panels: [], - highlightedStop: undefined, - mapFeatures: {}, - }); - - const getSearchText = (): string => { - return store.searchText; - } - - const debounce = async (fn: (...args: any[]) => Promise, delayMs: number) => { - let timerId: number; - return new Promise((...args) => { - clearTimeout(timerId); - timerId = setTimeout(fn, delayMs, ...args); - }); - } - - const setSearchText = async (text: string, businessDataStore: BusinessDataStore): Promise => { - setStore('searchText', text); - - if (store.searchPromise === undefined) { - const { searchStopByName } = businessDataStore; - const promise: Promise = debounce(async (onSuccess: () => void) => { - console.log(`Fetching data for "${store.searchText}" stop name`); - const stopsById = await searchStopByName(store.searchText); - console.log("stopsById=", stopsById); - setFoundStops(Object.values(stopsById)); - onSuccess(); - }, searchTextDelayMs).then(() => { - setStore('searchPromise', undefined); - }); - setStore('searchPromise', promise); - } - } - - const getFoundStops = (): Stop[] => { - return store.foundStops; - } - - const setFoundStops = (stops: Stop[]): void => { - setStore('foundStops', stops); - } - - const getDisplayedPanelId = (): number => { - return store.displayedPanelId; - } - - const setDisplayedPanelId = (panelId: number): void => { - setStore('displayedPanelId', panelId); - } - - 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} - - ); -} - - -const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) => { - - const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); - const searchStore: SearchStore | undefined = useContext(SearchContext); - - if (businessDataStore === undefined || searchStore === undefined) - return
; - - const { setSearchText } = searchStore; - - const onStopNameInput: JSX.EventHandler = async (event): Promise => { - /* TODO: Add a tempo before fetching stop for giving time to user to finish his request */ - const stopName = event.currentTarget.value; - if (stopName.length >= props.minCharsNb) { - await setSearchText(stopName, businessDataStore); - } - } - - return ( -
-
- - - {props.title} - - -
-
- - 🚉 🚏 - - -
-
- ); -}; - - -const StopRepr: VoidComponent<{ stop: Stop }> = (props) => { - const fontSize: number = 40; - - const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); - if (businessDataStore === undefined) - return
; - - const { getLine } = businessDataStore; - - const fetchLinesRepr = async (lineIds: string[]): Promise => { - const reprs = []; - for (const lineId of lineIds) { - const line = await getLine(lineId); - if (line !== undefined) { - reprs.push(
{renderLineTransportMode(line)}
); - reprs.push(renderLinePicto(line)); - } - } - return reprs; - } - - const [lineReprs] = createResource(props.stop.lines, fetchLinesRepr); - - return ( -
- - - {props.stop.name} - - - {(line: JSX.Element) => line} -
- ); -} - - -type ByTransportModeReprs = { - mode: JSX.Element | undefined; - lines: Record; -} - -const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => { - const fontSize: number = 10; - - const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); - const appContextStore: AppContextStore | undefined = useContext(AppContextContext); - 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); - const stops = stop.stops; - for (const stop of stops) { - stop.lines.forEach(lineIds.add, lineIds); - } - - const byModeReprs: Record = {}; - for (const lineId of lineIds) { - const line = await getLine(lineId); - if (line !== undefined) { - if (!(line.transportMode in byModeReprs)) { - byModeReprs[line.transportMode] = { - mode:
{renderLineTransportMode(line)}
, - lines: {} - }; - } - byModeReprs[line.transportMode].lines[line.shortName] = renderLinePicto(line); - } - } - - const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] < - TransportModeWeights[y] ? 1 : -1); - - return ( -
- {(transportMode) => { - const reprs = byModeReprs[transportMode]; - const lineNames = Object.keys(reprs.lines).sort((x, y) => x.localeCompare(y)); - return <> - {reprs.mode} -
- {(lineName) => reprs.lines[lineName]} -
- - }} -
-
- ); - } - const [lineReprs] = createResource(props.stop, fetchLinesRepr); - - return ( -
setDisplayedStops([props.stop])} - onMouseEnter={() => setHighlightedStop(props.stop)} - onMouseLeave={resetHighlightedStop} - > -
- -
- {lineReprs()} -
- ); -} - -const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => { - - return ( -
- x.name.localeCompare(y.name))}> - {(stop) => { - return }> - - ; - }} - -
- ); -} - -const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => { - const searchStore: SearchStore | undefined = useContext(SearchContext); - - if (searchStore === undefined) { - return
; - } - - const { getDisplayedPanelId, getFoundStops, getPanels, setDisplayedPanelId, setPanels } = searchStore; - - let stopsPanelsRef: HTMLDivElement | undefined = undefined - const stopsPanelsScroll = createScrollPosition(() => stopsPanelsRef); - const yStopsPanelsScroll = () => stopsPanelsScroll.y; - - createEffect(() => { - yStopsPanelsScroll(); - - for (const panel of getPanels()) { - const panelDiv = panel.panel(); - const panelDivClientRect = panelDiv.getBoundingClientRect(); - if (panelDivClientRect.y > 0) { - setDisplayedPanelId(panel.position); - break; - } - } - }); - - return ( -
- {() => { - setPanels([]); - - let newPanels = []; - let positioneds: PositionedPanel[] = []; - - let stops: Stop[] = []; - - for (const stop of getFoundStops()) { - if (stops.length < props.maxStopsPerPanel) { - stops.push(stop); - } - else { - const panelId = newPanels.length; - const panel = ; - newPanels.push(panel); - positioneds.push({ position: panelId, panel: panel }); - stops = [stop]; - } - } - if (stops.length) { - const panelId = newPanels.length; - const panel = ; - newPanels.push(panel); - positioneds.push({ position: panelId, panel: panel }); - } - - setPanels(positioneds); - - return newPanels; - }} -
- ); -} - - -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 ret; - } - const [destinations] = createResource(() => props.stop, getDestinations); - - return ( -
-
{props.stop?.name}
-
- - {(dst) => { - return
- {dst.lineId} -
- -
-
; - }} -
-
-
- ); -} - - -// 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 (businessDataStore === undefined || searchStore === undefined) - return
; - - 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, - }), - }); - - const selectedStopStyle = new Style({ - image: new Circle({ - fill: undefined, - stroke: new Stroke({ color: 'purple', width: 2 }), - radius: 10, - }), - }); - - const stopAreaStyle = new Style({ - stroke: new Stroke({ color: 'red' }), - fill: new Fill({ color: 'rgba(255,255,255,0.2)' }), - }); - - 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 { - 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); - }); - - return {stop => }; -} - - -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(() => { - 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 <> -
- -
- {(stop) => } - ; -} - -const Footer: VoidComponent<{}> = () => { - - const searchStore: SearchStore | undefined = useContext(SearchContext); - - if (searchStore === undefined) { - return
; - } - - const { getDisplayedPanelId, getPanels } = searchStore; - - return ( - - ); -} - -export const StopsSearchMenu: VoidComponent = () => { - - const maxStopsPerPanel = 5; - - return ( -
- -
-
- - -
-
- -
- ); -}; diff --git a/frontend/src/stopsSearchMenu/map.scss b/frontend/src/stopsSearchMenu/map.scss new file mode 100644 index 0000000..d744b2a --- /dev/null +++ b/frontend/src/stopsSearchMenu/map.scss @@ -0,0 +1,87 @@ +@use "../_common"; +@use "../_utils"; + + +.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/map.tsx b/frontend/src/stopsSearchMenu/map.tsx new file mode 100644 index 0000000..e20b5cb --- /dev/null +++ b/frontend/src/stopsSearchMenu/map.tsx @@ -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
; + + 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); + 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) => } + ; +} diff --git a/frontend/src/stopsSearchMenu/mapStop.tsx b/frontend/src/stopsSearchMenu/mapStop.tsx new file mode 100644 index 0000000..e2dc744 --- /dev/null +++ b/frontend/src/stopsSearchMenu/mapStop.tsx @@ -0,0 +1,85 @@ +import { createEffect, createResource, For, useContext, VoidComponent } from 'solid-js'; +import { Circle, Fill, Stroke, Style } from 'ol/style'; +import OlFeature from 'ol/Feature'; +import OlPoint from 'ol/geom/Point'; +import OlPolygon from 'ol/geom/Polygon'; + +import { Stop, StopShape } from '../types'; +import { BusinessDataContext, BusinessDataStore } from "../businessData"; +import { SearchContext, SearchStore } from "./searchStore"; + + +// TODO: Use boolean to set MapStop selected +export const MapStop: VoidComponent<{ stop: Stop, selected: Stop | undefined }> = (props) => { + const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); + const searchStore: SearchStore | undefined = useContext(SearchContext); + if (businessDataStore === undefined || searchStore === undefined) + return
; + + 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, + }), + }); + + const selectedStopStyle = new Style({ + image: new Circle({ + fill: undefined, + stroke: new Stroke({ color: 'purple', width: 2 }), + radius: 10, + }), + }); + + const stopAreaStyle = new Style({ + stroke: new Stroke({ color: 'red' }), + fill: new Fill({ color: 'rgba(255,255,255,0.2)' }), + }); + + 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.epsg3857_x !== undefined && props.stop.epsg3857_y !== undefined) { + const selectStopStyle = () => { + return (props.selected?.id === props.stop.id ? selectedStopStyle : stopStyle); + } + feature = new OlFeature({ + geometry: new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]), + }); + feature.setStyle(selectStopStyle); + } + + else { + let geometry = undefined; + const areaShape = shape(); + if (areaShape !== undefined) { + geometry = new OlPolygon([areaShape.epsg3857_points.slice(0, -1)]); + } + else { + geometry = new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]); + } + feature = new OlFeature({ geometry: geometry }); + feature.setStyle(stopAreaStyle); + } + feature.setId(props.stop.id); + + setMapFeature(props.stop.id, feature); + }); + + return {stop => }; +} diff --git a/frontend/src/stopsSearchMenu/searchStore.tsx b/frontend/src/stopsSearchMenu/searchStore.tsx new file mode 100644 index 0000000..e666eed --- /dev/null +++ b/frontend/src/stopsSearchMenu/searchStore.tsx @@ -0,0 +1,165 @@ +import { createContext, JSX } from 'solid-js'; +import { createStore } from "solid-js/store"; +import OlFeature from 'ol/Feature'; +import { BusinessDataStore } from "../businessData"; + +import { Stop } from '../types'; +import { PositionedPanel } from '../utils'; + +type ByStopIdMapFeatures = Record; + +export interface SearchStore { + + getSearchText: () => string; + setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise; + + getFoundStops: () => Stop[]; + setFoundStops: (stops: Stop[]) => void; + + getDisplayedPanelId: () => number; + setDisplayedPanelId: (panelId: number) => void; + + getPanels: () => PositionedPanel[]; + setPanels: (panels: PositionedPanel[]) => void; + + getHighlightedStop: () => Stop | undefined; + setHighlightedStop: (stop: Stop) => void; + resetHighlightedStop: () => void; + + enableMap: (enable: boolean) => void; + isMapEnabled: () => boolean; + getMapFeature: (stopId: number) => OlFeature | undefined; + getAllMapFeatures: () => ByStopIdMapFeatures; + setMapFeature: (stopId: number, feature: OlFeature) => void; +}; + +export const SearchContext = createContext(); + + +export function SearchProvider(props: { children: JSX.Element }) { + + const searchTextDelayMs = 1500; + + type Store = { + searchText: string; + searchPromise: Promise | undefined; + foundStops: Stop[]; + displayedPanelId: number; + panels: PositionedPanel[]; + highlightedStop: Stop | undefined; + mapEnabled: boolean; + mapFeatures: ByStopIdMapFeatures; + }; + + const [store, setStore] = createStore({ + searchText: "", + searchPromise: undefined, + foundStops: [], + displayedPanelId: 0, + panels: [], + highlightedStop: undefined, + // mapEnabled: false, + mapFeatures: {}, + }); + + const getSearchText = (): string => { + return store.searchText; + } + + const debounce = async (fn: (...args: any[]) => Promise, delayMs: number) => { + let timerId: number; + return new Promise((...args) => { + clearTimeout(timerId); + timerId = setTimeout(fn, delayMs, ...args); + }); + } + + const setSearchText = async (text: string, businessDataStore: BusinessDataStore): Promise => { + setStore('searchText', text); + + if (store.searchPromise === undefined) { + const { searchStopByName } = businessDataStore; + const promise: Promise = debounce(async (onSuccess: () => void) => { + console.log(`Fetching data for "${store.searchText}" stop name`); + const stopsById = await searchStopByName(store.searchText); + console.log("stopsById=", stopsById); + setFoundStops(Object.values(stopsById)); + onSuccess(); + }, searchTextDelayMs).then(() => { + setStore('searchPromise', undefined); + }); + setStore('searchPromise', promise); + } + } + + const getFoundStops = (): Stop[] => { + return store.foundStops; + } + + const setFoundStops = (stops: Stop[]): void => { + setStore('foundStops', stops); + } + + const getDisplayedPanelId = (): number => { + return store.displayedPanelId; + } + + const setDisplayedPanelId = (panelId: number): void => { + setStore('displayedPanelId', panelId); + } + + 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 enableMap = (enable: boolean): void => { + setStore("mapEnabled", enable); + } + + const isMapEnabled = (): boolean => { + return store.mapEnabled; + } + + 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} + + ); +} diff --git a/frontend/src/stopsSearchMenu/stopPanel.scss b/frontend/src/stopsSearchMenu/stopPanel.scss new file mode 100644 index 0000000..0f3986e --- /dev/null +++ b/frontend/src/stopsSearchMenu/stopPanel.scss @@ -0,0 +1,100 @@ +@use "../_common"; +@use "../_utils"; + + +.stopPanel { + scroll-snap-align: center; + + .stop { + width: calc(1880/1920*100%); + height: calc(100% / 5); + // margin: 0 calc(20/1920*100%); + margin: 0 calc(10/1920*100%); + + display: flex; + align-items: center; + flex-direction: row; + + /* TODO: compute the border weight according to the parent height */ + /* TODO: Disable border-bottom for the last .line */ + border-bottom: solid calc(2px); + + cursor: default; + + .name { + margin-left: calc(40/1920*100%); + width: 60%; + aspect-ratio: 2.5; + + display: flex; + align-items: center; + + font-family: IDFVoyageur-bold; + } + + .lineRepr { + width: 40%; + aspect-ratio: 2.5; + + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + + .transportMode { + @extend %transportMode; + + height: 50%; + } + + .linesRepresentationMatrix { + @extend %busLinePicto; // Use the larger picto aspect-ratio + width: 75%; + aspect-ratio: 3; + + display: flex; + flex-flow: row; + flex-wrap: wrap; + + %picto { + margin-left: 1%; + align-self: center; + justify-self: center; + } + + %singleLinePicto { + @extend %picto; + + height: 80%; + } + + .transportMode { + @extend %transportMode; + @extend %picto; + } + + .tramLinePicto { + @extend %tramLinePicto; + @extend %singleLinePicto; + } + + .trainLinePicto { + @extend %trainLinePicto; + @extend %singleLinePicto; + } + + .metroLinePicto { + @extend %metroLinePicto; + @extend %singleLinePicto; + } + + .busLinePicto { + @extend %busLinePicto; + @extend %picto; + + height: 40%; + } + } + } + } +} diff --git a/frontend/src/stopsSearchMenu/stopPanel.tsx b/frontend/src/stopsSearchMenu/stopPanel.tsx new file mode 100644 index 0000000..2c0d903 --- /dev/null +++ b/frontend/src/stopsSearchMenu/stopPanel.tsx @@ -0,0 +1,143 @@ +import { createResource, For, JSX, ParentComponent, Show, useContext, VoidComponent } from 'solid-js'; + +import { Stop } from '../types'; +import { renderLineTransportMode, renderLinePicto, ScrollingText, TransportModeWeights } from '../utils'; +import { AppContextContext, AppContextStore } from "../appContext"; +import { BusinessDataContext, BusinessDataStore } from "../businessData"; +import { SearchContext, SearchStore } from "./searchStore"; + + +import "./stopPanel.scss"; + + +const StopRepr: VoidComponent<{ stop: Stop }> = (props) => { + const fontSize: number = 40; + + const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); + if (businessDataStore === undefined) + return
; + + const { getLine } = businessDataStore; + + const fetchLinesRepr = async (lineIds: string[]): Promise => { + const reprs = []; + for (const lineId of lineIds) { + const line = await getLine(lineId); + if (line !== undefined) { + reprs.push(
{renderLineTransportMode(line)}
); + reprs.push(renderLinePicto(line)); + } + } + return reprs; + } + + const [lineReprs] = createResource(props.stop.lines, fetchLinesRepr); + + return ( +
+ + + {props.stop.name} + + + {(line: JSX.Element) => line} +
+ ); +} + + +type ByTransportModeReprs = { + mode: JSX.Element | undefined; + lines: Record; +} + + +const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => { + const fontSize: number = 10; + + const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); + const appContextStore: AppContextStore | undefined = useContext(AppContextContext); + 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); + const stops = stop.stops; + for (const stop of stops) { + stop.lines.forEach(lineIds.add, lineIds); + } + + const byModeReprs: Record = {}; + for (const lineId of lineIds) { + const line = await getLine(lineId); + if (line !== undefined) { + if (!(line.transportMode in byModeReprs)) { + byModeReprs[line.transportMode] = { + mode:
{renderLineTransportMode(line)}
, + lines: {} + }; + } + byModeReprs[line.transportMode].lines[line.shortName] = renderLinePicto(line); + } + } + + const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] < + TransportModeWeights[y] ? 1 : -1); + + return ( +
+ {(transportMode) => { + const reprs = byModeReprs[transportMode]; + const lineNames = Object.keys(reprs.lines).sort((x, y) => x.localeCompare(y)); + return <> + {reprs.mode} +
+ {(lineName) => reprs.lines[lineName]} +
+ + }} +
+
+ ); + } + const [lineReprs] = createResource(props.stop, fetchLinesRepr); + + return ( +
setDisplayedStops([props.stop])} + onMouseEnter={() => setHighlightedStop(props.stop)} + onMouseLeave={resetHighlightedStop} + > +
+ +
+ {lineReprs()} +
+ ); +} + +export const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => { + + return ( +
+ x.name.localeCompare(y.name))}> + {(stop) => { + return }> + + ; + }} + +
+ ); +} diff --git a/frontend/src/stopsSearchMenu/stopPopup.tsx b/frontend/src/stopsSearchMenu/stopPopup.tsx new file mode 100644 index 0000000..6c0f278 --- /dev/null +++ b/frontend/src/stopsSearchMenu/stopPopup.tsx @@ -0,0 +1,49 @@ +import { createResource, For, ParentComponent, useContext } from 'solid-js'; + +import { Stop } from '../types'; +import { BusinessDataContext, BusinessDataStore } from "../businessData"; +import { renderLinePicto, ScrollingText } from '../utils'; + +export 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 ret; + } + const [destinations] = createResource(() => props.stop, getDestinations); + + return ( +
+
{props.stop?.name}
+
+ + {(dst) => { + return
+ {dst.lineId} +
+ +
+
; + }} +
+
+
+ ); +} diff --git a/frontend/src/stopsSearchMenu/stopsSearchMenu.scss b/frontend/src/stopsSearchMenu/stopsSearchMenu.scss new file mode 100644 index 0000000..3a439d0 --- /dev/null +++ b/frontend/src/stopsSearchMenu/stopsSearchMenu.scss @@ -0,0 +1,85 @@ +@use "../_common"; +@use "../_utils"; + + +.mapPlaceholder { + --border-width: 0.1vh; + + height: calc(100% - 2*var(--border-width)); + width: 50%; + + display: flex; + align-items: center; + justify-content: center; + + border: solid var(--neutral-color) var(--border-width); + border-radius: var(--border-radius); + + background-color: var(--idfm-black); + font-family: IDFVoyageur-bold; + font-size: 2vh; + color: var(--idfm-white); +} + +.stopNameInput { + width: 50%; + height: 60%; + + display: flex; + flex-flow: row; + + border: solid var(--neutral-color) calc(0.01vh); + border-radius: var(--border-radius); + + background-color: transparent; + + .leftAddon { + width: 17%; + + display: flex; + align-items: center; + justify-content: center; + + background-color: var(--idfm-white); + } + + input { + width: 83%; + + padding-left: 3%; + padding-right: 3%; + + color: var(--idfm-white); + font-family: IDFVoyageur-regular; + background-color: transparent; + } +} + +.title { + @extend %title; + + display: flex; + justify-content: center; +} + +.stopSearchMenu { + @extend %widget; + + .body { + @extend %body; + + flex-direction: row; + + .stopsPanels { + width: 50%; + height: 100%; + + scroll-snap-type: y mandatory; + overflow-y: scroll; + + .displayed { + display: block; + } + } + } +} diff --git a/frontend/src/stopsSearchMenu/stopsSearchMenu.tsx b/frontend/src/stopsSearchMenu/stopsSearchMenu.tsx new file mode 100644 index 0000000..bb5fcdf --- /dev/null +++ b/frontend/src/stopsSearchMenu/stopsSearchMenu.tsx @@ -0,0 +1,200 @@ +import { createEffect, For, JSX, lazy, ParentComponent, useContext, Show, VoidComponent } from 'solid-js'; +import { lazily } from 'solidjs-lazily'; +import { createScrollPosition } from "@solid-primitives/scroll"; + +import { Stop } from '../types'; +import { PositionedPanel } from '../utils'; + +import { BusinessDataContext, BusinessDataStore } from "../businessData"; +import { SearchContext, SearchProvider, SearchStore } from "./searchStore"; +import { StopsPanel } from "./stopPanel"; + +const { Map } = lazily(() => import("./map")); + + +import "./stopsSearchMenu.scss"; + + +const StopNameInput: VoidComponent<{ onInput: JSX.EventHandler, leftAddon: string, placeholder: string }> = (props) => { + return ( +
+
{props.leftAddon}
+ +
); +}; + + +const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) => { + const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); + const searchStore: SearchStore | undefined = useContext(SearchContext); + + if (businessDataStore === undefined || searchStore === undefined) + return
; + + const { setSearchText } = searchStore; + + const onStopNameInput: JSX.EventHandler = async (event): Promise => { + const stopName = event.currentTarget.value; + if (stopName.length >= props.minCharsNb) { + await setSearchText(stopName, businessDataStore); + } + } + + return ( +
+
+ + + {props.title} + + +
+ +
+ ); +}; + + +const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => { + const searchStore: SearchStore | undefined = useContext(SearchContext); + + if (searchStore === undefined) { + return
; + } + + const { getDisplayedPanelId, getFoundStops, getPanels, setDisplayedPanelId, setPanels } = searchStore; + let stopsPanelsRef: HTMLDivElement | undefined = undefined + const stopsPanelsScroll = createScrollPosition(() => stopsPanelsRef); + const yStopsPanelsScroll = () => stopsPanelsScroll.y; + + createEffect(() => { + yStopsPanelsScroll(); + + for (const panel of getPanels()) { + const panelDiv = panel.panel(); + const panelDivClientRect = panelDiv.getBoundingClientRect(); + if (panelDivClientRect.y > 0) { + setDisplayedPanelId(panel.position); + break; + } + } + }); + + return ( +
+ {() => { + setPanels([]); + + let newPanels = []; + let positioneds: PositionedPanel[] = []; + + let stops: Stop[] = []; + + for (const stop of getFoundStops()) { + if (stops.length < props.maxStopsPerPanel) { + stops.push(stop); + } + else { + const panelId = newPanels.length; + const panel = ; + newPanels.push(panel); + positioneds.push({ position: panelId, panel: panel }); + stops = [stop]; + } + } + if (stops.length) { + const panelId = newPanels.length; + const panel = ; + newPanels.push(panel); + positioneds.push({ position: panelId, panel: panel }); + } + + setPanels(positioneds); + + return newPanels; + }} +
+ ); +}; + + +const MapPlaceholder: VoidComponent<{}> = () => { + const searchStore: SearchStore | undefined = useContext(SearchContext); + + if (searchStore === undefined) { + return
; + } + + const { enableMap } = searchStore; + + const onDoubleClick = (): void => { + console.log('!!! ON DOUBLE CLICK'); + enableMap(true); + } + + return
onDoubleClick()}> + Double-clic pour activer la carte +
; +}; + + +const Body: VoidComponent<{}> = () => { + const searchStore: SearchStore | undefined = useContext(SearchContext); + if (searchStore === undefined) { + return
; + } + + const { isMapEnabled } = searchStore; + + const maxStopsPerPanel = 5; + + return
+ + }> + + +
; +}; + + +const Footer: VoidComponent<{}> = () => { + const searchStore: SearchStore | undefined = useContext(SearchContext); + if (searchStore === undefined) { + return
; + } + + const { getDisplayedPanelId, getPanels } = searchStore; + + return ( + + ); +}; + + +export const StopsSearchMenu: VoidComponent = () => { + return ( +
+ +
+ +
+ ); +}; diff --git a/frontend/src/types.tsx b/frontend/src/types.tsx index 6af88e5..dd2da16 100644 --- a/frontend/src/types.tsx +++ b/frontend/src/types.tsx @@ -42,17 +42,17 @@ export class Stop { id: number; name: string; town: string; - lat: number; - lon: number; + epsg3857_x: number; + epsg3857_y: number; stops: Stop[]; lines: string[]; - constructor(id: number, name: string, town: string, lat: number, lon: number, stops: Stop[], lines: string[]) { + constructor(id: number, name: string, town: string, epsg3857_x: number, epsg3857_y: number, stops: Stop[], lines: string[]) { this.id = id; this.name = name; this.town = town; - this.lat = lat; - this.lon = lon; + this.epsg3857_x = epsg3857_x; + this.epsg3857_y = epsg3857_y; this.stops = stops; this.lines = lines; for (const stop of this.stops) { @@ -68,14 +68,14 @@ export type Points = [number, number][]; export class StopShape { stop_id: number; type_: number; - bounding_box: number[]; - points: Points; + epsg3857_bbox: number[]; + epsg3857_points: Points; - constructor(stop_id: number, type_: number, bounding_box: number[], points: Points) { + constructor(stop_id: number, type_: number, epsg3857_bbox: number[], epsg3857_points: Points) { this.stop_id = stop_id; this.type_ = type_; - this.bounding_box = bounding_box; - this.points = points; + this.epsg3857_bbox = epsg3857_bbox; + this.epsg3857_points = epsg3857_points; } };