diff --git a/backend/backend/idfm_interface/__init__.py b/backend/backend/idfm_interface/__init__.py index 070099a..21afc48 100644 --- a/backend/backend/idfm_interface/__init__.py +++ b/backend/backend/idfm_interface/__init__.py @@ -2,6 +2,7 @@ from .idfm_interface import IdfmInterface from .idfm_types import ( Coordinate, + Destinations, FramedVehicleJourney, IdfmLineState, IdfmOperator, @@ -35,6 +36,7 @@ from .idfm_types import ( __all__ = [ "Coordinate", + "Destinations", "FramedVehicleJourney", "IdfmInterface", "IdfmLineState", diff --git a/backend/backend/idfm_interface/idfm_interface.py b/backend/backend/idfm_interface/idfm_interface.py index faf2769..c6cf33e 100644 --- a/backend/backend/idfm_interface/idfm_interface.py +++ b/backend/backend/idfm_interface/idfm_interface.py @@ -1,6 +1,14 @@ +from collections import defaultdict from re import compile as re_compile from time import time -from typing import AsyncIterator, ByteString, Callable, Iterable, List, Type +from typing import ( + AsyncIterator, + ByteString, + Callable, + Iterable, + List, + Type, +) from aiofiles import open as async_open from aiohttp import ClientSession @@ -8,10 +16,13 @@ from aiohttp import ClientSession from msgspec import ValidationError from msgspec.json import Decoder from rich import print +from shapefile import Reader as ShapeFileReader, ShapeRecord from ..db import Database -from ..models import Line, LinePicto, Stop, StopArea +from ..models import ConnectionArea, Line, LinePicto, Stop, StopArea, StopShape from .idfm_types import ( + ConnectionArea as IdfmConnectionArea, + Destinations as IdfmDestinations, IdfmLineState, IdfmResponse, Line as IdfmLine, @@ -55,6 +66,7 @@ class IdfmInterface: 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]) self._json_lines_decoder = Decoder(type=List[IdfmLine]) self._json_stops_lines_assos_decoder = Decoder(type=List[IdfmStopLineAsso]) self._json_ratp_pictos_decoder = Decoder(type=List[RatpPicto]) @@ -66,7 +78,24 @@ class IdfmInterface: async def startup(self) -> None: BATCH_SIZE = 10000 - STEPS: tuple[tuple[Type[Stop] | Type[StopArea], Callable, Callable], ...] = ( + STEPS: tuple[ + tuple[ + Type[ConnectionArea] | Type[Stop] | Type[StopArea] | Type[StopShape], + Callable, + Callable, + ], + ..., + ] = ( + ( + StopShape, + self._request_stop_shapes, + IdfmInterface._format_idfm_stop_shapes, + ), + ( + ConnectionArea, + self._request_idfm_connection_areas, + IdfmInterface._format_idfm_connection_areas, + ), ( StopArea, self._request_idfm_stop_areas, @@ -104,7 +133,7 @@ class IdfmInterface: print(f"Link Stops to Lines: {time() - begin_ts}s") begin_ts = time() - await self._load_stop_areas_stops_assos() + await self._load_stop_assos() print(f"Link Stops to StopAreas: {time() - begin_ts}s") async def _load_lines(self, batch_size: int = 5000) -> None: @@ -167,25 +196,51 @@ class IdfmInterface: print(f"{total_found_nb} line <-> stop ({total_assos_nb = } found)") - async def _load_stop_areas_stops_assos(self, batch_size: int = 5000) -> None: - total_assos_nb = total_found_nb = 0 - assos = [] + async def _load_stop_assos(self, batch_size: int = 5000) -> None: + total_assos_nb = area_stop_assos_nb = conn_stop_assos_nb = 0 + area_stop_assos = [] + connection_stop_assos = [] + async for asso in self._request_idfm_stop_area_stop_associations(): fields = asso.fields - assos.append((int(fields.zdaid), int(fields.arrid))) - if len(assos) == batch_size: + stop_id = int(fields.arrid) + + area_stop_assos.append((int(fields.zdaid), stop_id)) + connection_stop_assos.append((int(fields.zdcid), stop_id)) + + if len(area_stop_assos) == batch_size: total_assos_nb += batch_size - if (found_nb := await StopArea.add_stops(assos)) is not None: - total_found_nb += found_nb - assos.clear() - if assos: - total_assos_nb += len(assos) - if (found_nb := await StopArea.add_stops(assos)) is not None: - total_found_nb += found_nb + if (found_nb := await StopArea.add_stops(area_stop_assos)) is not None: + area_stop_assos_nb += found_nb + area_stop_assos.clear() - print(f"{total_found_nb} stop area <-> stop ({total_assos_nb = } found)") + if ( + found_nb := await ConnectionArea.add_stops(connection_stop_assos) + ) is not None: + conn_stop_assos_nb += found_nb + connection_stop_assos.clear() + + if area_stop_assos: + total_assos_nb += len(area_stop_assos) + if (found_nb := await StopArea.add_stops(area_stop_assos)) is not None: + area_stop_assos_nb += found_nb + if ( + found_nb := await ConnectionArea.add_stops(connection_stop_assos) + ) is not None: + conn_stop_assos_nb += found_nb + + print(f"{area_stop_assos_nb} stop area <-> stop ({total_assos_nb = } found)") + print(f"{conn_stop_assos_nb} stop area <-> stop ({total_assos_nb = } found)") + + # TODO: This method is synchronous due to the shapefile library. + # It's not a blocking issue but it could be nice to find an alternative. + async def _request_stop_shapes(self) -> AsyncIterator[ShapeRecord]: + # TODO: Use HTTP + with ShapeFileReader("./tests/datasets/REF_LDA.zip") as reader: + for record in reader.shapeRecords(): + yield record async def _request_idfm_stops(self) -> AsyncIterator[IdfmStop]: # headers = {"Accept": "application/json", "apikey": self._api_key} @@ -206,6 +261,13 @@ class IdfmInterface: for element in self._json_stop_areas_decoder.decode(await raw.read()): yield element + async def _request_idfm_connection_areas(self) -> AsyncIterator[IdfmConnectionArea]: + async with async_open( + "./tests/datasets/zones-de-correspondance.json", "rb" + ) as raw: + for element in self._json_connection_areas_decoder.decode(await raw.read()): + yield element + async def _request_idfm_lines(self) -> AsyncIterator[IdfmLine]: # TODO: Use HTTP async with async_open("./tests/datasets/lines_dataset.json", "rb") as raw: @@ -378,6 +440,34 @@ class IdfmInterface: changed_ts=int(fields.zdachanged.timestamp()), ) + @staticmethod + def _format_idfm_connection_areas( + *connection_areas: IdfmConnectionArea, + ) -> Iterable[ConnectionArea]: + for connection_area in connection_areas: + 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, + 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]: + for shape_record in shape_records: + yield StopShape( + id=shape_record.record[1], + type=shape_record.shape.shapeType, + bounding_box=list(shape_record.shape.bbox), + points=shape_record.shape.points, + ) + async def render_line_picto(self, line: Line) -> tuple[None | str, None | str]: begin_ts = time() line_picto_path = line_picto_format = None @@ -432,20 +522,40 @@ class IdfmInterface: return ret - async def get_destinations(self, stop_point_id: str) -> Iterable[str]: - # TODO: Store in database the destination for the given stop and line id. + async def get_destinations(self, stop_id: int) -> IdfmDestinations | None: begin_ts = time() - destinations: dict[str, str] = {} - if (res := await self.get_next_passages(stop_point_id)) is not None: + + destinations: IdfmDestinations = defaultdict(set) + + if (stop := await Stop.get_by_id(stop_id)) is not None: + expected_stop_ids = {stop.id} + + elif (stop_area := await StopArea.get_by_id(stop_id)) is not None: + expected_stop_ids = {stop.id for stop in stop_area.stops} + + else: + return None + + if (res := await self.get_next_passages(stop_id)) is not None: + for delivery in res.Siri.ServiceDelivery.StopMonitoringDelivery: if delivery.Status == IdfmState.true: for stop_visit in delivery.MonitoredStopVisit: + + monitoring_ref = stop_visit.MonitoringRef.value + try: + monitored_stop_id = int(monitoring_ref.split(":")[-2]) + except (IndexError, ValueError): + print(f"Unable to get stop id from {monitoring_ref}") + continue + journey = stop_visit.MonitoredVehicleJourney - if (destination_name := journey.DestinationName) and ( - line_ref := journey.LineRef - ): - line_id = line_ref.value.replace("STIF:Line::", "")[:-1] - print(f"{line_id = }") - destinations[line_id] = destination_name[0].value + if ( + dst_names := journey.DestinationName + ) and monitored_stop_id in expected_stop_ids: + + line_id = journey.LineRef.value.split(":")[-2] + destinations[line_id].add(dst_names[0].value) + print(f"get_next_passages: {time() - begin_ts}") return destinations diff --git a/backend/backend/idfm_interface/idfm_types.py b/backend/backend/idfm_interface/idfm_types.py index 82b9cbb..e742171 100644 --- a/backend/backend/idfm_interface/idfm_types.py +++ b/backend/backend/idfm_interface/idfm_types.py @@ -116,6 +116,19 @@ class StopArea(Struct): record_timestamp: datetime +class ConnectionArea(Struct): + zdcid: str + zdcversion: str + zdccreated: datetime + zdcchanged: datetime + zdcname: str + zdcxepsg2154: int + zdcyepsg2154: int + zdctown: str + zdcpostalregion: str + zdctype: StopAreaType + + class StopAreaStopAssociationFields(Struct, kw_only=True): arrid: str # TODO: use int ? artid: str | None = None @@ -184,6 +197,8 @@ class Line(Struct): Lines = dict[str, Line] +Destinations = dict[str, set[str]] + # TODO: Set structs frozen class StopLineAssoFields(Struct): diff --git a/backend/backend/models/__init__.py b/backend/backend/models/__init__.py index ef1a352..a7c455a 100644 --- a/backend/backend/models/__init__.py +++ b/backend/backend/models/__init__.py @@ -1,6 +1,14 @@ from .line import Line, LinePicto -from .stop import Stop, StopArea +from .stop import ConnectionArea, Stop, StopArea, StopShape from .user import UserLastStopSearchResults -__all__ = ["Line", "LinePicto", "Stop", "StopArea", "UserLastStopSearchResults"] +__all__ = [ + "ConnectionArea", + "Line", + "LinePicto", + "Stop", + "StopArea", + "StopShape", + "UserLastStopSearchResults", +] diff --git a/backend/backend/models/stop.py b/backend/backend/models/stop.py index bf7daa1..d286878 100644 --- a/backend/backend/models/stop.py +++ b/backend/backend/models/stop.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Iterable, Self, Sequence, TYPE_CHECKING +from typing import Iterable, Sequence, TYPE_CHECKING from sqlalchemy import ( BigInteger, @@ -8,6 +8,8 @@ from sqlalchemy import ( Enum, Float, ForeignKey, + Integer, + JSON, select, String, Table, @@ -48,19 +50,26 @@ class _Stop(Base): postal_region = mapped_column(String, nullable=False) xepsg2154 = mapped_column(BigInteger, nullable=False) yepsg2154 = mapped_column(BigInteger, nullable=False) + version = mapped_column(String, nullable=False) created_ts = mapped_column(BigInteger) changed_ts = mapped_column(BigInteger, nullable=False) + lines: Mapped[list[Line]] = relationship( "Line", secondary="line_stop_association_table", back_populates="stops", - # lazy="joined", lazy="selectin", ) areas: Mapped[list["StopArea"]] = relationship( "StopArea", secondary=stop_area_stop_association_table, back_populates="stops" ) + connection_area_id: Mapped[int] = mapped_column( + ForeignKey("connection_areas.id"), nullable=True + ) + connection_area: Mapped["ConnectionArea"] = relationship( + back_populates="stops", lazy="selectin" + ) __tablename__ = "_stops" __mapper_args__ = {"polymorphic_identity": "_stops", "polymorphic_on": kind} @@ -108,6 +117,7 @@ class Stop(_Stop): accessibility = mapped_column(Enum(IdfmState), nullable=False) visual_signs_available = mapped_column(Enum(IdfmState), nullable=False) audible_signs_available = mapped_column(Enum(IdfmState), nullable=False) + record_id = mapped_column(String, nullable=False) record_ts = mapped_column(BigInteger, nullable=False) @@ -120,12 +130,12 @@ class StopArea(_Stop): id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True) type = mapped_column(Enum(StopAreaType), nullable=False) - stops: Mapped[list["_Stop"]] = relationship( - "_Stop", + + stops: Mapped[list["Stop"]] = relationship( + "Stop", secondary=stop_area_stop_association_table, back_populates="areas", lazy="selectin", - # lazy="joined", ) __tablename__ = "stop_areas" @@ -147,17 +157,17 @@ class StopArea(_Stop): stop_area_ids.add(stop_area_id) stop_ids.add(stop_id) - stop_areas_res = await session.execute( + stop_areas_res = await session.scalars( select(StopArea) .where(StopArea.id.in_(stop_area_ids)) .options(selectinload(StopArea.stops)) ) stop_areas: dict[int, StopArea] = { - stop_area.id: stop_area for stop_area in stop_areas_res.scalars() + stop_area.id: stop_area for stop_area in stop_areas_res.all() } - stop_res = await session.execute(select(_Stop).where(_Stop.id.in_(stop_ids))) - stops: dict[int, _Stop] = {stop.id: stop for stop in stop_res.scalars()} + stop_res = await session.execute(select(Stop).where(Stop.id.in_(stop_ids))) + stops: dict[int, Stop] = {stop.id: stop for stop in stop_res.scalars()} found = 0 for stop_area_id, stop_id in stop_area_to_stop_ids: @@ -173,3 +183,78 @@ class StopArea(_Stop): await session.commit() return found + + +class StopShape(Base): + + db = db + + 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) + + __tablename__ = "stop_shapes" + + +class ConnectionArea(Base): + + db = db + + id = mapped_column(BigInteger, primary_key=True) + + 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) + transport_mode = mapped_column(Enum(StopAreaType), nullable=False) + + version = mapped_column(String, nullable=False) + created_ts = mapped_column(BigInteger) + changed_ts = mapped_column(BigInteger, nullable=False) + + stops: Mapped[list["_Stop"]] = relationship(back_populates="connection_area") + + __tablename__ = "connection_areas" + + # TODO: Merge with StopArea.add_stops + @classmethod + async def add_stops( + cls, conn_area_to_stop_ids: Iterable[tuple[int, int]] + ) -> int | None: + session = cls.db.session + if session is None: + return None + + conn_area_ids, stop_ids = set(), set() + for conn_area_id, stop_id in conn_area_to_stop_ids: + conn_area_ids.add(conn_area_id) + stop_ids.add(stop_id) + + conn_area_res = await session.execute( + select(ConnectionArea) + .where(ConnectionArea.id.in_(conn_area_ids)) + .options(selectinload(ConnectionArea.stops)) + ) + conn_areas: dict[int, ConnectionArea] = { + conn.id: conn for conn in conn_area_res.scalars() + } + + stop_res = await session.execute(select(_Stop).where(_Stop.id.in_(stop_ids))) + stops: dict[int, _Stop] = {stop.id: stop for stop in stop_res.scalars()} + + found = 0 + for conn_area_id, stop_id in conn_area_to_stop_ids: + if (conn_area := conn_areas.get(conn_area_id)) is not None: + if (stop := stops.get(stop_id)) is not None: + conn_area.stops.append(stop) + found += 1 + else: + print(f"No stop found for {stop_id} id") + else: + print(f"No connection area found for {conn_area_id}") + + await session.commit() + + return found diff --git a/backend/backend/schemas/__init__.py b/backend/backend/schemas/__init__.py index 010c439..b8a30cf 100644 --- a/backend/backend/schemas/__init__.py +++ b/backend/backend/schemas/__init__.py @@ -1,5 +1,14 @@ from .line import Line, TransportMode from .next_passage import NextPassage, NextPassages -from .stop import Stop, StopArea +from .stop import Stop, StopArea, StopShape -__all__ = ["Line", "NextPassage", "NextPassages", "Stop", "StopArea", "TransportMode"] + +__all__ = [ + "Line", + "NextPassage", + "NextPassages", + "Stop", + "StopArea", + "StopShape", + "TransportMode", +] diff --git a/backend/backend/schemas/stop.py b/backend/backend/schemas/stop.py index f4e9277..39b8fc0 100644 --- a/backend/backend/schemas/stop.py +++ b/backend/backend/schemas/stop.py @@ -23,3 +23,10 @@ class StopArea(BaseModel): type: StopAreaType lines: list[str] # SNCF lines are linked to stop areas and not stops. stops: list[Stop] + + +class StopShape(BaseModel): + id: int + type: int + bbox: list[float] + points: list[tuple[float, float]] diff --git a/backend/main.py b/backend/main.py index fa9b01e..7387c1f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,8 +9,8 @@ from fastapi.staticfiles import StaticFiles from rich import print from backend.db import db -from backend.idfm_interface import IdfmInterface -from backend.models import Line, Stop, StopArea +from backend.idfm_interface import Destinations as IdfmDestinations, IdfmInterface +from backend.models import Line, Stop, StopArea, StopShape from backend.schemas import ( Line as LineSchema, TransportMode, @@ -18,6 +18,7 @@ from backend.schemas import ( NextPassages as NextPassagesSchema, Stop as StopSchema, StopArea as StopAreaSchema, + StopShape as StopShapeSchema, ) API_KEY = environ.get("API_KEY") @@ -214,3 +215,43 @@ async def get_next_passages(stop_id: str) -> NextPassagesSchema | None: ts=service_delivery.ResponseTimestamp.timestamp(), passages=by_line_by_dst_passages, ) + + +@app.get("/stop/{stop_id}/destinations") +async def get_stop_destinations( + stop_id: int, +) -> IdfmDestinations | None: + destinations = await idfm_interface.get_destinations(stop_id) + + return destinations + + +# TODO: Rename endpoint -> /stop/{stop_id}/shape +@app.get("/stop_shape/{stop_id}") +async def get_stop_shape(stop_id: int) -> StopShapeSchema | None: + connection_area = None + + if (stop := await Stop.get_by_id(stop_id)) is not None: + connection_area = stop.connection_area + + elif (stop_area := await StopArea.get_by_id(stop_id)) is not None: + connection_areas = {stop.connection_area for stop in stop_area.stops} + connection_areas_len = len(connection_areas) + if connection_areas_len == 1: + connection_area = connection_areas.pop() + + else: + prefix = "More than one" if connection_areas_len else "No" + msg = f"{prefix} connection area has been found for stop area #{stop_id}" + raise HTTPException(status_code=500, detail=msg) + + if ( + connection_area is not 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 + ) + + msg = f"No shape found for stop {stop_id}" + raise HTTPException(status_code=404, detail=msg) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index fbd10f6..6d16503 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -16,6 +16,7 @@ fastapi = "^0.88.0" uvicorn = "^0.20.0" asyncpg = "^0.27.0" msgspec = "^0.12.0" +pyshp = "^2.3.1" [build-system] requires = ["poetry-core"] 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/businessData.tsx b/frontend/src/businessData.tsx index 53355f4..93e4a3b 100644 --- a/frontend/src/businessData.tsx +++ b/frontend/src/businessData.tsx @@ -1,9 +1,11 @@ import { batch, createContext, createSignal, JSX } from 'solid-js'; import { createStore } from 'solid-js/store'; -import { Line, Lines, Passage, Passages, Stop, Stops } from './types'; +import { Line, Lines, Passage, Passages, Stop, StopShape, StopShapes, Stops } from './types'; +export type StopDestinations = Record; + export interface BusinessDataStore { getLine: (lineId: string) => Promise; getLinePassages: (lineId: string) => Record; @@ -18,6 +20,9 @@ export interface BusinessDataStore { getStop: (stopId: number) => Stop | undefined; searchStopByName: (name: string) => Promise; + + getStopDestinations: (stopId: number) => Promise; + getStopShape: (stopId: number) => Promise; }; export const BusinessDataContext = createContext(); @@ -30,9 +35,10 @@ export function BusinessDataProvider(props: { children: JSX.Element }) { lines: Lines; passages: Passages; stops: Stops; + stopShapes: StopShapes; }; - const [store, setStore] = createStore({ lines: {}, passages: {}, stops: {} }); + const [store, setStore] = createStore({ lines: {}, passages: {}, stops: {}, stopShapes: {} }); const getLine = async (lineId: string): Promise => { let line = store.lines[lineId]; @@ -55,6 +61,7 @@ export function BusinessDataProvider(props: { children: JSX.Element }) { return Object.keys(store.passages[lineId]); } + // TODO: Remove this method: it's based on the next passages and return nothing until the refreshPassages is called. const getDestinationPassages = (lineId: string, destination: string): Passage[] => { return store.passages[lineId][destination]; } @@ -157,14 +164,39 @@ ${linePassagesDestination.length} here... refresh all them.`); for (const stop of stops) { byIdStops[stop.id] = stop; setStore('stops', stop.id, stop); + for (const innerStop of stop.stops) { + setStore('stops', innerStop.id, innerStop); + } } return byIdStops; } + const getStopDestinations = async (stopId: number): Promise => { + const data = await fetch(`${serverUrl()}/stop/${stopId}/destinations`, { + headers: { 'Content-Type': 'application/json' } + }); + const response = await data.json(); + return response; + } + + const getStopShape = async (stopId: number): Promise => { + let shape = store.stopShapes[stopId]; + if (shape === undefined) { + console.log(`No shape found for ${stopId} stop... fetch it from backend.`); + const data = await fetch(`${serverUrl()}/stop_shape/${stopId}`, { + headers: { 'Content-Type': 'application/json' } + }); + shape = await data.json(); + setStore('stopShapes', stopId, shape); + } + return shape; + } + return ( {props.children} 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..5456a37 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,318 @@ const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => { ); } -const Map: VoidComponent<{}> = () => { - const mapCenter: LeafletLatLngLiteral = { lat: 48.853, lng: 2.35 }; - - const searchStore: SearchStore | undefined = useContext(SearchContext); - if (searchStore === undefined) +const StopPopup: ParentComponent<{ stop: Stop, show: boolean }> = (props) => { + const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext); + if (businessDataStore === undefined) return
; - const { addMarkers, getFoundStops } = searchStore; + const { getLine, getStopDestinations } = businessDataStore; + let popupDiv: HTMLDivElement | undefined = undefined; - let mapDiv: any; - let map: LeafletMap | undefined = undefined; - const stopsLayerGroup = leafletFeatureGroup(); + const getDestinations = async (stop: Stop): Promise<{ lineId: string, destinations: string[] }[]> => { + let ret = []; - 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 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()); - } - else { - for (const _stop of stop.stops) { - markers.push(...setMarker(_stop)); + 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} +
+ +
+
; + }} +
+
+
+ ); +} + + +// 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(() => { - /* TODO: Avoid to clear all layers... */ - stopsLayerGroup.clearLayers(); + 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 stop of getFoundStops()) { - const markers = setMarker(stop); - addMarkers(stop.id, markers); - for (const marker of markers) { - stopsLayerGroup.addLayer(marker); + 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 stopsBound = stopsLayerGroup.getBounds(); - if (map !== undefined && Object.keys(stopsBound).length) { - map.fitBounds(stopsBound); + 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 }); } }); - 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 +753,14 @@ const Footer: VoidComponent<{}> = () => { export const StopsSearchMenu: VoidComponent = () => { - const MAX_STOPS_PER_PANEL = 5; + const maxStopsPerPanel = 5; return (
- +