commit dde835760a9500d6b14afbc85a7f44e63a6840b8 Author: Adrien Date: Sun Jan 22 16:53:45 2023 +0100 🎉 First commit !!! diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..7fce5c8 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +!**/__pycache__/ diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..09d1ab1 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3.7' + +services: + + database: + image: postgres:15.1-alpine + restart: always + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + logging: + options: + max-size: 10m + max-file: "3" + ports: + - '127.0.0.1:5438:5432' + volumes: + - ./docker/database/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d + - ./docker/database/data:/var/lib/postgresql/data diff --git a/backend/docker/database/docker-entrypoint-initdb.d/init-user-db.sh b/backend/docker/database/docker-entrypoint-initdb.d/init-user-db.sh new file mode 100755 index 0000000..9b9f4ef --- /dev/null +++ b/backend/docker/database/docker-entrypoint-initdb.d/init-user-db.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + CREATE USER idfm_matrix_bot; + CREATE DATABASE bot; + CREATE DATABASE idfm; + GRANT ALL PRIVILEGES ON DATABASE bot TO idfm_matrix_bot; + GRANT ALL PRIVILEGES ON DATABASE idfm TO idfm_matrix_bot; +EOSQL + diff --git a/backend/idfm_matrix_backend/db/__init__.py b/backend/idfm_matrix_backend/db/__init__.py new file mode 100644 index 0000000..8d9bf84 --- /dev/null +++ b/backend/idfm_matrix_backend/db/__init__.py @@ -0,0 +1,4 @@ +from .db import Database +from .base_class import Base + +db = Database() diff --git a/backend/idfm_matrix_backend/db/base_class.py b/backend/idfm_matrix_backend/db/base_class.py new file mode 100644 index 0000000..2334a75 --- /dev/null +++ b/backend/idfm_matrix_backend/db/base_class.py @@ -0,0 +1,34 @@ +from collections.abc import Iterable + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import declarative_base +from typing import Iterable, Self + +Base = declarative_base() +Base.db = None + + +async def base_add(cls, stops: Self | Iterable[Self]) -> bool: + try: + method = ( + cls.db.session.add_all + if isinstance(stops, Iterable) + else cls.db.session.add + ) + method(stops) + await cls.db.session.commit() + except IntegrityError as err: + print(err) + + +Base.add = classmethod(base_add) + + +async def base_get_by_id(cls, id_: int | str) -> None | Base: + res = await cls.db.session.execute(select(cls).where(cls.id == id_)) + element = res.scalar_one_or_none() + return element + + +Base.get_by_id = classmethod(base_get_by_id) diff --git a/backend/idfm_matrix_backend/db/db.py b/backend/idfm_matrix_backend/db/db.py new file mode 100644 index 0000000..e050258 --- /dev/null +++ b/backend/idfm_matrix_backend/db/db.py @@ -0,0 +1,80 @@ +from asyncio import gather as asyncio_gather +from functools import wraps +from pathlib import Path +from time import time +from typing import Callable, Iterable, Optional + +from rich import print +from sqlalchemy import event, select, tuple_ +from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import ( + selectinload, + sessionmaker, + with_polymorphic, +) +from sqlalchemy.orm.attributes import set_committed_value + +from .base_class import Base + + +# import logging + +# logging.basicConfig() +# logger = logging.getLogger("bot.sqltime") +# logger.setLevel(logging.DEBUG) + + +# @event.listens_for(Engine, "before_cursor_execute") +# def before_cursor_execute(conn, cursor, statement, parameters, context, executemany): +# conn.info.setdefault("query_start_time", []).append(time()) +# logger.debug("Start Query: %s", statement) + + +# @event.listens_for(Engine, "after_cursor_execute") +# def after_cursor_execute(conn, cursor, statement, parameters, context, executemany): +# total = time() - conn.info["query_start_time"].pop(-1) +# logger.debug("Query Complete!") +# logger.debug("Total Time: %f", total) + + +class Database: + def __init__(self) -> None: + self._engine = None + self._session_maker = None + self._session = None + + @property + def session(self) -> None: + if self._session is None: + self._session = self._session_maker() + return self._session + + def use_session(func: Callable): + @wraps(func) + async def wrapper(self, *args, **kwargs): + if self._check_session() is not None: + return await func(self, *args, **kwargs) + # TODO: Raise an exception ? + + return wrapper + + async def connect(self, db_path: str, clear_static_data: bool = False) -> None: + # TODO: Preserve UserLastStopSearchResults table from drop. + self._engine = create_async_engine(db_path) + self._session_maker = sessionmaker( + self._engine, expire_on_commit=False, class_=AsyncSession + ) + await self.session.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;") + + async with self._engine.begin() as conn: + if clear_static_data: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + async def disconnect(self) -> None: + if self._session is not None: + await self._session.close() + self._session = None + await self._engine.dispose() diff --git a/backend/idfm_matrix_backend/idfm_interface/__init__.py b/backend/idfm_matrix_backend/idfm_interface/__init__.py new file mode 100644 index 0000000..c97a846 --- /dev/null +++ b/backend/idfm_matrix_backend/idfm_interface/__init__.py @@ -0,0 +1,2 @@ +from .idfm_interface import IdfmInterface +from .idfm_types import * diff --git a/backend/idfm_matrix_backend/idfm_interface/idfm_interface.py b/backend/idfm_matrix_backend/idfm_interface/idfm_interface.py new file mode 100644 index 0000000..5744be9 --- /dev/null +++ b/backend/idfm_matrix_backend/idfm_interface/idfm_interface.py @@ -0,0 +1,447 @@ +from pathlib import Path +from re import compile as re_compile +from time import time +from typing import ByteString, Iterable, List, Optional + +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 ..db import Database +from ..models import Line, LinePicto, Stop, StopArea +from .idfm_types import ( + IdfmLineState, + IdfmResponse, + Line as IdfmLine, + MonitoredVehicleJourney, + LinePicto as IdfmPicto, + IdfmState, + Stop as IdfmStop, + StopArea as IdfmStopArea, + StopAreaStopAssociation, + StopLineAsso as IdfmStopLineAsso, + Stops, +) +from .ratp_types import Picto as RatpPicto + + +class IdfmInterface: + + IDFM_ROOT_URL = "https://prim.iledefrance-mobilites.fr/marketplace" + IDFM_STOP_MON_URL = f"{IDFM_ROOT_URL}/stop-monitoring" + + IDFM_ROOT_URL = "https://data.iledefrance-mobilites.fr/explore/dataset" + IDFM_STOPS_URL = ( + f"{IDFM_ROOT_URL}/arrets/download/?format=json&timezone=Europe/Berlin" + ) + IDFM_PICTO_URL = f"{IDFM_ROOT_URL}/referentiel-des-lignes/files" + + RATP_ROOT_URL = "https://data.ratp.fr/explore/dataset" + RATP_PICTO_URL = f"{RATP_ROOT_URL}/pictogrammes-des-lignes-de-metro-rer-tramway-bus-et-noctilien/files" + + OPERATOR_RE = re_compile(r"[^:]+:Operator::([^:]+):") + LINE_RE = re_compile(r"[^:]+:Line::([^:]+):") + + def __init__(self, api_key: str, database: Database) -> None: + self._api_key = api_key + self._database = database + + self._http_headers = {"Accept": "application/json", "apikey": self._api_key} + + self._json_stops_decoder = Decoder(type=List[IdfmStop]) + self._json_stop_areas_decoder = Decoder(type=List[IdfmStopArea]) + 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]) + self._json_stop_area_stop_asso_decoder = Decoder( + type=List[StopAreaStopAssociation] + ) + + self._response_json_decoder = Decoder(type=IdfmResponse) + + async def startup(self) -> None: + BATCH_SIZE = 10000 + STEPS = ( + ( + StopArea, + self._request_idfm_stop_areas, + IdfmInterface._format_idfm_stop_areas, + ), + (Stop, self._request_idfm_stops, IdfmInterface._format_idfm_stops), + ) + + for model, get_method, format_method in STEPS: + step_begin_ts = time() + elements = [] + + async for element in get_method(): + elements.append(element) + + if len(elements) == BATCH_SIZE: + await model.add(format_method(*elements)) + elements.clear() + + if elements: + await model.add(format_method(*elements)) + + print(f"Add {model.__name__}s: {time() - step_begin_ts}s") + + begin_ts = time() + await self._load_lines() + print(f"Add Lines and IDFM LinePictos: {time() - begin_ts}s") + + begin_ts = time() + await self._load_ratp_pictos(30) + print(f"Add RATP LinePictos: {time() - begin_ts}s") + + begin_ts = time() + await self._load_lines_stops_assos() + print(f"Link Stops to Lines: {time() - begin_ts}s") + + begin_ts = time() + await self._load_stop_areas_stops_assos() + print(f"Link Stops to StopAreas: {time() - begin_ts}s") + + async def _load_lines(self, batch_size: int = 5000) -> None: + lines, pictos = [], [] + picto_ids = set() + async for line in self._request_idfm_lines(): + if (picto := line.fields.picto) is not None and picto.id_ not in picto_ids: + picto_ids.add(picto.id_) + pictos.append(picto) + + lines.append(line) + if len(lines) == batch_size: + await LinePicto.add(IdfmInterface._format_idfm_pictos(*pictos)) + await Line.add(await self._format_idfm_lines(*lines)) + lines.clear() + pictos.clear() + + if pictos: + await LinePicto.add(IdfmInterface._format_idfm_pictos(*pictos)) + if lines: + await Line.add(await self._format_idfm_lines(*lines)) + + async def _load_ratp_pictos(self, batch_size: int = 5) -> None: + pictos = [] + + async for picto in self._request_ratp_pictos(): + pictos.append(picto) + if len(pictos) == batch_size: + formatted_pictos = IdfmInterface._format_ratp_pictos(*pictos) + await LinePicto.add(formatted_pictos.values()) + await Line.add_pictos(formatted_pictos) + pictos.clear() + + if pictos: + formatted_pictos = IdfmInterface._format_ratp_pictos(*pictos) + await LinePicto.add(formatted_pictos.values()) + await Line.add_pictos(formatted_pictos) + + async def _load_lines_stops_assos(self, batch_size: int = 5000) -> None: + total_assos_nb = total_found_nb = 0 + assos = [] + async for asso in self._request_idfm_stops_lines_associations(): + fields = asso.fields + try: + stop_id = int(fields.stop_id.rsplit(":", 1)[-1]) + except ValueError as err: + print(err) + print(f"{fields.stop_id = }") + continue + + assos.append((fields.route_long_name, fields.operatorname, stop_id)) + if len(assos) == batch_size: + total_assos_nb += batch_size + total_found_nb += await Line.add_stops(assos) + assos.clear() + + if assos: + total_assos_nb += len(assos) + total_found_nb += await Line.add_stops(assos) + + 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 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: + total_assos_nb += batch_size + total_found_nb += await StopArea.add_stops(assos) + assos.clear() + + if assos: + total_assos_nb += len(assos) + total_found_nb += await StopArea.add_stops(assos) + + print(f"{total_found_nb} stop area <-> stop ({total_assos_nb = } found)") + + async def _request_idfm_stops(self): + # headers = {"Accept": "application/json", "apikey": self._api_key} + # async with ClientSession(headers=headers) as session: + # async with session.get(self.STOPS_URL) as response: + # # print("Status:", response.status) + # if response.status == 200: + # for point in self._json_stops_decoder.decode(await response.read()): + # yield point + # TODO: Use HTTP + async with async_open("./tests/datasets/stops_dataset.json", "rb") as raw: + for element in self._json_stops_decoder.decode(await raw.read()): + yield element + + async def _request_idfm_stop_areas(self): + # TODO: Use HTTP + async with async_open("./tests/datasets/zones-d-arrets.json", "rb") as raw: + for element in self._json_stop_areas_decoder.decode(await raw.read()): + yield element + + async def _request_idfm_lines(self): + # TODO: Use HTTP + async with async_open("./tests/datasets/lines_dataset.json", "rb") as raw: + for element in self._json_lines_decoder.decode(await raw.read()): + yield element + + async def _request_idfm_stops_lines_associations(self): + # TODO: Use HTTP + async with async_open("./tests/datasets/arrets-lignes.json", "rb") as raw: + for element in self._json_stops_lines_assos_decoder.decode( + await raw.read() + ): + yield element + + async def _request_idfm_stop_area_stop_associations(self): + # TODO: Use HTTP + async with async_open("./tests/datasets/relations.json", "rb") as raw: + for element in self._json_stop_area_stop_asso_decoder.decode( + await raw.read() + ): + yield element + + async def _request_ratp_pictos(self): + # TODO: Use HTTP + async with async_open( + "./tests/datasets/pictogrammes-des-lignes-de-metro-rer-tramway-bus-et-noctilien.json", + "rb", + ) as fd: + for element in self._json_ratp_pictos_decoder.decode(await fd.read()): + yield element + + @classmethod + def _format_idfm_pictos(cls, *pictos: IdfmPicto) -> Iterable[LinePicto]: + ret = [] + + for picto in pictos: + ret.append( + LinePicto( + id=picto.id_, + mime_type=picto.mimetype, + height_px=picto.height, + width_px=picto.width, + filename=picto.filename, + url=f"{cls.IDFM_PICTO_URL}/{picto.id_}/download", + thumbnail=picto.thumbnail, + format=picto.format, + ) + ) + + return ret + + @classmethod + def _format_ratp_pictos(cls, *pictos: RatpPicto) -> dict[str, None | LinePicto]: + ret = {} + + for picto in pictos: + if (fields := picto.fields.noms_des_fichiers) is not None: + ret[picto.fields.indices_commerciaux] = LinePicto( + id=fields.id_, + mime_type=f"image/{fields.format.lower()}", + height_px=fields.height, + width_px=fields.width, + filename=fields.filename, + url=f"{cls.RATP_PICTO_URL}/{fields.id_}/download", + thumbnail=fields.thumbnail, + format=fields.format, + ) + + return ret + + async def _format_idfm_lines(self, *lines: IdfmLine) -> Iterable[Line]: + ret = [] + + optional_value = IdfmLine.optional_value + + for line in lines: + fields = line.fields + + picto_id = fields.picto.id_ if fields.picto is not None else None + picto = await LinePicto.get_by_id(picto_id) if picto_id else None + + ret.append( + Line( + id=fields.id_line, + short_name=fields.shortname_line, + name=fields.name_line, + status=IdfmLineState(fields.status.value), + transport_mode=fields.transportmode.value, + transport_submode=optional_value(fields.transportsubmode), + network_name=optional_value(fields.networkname), + group_of_lines_id=optional_value(fields.id_groupoflines), + group_of_lines_shortname=optional_value( + fields.shortname_groupoflines + ), + colour_web_hexa=fields.colourweb_hexa, + text_colour_hexa=fields.textcolourprint_hexa, + operator_id=optional_value(fields.operatorref), + operator_name=optional_value(fields.operatorname), + accessibility=fields.accessibility.value, + visual_signs_available=fields.visualsigns_available.value, + audible_signs_available=fields.audiblesigns_available.value, + picto_id=fields.picto.id_ if fields.picto is not None else None, + picto=picto, + record_id=line.recordid, + record_ts=int(line.record_timestamp.timestamp()), + ) + ) + + return ret + + @staticmethod + def _format_idfm_stops(*stops: IdfmStop) -> Iterable[Stop]: + for stop in stops: + fields = stop.fields + try: + created_ts = int(fields.arrcreated.timestamp()) + except AttributeError: + created_ts = None + yield Stop( + id=int(fields.arrid), + name=fields.arrname, + latitude=fields.arrgeopoint.lat, + longitude=fields.arrgeopoint.lon, + town_name=fields.arrtown, + postal_region=fields.arrpostalregion, + xepsg2154=fields.arrxepsg2154, + yepsg2154=fields.arryepsg2154, + transport_mode=fields.arrtype.value, + version=fields.arrversion, + created_ts=created_ts, + changed_ts=int(fields.arrchanged.timestamp()), + accessibility=fields.arraccessibility.value, + visual_signs_available=fields.arrvisualsigns.value, + audible_signs_available=fields.arraudiblesignals.value, + record_id=stop.recordid, + record_ts=int(stop.record_timestamp.timestamp()), + ) + + @staticmethod + def _format_idfm_stop_areas(*stop_areas: IdfmStopArea) -> Iterable[StopArea]: + for stop_area in stop_areas: + fields = stop_area.fields + try: + created_ts = int(fields.arrcreated.timestamp()) + except AttributeError: + created_ts = None + yield StopArea( + id=int(fields.zdaid), + name=fields.zdaname, + town_name=fields.zdatown, + postal_region=fields.zdapostalregion, + xepsg2154=fields.zdaxepsg2154, + yepsg2154=fields.zdayepsg2154, + type=fields.zdatype.value, + version=fields.zdaversion, + created_ts=created_ts, + changed_ts=int(fields.zdachanged.timestamp()), + ) + + async def render_line_picto(self, line: Line) -> tuple[None | str, None | str]: + begin_ts = time() + line_picto_path = line_picto_format = None + target = f"/tmp/{line.id}_repr" + + picto = line.picto + if picto is not None: + picto_data = await self._get_line_picto(line) + async with async_open(target, "wb") as fd: + await fd.write(picto_data) + line_picto_path = target + line_picto_format = picto.mime_type + + print(f"render_line_picto: {time() - begin_ts}") + return (line_picto_path, line_picto_format) + + async def _get_line_picto(self, line: Line) -> Optional[ByteString]: + print("---------------------------------------------------------------------") + begin_ts = time() + data = None + + picto = line.picto + if picto is not None: + headers = ( + self._http_headers if picto.url.startswith(self.IDFM_ROOT_URL) else None + ) + session_begin_ts = time() + async with ClientSession(headers=headers) as session: + session_creation_ts = time() + print(f"Session creation {session_creation_ts - session_begin_ts}") + async with session.get(picto.url) as response: + get_end_ts = time() + print(f"GET {get_end_ts - session_creation_ts}") + data = await response.read() + print(f"read {time() - get_end_ts}") + + print(f"render_line_picto: {time() - begin_ts}") + print("---------------------------------------------------------------------") + return data + + async def get_next_passages(self, stop_point_id: str) -> Optional[IdfmResponse]: + # print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + begin_ts = time() + ret = None + params = {"MonitoringRef": f"STIF:StopPoint:Q:{stop_point_id}:"} + session_begin_ts = time() + async with ClientSession(headers=self._http_headers) as session: + session_creation_ts = time() + # print(f"Session creation {session_creation_ts - session_begin_ts}") + async with session.get(self.IDFM_STOP_MON_URL, params=params) as response: + get_end_ts = time() + # print(f"GET {get_end_ts - session_creation_ts}") + if response.status == 200: + get_end_ts = time() + # print(f"GET {get_end_ts - session_creation_ts}") + data = await response.read() + # print(data) + try: + ret = self._response_json_decoder.decode(data) + except ValidationError as err: + print(err) + # print(f"read {time() - get_end_ts}") + + # print(f"get_next_passages: {time() - begin_ts}") + # print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + 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. + begin_ts = time() + destinations: dict[str, str] = {} + if (res := await self.get_next_passages(stop_point_id)) is not None: + for delivery in res.Siri.ServiceDelivery.StopMonitoringDelivery: + if delivery.Status == IdfmState.true: + for stop_visit in delivery.MonitoredStopVisit: + 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 + print(f"get_next_passages: {time() - begin_ts}") + return destinations diff --git a/backend/idfm_matrix_backend/idfm_interface/idfm_types.py b/backend/idfm_matrix_backend/idfm_interface/idfm_types.py new file mode 100644 index 0000000..b703e1f --- /dev/null +++ b/backend/idfm_matrix_backend/idfm_interface/idfm_types.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum, StrEnum +from typing import Any, Literal, Optional, NamedTuple + +from msgspec import Struct + + +class Coordinate(NamedTuple): + lat: float + lon: float + + +class IdfmState(Enum): + unknown = "unknown" + false = "false" + partial = "partial" + true = "true" + + +class TrainStatus(Enum): + unknown = "" + arrived = "arrived" + onTime = "onTime" + delayed = "delayed" + noReport = "noReport" + early = "early" + cancelled = "cancelled" + undefined = "undefined" + + +class TransportMode(StrEnum): + bus = "bus" + tram = "tram" + metro = "metro" + rail = "rail" + funicular = "funicular" + + +class TransportSubMode(Enum): + unknown = "unknown" + + localBus = "localBus" + regionalBus = "regionalBus" + highFrequencyBus = "highFrequencyBus" + expressBus = "expressBus" + nightBus = "nightBus" + demandAndResponseBus = "demandAndResponseBus" + airportLinkBus = "airportLinkBus" + + regionalRail = "regionalRail" + railShuttle = "railShuttle" + suburbanRailway = "suburbanRailway" + + local = "local" + + +class StopFields(Struct, kw_only=True): + arrgeopoint: Coordinate + arrtown: str + arrcreated: None | datetime = None + arryepsg2154: int + arrpostalregion: str + arrid: str + arrxepsg2154: int + arraccessibility: IdfmState + arrvisualsigns: IdfmState + arrtype: TransportMode + arrname: str + arrversion: str + arrchanged: datetime + arraudiblesignals: IdfmState + + +class Point(Struct): + coordinates: Coordinate + + +class Stop(Struct): + datasetid: str + recordid: str + fields: StopFields + record_timestamp: datetime + # geometry: Union[Point] + + +Stops = dict[str, Stop] + + +class StopAreaType(Enum): + metroStation = "metroStation" + onstreetBus = "onstreetBus" + onstreetTram = "onstreetTram" + railStation = "railStation" + + +class StopAreaFields(Struct, kw_only=True): + zdaname: str + zdcid: str + zdatown: str + zdaversion: str + zdaid: str + zdacreated: Optional[datetime] = None + zdatype: StopAreaType + zdayepsg2154: int + zdapostalregion: str + zdachanged: datetime + zdaxepsg2154: int + + +class StopArea(Struct): + datasetid: str + recordid: str + fields: StopAreaFields + record_timestamp: datetime + + +class StopAreaStopAssociationFields(Struct, kw_only=True): + arrid: str # TODO: use int ? + artid: Optional[str] = None + arrversion: str + zdcid: str + version: int + zdaid: str + zdaversion: str + artversion: Optional[str] = None + + +class StopAreaStopAssociation(Struct): + datasetid: str + recordid: str + fields: StopAreaStopAssociationFields + record_timestamp: datetime + + +class IdfmLineState(Enum): + active = "active" + + +class LinePicto(Struct, rename={"id_": "id"}): + id_: str + mimetype: str + height: int + width: int + filename: str + thumbnail: bool + format: str + # color_summary: list[str] + + +class LineFields(Struct, kw_only=True): + name_line: str + status: IdfmLineState + accessibility: IdfmState + shortname_groupoflines: Optional[str] = None + transportmode: TransportMode + colourweb_hexa: str + textcolourprint_hexa: str + transportsubmode: Optional[TransportSubMode] = TransportSubMode.unknown + operatorref: Optional[str] = None + visualsigns_available: IdfmState + networkname: Optional[str] = None + id_line: str + id_groupoflines: Optional[str] = None + operatorname: Optional[str] = None + audiblesigns_available: IdfmState + shortname_line: str + picto: Optional[LinePicto] = None + + +class Line(Struct): + datasetid: str + recordid: str + fields: LineFields + record_timestamp: datetime + + @staticmethod + def optional_value(value: Any) -> Any: + if value: + return value.value if isinstance(value, Enum) else value + return "NULL" + + +Lines = dict[str, Line] + + +# TODO: Set structs frozen +class StopLineAssoFields(Struct): + pointgeo: Coordinate + stop_id: str + stop_name: str + operatorname: str + nom_commune: str + route_long_name: str + id: str + stop_lat: str + stop_lon: str + code_insee: str + + +class StopLineAsso(Struct): + datasetid: str + recordid: str + fields: StopLineAssoFields + # geometry: Union[Point] + + +class Value(Struct): + value: str + + +class FramedVehicleJourney(Struct): + DataFrameRef: Value + DatedVehicleJourneyRef: str + + +class TrainNumber(Struct): + TrainNumberRef: list[Value] + + +class MonitoredCall(Struct, kw_only=True): + Order: Optional[int] = None + StopPointName: list[Value] + VehicleAtStop: bool + DestinationDisplay: list[Value] + AimedArrivalTime: Optional[datetime] = None + ExpectedArrivalTime: Optional[datetime] = None + ArrivalPlatformName: Optional[Value] = None + AimedDepartureTime: Optional[datetime] = None + ExpectedDepartureTime: Optional[datetime] = None + ArrivalStatus: TrainStatus = None + DepartureStatus: TrainStatus = None + + +class MonitoredVehicleJourney(Struct, kw_only=True): + LineRef: Value + OperatorRef: Value + FramedVehicleJourneyRef: FramedVehicleJourney + DestinationRef: Value + DestinationName: list[Value] | None = None + JourneyNote: list[Value] | None = None + TrainNumbers: Optional[TrainNumber] = None + MonitoredCall: MonitoredCall + + +class StopDelivery(Struct): + RecordedAtTime: datetime + ItemIdentifier: str + MonitoringRef: Value + MonitoredVehicleJourney: MonitoredVehicleJourney + + +class StopMonitoringDelivery(Struct): + ResponseTimestamp: datetime + Version: str + Status: IdfmState + MonitoredStopVisit: list[StopDelivery] + + +class ServiceDelivery(Struct): + ResponseTimestamp: datetime + ProducerRef: str + ResponseMessageIdentifier: str + StopMonitoringDelivery: list[StopMonitoringDelivery] + + +class Siri(Struct): + ServiceDelivery: ServiceDelivery + + +class IdfmOperator(Enum): + SNCF = "SNCF" + + +class IdfmResponse(Struct): + Siri: Siri diff --git a/backend/idfm_matrix_backend/idfm_interface/ratp_types.py b/backend/idfm_matrix_backend/idfm_interface/ratp_types.py new file mode 100644 index 0000000..222fad7 --- /dev/null +++ b/backend/idfm_matrix_backend/idfm_interface/ratp_types.py @@ -0,0 +1,25 @@ +from datetime import datetime +from typing import Optional + +from msgspec import Struct + + +class PictoFieldsFile(Struct, rename={"id_": "id"}): + id_: str + height: int + width: int + filename: str + thumbnail: bool + format: str + + +class PictoFields(Struct): + indices_commerciaux: str + noms_des_fichiers: Optional[PictoFieldsFile] = None + + +class Picto(Struct): + datasetid: str + recordid: str + fields: PictoFields + record_timestamp: datetime diff --git a/backend/idfm_matrix_backend/models/__init__.py b/backend/idfm_matrix_backend/models/__init__.py new file mode 100644 index 0000000..c6060f7 --- /dev/null +++ b/backend/idfm_matrix_backend/models/__init__.py @@ -0,0 +1,3 @@ +from .line import Line, LinePicto +from .stop import Stop, StopArea +from .user import UserLastStopSearchResults diff --git a/backend/idfm_matrix_backend/models/line.py b/backend/idfm_matrix_backend/models/line.py new file mode 100644 index 0000000..805968c --- /dev/null +++ b/backend/idfm_matrix_backend/models/line.py @@ -0,0 +1,176 @@ +from asyncio import gather as asyncio_gather +from collections import defaultdict +from typing import Iterable, Self + +from sqlalchemy import ( + BigInteger, + Boolean, + Column, + Enum, + ForeignKey, + Integer, + select, + String, + Table, +) +from sqlalchemy.orm import Mapped, relationship, selectinload +from sqlalchemy.orm.attributes import set_committed_value +from sqlalchemy.sql.expression import tuple_ + +from ..db import Base, db +from ..idfm_interface.idfm_types import ( + IdfmState, + IdfmLineState, + TransportMode, + TransportSubMode, +) +from .stop import _Stop + +line_stop_association_table = Table( + "line_stop_association_table", + Base.metadata, + Column("line_id", ForeignKey("lines.id")), + Column("stop_id", ForeignKey("_stops.id")), +) + + +class LinePicto(Base): + + db = db + + id = Column(String, primary_key=True) + mime_type = Column(String, nullable=False) + height_px = Column(Integer, nullable=False) + width_px = Column(Integer, nullable=False) + filename = Column(String, nullable=False) + url = Column(String, nullable=False) + thumbnail = Column(Boolean, nullable=False) + format = Column(String, nullable=False) + + __tablename__ = "line_pictos" + + +class Line(Base): + + db = db + + id = Column(String, primary_key=True) + + short_name = Column(String) + name = Column(String, nullable=False) + status = Column(Enum(IdfmLineState), nullable=False) + transport_mode = Column(Enum(TransportMode), nullable=False) + transport_submode = Column(Enum(TransportSubMode), nullable=False) + + network_name = Column(String) + group_of_lines_id = Column(String) + group_of_lines_shortname = Column(String) + + colour_web_hexa = Column(String, nullable=False) + text_colour_hexa = Column(String, nullable=False) + + operator_id = Column(String) + operator_name = Column(String) + + accessibility = Column(Enum(IdfmState), nullable=False) + visual_signs_available = Column(Enum(IdfmState), nullable=False) + audible_signs_available = Column(Enum(IdfmState), nullable=False) + + picto_id = Column(String, ForeignKey("line_pictos.id")) + picto: Mapped[LinePicto] = relationship(LinePicto, lazy="selectin") + + record_id = Column(String, nullable=False) + record_ts = Column(BigInteger, nullable=False) + + stops: Mapped[list["_Stop"]] = relationship( + "_Stop", + secondary=line_stop_association_table, + back_populates="lines", + lazy="selectin", + ) + + __tablename__ = "lines" + + @classmethod + async def get_by_name( + cls, name: str, operator_name: None | str = None + ) -> list[Self]: + filters = {"name": name} + if operator_name is not None: + filters["operator_name"] = operator_name + + lines = None + stmt = ( + select(Line) + .filter_by(**filters) + .options(selectinload(Line.stops), selectinload(Line.picto)) + ) + res = await cls.db.session.execute(stmt) + lines = res.scalars().all() + return lines + + @classmethod + async def _add_picto_to_line(cls, line: str | Self, picto: LinePicto) -> None: + if isinstance(line, str): + if (lines := await cls.get_by_name(line)) is not None: + if len(lines) == 1: + line = lines[0] + else: + for candidate_line in lines: + if candidate_line.operator_name == "RATP": + line = candidate_line + break + + if isinstance(line, Line) and line.picto is None: + line.picto = picto + line.picto_id = picto.id + + @classmethod + async def add_pictos(cls, line_to_pictos: dict[str | Self, LinePicto]) -> None: + await asyncio_gather( + *[ + cls._add_picto_to_line(line, picto) + for line, picto in line_to_pictos.items() + ] + ) + + await cls.db.session.commit() + + @classmethod + async def add_stops(cls, line_to_stop_ids: Iterable[tuple[str, str, str]]) -> int: + line_names_ops, stop_ids = set(), set() + for line_name, operator_name, stop_id in line_to_stop_ids: + line_names_ops.add((line_name, operator_name)) + stop_ids.add(stop_id) + + res = await cls.db.session.execute( + select(Line).where( + tuple_(Line.name, Line.operator_name).in_(line_names_ops) + ) + ) + + lines = defaultdict(list) + for line in res.scalars(): + lines[(line.name, line.operator_name)].append(line) + + res = await cls.db.session.execute(select(_Stop).where(_Stop.id.in_(stop_ids))) + stops = {stop.id: stop for stop in res.scalars()} + + found = 0 + for line_name, operator_name, stop_id in line_to_stop_ids: + if (stop := stops.get(stop_id)) is not None: + if (stop_lines := lines.get((line_name, operator_name))) is not None: + if len(stop_lines) > 1: + print(stop_lines) + for stop_line in stop_lines: + stop_line.stops.append(stop) + found += 1 + else: + print(f"No line found for {line_name}/{operator_name}") + else: + print( + f"No stop found for {stop_id} id (used by {line_name}/{operator_name})" + ) + + await cls.db.session.commit() + return found diff --git a/backend/idfm_matrix_backend/models/stop.py b/backend/idfm_matrix_backend/models/stop.py new file mode 100644 index 0000000..6bb9543 --- /dev/null +++ b/backend/idfm_matrix_backend/models/stop.py @@ -0,0 +1,144 @@ +from typing import Iterable, Self + +from sqlalchemy import ( + BigInteger, + Column, + Enum, + Float, + ForeignKey, + select, + String, + Table, +) +from sqlalchemy.orm import Mapped, relationship, selectinload, with_polymorphic +from sqlalchemy.schema import Index + +from ..db import Base, db +from ..idfm_interface.idfm_types import TransportMode, IdfmState, StopAreaType + +stop_area_stop_association_table = Table( + "stop_area_stop_association_table", + Base.metadata, + Column("stop_id", ForeignKey("_stops.id")), + Column("stop_area_id", ForeignKey("stop_areas.id")), +) + + +class _Stop(Base): + + db = db + + id = Column(BigInteger, primary_key=True) + kind = Column(String) + + name = Column(String, nullable=False, index=True) + town_name = Column(String, nullable=False) + postal_region = Column(String, nullable=False) + xepsg2154 = Column(BigInteger, nullable=False) + yepsg2154 = Column(BigInteger, nullable=False) + version = Column(String, nullable=False) + created_ts = Column(BigInteger) + changed_ts = 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" + ) + + __tablename__ = "_stops" + __mapper_args__ = {"polymorphic_identity": "_stops", "polymorphic_on": kind} + __table_args__ = ( + # To optimize the ilike requests + Index( + "name_idx_gin", + name, + postgresql_ops={"name": "gin_trgm_ops"}, + postgresql_using="gin", + ), + ) + + # TODO: Test https://www.cybertec-postgresql.com/en/postgresql-more-performance-for-like-and-ilike-statements/ + # TODO: Should be able to remove with_polymorphic ? + @classmethod + async def get_by_name(cls, name: str) -> list[Self]: + stop_stop_area = with_polymorphic(_Stop, [Stop, StopArea]) + stmt = ( + select(stop_stop_area) + .where(stop_stop_area.name.ilike(f"%{name}%")) + .options( + selectinload(stop_stop_area.areas), + selectinload(stop_stop_area.lines), + ) + ) + res = await cls.db.session.execute(stmt) + return res.scalars() + + +class Stop(_Stop): + + id = Column(BigInteger, ForeignKey("_stops.id"), primary_key=True) + + latitude = Column(Float, nullable=False) + longitude = Column(Float, nullable=False) + transport_mode = Column(Enum(TransportMode), nullable=False) + accessibility = Column(Enum(IdfmState), nullable=False) + visual_signs_available = Column(Enum(IdfmState), nullable=False) + audible_signs_available = Column(Enum(IdfmState), nullable=False) + record_id = Column(String, nullable=False) + record_ts = Column(BigInteger, nullable=False) + + __tablename__ = "stops" + __mapper_args__ = {"polymorphic_identity": "stops", "polymorphic_load": "inline"} + + +class StopArea(_Stop): + + id = Column(BigInteger, ForeignKey("_stops.id"), primary_key=True) + + type = Column(Enum(StopAreaType), nullable=False) + stops: Mapped[list[_Stop]] = relationship( + _Stop, + secondary=stop_area_stop_association_table, + back_populates="areas", + lazy="selectin", + # lazy="joined", + ) + + __tablename__ = "stop_areas" + __mapper_args__ = {"polymorphic_identity": "stop_areas", "polymorphic_load": "inline"} + + @classmethod + async def add_stops(cls, stop_area_to_stop_ids: Iterable[tuple[str, str]]) -> int: + stop_area_ids, stop_ids = set(), set() + for stop_area_id, stop_id in stop_area_to_stop_ids: + stop_area_ids.add(stop_area_id) + stop_ids.add(stop_id) + + res = await cls.db.session.execute( + select(StopArea) + .where(StopArea.id.in_(stop_area_ids)) + .options(selectinload(StopArea.stops)) + ) + stop_areas = {stop_area.id: stop_area for stop_area in res.scalars()} + + res = await cls.db.session.execute(select(_Stop).where(_Stop.id.in_(stop_ids))) + stops = {stop.id: stop for stop in res.scalars()} + + found = 0 + for stop_area_id, stop_id in stop_area_to_stop_ids: + if (stop_area := stop_areas.get(stop_area_id)) is not None: + if (stop := stops.get(stop_id)) is not None: + stop_area.stops.append(stop) + found += 1 + else: + print(f"No stop found for {stop_id} id") + else: + print(f"No stop area found for {stop_area_id}") + + await cls.db.session.commit() + return found diff --git a/backend/idfm_matrix_backend/models/user.py b/backend/idfm_matrix_backend/models/user.py new file mode 100644 index 0000000..4705d8e --- /dev/null +++ b/backend/idfm_matrix_backend/models/user.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, ForeignKey, String, Table +from sqlalchemy.orm import Mapped, relationship + +from ..db import Base, db +from .stop import _Stop + +user_last_stop_search_stops_associations_table = Table( + "user_last_stop_search_stops_associations_table", + Base.metadata, + Column("user_mxid", ForeignKey("user_last_stop_search_results.user_mxid")), + Column("stop_id", ForeignKey("_stops.id")), +) + + +class UserLastStopSearchResults(Base): + + db = db + + __tablename__ = "user_last_stop_search_results" + + user_mxid = Column(String, primary_key=True) + request_content = Column(String, nullable=False) + stops: Mapped[list[_Stop]] = relationship( + _Stop, secondary=user_last_stop_search_stops_associations_table + ) diff --git a/backend/idfm_matrix_backend/schemas/__init__.py b/backend/idfm_matrix_backend/schemas/__init__.py new file mode 100644 index 0000000..232658b --- /dev/null +++ b/backend/idfm_matrix_backend/schemas/__init__.py @@ -0,0 +1,3 @@ +from .line import Line, TransportMode +from .next_passage import NextPassage, NextPassages +from .stop import Stop, StopArea diff --git a/backend/idfm_matrix_backend/schemas/line.py b/backend/idfm_matrix_backend/schemas/line.py new file mode 100644 index 0000000..74f6369 --- /dev/null +++ b/backend/idfm_matrix_backend/schemas/line.py @@ -0,0 +1,60 @@ +from enum import StrEnum +from typing import Self + +from pydantic import BaseModel + +from ..idfm_interface import ( + IdfmLineState, + IdfmState, + TransportMode as IdfmTransportMode, + TransportSubMode as IdfmTransportSubMode, +) + + +class TransportMode(StrEnum): + """Computed transport mode from + idfm_interface.TransportMode and idfm_interface.TransportSubMode. + """ + + bus = "bus" + tram = "tram" + metro = "metro" + funicular = "funicular" + # idfm_types.TransportMode.rail + idfm_types.TransportSubMode.regionalRail + rail_ter = "ter" + # idfm_types.TransportMode.rail + idfm_types.TransportSubMode.local + rail_rer = "rer" + # idfm_types.TransportMode.rail + idfm_types.TransportSubMode.suburbanRailway + rail_transilien = "transilien" + # idfm_types.TransportMode.rail + idfm_types.TransportSubMode.railShuttle + val = "val" + + @classmethod + def from_idfm_transport_mode( + cls, mode: IdfmTransportMode, sub_mode: IdfmTransportSubMode + ) -> Self: + if mode == IdfmTransportMode.rail: + if sub_mode == IdfmTransportSubMode.regionalRail: + return cls.rail_ter + if sub_mode == IdfmTransportSubMode.local: + return cls.rail_rer + if sub_mode == IdfmTransportSubMode.suburbanRailway: + return cls.rail_transilien + if sub_mode == IdfmTransportSubMode.railShuttle: + return cls.val + return TransportMode(mode) + + +class Line(BaseModel): + id: str + shortName: str + name: str + status: IdfmLineState + transportMode: TransportMode + backColorHexa: str + foreColorHexa: str + operatorId: str + accessibility: IdfmState + visualSignsAvailable: IdfmState + audibleSignsAvailable: IdfmState + stopIds: list[str] diff --git a/backend/idfm_matrix_backend/schemas/next_passage.py b/backend/idfm_matrix_backend/schemas/next_passage.py new file mode 100644 index 0000000..4bf32a9 --- /dev/null +++ b/backend/idfm_matrix_backend/schemas/next_passage.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel + +from ..idfm_interface.idfm_types import TrainStatus + + +class NextPassage(BaseModel): + line: str + operator: str + destinations: list[str] + atStop: bool + aimedArrivalTs: None | int + expectedArrivalTs: None | int + arrivalPlatformName: None | str + aimedDepartTs: None | int + expectedDepartTs: None | int + arrivalStatus: TrainStatus + departStatus: TrainStatus + + +class NextPassages(BaseModel): + ts: int + passages: dict[str, dict[str, list[NextPassage]]] diff --git a/backend/idfm_matrix_backend/schemas/stop.py b/backend/idfm_matrix_backend/schemas/stop.py new file mode 100644 index 0000000..f239223 --- /dev/null +++ b/backend/idfm_matrix_backend/schemas/stop.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel + +from ..idfm_interface import IdfmLineState, IdfmState, StopAreaType, TransportMode + + +class Stop(BaseModel): + id: int + name: str + town: str + lat: float + lon: float + # xepsg2154: int + # yepsg2154: int + lines: list[str] + + +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] diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..f9e58a1 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,208 @@ +from collections import defaultdict +from datetime import datetime +from os import environ + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles +from rich import print + +from idfm_matrix_backend.db import db +from idfm_matrix_backend.idfm_interface import IdfmInterface +from idfm_matrix_backend.models import Line, Stop, StopArea +from idfm_matrix_backend.schemas import ( + Line as LineSchema, + TransportMode, + NextPassage as NextPassageSchema, + NextPassages as NextPassagesSchema, + Stop as StopSchema, + StopArea as StopAreaSchema, +) + +API_KEY = environ.get("API_KEY") +# TODO: Add error message if no key is given. + +# TODO: Remove postgresql+asyncpg from environ variable +DB_PATH = "postgresql+asyncpg://postgres:postgres@127.0.0.1:5438/idfm" + + +app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "https://localhost:4443", + "https://localhost:3000", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +idfm_interface = IdfmInterface(API_KEY, db) + + +@app.on_event("startup") +async def startup(): + # await db.connect(DB_PATH, clear_static_data=True) + # await idfm_interface.startup() + await db.connect(DB_PATH, clear_static_data=False) + print("Connected") + + +@app.on_event("shutdown") +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") + + +def optional_datetime_to_ts(dt: datetime) -> int | None: + return dt.timestamp() if dt else None + + +@app.get("/line/{line_id}", response_model=LineSchema) +async def get_line(line_id: str) -> JSONResponse: + line: Line | None = await Line.get_by_id(line_id) + + if line is None: + raise HTTPException(status_code=404, detail=f'Line "{line_id}" not found') + + return LineSchema( + id=line.id, + shortName=line.short_name, + name=line.name, + status=line.status, + transportMode=TransportMode.from_idfm_transport_mode( + line.transport_mode, line.transport_submode + ), + backColorHexa=line.colour_web_hexa, + foreColorHexa=line.text_colour_hexa, + operatorId=line.operator_id, + accessibility=line.accessibility, + visualSignsAvailable=line.visual_signs_available, + audibleSignsAvailable=line.audible_signs_available, + stopIds=[stop.id for stop in line.stops], + ) + + +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, + lines=[line.id for line in stop.lines], + ) + +# châtelet + +@app.get("/stop/") +async def get_stop( + name: str = "", limit: int = 10 +) -> list[StopAreaSchema | StopSchema]: + # TODO: Add limit support + + formatted = [] + matching_stops = await Stop.get_by_name(name) + # print(matching_stops, flush=True) + + stop_areas: dict[int, StopArea] = {} + stops: dict[int, Stop] = {} + for stop in matching_stops: + # print(f"{stop.__dict__ = }", flush=True) + dst = stop_areas if isinstance(stop, StopArea) else stops + dst[stop.id] = stop + + for stop_area in stop_areas.values(): + + formatted_stops = [] + for stop in stop_area.stops: + formatted_stops.append(_format_stop(stop)) + try: + del stops[stop.id] + except KeyError as err: + print(err) + + formatted.append( + StopAreaSchema( + 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, + ) + ) + + # print(f"{stops = }", flush=True) + formatted.extend(_format_stop(stop) for stop in stops.values()) + + return formatted + + +# TODO: Cache response for 30 secs ? +@app.get("/stop/nextPassages/{stop_id}") +async def get_next_passages(stop_id: str) -> JSONResponse: + res = await idfm_interface.get_next_passages(stop_id) + + # print(res) + + service_delivery = res.Siri.ServiceDelivery + stop_monitoring_deliveries = service_delivery.StopMonitoringDelivery + + by_line_by_dst_passages = defaultdict(lambda: defaultdict(list)) + + for delivery in stop_monitoring_deliveries: + for stop_visit in delivery.MonitoredStopVisit: + + journey = stop_visit.MonitoredVehicleJourney + + # re.match will return None if the given journey.LineRef.value is not valid. + try: + line_id = IdfmInterface.LINE_RE.match(journey.LineRef.value).group(1) + except AttributeError as exc: + raise HTTPException( + status_code=404, detail=f'Line "{journey.LineRef.value}" not found' + ) from exc + + call = journey.MonitoredCall + + dst_names = call.DestinationDisplay + dsts = [dst.value for dst in dst_names] if dst_names else [] + + print(f"{call.ArrivalPlatformName = }") + + next_passage = NextPassageSchema( + line=line_id, + operator=journey.OperatorRef.value, + destinations=dsts, + 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, + aimedDepartTs=optional_datetime_to_ts(call.AimedDepartureTime), + expectedDepartTs=optional_datetime_to_ts(call.ExpectedDepartureTime), + arrivalStatus=call.ArrivalStatus.value, + departStatus=call.DepartureStatus.value, + ) + + by_line_passages = by_line_by_dst_passages[line_id] + # TODO: by_line_passages[dst].extend(dsts) instead ? + for dst in dsts: + by_line_passages[dst].append(next_passage) + + return NextPassagesSchema( + ts=service_delivery.ResponseTimestamp.timestamp(), + passages=by_line_by_dst_passages, + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..06dcbcf --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,58 @@ +[tool.poetry] +name = "idfm-matrix-widget" +version = "0.1.0" +description = "" +authors = ["Adrien SUEUR "] +readme = "README.md" +packages = [{include = "idfm_matrix_backend"}] + +[tool.poetry.dependencies] +python = "^3.11" +aiohttp = "^3.8.3" +rich = "^12.6.0" +aiofiles = "^22.1.0" +sqlalchemy = {extras = ["asyncio"], version = "^1.4.46"} +fastapi = "^0.88.0" +uvicorn = "^0.20.0" +asyncpg = "^0.27.0" +msgspec = "^0.12.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.dev-dependencies] +mypy = "^0.971" +pylsp-mypy = "^0.6.2" +autopep8 = "^1.7.0" +mccabe = "^0.7.0" +pycodestyle = "^2.9.1" +pydocstyle = "^6.1.1" +pyflakes = "^2.5.0" +pylint = "^2.14.5" +rope = "^1.3.0" +python-lsp-server = {extras = ["yapf"], version = "^1.5.0"} +python-lsp-black = "^1.2.1" +black = "^22.10.0" +whatthepatch = "^1.0.2" + +[tool.poetry.group.dev.dependencies] +types-aiofiles = "^22.1.0.2" +sqlalchemy-stubs = "^0.4" +wrapt = "^1.14.1" +pydocstyle = "^6.2.2" +pylint = "^2.15.9" +dill = "^0.3.6" + +[tool.pylsp-mypy] +enabled = true + +[mypy] +plugins = "sqlmypy" + +[pycodestyle] +max_line_length = 100 + +[pylint] +max-line-length = 100 + diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js new file mode 100644 index 0000000..b54d687 --- /dev/null +++ b/frontend/.eslintrc.js @@ -0,0 +1,23 @@ +module.exports = { + "env": { + "node": true, + "browser": true, + "es2021": true + }, + "parser": "@typescript-eslint/parser", + "extends": [ + "eslint:recommended", + "plugin:solid/typescript", + ], + "overrides": [ + ], + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": [ + "solid" + ], + "rules": { + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..16acd49 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +package-lock.json diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..434f7bb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,34 @@ +## Usage + +Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`. + +This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template. + +```bash +$ npm install # or pnpm install or yarn install +``` + +### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) + +## Available Scripts + +In the project directory, you can run: + +### `npm dev` or `npm start` + +Runs the app in the development mode.
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.
+ +### `npm run build` + +Builds the app for production to the `dist` folder.
+It correctly bundles Solid in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
+Your app is ready to be deployed! + +## Deployment + +You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9c6d652 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + Métro-Boulot-Dodo + + + +
+ + + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..71f07ee --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "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": { + "@vitejs/plugin-basic-ssl": "^1.0.1", + "eslint": "^8.32.0", + "eslint-plugin-solid": "^0.9.3", + "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", + "@stitches/core": "^1.2.8", + "date-fns": "^2.29.3", + "leaflet": "^1.9.3", + "matrix-widget-api": "^1.1.1", + "solid-js": "^1.6.6", + "solid-transition-group": "^0.0.10" + } +} diff --git a/frontend/public/Trafic_fluide_RVB.svg b/frontend/public/Trafic_fluide_RVB.svg new file mode 100644 index 0000000..1d33d7e --- /dev/null +++ b/frontend/public/Trafic_fluide_RVB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Trafic_perturbe_RVB.svg b/frontend/public/Trafic_perturbe_RVB.svg new file mode 100644 index 0000000..4243685 --- /dev/null +++ b/frontend/public/Trafic_perturbe_RVB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Trafic_tres_perturbe_RVB.svg b/frontend/public/Trafic_tres_perturbe_RVB.svg new file mode 100644 index 0000000..e085c30 --- /dev/null +++ b/frontend/public/Trafic_tres_perturbe_RVB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/fonts/IDFVoyageur-Bold.otf b/frontend/public/fonts/IDFVoyageur-Bold.otf new file mode 100644 index 0000000..f9cdb70 Binary files /dev/null and b/frontend/public/fonts/IDFVoyageur-Bold.otf differ diff --git a/frontend/public/fonts/IDFVoyageur-Medium.otf b/frontend/public/fonts/IDFVoyageur-Medium.otf new file mode 100644 index 0000000..18d7f31 Binary files /dev/null and b/frontend/public/fonts/IDFVoyageur-Medium.otf differ diff --git a/frontend/public/fonts/IDFVoyageur-Regular.otf b/frontend/public/fonts/IDFVoyageur-Regular.otf new file mode 100644 index 0000000..0b610c4 Binary files /dev/null and b/frontend/public/fonts/IDFVoyageur-Regular.otf differ diff --git a/frontend/public/symbole_bus_RVB.svg b/frontend/public/symbole_bus_RVB.svg new file mode 100644 index 0000000..caefdda --- /dev/null +++ b/frontend/public/symbole_bus_RVB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/symbole_bus_support_fonce_RVB.svg b/frontend/public/symbole_bus_support_fonce_RVB.svg new file mode 100644 index 0000000..ac3886a --- /dev/null +++ b/frontend/public/symbole_bus_support_fonce_RVB.svg @@ -0,0 +1,23 @@ + + + + + + + diff --git a/frontend/public/symbole_cable_RVB.svg b/frontend/public/symbole_cable_RVB.svg new file mode 100644 index 0000000..f9c4642 --- /dev/null +++ b/frontend/public/symbole_cable_RVB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/symbole_cable_support_fonce_RVB.svg b/frontend/public/symbole_cable_support_fonce_RVB.svg new file mode 100644 index 0000000..b79a620 --- /dev/null +++ b/frontend/public/symbole_cable_support_fonce_RVB.svg @@ -0,0 +1,23 @@ + + + + + + + diff --git a/frontend/public/symbole_funicular_RVB.svg b/frontend/public/symbole_funicular_RVB.svg new file mode 120000 index 0000000..825b34e --- /dev/null +++ b/frontend/public/symbole_funicular_RVB.svg @@ -0,0 +1 @@ +symbole_metro_RVB.svg \ No newline at end of file diff --git a/frontend/public/symbole_funicular_support_fonce_RVB.svg b/frontend/public/symbole_funicular_support_fonce_RVB.svg new file mode 120000 index 0000000..d8caaa2 --- /dev/null +++ b/frontend/public/symbole_funicular_support_fonce_RVB.svg @@ -0,0 +1 @@ +symbole_metro_support_fonce_RVB.svg \ No newline at end of file diff --git a/frontend/public/symbole_metro_RVB.svg b/frontend/public/symbole_metro_RVB.svg new file mode 100644 index 0000000..e47a681 --- /dev/null +++ b/frontend/public/symbole_metro_RVB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/symbole_metro_support_fonce_RVB.svg b/frontend/public/symbole_metro_support_fonce_RVB.svg new file mode 100644 index 0000000..ffdedd5 --- /dev/null +++ b/frontend/public/symbole_metro_support_fonce_RVB.svg @@ -0,0 +1,14 @@ + + + + + + + diff --git a/frontend/public/symbole_navette_fluviale_RVB.svg b/frontend/public/symbole_navette_fluviale_RVB.svg new file mode 100644 index 0000000..0998f18 --- /dev/null +++ b/frontend/public/symbole_navette_fluviale_RVB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/symbole_rer_RVB.svg b/frontend/public/symbole_rer_RVB.svg new file mode 100644 index 0000000..9e78172 --- /dev/null +++ b/frontend/public/symbole_rer_RVB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/symbole_rer_support_fonce_RVB.svg b/frontend/public/symbole_rer_support_fonce_RVB.svg new file mode 100644 index 0000000..8dc8ab7 --- /dev/null +++ b/frontend/public/symbole_rer_support_fonce_RVB.svg @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/frontend/public/symbole_rer_velo_RVB.svg b/frontend/public/symbole_rer_velo_RVB.svg new file mode 100644 index 0000000..2b462a9 --- /dev/null +++ b/frontend/public/symbole_rer_velo_RVB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/symbole_ter_RVB.svg b/frontend/public/symbole_ter_RVB.svg new file mode 120000 index 0000000..ee316e9 --- /dev/null +++ b/frontend/public/symbole_ter_RVB.svg @@ -0,0 +1 @@ +symbole_transilien_RVB.svg \ No newline at end of file diff --git a/frontend/public/symbole_ter_support_fonce_RVB.svg b/frontend/public/symbole_ter_support_fonce_RVB.svg new file mode 120000 index 0000000..36f00bc --- /dev/null +++ b/frontend/public/symbole_ter_support_fonce_RVB.svg @@ -0,0 +1 @@ +symbole_transilien_support_fonce_RVB.svg \ No newline at end of file diff --git a/frontend/public/symbole_train_RER_RVB.svg b/frontend/public/symbole_train_RER_RVB.svg new file mode 100644 index 0000000..7aef724 --- /dev/null +++ b/frontend/public/symbole_train_RER_RVB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/symbole_train_RER_support_fonce_RVB.svg b/frontend/public/symbole_train_RER_support_fonce_RVB.svg new file mode 100644 index 0000000..b740d6f --- /dev/null +++ b/frontend/public/symbole_train_RER_support_fonce_RVB.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + diff --git a/frontend/public/symbole_tram_RVB.svg b/frontend/public/symbole_tram_RVB.svg new file mode 100644 index 0000000..abe30fe --- /dev/null +++ b/frontend/public/symbole_tram_RVB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/symbole_tram_support_fonce_RVB.svg b/frontend/public/symbole_tram_support_fonce_RVB.svg new file mode 100644 index 0000000..6e5d966 --- /dev/null +++ b/frontend/public/symbole_tram_support_fonce_RVB.svg @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/frontend/public/symbole_transilien_RVB.svg b/frontend/public/symbole_transilien_RVB.svg new file mode 100644 index 0000000..bd00ac1 --- /dev/null +++ b/frontend/public/symbole_transilien_RVB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/symbole_transilien_support_fonce_RVB.svg b/frontend/public/symbole_transilien_support_fonce_RVB.svg new file mode 100644 index 0000000..0222c7b --- /dev/null +++ b/frontend/public/symbole_transilien_support_fonce_RVB.svg @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css new file mode 100644 index 0000000..a606582 --- /dev/null +++ b/frontend/src/App.module.css @@ -0,0 +1,21 @@ +.App { + --idfm-black: #2c2e35; + --idfm-white: #ffffff; + + height: inherit; + width: inherit; + + scroll-snap-type: x mandatory; + overflow-x: scroll; + + display: flex; + text-align: center; +} + +.panel { + min-width: 100%; + height: inherit; + width: inherit; + + scroll-snap-align: center; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..c3083b3 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,65 @@ +import { Component } from 'solid-js'; +import { MatrixCapabilities, WidgetApi, WidgetApiToWidgetAction, CustomEvent, IVisibilityActionRequest } from 'matrix-widget-api'; + +import { HopeProvider } from "@hope-ui/solid"; + +import { BusinessDataProvider } from './businessData'; + +import { SearchProvider } from './search'; +import { NextPassagesDisplay } from './nextPassagesDisplay'; +import { StopsManager } from './stopsManager'; + +import styles from './App.module.css'; + + +function parseFragment() { + const fragmentString = (window.location.hash || "?"); + return new URLSearchParams(fragmentString.substring(Math.max(fragmentString.indexOf('?'), 0))); +} + +const App: Component = () => { + + console.log('App: New'); + + const qs = parseFragment(); + const widgetId = qs.get('widgetId'); + const userId = qs.get('userId'); + + console.log("App: widgetId:" + widgetId); + console.log("App: userId:" + userId); + + const api = new WidgetApi(widgetId); + api.requestCapability(MatrixCapabilities.AlwaysOnScreen); + api.start(); + api.on("ready", function() { + console.log("App: widget API is READY !!!!"); + }); + + // Seems to don´t be used... + api.on(`action:${WidgetApiToWidgetAction.UpdateVisibility}`, (ev: CustomEvent) => { + console.log("App: Visibility change"); + ev.preventDefault(); // we're handling it, so stop the widget API from doing something. + console.log("App: ", ev.detail); // custom handling here + /* api.transport.reply(ev.detail, {}); */ + api.transport.reply(ev.detail, {}); + }); + + return ( + + + +
+
+ +
+
+ +
+
+
+
+
+ ); +}; + +export default App; diff --git a/frontend/src/assets/favicon.ico b/frontend/src/assets/favicon.ico new file mode 100644 index 0000000..b836b2b Binary files /dev/null and b/frontend/src/assets/favicon.ico differ diff --git a/frontend/src/businessData.tsx b/frontend/src/businessData.tsx new file mode 100644 index 0000000..8e6e617 --- /dev/null +++ b/frontend/src/businessData.tsx @@ -0,0 +1,79 @@ +import { createContext, createSignal } from 'solid-js'; +import { createStore } from 'solid-js/store'; + +import { Passages, Stops } from './types'; + + +interface Store { + passages: () => Passages; + getLinePassages?: (lineId: string) => Passages; + addPassages?: (passages) => void; + clearPassages?: () => void; + + stops: () => Stops; + addStops?: (stops) => void; +}; + +export const BusinessDataContext = createContext(); + +export function BusinessDataProvider(props: { children: JSX.Element }) { + + const [serverUrl, setServerUrl] = createSignal("https://localhost:4443"); + + const [store, setStore] = createStore({ lines: {}, passages: {}, stops: {} }); + + async function getLine(lineId: number) { + let line = store.lines[lineId]; + if (line === undefined) { + console.log(`${lineId} not found... fetch it from backend.`); + const data = await fetch(`${serverUrl()}/line/${lineId}`, { + headers: { 'Content-Type': 'application/json' } + }); + line = await data.json(); + setStore('lines', lineId, line); + } + return line; + } + + const passages = () => { + return store.passages; + }; + + const getLinePassages = (lineId: string) => { + return store.passages[lineId]; + }; + + const addPassages = (passages) => { + setStore((s) => { + // console.log("s=", s); + setStore('passages', passages); + // console.log("s=", s); + }); + } + + const clearPassages = () => { + setStore((s) => { + // TODO: Really need to set to undefined to reset ? + console.log("s=", s); + console.log("s.passages=", s.passages); + // setStore('passages', undefined); + // setStore('passages', {}); + console.log("Object.keys(s.passages)=", Object.keys(s.passages)); + for (const lineId of Object.keys(s.passages)) { + console.log("lineId=", lineId); + setStore('passages', lineId, undefined); + } + console.log("s=", s); + }); + // setStore('passages', undefined); + // setStore('passages', {}); + // } + console.log("passages=", store.passages); + } + + return ( + + {props.children} + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..5bfd736 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,28 @@ +@font-face { + font-family: IDFVoyageur-regular; + src: url(/public/fonts/IDFVoyageur-Regular.otf) +} + +@font-face { + font-family: IDFVoyageur-bold; + src: url(/public/fonts/IDFVoyageur-Bold.otf); +} + +@font-face { + font-family: IDFVoyageur-medium; + src: url(/public/fonts/IDFVoyageur-Medium.otf); +} + +body { + aspect-ratio: 16/9; + width: 100vw; + + margin: 0; + + font-family: IDFVoyageur; +} + +#root { + height: inherit; + width: inherit; +} diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx new file mode 100644 index 0000000..3fe2a6a --- /dev/null +++ b/frontend/src/index.tsx @@ -0,0 +1,7 @@ +/* @refresh reload */ +import { render } from 'solid-js/web'; + +import './index.css'; +import App from './App'; + +render(() => (), document.getElementById('root') as HTMLElement); diff --git a/frontend/src/nextPassagesDisplay.module.css b/frontend/src/nextPassagesDisplay.module.css new file mode 100644 index 0000000..b24889f --- /dev/null +++ b/frontend/src/nextPassagesDisplay.module.css @@ -0,0 +1,226 @@ + +/* TODO: Remove this class */ +.ar16x9 { + aspect-ratio: 16 / 9; +} + +/* Idfm: 1860x1080px */ +.NextPassagesDisplay { + aspect-ratio: 16/9; + --reverse-aspect-ratio: 9/16; + /* height is set according to the aspect-ratio, don´t touch it */ + width: 100%; + + display: flex; + flex-direction: column; + + background-color: var(--idfm-black); +} + +/* Idfm: 1800x100px (margin: 17px 60px) */ +.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 */ + /* cf. https://developer.mozilla.org/en-US/docs/Web/CSS/margin-bottom */ + margin: calc(17/1080*var(--reverse-aspect-ratio)*100%) calc(60/1920*100%); + + display: flex; + align-items: center; + + font-family: IDFVoyageur-bold; +} + +.header .transportMode { + height: 100%; + margin: 0; + margin-right: calc(23/1920*100%); +} + +.header .title { + height: 50%; + width: 70%; + + margin-right: auto; +} + +.header .clock { + width: calc(175/1920*100%); + height: calc(80/100*100%); + + display: flex; + align-items: center; + justify-content: center; + + border:solid var(--idfm-white) 3px; + border-radius: calc(9/86*100%); +} + +.header .clock svg { + aspect-ratio: 2.45; + height: calc(0.7*100%); +} + +/* Idfm: 1860x892px (margin: 0px 30px) */ +.panelsContainer { + width: calc(1860/1920*100%); + height: calc(892/1080*100%); + margin: 0 calc(30/1920*100%); + + display: flex; + flex-direction: column; + + background-color: white; + + border-collapse:separate; + border:solid var(--idfm-black) 1px; + border-radius: calc(15/1920*100%); +} + +.nextPassagesContainer { + height: 100%; + width: 100%; + + display: none; + + position: relative; +} + +.nextPassagesContainer .line:last-child { + border-bottom: 0; + /* To make up for the bottom border deletion */ + padding-bottom: calc(2px); +} + +.displayed { + display: block; +} + + +/* Idfm: 1880x176px (margin: 0px 20px) */ +.line { + width: calc(1880/1920*100%); + height: calc(100% / 5); + margin: 0 calc(20/1920*100%); + + display: flex; + align-items: center; + + /* TODO: compute the border weight according to the parent height */ + /* TODO: Disable border-bottom for the last .line */ + border-bottom: solid calc(2px); +} + +.line svg { + font-family: IDFVoyageur-bold; + max-width: 100%; + max-height: 100%; +} + +/* Idfm: 100x100px (margin: 0px 15px) */ +.transportMode { + aspect-ratio : 1 / 1; + height: calc(100/176*100%); + margin: 0 calc(15/1920*100%); +} + +.tramLinePicto { + aspect-ratio : 1 / 1; + height: calc(100/176*100%); + margin-right: calc(23/1920*100%); +} + +.busLinePicto { + aspect-ratio : 2.25; + height: calc(70/176*100%); + margin-right: calc(23/1920*100%); +} + +.destination { + height: calc(60/176*100%); + width: 50%; + + font-family: IDFVoyageur-bold; + text-align: left; +} + + +.trafficStatus { + height: calc(50/176*100%); + aspect-ratio: 35/50; + margin-left: auto; + + display: flex; + align-items: center; + justify-content: center; +} + +.trafficStatus svg { + width: 100%; +} + +.firstPassage { + height: calc(100/176*100%); + aspect-ratio: 2.5; + + display: flex; + align-items: center; + justify-content: center; + + padding-right: calc(30/1920*100%); + + /* TODO: compute the border weight according to the parent width */ + border-right: solid calc(5px); +} + +.unavailableFirstPassage { + height: calc(100/176*100%); + aspect-ratio: calc(230/100); + margin-right: calc(30/1920*100%); + + /* TODO: compute the border weight according to the parent width */ + border-right: solid calc(5px); +} + +.firstPassage svg { + aspect-ratio: 215/50; + height: calc(1/2*100%); +} + +.secondPassage { + height: calc(45/176*100%); + aspect-ratio: calc(230/45); + margin-right: calc(30/1920*100%); +} + +.secondPassage svg { + font-family: IDFVoyageur-regular; +} + +.unavailableSecondPassage { + height: calc(100/176*100%); + aspect-ratio: calc(230/100); + margin-right: calc(30/1920*100%); +} + +.unavailableSecondNextPassage svg { + font-family: IDFVoyageur-regular; +} + +/* Idfm: 1800x54px (margin: 0px 50px) */ +.footer { + width: calc(1820/1920*100%); + height: calc(54/1080*100%); + margin: 0 calc(50/1920*100%); + + display: flex; + align-items: center; + justify-content: right; +} + +.footer div { + aspect-ratio: 1; + height: 50%; + + margin-left: calc(42/1920*100%); +} diff --git a/frontend/src/nextPassagesDisplay.tsx b/frontend/src/nextPassagesDisplay.tsx new file mode 100644 index 0000000..4706dc1 --- /dev/null +++ b/frontend/src/nextPassagesDisplay.tsx @@ -0,0 +1,253 @@ +import { Component, createEffect, createSignal, useContext } from "solid-js"; +import { createStore } from "solid-js/store"; +import { createDateNow } from "@solid-primitives/date"; +import { format } from "date-fns"; + +import { getTransportModeSrc } from "./types"; +import { BusinessDataContext } from "./businessData"; +import { NextPassagesPanel } from "./nextPassagesPanel"; + +import { SearchContext } from "./search"; + +import styles from "./nextPassagesDisplay.module.css"; + + +export const NextPassagesDisplay: Component = () => { + const maxPassagePerPanel = 5; + const syncPeriodMsec = 20 * 1000; + + const { passages, getLinePassages, addPassages, clearPassages, serverUrl } = + useContext(BusinessDataContext); + const { getDisplayedStop } = useContext(SearchContext); + + const [panels, setPanels] = createStore([]); + const [displayedPanelId, setDisplayedPanelId] = createSignal(0); + + let _lines = new Map(); + + const [dateNow] = createDateNow(1000); + + const panelSwapInterval = setInterval(() => { + let nextPanelId = displayedPanelId() + 1; + if (nextPanelId >= panels.length) { + nextPanelId = 0; + } + /* console.log(`Display panel #${nextPanelId}`); */ + setDisplayedPanelId(nextPanelId); + }, 4000); + + createEffect(() => { + console.log("######### onStopIdUpdate #########"); + // Track local.stopIp to force dependency. + console.log("getDisplayedStop=", getDisplayedStop()); + clearPassages(); + }); + + createEffect(async () => { + console.log(`## OnPassageUpdate ${passages()} ##`); + /* console.log(passages()); */ + await requestPassages(); + }); + + async function _fetchLine(lineId: string) { + if (!_lines.has(lineId)) { + const data = await fetch(`${serverUrl()}/line/${lineId}`, { + headers: { "Content-Type": "application/json" }, + }); + const line = await data.json(); + _lines.set(line.id, line); + } + } + + async function requestPassages() { + console.log("### requestPassages ###"); + /* TODO: Manage several displays (one by stop) */ + const stops = getDisplayedStop(); + if (stops.length == 0) { + return; + } + const stop = stops[0]; + + const httpOptions = { headers: { "Content-Type": "application/json" } }; + if (stop !== undefined) { + const stopId = stop.id; + console.log(`Fetching data for ${stopId}`); + const url = `${serverUrl()}/stop/nextPassages/${stopId}`; + /* console.log(`url=${url}`); */ + const data = await fetch(url, httpOptions); + const response = await data.json(); + /* console.log(response); */ + const byLineByDstPassages = response.passages; + /* console.log(byLineByDstPassages); */ + const linePromises = []; + for (const lineId of Object.keys(byLineByDstPassages)) { + linePromises.push(_fetchLine(lineId)); + } + await Promise.all(linePromises); + console.log("byLineByDstPassages=", byLineByDstPassages); + // console.log("before addPassages passages=", passages()); + addPassages(byLineByDstPassages); + console.log("AFTER passages=", passages()); + } + } + + setInterval( + // const nextPassagesRequestsInterval = setTimeout( + async () => { + await requestPassages(); + }, + syncPeriodMsec + ); + + // TODO: Sort transport modes by weight + // TODO: Split this method to isolate the nextPassagesPanel part. + function _computeHeader(title: string): JSX.Element { + let transportModes = []; + transportModes = new Set( + Object.keys(passages()).map((lineId) => { + const line = _lines.get(lineId); + if (line !== undefined) { + return getTransportModeSrc(line.transportMode, false); + } + return null; + }) + ); + return ( +
+ + {(transportMode) => { + return ( +
+ +
+ ); + }} +
+
+ + + {title} + + +
+
+ + + {format(dateNow(), "HH:mm")} + + +
+
+ ); + } + + function _computeFooter(): JSX.Element { + return ( +
+ + {(positioned) => { + const { position, panel } = positioned; + const circleStyle = { + fill: `var(--idfm-${position == displayedPanelId() ? "white" : "black" + })`, + }; + return ( +
+ + + +
+ ); + }} +
+
+ ); + } + + const mainDivClasses = `${styles.NextPassagesDisplay} ${styles.ar16x9}`; + return ( +
+ {_computeHeader("Prochains passages")} +
+ {() => { + setPanels([]); + + let newPanels = []; + let positioneds = []; + let index = 0; + + let chunk = {}; + let chunkSize = 0; + + console.log("passages=", passages()); + for (const lineId of Object.keys(passages())) { + console.log("lineId=", lineId); + const byLinePassages = getLinePassages(lineId); + console.log("byLinePassages=", byLinePassages); + const byLinePassagesKeys = Object.keys(byLinePassages); + console.log("byLinePassagesKeys=", byLinePassagesKeys); + + if (byLinePassagesKeys.length <= maxPassagePerPanel - chunkSize) { + chunk[lineId] = byLinePassages; + chunkSize += byLinePassagesKeys.length; + } else { + console.log("chunk=", chunk); + const [store, setStore] = createStore(chunk); + const panelid = index++; + const panel = ( + + ); + newPanels.push(panel); + positioneds.push({ position: panelid, panel }); + + chunk = {}; + chunk[lineId] = byLinePassages; + chunkSize = byLinePassagesKeys.length; + } + } + if (chunkSize) { + const panelId = index++; + const [store, setStore] = createStore(chunk); + const panel = ( + + ); + newPanels.push(panel); + positioneds.push({ position: panelId, panel }); + } + + setPanels(positioneds); + return newPanels; + }} +
+ {_computeFooter()} +
+ ); +}; diff --git a/frontend/src/nextPassagesPanel.tsx b/frontend/src/nextPassagesPanel.tsx new file mode 100644 index 0000000..7da6798 --- /dev/null +++ b/frontend/src/nextPassagesPanel.tsx @@ -0,0 +1,121 @@ +import { Component } from 'solid-js'; +import { createStore } from 'solid-js/store'; +import { createDateNow, getTime } from '@solid-primitives/date'; +import { Motion } from "@motionone/solid"; + +import { TrafficStatus } from './types'; +import { renderLineTransportMode, renderLinePicto } from './utils'; + +import styles from './nextPassagesDisplay.module.css'; + + +export const NextPassagesPanel: Component = (props) => { + + /* TODO: Find where to get data to compute traffic status. */ + const trafficStatusColor = new Map([ + [TrafficStatus.UNKNOWN, "#ffffff"], + [TrafficStatus.FLUID, "#00643c"], + [TrafficStatus.DISRUPTED, "#ffbe00"], + [TrafficStatus.VERY_DISRUPTED, "#ff5a00"], + [TrafficStatus.BYPASSED, "#ffffff"] + ]); + + const [dateNow] = createDateNow(5000); + + function _computeTtwPassage(class_, passage, fontSize) { + const refTs = passage.expectedDepartTs !== null ? passage.expectedDepartTs : passage.expectedArrivalTs; + const ttwSec = refTs - (getTime(dateNow()) / 1000); + const isApproaching = ttwSec <= 60; + return ( +
+ + + {Math.floor(ttwSec / 60)} min + + +
+ ); + } + + function _computeUnavailablePassage(class_) { + const textStyle = { fill: "#000000" }; + return ( +
+ + Information + non + disponible + +
+ ); + } + + function _computeSecondPassage(passage): JSX.Element { + return ( + + {_computeTtwPassage(styles.secondPassage, passage, 45)} + + ); + } + + function _computeFirstPassage(passage): JSX.Element { + return ( + + {_computeTtwPassage(styles.firstPassage, passage, 50)} + + ); + } + + /* TODO: Manage end of service */ + function _genNextPassages(nextPassages, line, destination) { + const nextPassagesLength = nextPassages.length; + const firstPassage = nextPassagesLength > 0 ? nextPassages[0] : undefined; + const secondPassage = nextPassagesLength > 1 ? nextPassages[1] : undefined; + const trafficStatusStyle = { fill: trafficStatusColor.get(line.trafficStatus) }; + return ( +
+
+ {renderLineTransportMode(line)} +
+ {renderLinePicto(line, styles)} +
+ + + {destination} + + +
+
+ + + +
+ {firstPassage ? _computeFirstPassage(firstPassage) : null} + {secondPassage ? _computeSecondPassage(secondPassage) : null} +
+ ); + } + + return ( +
+ {() => { + const ret = []; + for (const lineId of Object.keys(props.nextPassages)) { + const line = props.lines.get(lineId); + const byLineNextPassages = props.nextPassages[lineId]; + for (const destination of Object.keys(byLineNextPassages)) { + const nextPassages = byLineNextPassages[destination]; + ret.push(_genNextPassages(nextPassages, line, destination)); + } + } + return ret; + }} +
+ ); +} diff --git a/frontend/src/search.tsx b/frontend/src/search.tsx new file mode 100644 index 0000000..c9e2163 --- /dev/null +++ b/frontend/src/search.tsx @@ -0,0 +1,75 @@ +import { batch, createContext, createSignal } from 'solid-js'; +import { createStore } from 'solid-js/store'; + +import { Stop, Stops } from './types'; + + +interface Store { + getMarkers: () => Markers; + addMarkers?: (stopId, markers) => void; + setMarkers?: (markers) => void; + + getStops: () => Stops; + setStops?: (stops) => void; + removeStops?: (stopIds) => void; + + getDisplayedStop: () => Stop; + setDisplayedStop: (stop: Stop) => void; +}; + +export const SearchContext = createContext(); + +export function SearchProvider(props: { children: JSX.Element }) { + + const [store, setStore] = createStore({stops: {}, markers: {}, displayedStop: []}); + + const getStops = () => { + return store.stops; + }; + + const setStops = (stops) => { + setStore((s) => { + setStore('stops', stops); + }); + }; + + const removeStops = (stopIds) => { + batch(() => { + for(const stopId of stopIds) { + setStore('stops', stopId, undefined); + setStore('markers', stopId, undefined); + } + }); + }; + + const getMarkers = () => { + return store.markers; + }; + + const addMarkers = (stopId, markers) => { + setStore('markers', stopId, markers); + }; + + const setMarkers = (markers) => { + setStore('markers', markers); + }; + + const getDisplayedStop = () => { + /* console.log(store.displayedStop); */ + return store.displayedStop; + }; + const setDisplayedStop = (stop: Stop) => { + /* console.log(stop); */ + setStore((s) => { + console.log("s.displayedStop=", s.displayedStop); + setStore('displayedStop', [stop]); + }); + /* console.log(store.displayedStop); */ + }; + + return ( + + {props.children} + + ); +} diff --git a/frontend/src/stopManager.module.css b/frontend/src/stopManager.module.css new file mode 100644 index 0000000..276d6b2 --- /dev/null +++ b/frontend/src/stopManager.module.css @@ -0,0 +1,33 @@ +svg { + font-family: IDFVoyageur-bold; +} + +.transportMode { + aspect-ratio : 1 / 1; + height: 70%; + margin-left: 1%; +} + +.tramLinePicto { + height: 70%; + margin-left: 1%; + aspect-ratio : 1 / 1; +} + +.trainLinePicto { + height: 70%; + margin-left: 1%; + aspect-ratio : 1 / 1; +} + +.metroLinePicto { + height: 70%; + margin-left: 1%; + aspect-ratio : 1 / 1; +} + +.busLinePicto { + height: 70%; + margin-left: 1%; + aspect-ratio : 2.25; +} diff --git a/frontend/src/stopsManager.tsx b/frontend/src/stopsManager.tsx new file mode 100644 index 0000000..db7ee00 --- /dev/null +++ b/frontend/src/stopsManager.tsx @@ -0,0 +1,224 @@ +import { batch, Component, createEffect, createResource, createSignal, onMount, Show, useContext } from 'solid-js'; + +import { + Box, Button, Input, InputLeftAddon, InputGroup, HStack, List, ListItem, Progress, + ProgressIndicator, VStack +} from "@hope-ui/solid"; +import 'leaflet/dist/leaflet.css'; +import L from 'leaflet'; + +import { BusinessDataContext } from './businessData'; +import { SearchContext } from './search'; + +import { renderLineTransportMode, renderLinePicto, TransportModeWeights } from './utils'; + +import styles from './stopManager.module.css'; + + +const StopRepr: Component = (props) => { + + const { getLine } = useContext(BusinessDataContext); + + const [lineReprs] = createResource(props.stop.lines, fetchLinesRepr); + + async function fetchLinesRepr(lineIds) { + const reprs = []; + for (const lineId of lineIds) { + const line = await getLine(lineId); + if (line !== undefined) { + reprs.push(
{renderLineTransportMode(line)}
); + reprs.push(renderLinePicto(line, styles)); + } + } + return reprs; + } + + return ( + + {props.stop.name} + {(line) => line} + + ); +} + +const StopAreaRepr: Component = (props) => { + + const { getLine } = useContext(BusinessDataContext); + + const [lineReprs] = createResource(props.stop, fetchLinesRepr); + + async function fetchLinesRepr(stop) { + const lineIds = new Set(stop.lines); + const stops = stop.stops; + for (const stop of stops) { + stop.lines.forEach(lineIds.add, lineIds); + } + + const byModeReprs = {}; + for (const lineId of lineIds) { + const line = await getLine(lineId); + if (line !== undefined) { + if (!(line.transportMode in byModeReprs)) { + byModeReprs[line.transportMode] = { + mode:
{renderLineTransportMode(line)}
+ }; + } + byModeReprs[line.transportMode][line.shortName] = renderLinePicto(line, styles); + } + } + + const reprs = []; + const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] < TransportModeWeights[y]); + for (const transportMode of sortedTransportModes) { + const lines = byModeReprs[transportMode]; + const repr = [lines.mode]; + delete lines.mode; + for (const lineId of Object.keys(lines).sort((x, y) => x.localeCompare(y))) { + repr.push(lines[lineId]); + } + reprs.push(repr); + } + return reprs; + } + + return ( + + {props.stop.name} + {(line) => line} + + ); +} + + +const Map: Component = (props) => { + + const mapCenter = [48.853, 2.35]; + + const { addMarkers, getStops } = useContext(SearchContext); + + let mapDiv: any; + let map = null; + const stopsLayerGroup = L.featureGroup(); + + function buildMap(div: HTMLDivElement) { + map = L.map(div).setView(mapCenter, 11); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(map); + stopsLayerGroup.addTo(map); + } + + function setMarker(stop) { + const markers = []; + if (stop.lat !== undefined && stop.lon !== undefined) { + /* TODO: Add stop lines representation to popup. */ + markers.push(L.marker([stop.lat, stop.lon]).bindPopup(`${stop.name}`).openPopup()); + } + else { + for (const _stop of stop.stops) { + markers.push(...setMarker(_stop)); + } + } + return markers; + } + + onMount(() => buildMap(mapDiv)); + + const onStopUpdate = createEffect(() => { + /* TODO: Avoid to clear all layers... */ + stopsLayerGroup.clearLayers(); + + for (const stop of Object.values(getStops())) { + const markers = setMarker(stop); + addMarkers(stop.id, markers); + for (const marker of markers) { + stopsLayerGroup.addLayer(marker); + } + } + + const stopsBound = stopsLayerGroup.getBounds(); + if (Object.keys(stopsBound).length) { + map.fitBounds(stopsBound); + } + }); + + return
; +} + +export const StopsManager: Component = (props) => { + + const [minCharactersNb, setMinCharactersNb] = createSignal(4); + const [_inProgress, _setInProgress] = createSignal(false); + + const { serverUrl } = useContext(BusinessDataContext); + const { getStops, removeStops, setStops, setDisplayedStop } = useContext(SearchContext); + + async function _fetchStopByName(name) { + const data = await fetch(`${serverUrl()}/stop/?name=${name}`, { + headers: { 'Content-Type': 'application/json' } + }); + const stops = await data.json(); + const stopIds = stops.map((stop) => stop.id); + + const stopIdsToRemove = Object.keys(getStops()).filter(stopId => !(stopId in stopIds)); + + const byIdStops = {}; + for (const stop of stops) { + byIdStops[stop.id] = stop; + } + + batch(() => { + removeStops(stopIdsToRemove); + setStops(byIdStops); + }); + } + + async function _onStopNameInput(event) { + /* TODO: Add a tempo before fetching stop for giving time to user to finish his request */ + const stopName = event.target.value; + if (stopName.length >= minCharactersNb()) { + console.log(`Fetching data for ${stopName}`); + _setInProgress(true); + await _fetchStopByName(stopName); + _setInProgress(false); + } + } + + return ( + + + 🚉 🚏 + + + + + + + + {() => { + const items = []; + for (const stop of Object.values(getStops()).sort((x, y) => x.name.localeCompare(y.name))) { + items.push( + + + ); + } + return items; + }} + + + + + + + ); +}; diff --git a/frontend/src/types.tsx b/frontend/src/types.tsx new file mode 100644 index 0000000..73607c0 --- /dev/null +++ b/frontend/src/types.tsx @@ -0,0 +1,43 @@ +const validTransportModes = ["bus", "tram", "metro", "rer", "transilien", "funicular", "ter", "unknown"]; + +export function getTransportModeSrc(mode: string, color: bool = true): string { + let ret = null; + if (validTransportModes.includes(mode)) { + ret = `/public/symbole_${mode}_${color ? "" : "support_fonce_"}RVB.svg`; + } + return ret; +} + +export enum TrafficStatus { + UNKNOWN = 0, + FLUID, + DISRUPTED, + VERY_DISRUPTED, + BYPASSED +} + +export interface Passages { }; +export interface Passage { + line: number, + operator: string, + destinations: Array, + atStop: boolean, + aimedArrivalTs: number, + expectedArrivalTs: number, + arrivalPlatformName: string, + aimedDepartTs: number, + expectedDepartTs: number, + arrivalStatus: string, + departStatus: string, +}; + + +export interface Stops { }; +export interface Stop { + id: number, + name: string, + town: string, + lat: number, + lon: number, + lines: Array +}; diff --git a/frontend/src/utils.tsx b/frontend/src/utils.tsx new file mode 100644 index 0000000..64b9558 --- /dev/null +++ b/frontend/src/utils.tsx @@ -0,0 +1,106 @@ +import { getTransportModeSrc } from './types'; + +export const TransportModeWeights = { + bus: 1, + tram: 2, + val: 3, + funicular: 4, + metro: 5, + rer: 6, + transilien: 7, + ter: 8, +}; + +export function renderLineTransportMode(line): JSX.Element { + return +} + +function renderBusLinePicto(line, styles): JSX.Element { + return ( +
+ + + + {line.shortName} + + +
+ ); +} + +function renderTramLinePicto(line, styles): JSX.Element { + const lineStyle = { fill: `#${line.backColorHexa}` }; + return ( +
+ + + + + {line.shortName} + + +
+ ); +} + +function renderMetroLinePicto(line, styles): JSX.Element { + return ( +
+ + + + {line.shortName} + + +
+ ); +} + +function renderTrainLinePicto(line, styles): JSX.Element { + return ( +
+ + + + {line.shortName} + + +
+ ); +} + +export function renderLinePicto(line, styles): JSX.Element { + switch (line.transportMode) { + case "bus": + case "funicular": + return renderBusLinePicto(line, styles); + case "tram": + return renderTramLinePicto(line, styles); + /* case "val": */ + case "metro": + return renderMetroLinePicto(line, styles); + case "transilien": + case "rer": + case "ter": + return renderTrainLinePicto(line, styles); + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..8c967df --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js", + "noImplicitAny": true, + "target": "ES6", + "moduleResolution": "node", + "allowJs": true, + "outDir": "build", + "strict": true, + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true, + "plugins": [ + { + "name": "typescript-eslint-language-service" + } + ] + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..7d44c05 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite'; +import solidPlugin from 'vite-plugin-solid'; +import basicSsl from '@vitejs/plugin-basic-ssl' + +export default defineConfig({ + plugins: [solidPlugin(), basicSsl()], + server: { + port: 3000, + base: '/widget', + proxy: { + '/widget/.*': { + target: 'https://localhost:3000/', + rewrite: (path) => { + console.error("PATH: ", path); + return path.replace(/\/widget/, '#'); + }, + }, + }, + }, + build: { + target: 'esnext', + }, +});