21 Commits

Author SHA1 Message Date
0dd44372e8 ♻️ Don´t overwrite store.passages structure during its update by the addPassage method 2023-02-09 21:27:47 +01:00
f0fe3f8803 🎨 Remove not essential store from the rendering of the PassagesDisplay component 2023-02-09 21:17:58 +01:00
275954f52d 🎨 Replace for loop with a <For/> control flow for PassagesPanel component 2023-02-09 21:14:16 +01:00
e34355e8be 🏷️ Make python linters happy 2023-02-08 22:10:21 +01:00
d1db97554c 🧑‍💻 Use ruff/mypy/black linter/type-checker/formatter
* Use of python-lsp-server with emacs (cf. .dir-locals.el file)
 * Remove autopep8/pycodestyle/pydocstyle/pyflakes/pylint/yapf
2023-02-05 23:10:58 +01:00
aaab0a933b 🎨 Replace for loop with a <For/> control flow for StopsManager 2023-01-30 22:47:12 +01:00
bd8ccc5978 🔥 Remove unused functions from search module
removeStops, getMarkers and addMarkers are not used anymore... remove them.
2023-01-30 22:10:41 +01:00
d490236456 💄 Force root div to fit its content (all height before) 2023-01-30 22:08:54 +01:00
2fd6783534 🚨 Try to make TS linter happy 2023-01-30 22:07:20 +01:00
27f895ce0c 🔇 No need to log line stops 2023-01-28 17:58:43 +01:00
f4d6a3e684 💄 Fix the Metro picto rendering
No metro picto was displayed... lack of metroLinePicto CSS class.
2023-01-28 16:38:42 +01:00
79f4ad0c4c Merge branch 'frontend-file-reorg' into develop 2023-01-28 16:37:13 +01:00
43cbfc17b6 🚨 Make ts linter less depressed 2023-01-28 16:27:40 +01:00
29ba26e80b ♻️ Replace methods called to render Component with small Components 2023-01-28 16:26:50 +01:00
e141aa15e5 🚚 Remove business logic from Component instances 2023-01-28 16:18:55 +01:00
207fe12842 🚚 Move utils function from types.tsx to utils.tsx 2023-01-27 20:23:43 +01:00
cc5205c318 🐛 Fix PassageDisplay footer bullets behavior
The bullets was not updated according to the displayed panel.
2023-01-27 20:20:17 +01:00
495b2bafe2 🔥 Remove ar16x9 CSS class 2023-01-23 22:50:29 +01:00
e96e7aeae0 🚚 Rename NextPassagesDisplay/NextPassagesPanel (remove Next prefix) 2023-01-23 22:34:33 +01:00
b8984e455c 🚚 Add a dedicated CSS file for NextPassagePanel component 2023-01-23 21:16:47 +01:00
f8786fc863 Merge branch 'remove-idfm-extra-references' into develop 2023-01-23 21:09:20 +01:00
34 changed files with 1189 additions and 1024 deletions

10
backend/.dir-locals.el Normal file
View File

@@ -0,0 +1,10 @@
;; Cf. https://gist.github.com/doolio/8c1768ebf33c483e6d26e5205896217f
((python-mode
. ((eglot-workspace-configuration
. (:pylsp (:plugins (:pylint (:enabled :json-false)
:pycodestyle (:enabled :json-false)
:yapf (:enabled :json-false)
:pyflakes (:enabled :json-false)
:ruff (:enabled t)
:mypy (:enabled t)
:black (:enabled t))))))))

View File

View File

@@ -1,4 +1,6 @@
from .db import Database from .db import Database
from .base_class import Base from .base_class import Base
__all__ = ["Base"]
db = Database() db = Database()

View File

@@ -1,34 +1,39 @@
from collections.abc import Iterable from __future__ import annotations
from typing import Iterable, Self, TYPE_CHECKING
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import DeclarativeBase
from typing import Iterable, Self
Base = declarative_base() if TYPE_CHECKING:
Base.db = None from .db import Database
async def base_add(cls, stops: Self | Iterable[Self]) -> bool: class Base(DeclarativeBase):
try: db: Database | None = None
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)
@classmethod
async def add(cls, stops: Self | Iterable[Self]) -> bool:
try:
if isinstance(stops, Iterable):
cls.db.session.add_all(stops) # type: ignore
else:
cls.db.session.add(stops) # type: ignore
await cls.db.session.commit() # type: ignore
except (AttributeError, IntegrityError) as err:
print(err)
return False
Base.add = classmethod(base_add) return True
@classmethod
async def base_get_by_id(cls, id_: int | str) -> None | Base: async def get_by_id(cls, id_: int | str) -> Self | None:
res = await cls.db.session.execute(select(cls).where(cls.id == id_)) try:
element = res.scalar_one_or_none() stmt = select(cls).where(cls.id == id_) # type: ignore
return element res = await cls.db.session.execute(stmt) # type: ignore
element = res.scalar_one_or_none()
except AttributeError as err:
Base.get_by_id = classmethod(base_get_by_id) print(err)
element = None
return element

View File

@@ -1,80 +1,48 @@
from asyncio import gather as asyncio_gather from sqlalchemy import text
from functools import wraps from sqlalchemy.ext.asyncio import (
from pathlib import Path async_sessionmaker,
from time import time AsyncEngine,
from typing import Callable, Iterable, Optional AsyncSession,
create_async_engine,
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 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: class Database:
def __init__(self) -> None: def __init__(self) -> None:
self._engine = None self._engine: AsyncEngine | None = None
self._session_maker = None self._session_maker: async_sessionmaker[AsyncSession] | None = None
self._session = None self._session: AsyncSession | None = None
@property @property
def session(self) -> None: def session(self) -> AsyncSession | None:
if self._session is None: if self._session is None and (session_maker := self._session_maker) is not None:
self._session = self._session_maker() self._session = session_maker()
return self._session return self._session
def use_session(func: Callable): async def connect(self, db_path: str, clear_static_data: bool = False) -> bool:
@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. # TODO: Preserve UserLastStopSearchResults table from drop.
self._engine = create_async_engine(db_path) self._engine = create_async_engine(db_path)
self._session_maker = sessionmaker( if self._engine is not None:
self._engine, expire_on_commit=False, class_=AsyncSession self._session_maker = async_sessionmaker(
) self._engine, expire_on_commit=False, class_=AsyncSession
await self.session.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;") )
if (session := self.session) is not None:
await session.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm;"))
async with self._engine.begin() as conn: async with self._engine.begin() as conn:
if clear_static_data: if clear_static_data:
await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
return True
async def disconnect(self) -> None: async def disconnect(self) -> None:
if self._session is not None: if self._session is not None:
await self._session.close() await self._session.close()
self._session = None self._session = None
await self._engine.dispose()
if self._engine is not None:
await self._engine.dispose()

View File

@@ -1,2 +1,68 @@
from .idfm_interface import IdfmInterface from .idfm_interface import IdfmInterface
from .idfm_types import *
from .idfm_types import (
Coordinate,
FramedVehicleJourney,
IdfmLineState,
IdfmOperator,
IdfmResponse,
IdfmState,
LinePicto,
LineFields,
Line,
MonitoredCall,
MonitoredVehicleJourney,
Point,
Siri,
ServiceDelivery,
Stop,
StopArea,
StopAreaFields,
StopAreaStopAssociation,
StopAreaStopAssociationFields,
StopAreaType,
StopDelivery,
StopFields,
StopLineAsso,
StopLineAssoFields,
StopMonitoringDelivery,
TrainNumber,
TrainStatus,
TransportMode,
TransportSubMode,
Value,
)
__all__ = [
"Coordinate",
"FramedVehicleJourney",
"IdfmInterface",
"IdfmLineState",
"IdfmOperator",
"IdfmResponse",
"IdfmState",
"LinePicto",
"LineFields",
"Line",
"MonitoredCall",
"MonitoredVehicleJourney",
"Point",
"Siri",
"ServiceDelivery",
"Stop",
"StopArea",
"StopAreaFields",
"StopAreaStopAssociation",
"StopAreaStopAssociationFields",
"StopAreaType",
"StopDelivery",
"StopFields",
"StopLineAsso",
"StopLineAssoFields",
"StopMonitoringDelivery",
"TrainNumber",
"TrainStatus",
"TransportMode",
"TransportSubMode",
"Value",
]

View File

@@ -1,7 +1,6 @@
from pathlib import Path
from re import compile as re_compile from re import compile as re_compile
from time import time from time import time
from typing import ByteString, Iterable, List, Optional from typing import AsyncIterator, ByteString, Callable, Iterable, List, Type
from aiofiles import open as async_open from aiofiles import open as async_open
from aiohttp import ClientSession from aiohttp import ClientSession
@@ -16,14 +15,14 @@ from .idfm_types import (
IdfmLineState, IdfmLineState,
IdfmResponse, IdfmResponse,
Line as IdfmLine, Line as IdfmLine,
MonitoredVehicleJourney,
LinePicto as IdfmPicto, LinePicto as IdfmPicto,
IdfmState, IdfmState,
Stop as IdfmStop, Stop as IdfmStop,
StopArea as IdfmStopArea, StopArea as IdfmStopArea,
StopAreaStopAssociation, StopAreaStopAssociation,
StopAreaType,
StopLineAsso as IdfmStopLineAsso, StopLineAsso as IdfmStopLineAsso,
Stops, TransportMode,
) )
from .ratp_types import Picto as RatpPicto from .ratp_types import Picto as RatpPicto
@@ -40,7 +39,10 @@ class IdfmInterface:
IDFM_PICTO_URL = f"{IDFM_ROOT_URL}/referentiel-des-lignes/files" IDFM_PICTO_URL = f"{IDFM_ROOT_URL}/referentiel-des-lignes/files"
RATP_ROOT_URL = "https://data.ratp.fr/explore/dataset" 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" RATP_PICTO_URL = (
f"{RATP_ROOT_URL}"
"/pictogrammes-des-lignes-de-metro-rer-tramway-bus-et-noctilien/files"
)
OPERATOR_RE = re_compile(r"[^:]+:Operator::([^:]+):") OPERATOR_RE = re_compile(r"[^:]+:Operator::([^:]+):")
LINE_RE = re_compile(r"[^:]+:Line::([^:]+):") LINE_RE = re_compile(r"[^:]+:Line::([^:]+):")
@@ -64,7 +66,7 @@ class IdfmInterface:
async def startup(self) -> None: async def startup(self) -> None:
BATCH_SIZE = 10000 BATCH_SIZE = 10000
STEPS = ( STEPS: tuple[tuple[Type[Stop] | Type[StopArea], Callable, Callable], ...] = (
( (
StopArea, StopArea,
self._request_idfm_stop_areas, self._request_idfm_stop_areas,
@@ -132,13 +134,13 @@ class IdfmInterface:
pictos.append(picto) pictos.append(picto)
if len(pictos) == batch_size: if len(pictos) == batch_size:
formatted_pictos = IdfmInterface._format_ratp_pictos(*pictos) formatted_pictos = IdfmInterface._format_ratp_pictos(*pictos)
await LinePicto.add(formatted_pictos.values()) await LinePicto.add(map(lambda picto: picto[1], formatted_pictos))
await Line.add_pictos(formatted_pictos) await Line.add_pictos(formatted_pictos)
pictos.clear() pictos.clear()
if pictos: if pictos:
formatted_pictos = IdfmInterface._format_ratp_pictos(*pictos) formatted_pictos = IdfmInterface._format_ratp_pictos(*pictos)
await LinePicto.add(formatted_pictos.values()) await LinePicto.add(map(lambda picto: picto[1], formatted_pictos))
await Line.add_pictos(formatted_pictos) await Line.add_pictos(formatted_pictos)
async def _load_lines_stops_assos(self, batch_size: int = 5000) -> None: async def _load_lines_stops_assos(self, batch_size: int = 5000) -> None:
@@ -174,16 +176,18 @@ class IdfmInterface:
assos.append((int(fields.zdaid), int(fields.arrid))) assos.append((int(fields.zdaid), int(fields.arrid)))
if len(assos) == batch_size: if len(assos) == batch_size:
total_assos_nb += batch_size total_assos_nb += batch_size
total_found_nb += await StopArea.add_stops(assos) if (found_nb := await StopArea.add_stops(assos)) is not None:
total_found_nb += found_nb
assos.clear() assos.clear()
if assos: if assos:
total_assos_nb += len(assos) total_assos_nb += len(assos)
total_found_nb += await StopArea.add_stops(assos) if (found_nb := await StopArea.add_stops(assos)) is not None:
total_found_nb += found_nb
print(f"{total_found_nb} stop area <-> stop ({total_assos_nb = } found)") print(f"{total_found_nb} stop area <-> stop ({total_assos_nb = } found)")
async def _request_idfm_stops(self): async def _request_idfm_stops(self) -> AsyncIterator[IdfmStop]:
# headers = {"Accept": "application/json", "apikey": self._api_key} # headers = {"Accept": "application/json", "apikey": self._api_key}
# async with ClientSession(headers=headers) as session: # async with ClientSession(headers=headers) as session:
# async with session.get(self.STOPS_URL) as response: # async with session.get(self.STOPS_URL) as response:
@@ -196,19 +200,21 @@ class IdfmInterface:
for element in self._json_stops_decoder.decode(await raw.read()): for element in self._json_stops_decoder.decode(await raw.read()):
yield element yield element
async def _request_idfm_stop_areas(self): async def _request_idfm_stop_areas(self) -> AsyncIterator[IdfmStopArea]:
# TODO: Use HTTP # TODO: Use HTTP
async with async_open("./tests/datasets/zones-d-arrets.json", "rb") as raw: 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()): for element in self._json_stop_areas_decoder.decode(await raw.read()):
yield element yield element
async def _request_idfm_lines(self): async def _request_idfm_lines(self) -> AsyncIterator[IdfmLine]:
# TODO: Use HTTP # TODO: Use HTTP
async with async_open("./tests/datasets/lines_dataset.json", "rb") as raw: async with async_open("./tests/datasets/lines_dataset.json", "rb") as raw:
for element in self._json_lines_decoder.decode(await raw.read()): for element in self._json_lines_decoder.decode(await raw.read()):
yield element yield element
async def _request_idfm_stops_lines_associations(self): async def _request_idfm_stops_lines_associations(
self,
) -> AsyncIterator[IdfmStopLineAsso]:
# TODO: Use HTTP # TODO: Use HTTP
async with async_open("./tests/datasets/arrets-lignes.json", "rb") as raw: async with async_open("./tests/datasets/arrets-lignes.json", "rb") as raw:
for element in self._json_stops_lines_assos_decoder.decode( for element in self._json_stops_lines_assos_decoder.decode(
@@ -216,7 +222,9 @@ class IdfmInterface:
): ):
yield element yield element
async def _request_idfm_stop_area_stop_associations(self): async def _request_idfm_stop_area_stop_associations(
self,
) -> AsyncIterator[StopAreaStopAssociation]:
# TODO: Use HTTP # TODO: Use HTTP
async with async_open("./tests/datasets/relations.json", "rb") as raw: async with async_open("./tests/datasets/relations.json", "rb") as raw:
for element in self._json_stop_area_stop_asso_decoder.decode( for element in self._json_stop_area_stop_asso_decoder.decode(
@@ -224,7 +232,7 @@ class IdfmInterface:
): ):
yield element yield element
async def _request_ratp_pictos(self): async def _request_ratp_pictos(self) -> AsyncIterator[RatpPicto]:
# TODO: Use HTTP # TODO: Use HTTP
async with async_open( async with async_open(
"./tests/datasets/pictogrammes-des-lignes-de-metro-rer-tramway-bus-et-noctilien.json", "./tests/datasets/pictogrammes-des-lignes-de-metro-rer-tramway-bus-et-noctilien.json",
@@ -254,20 +262,25 @@ class IdfmInterface:
return ret return ret
@classmethod @classmethod
def _format_ratp_pictos(cls, *pictos: RatpPicto) -> dict[str, None | LinePicto]: def _format_ratp_pictos(cls, *pictos: RatpPicto) -> Iterable[tuple[str, LinePicto]]:
ret = {} ret = []
for picto in pictos: for picto in pictos:
if (fields := picto.fields.noms_des_fichiers) is not None: if (fields := picto.fields.noms_des_fichiers) is not None:
ret[picto.fields.indices_commerciaux] = LinePicto( ret.append(
id=fields.id_, (
mime_type=f"image/{fields.format.lower()}", picto.fields.indices_commerciaux,
height_px=fields.height, LinePicto(
width_px=fields.width, id=fields.id_,
filename=fields.filename, mime_type=f"image/{fields.format.lower()}",
url=f"{cls.RATP_PICTO_URL}/{fields.id_}/download", height_px=fields.height,
thumbnail=fields.thumbnail, width_px=fields.width,
format=fields.format, filename=fields.filename,
url=f"{cls.RATP_PICTO_URL}/{fields.id_}/download",
thumbnail=fields.thumbnail,
format=fields.format,
),
)
) )
return ret return ret
@@ -289,7 +302,7 @@ class IdfmInterface:
short_name=fields.shortname_line, short_name=fields.shortname_line,
name=fields.name_line, name=fields.name_line,
status=IdfmLineState(fields.status.value), status=IdfmLineState(fields.status.value),
transport_mode=fields.transportmode.value, transport_mode=TransportMode(fields.transportmode.value),
transport_submode=optional_value(fields.transportsubmode), transport_submode=optional_value(fields.transportsubmode),
network_name=optional_value(fields.networkname), network_name=optional_value(fields.networkname),
group_of_lines_id=optional_value(fields.id_groupoflines), group_of_lines_id=optional_value(fields.id_groupoflines),
@@ -300,9 +313,13 @@ class IdfmInterface:
text_colour_hexa=fields.textcolourprint_hexa, text_colour_hexa=fields.textcolourprint_hexa,
operator_id=optional_value(fields.operatorref), operator_id=optional_value(fields.operatorref),
operator_name=optional_value(fields.operatorname), operator_name=optional_value(fields.operatorname),
accessibility=fields.accessibility.value, accessibility=IdfmState(fields.accessibility.value),
visual_signs_available=fields.visualsigns_available.value, visual_signs_available=IdfmState(
audible_signs_available=fields.audiblesigns_available.value, fields.visualsigns_available.value
),
audible_signs_available=IdfmState(
fields.audiblesigns_available.value
),
picto_id=fields.picto.id_ if fields.picto is not None else None, picto_id=fields.picto.id_ if fields.picto is not None else None,
picto=picto, picto=picto,
record_id=line.recordid, record_id=line.recordid,
@@ -317,7 +334,7 @@ class IdfmInterface:
for stop in stops: for stop in stops:
fields = stop.fields fields = stop.fields
try: try:
created_ts = int(fields.arrcreated.timestamp()) created_ts = int(fields.arrcreated.timestamp()) # type: ignore
except AttributeError: except AttributeError:
created_ts = None created_ts = None
yield Stop( yield Stop(
@@ -329,13 +346,13 @@ class IdfmInterface:
postal_region=fields.arrpostalregion, postal_region=fields.arrpostalregion,
xepsg2154=fields.arrxepsg2154, xepsg2154=fields.arrxepsg2154,
yepsg2154=fields.arryepsg2154, yepsg2154=fields.arryepsg2154,
transport_mode=fields.arrtype.value, transport_mode=TransportMode(fields.arrtype.value),
version=fields.arrversion, version=fields.arrversion,
created_ts=created_ts, created_ts=created_ts,
changed_ts=int(fields.arrchanged.timestamp()), changed_ts=int(fields.arrchanged.timestamp()),
accessibility=fields.arraccessibility.value, accessibility=IdfmState(fields.arraccessibility.value),
visual_signs_available=fields.arrvisualsigns.value, visual_signs_available=IdfmState(fields.arrvisualsigns.value),
audible_signs_available=fields.arraudiblesignals.value, audible_signs_available=IdfmState(fields.arraudiblesignals.value),
record_id=stop.recordid, record_id=stop.recordid,
record_ts=int(stop.record_timestamp.timestamp()), record_ts=int(stop.record_timestamp.timestamp()),
) )
@@ -345,7 +362,7 @@ class IdfmInterface:
for stop_area in stop_areas: for stop_area in stop_areas:
fields = stop_area.fields fields = stop_area.fields
try: try:
created_ts = int(fields.arrcreated.timestamp()) created_ts = int(fields.zdacreated.timestamp()) # type: ignore
except AttributeError: except AttributeError:
created_ts = None created_ts = None
yield StopArea( yield StopArea(
@@ -355,7 +372,7 @@ class IdfmInterface:
postal_region=fields.zdapostalregion, postal_region=fields.zdapostalregion,
xepsg2154=fields.zdaxepsg2154, xepsg2154=fields.zdaxepsg2154,
yepsg2154=fields.zdayepsg2154, yepsg2154=fields.zdayepsg2154,
type=fields.zdatype.value, type=StopAreaType(fields.zdatype.value),
version=fields.zdaversion, version=fields.zdaversion,
created_ts=created_ts, created_ts=created_ts,
changed_ts=int(fields.zdachanged.timestamp()), changed_ts=int(fields.zdachanged.timestamp()),
@@ -368,22 +385,22 @@ class IdfmInterface:
picto = line.picto picto = line.picto
if picto is not None: if picto is not None:
picto_data = await self._get_line_picto(line) if (picto_data := await self._get_line_picto(line)) is not None:
async with async_open(target, "wb") as fd: async with async_open(target, "wb") as fd:
await fd.write(picto_data) await fd.write(bytes(picto_data))
line_picto_path = target line_picto_path = target
line_picto_format = picto.mime_type line_picto_format = picto.mime_type
print(f"render_line_picto: {time() - begin_ts}") print(f"render_line_picto: {time() - begin_ts}")
return (line_picto_path, line_picto_format) return (line_picto_path, line_picto_format)
async def _get_line_picto(self, line: Line) -> Optional[ByteString]: async def _get_line_picto(self, line: Line) -> ByteString | None:
print("---------------------------------------------------------------------") print("---------------------------------------------------------------------")
begin_ts = time() begin_ts = time()
data = None data = None
picto = line.picto picto = line.picto
if picto is not None: if picto is not None and picto.url is not None:
headers = ( headers = (
self._http_headers if picto.url.startswith(self.IDFM_ROOT_URL) else None self._http_headers if picto.url.startswith(self.IDFM_ROOT_URL) else None
) )
@@ -401,31 +418,18 @@ class IdfmInterface:
print("---------------------------------------------------------------------") print("---------------------------------------------------------------------")
return data return data
async def get_next_passages(self, stop_point_id: str) -> Optional[IdfmResponse]: async def get_next_passages(self, stop_point_id: str) -> IdfmResponse | None:
# print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
begin_ts = time()
ret = None ret = None
params = {"MonitoringRef": f"STIF:StopPoint:Q:{stop_point_id}:"} params = {"MonitoringRef": f"STIF:StopPoint:Q:{stop_point_id}:"}
session_begin_ts = time()
async with ClientSession(headers=self._http_headers) as session: 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: 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: if response.status == 200:
get_end_ts = time()
# print(f"GET {get_end_ts - session_creation_ts}")
data = await response.read() data = await response.read()
# print(data)
try: try:
ret = self._response_json_decoder.decode(data) ret = self._response_json_decoder.decode(data)
except ValidationError as err: except ValidationError as err:
print(err) print(err)
# print(f"read {time() - get_end_ts}")
# print(f"get_next_passages: {time() - begin_ts}")
# print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
return ret return ret
async def get_destinations(self, stop_point_id: str) -> Iterable[str]: async def get_destinations(self, stop_point_id: str) -> Iterable[str]:

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from enum import Enum, StrEnum from enum import Enum, StrEnum
from typing import Any, Literal, Optional, NamedTuple from typing import Any, NamedTuple
from msgspec import Struct from msgspec import Struct
@@ -88,7 +88,7 @@ class Stop(Struct):
Stops = dict[str, Stop] Stops = dict[str, Stop]
class StopAreaType(Enum): class StopAreaType(StrEnum):
metroStation = "metroStation" metroStation = "metroStation"
onstreetBus = "onstreetBus" onstreetBus = "onstreetBus"
onstreetTram = "onstreetTram" onstreetTram = "onstreetTram"
@@ -101,7 +101,7 @@ class StopAreaFields(Struct, kw_only=True):
zdatown: str zdatown: str
zdaversion: str zdaversion: str
zdaid: str zdaid: str
zdacreated: Optional[datetime] = None zdacreated: datetime | None = None
zdatype: StopAreaType zdatype: StopAreaType
zdayepsg2154: int zdayepsg2154: int
zdapostalregion: str zdapostalregion: str
@@ -118,13 +118,13 @@ class StopArea(Struct):
class StopAreaStopAssociationFields(Struct, kw_only=True): class StopAreaStopAssociationFields(Struct, kw_only=True):
arrid: str # TODO: use int ? arrid: str # TODO: use int ?
artid: Optional[str] = None artid: str | None = None
arrversion: str arrversion: str
zdcid: str zdcid: str
version: int version: int
zdaid: str zdaid: str
zdaversion: str zdaversion: str
artversion: Optional[str] = None artversion: str | None = None
class StopAreaStopAssociation(Struct): class StopAreaStopAssociation(Struct):
@@ -153,20 +153,20 @@ class LineFields(Struct, kw_only=True):
name_line: str name_line: str
status: IdfmLineState status: IdfmLineState
accessibility: IdfmState accessibility: IdfmState
shortname_groupoflines: Optional[str] = None shortname_groupoflines: str | None = None
transportmode: TransportMode transportmode: TransportMode
colourweb_hexa: str colourweb_hexa: str
textcolourprint_hexa: str textcolourprint_hexa: str
transportsubmode: Optional[TransportSubMode] = TransportSubMode.unknown transportsubmode: TransportSubMode | None = TransportSubMode.unknown
operatorref: Optional[str] = None operatorref: str | None = None
visualsigns_available: IdfmState visualsigns_available: IdfmState
networkname: Optional[str] = None networkname: str | None = None
id_line: str id_line: str
id_groupoflines: Optional[str] = None id_groupoflines: str | None = None
operatorname: Optional[str] = None operatorname: str | None = None
audiblesigns_available: IdfmState audiblesigns_available: IdfmState
shortname_line: str shortname_line: str
picto: Optional[LinePicto] = None picto: LinePicto | None = None
class Line(Struct): class Line(Struct):
@@ -220,17 +220,17 @@ class TrainNumber(Struct):
class MonitoredCall(Struct, kw_only=True): class MonitoredCall(Struct, kw_only=True):
Order: Optional[int] = None Order: int | None = None
StopPointName: list[Value] StopPointName: list[Value]
VehicleAtStop: bool VehicleAtStop: bool
DestinationDisplay: list[Value] DestinationDisplay: list[Value]
AimedArrivalTime: Optional[datetime] = None AimedArrivalTime: datetime | None = None
ExpectedArrivalTime: Optional[datetime] = None ExpectedArrivalTime: datetime | None = None
ArrivalPlatformName: Optional[Value] = None ArrivalPlatformName: Value | None = None
AimedDepartureTime: Optional[datetime] = None AimedDepartureTime: datetime | None = None
ExpectedDepartureTime: Optional[datetime] = None ExpectedDepartureTime: datetime | None = None
ArrivalStatus: TrainStatus = None ArrivalStatus: TrainStatus | None = None
DepartureStatus: TrainStatus = None DepartureStatus: TrainStatus | None = None
class MonitoredVehicleJourney(Struct, kw_only=True): class MonitoredVehicleJourney(Struct, kw_only=True):
@@ -240,7 +240,7 @@ class MonitoredVehicleJourney(Struct, kw_only=True):
DestinationRef: Value DestinationRef: Value
DestinationName: list[Value] | None = None DestinationName: list[Value] | None = None
JourneyNote: list[Value] | None = None JourneyNote: list[Value] | None = None
TrainNumbers: Optional[TrainNumber] = None TrainNumbers: TrainNumber | None = None
MonitoredCall: MonitoredCall MonitoredCall: MonitoredCall

View File

@@ -1,3 +1,6 @@
from .line import Line, LinePicto from .line import Line, LinePicto
from .stop import Stop, StopArea from .stop import Stop, StopArea
from .user import UserLastStopSearchResults from .user import UserLastStopSearchResults
__all__ = ["Line", "LinePicto", "Stop", "StopArea", "UserLastStopSearchResults"]

View File

@@ -1,6 +1,6 @@
from asyncio import gather as asyncio_gather from asyncio import gather as asyncio_gather
from collections import defaultdict from collections import defaultdict
from typing import Iterable, Self from typing import Iterable, Self, Sequence
from sqlalchemy import ( from sqlalchemy import (
BigInteger, BigInteger,
@@ -13,8 +13,7 @@ from sqlalchemy import (
String, String,
Table, Table,
) )
from sqlalchemy.orm import Mapped, relationship, selectinload from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload
from sqlalchemy.orm.attributes import set_committed_value
from sqlalchemy.sql.expression import tuple_ from sqlalchemy.sql.expression import tuple_
from ..db import Base, db from ..db import Base, db
@@ -38,14 +37,14 @@ class LinePicto(Base):
db = db db = db
id = Column(String, primary_key=True) id = mapped_column(String, primary_key=True)
mime_type = Column(String, nullable=False) mime_type = mapped_column(String, nullable=False)
height_px = Column(Integer, nullable=False) height_px = mapped_column(Integer, nullable=False)
width_px = Column(Integer, nullable=False) width_px = mapped_column(Integer, nullable=False)
filename = Column(String, nullable=False) filename = mapped_column(String, nullable=False)
url = Column(String, nullable=False) url = mapped_column(String, nullable=False)
thumbnail = Column(Boolean, nullable=False) thumbnail = mapped_column(Boolean, nullable=False)
format = Column(String, nullable=False) format = mapped_column(String, nullable=False)
__tablename__ = "line_pictos" __tablename__ = "line_pictos"
@@ -54,35 +53,35 @@ class Line(Base):
db = db db = db
id = Column(String, primary_key=True) id = mapped_column(String, primary_key=True)
short_name = Column(String) short_name = mapped_column(String)
name = Column(String, nullable=False) name = mapped_column(String, nullable=False)
status = Column(Enum(IdfmLineState), nullable=False) status = mapped_column(Enum(IdfmLineState), nullable=False)
transport_mode = Column(Enum(TransportMode), nullable=False) transport_mode = mapped_column(Enum(TransportMode), nullable=False)
transport_submode = Column(Enum(TransportSubMode), nullable=False) transport_submode = mapped_column(Enum(TransportSubMode), nullable=False)
network_name = Column(String) network_name = mapped_column(String)
group_of_lines_id = Column(String) group_of_lines_id = mapped_column(String)
group_of_lines_shortname = Column(String) group_of_lines_shortname = mapped_column(String)
colour_web_hexa = Column(String, nullable=False) colour_web_hexa = mapped_column(String, nullable=False)
text_colour_hexa = Column(String, nullable=False) text_colour_hexa = mapped_column(String, nullable=False)
operator_id = Column(String) operator_id = mapped_column(String)
operator_name = Column(String) operator_name = mapped_column(String)
accessibility = Column(Enum(IdfmState), nullable=False) accessibility = mapped_column(Enum(IdfmState), nullable=False)
visual_signs_available = Column(Enum(IdfmState), nullable=False) visual_signs_available = mapped_column(Enum(IdfmState), nullable=False)
audible_signs_available = Column(Enum(IdfmState), nullable=False) audible_signs_available = mapped_column(Enum(IdfmState), nullable=False)
picto_id = Column(String, ForeignKey("line_pictos.id")) picto_id = mapped_column(String, ForeignKey("line_pictos.id"))
picto: Mapped[LinePicto] = relationship(LinePicto, lazy="selectin") picto: Mapped[LinePicto] = relationship(LinePicto, lazy="selectin")
record_id = Column(String, nullable=False) record_id = mapped_column(String, nullable=False)
record_ts = Column(BigInteger, nullable=False) record_ts = mapped_column(BigInteger, nullable=False)
stops: Mapped[list["_Stop"]] = relationship( stops: Mapped[list[_Stop]] = relationship(
"_Stop", "_Stop",
secondary=line_stop_association_table, secondary=line_stop_association_table,
back_populates="lines", back_populates="lines",
@@ -94,74 +93,86 @@ class Line(Base):
@classmethod @classmethod
async def get_by_name( async def get_by_name(
cls, name: str, operator_name: None | str = None cls, name: str, operator_name: None | str = None
) -> list[Self]: ) -> Sequence[Self] | None:
session = cls.db.session
if session is None:
return None
filters = {"name": name} filters = {"name": name}
if operator_name is not None: if operator_name is not None:
filters["operator_name"] = operator_name filters["operator_name"] = operator_name
lines = None
stmt = ( stmt = (
select(Line) select(cls)
.filter_by(**filters) .filter_by(**filters)
.options(selectinload(Line.stops), selectinload(Line.picto)) .options(selectinload(cls.stops), selectinload(cls.picto))
) )
res = await cls.db.session.execute(stmt) res = await session.execute(stmt)
lines = res.scalars().all() lines = res.scalars().all()
return lines return lines
@classmethod @classmethod
async def _add_picto_to_line(cls, line: str | Self, picto: LinePicto) -> None: async def _add_picto_to_line(cls, line: str | Self, picto: LinePicto) -> None:
formatted_line: Self | None = None
if isinstance(line, str): if isinstance(line, str):
if (lines := await cls.get_by_name(line)) is not None: if (lines := await cls.get_by_name(line)) is not None:
if len(lines) == 1: if len(lines) == 1:
line = lines[0] formatted_line = lines[0]
else: else:
for candidate_line in lines: for candidate_line in lines:
if candidate_line.operator_name == "RATP": if candidate_line.operator_name == "RATP":
line = candidate_line formatted_line = candidate_line
break break
else:
formatted_line = line
if isinstance(line, Line) and line.picto is None: if isinstance(formatted_line, Line) and formatted_line.picto is None:
line.picto = picto formatted_line.picto = picto
line.picto_id = picto.id formatted_line.picto_id = picto.id
@classmethod @classmethod
async def add_pictos(cls, line_to_pictos: dict[str | Self, LinePicto]) -> None: async def add_pictos(cls, line_to_pictos: Iterable[tuple[str, LinePicto]]) -> bool:
session = cls.db.session
if session is None:
return False
await asyncio_gather( await asyncio_gather(
*[ *[cls._add_picto_to_line(line, picto) for line, picto in line_to_pictos]
cls._add_picto_to_line(line, picto)
for line, picto in line_to_pictos.items()
]
) )
await cls.db.session.commit() await session.commit()
return True
@classmethod @classmethod
async def add_stops(cls, line_to_stop_ids: Iterable[tuple[str, str, str]]) -> int: async def add_stops(cls, line_to_stop_ids: Iterable[tuple[str, str, int]]) -> int:
session = cls.db.session
if session is None:
return 0
line_names_ops, stop_ids = set(), set() line_names_ops, stop_ids = set(), set()
for line_name, operator_name, stop_id in line_to_stop_ids: for line_name, operator_name, stop_id in line_to_stop_ids:
line_names_ops.add((line_name, operator_name)) line_names_ops.add((line_name, operator_name))
stop_ids.add(stop_id) stop_ids.add(stop_id)
res = await cls.db.session.execute( lines_res = await session.execute(
select(Line).where( select(Line).where(
tuple_(Line.name, Line.operator_name).in_(line_names_ops) tuple_(Line.name, Line.operator_name).in_(line_names_ops)
) )
) )
lines = defaultdict(list) lines = defaultdict(list)
for line in res.scalars(): for line in lines_res.scalars():
lines[(line.name, line.operator_name)].append(line) lines[(line.name, line.operator_name)].append(line)
res = await cls.db.session.execute(select(_Stop).where(_Stop.id.in_(stop_ids))) stops_res = await session.execute(select(_Stop).where(_Stop.id.in_(stop_ids)))
stops = {stop.id: stop for stop in res.scalars()} stops = {stop.id: stop for stop in stops_res.scalars()}
found = 0 found = 0
for line_name, operator_name, stop_id in line_to_stop_ids: for line_name, operator_name, stop_id in line_to_stop_ids:
if (stop := stops.get(stop_id)) is not None: if (stop := stops.get(stop_id)) is not None:
if (stop_lines := lines.get((line_name, operator_name))) 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: for stop_line in stop_lines:
stop_line.stops.append(stop) stop_line.stops.append(stop)
found += 1 found += 1
@@ -169,8 +180,10 @@ class Line(Base):
print(f"No line found for {line_name}/{operator_name}") print(f"No line found for {line_name}/{operator_name}")
else: else:
print( print(
f"No stop found for {stop_id} id (used by {line_name}/{operator_name})" f"No stop found for {stop_id} id"
f"(used by {line_name}/{operator_name})"
) )
await cls.db.session.commit() await session.commit()
return found return found

View File

@@ -1,4 +1,6 @@
from typing import Iterable, Self from __future__ import annotations
from typing import Iterable, Self, Sequence, TYPE_CHECKING
from sqlalchemy import ( from sqlalchemy import (
BigInteger, BigInteger,
@@ -10,12 +12,22 @@ from sqlalchemy import (
String, String,
Table, Table,
) )
from sqlalchemy.orm import Mapped, relationship, selectinload, with_polymorphic from sqlalchemy.orm import (
mapped_column,
Mapped,
relationship,
selectinload,
with_polymorphic,
)
from sqlalchemy.schema import Index from sqlalchemy.schema import Index
from ..db import Base, db from ..db import Base, db
from ..idfm_interface.idfm_types import TransportMode, IdfmState, StopAreaType from ..idfm_interface.idfm_types import TransportMode, IdfmState, StopAreaType
if TYPE_CHECKING:
from .line import Line
stop_area_stop_association_table = Table( stop_area_stop_association_table = Table(
"stop_area_stop_association_table", "stop_area_stop_association_table",
Base.metadata, Base.metadata,
@@ -28,18 +40,18 @@ class _Stop(Base):
db = db db = db
id = Column(BigInteger, primary_key=True) id = mapped_column(BigInteger, primary_key=True)
kind = Column(String) kind = mapped_column(String)
name = Column(String, nullable=False, index=True) name = mapped_column(String, nullable=False, index=True)
town_name = Column(String, nullable=False) town_name = mapped_column(String, nullable=False)
postal_region = Column(String, nullable=False) postal_region = mapped_column(String, nullable=False)
xepsg2154 = Column(BigInteger, nullable=False) xepsg2154 = mapped_column(BigInteger, nullable=False)
yepsg2154 = Column(BigInteger, nullable=False) yepsg2154 = mapped_column(BigInteger, nullable=False)
version = Column(String, nullable=False) version = mapped_column(String, nullable=False)
created_ts = Column(BigInteger) created_ts = mapped_column(BigInteger)
changed_ts = Column(BigInteger, nullable=False) changed_ts = mapped_column(BigInteger, nullable=False)
lines: Mapped[list["Line"]] = relationship( lines: Mapped[list[Line]] = relationship(
"Line", "Line",
secondary="line_stop_association_table", secondary="line_stop_association_table",
back_populates="stops", back_populates="stops",
@@ -65,7 +77,11 @@ class _Stop(Base):
# TODO: Test https://www.cybertec-postgresql.com/en/postgresql-more-performance-for-like-and-ilike-statements/ # TODO: Test https://www.cybertec-postgresql.com/en/postgresql-more-performance-for-like-and-ilike-statements/
# TODO: Should be able to remove with_polymorphic ? # TODO: Should be able to remove with_polymorphic ?
@classmethod @classmethod
async def get_by_name(cls, name: str) -> list[Self]: async def get_by_name(cls, name: str) -> Sequence[type[_Stop]] | None:
session = cls.db.session
if session is None:
return None
stop_stop_area = with_polymorphic(_Stop, [Stop, StopArea]) stop_stop_area = with_polymorphic(_Stop, [Stop, StopArea])
stmt = ( stmt = (
select(stop_stop_area) select(stop_stop_area)
@@ -75,22 +91,25 @@ class _Stop(Base):
selectinload(stop_stop_area.lines), selectinload(stop_stop_area.lines),
) )
) )
res = await cls.db.session.execute(stmt)
return res.scalars() res = await session.execute(stmt)
stops = res.scalars().all()
return stops
class Stop(_Stop): class Stop(_Stop):
id = Column(BigInteger, ForeignKey("_stops.id"), primary_key=True) id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True)
latitude = Column(Float, nullable=False) latitude = mapped_column(Float, nullable=False)
longitude = Column(Float, nullable=False) longitude = mapped_column(Float, nullable=False)
transport_mode = Column(Enum(TransportMode), nullable=False) transport_mode = mapped_column(Enum(TransportMode), nullable=False)
accessibility = Column(Enum(IdfmState), nullable=False) accessibility = mapped_column(Enum(IdfmState), nullable=False)
visual_signs_available = Column(Enum(IdfmState), nullable=False) visual_signs_available = mapped_column(Enum(IdfmState), nullable=False)
audible_signs_available = Column(Enum(IdfmState), nullable=False) audible_signs_available = mapped_column(Enum(IdfmState), nullable=False)
record_id = Column(String, nullable=False) record_id = mapped_column(String, nullable=False)
record_ts = Column(BigInteger, nullable=False) record_ts = mapped_column(BigInteger, nullable=False)
__tablename__ = "stops" __tablename__ = "stops"
__mapper_args__ = {"polymorphic_identity": "stops", "polymorphic_load": "inline"} __mapper_args__ = {"polymorphic_identity": "stops", "polymorphic_load": "inline"}
@@ -98,11 +117,11 @@ class Stop(_Stop):
class StopArea(_Stop): class StopArea(_Stop):
id = Column(BigInteger, ForeignKey("_stops.id"), primary_key=True) id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True)
type = Column(Enum(StopAreaType), nullable=False) type = mapped_column(Enum(StopAreaType), nullable=False)
stops: Mapped[list[_Stop]] = relationship( stops: Mapped[list["_Stop"]] = relationship(
_Stop, "_Stop",
secondary=stop_area_stop_association_table, secondary=stop_area_stop_association_table,
back_populates="areas", back_populates="areas",
lazy="selectin", lazy="selectin",
@@ -110,24 +129,35 @@ class StopArea(_Stop):
) )
__tablename__ = "stop_areas" __tablename__ = "stop_areas"
__mapper_args__ = {"polymorphic_identity": "stop_areas", "polymorphic_load": "inline"} __mapper_args__ = {
"polymorphic_identity": "stop_areas",
"polymorphic_load": "inline",
}
@classmethod @classmethod
async def add_stops(cls, stop_area_to_stop_ids: Iterable[tuple[str, str]]) -> int: async def add_stops(
cls, stop_area_to_stop_ids: Iterable[tuple[int, int]]
) -> int | None:
session = cls.db.session
if session is None:
return None
stop_area_ids, stop_ids = set(), set() stop_area_ids, stop_ids = set(), set()
for stop_area_id, stop_id in stop_area_to_stop_ids: for stop_area_id, stop_id in stop_area_to_stop_ids:
stop_area_ids.add(stop_area_id) stop_area_ids.add(stop_area_id)
stop_ids.add(stop_id) stop_ids.add(stop_id)
res = await cls.db.session.execute( stop_areas_res = await session.execute(
select(StopArea) select(StopArea)
.where(StopArea.id.in_(stop_area_ids)) .where(StopArea.id.in_(stop_area_ids))
.options(selectinload(StopArea.stops)) .options(selectinload(StopArea.stops))
) )
stop_areas = {stop_area.id: stop_area for stop_area in res.scalars()} stop_areas: dict[int, StopArea] = {
stop_area.id: stop_area for stop_area in stop_areas_res.scalars()
}
res = await cls.db.session.execute(select(_Stop).where(_Stop.id.in_(stop_ids))) stop_res = await session.execute(select(_Stop).where(_Stop.id.in_(stop_ids)))
stops = {stop.id: stop for stop in res.scalars()} stops: dict[int, _Stop] = {stop.id: stop for stop in stop_res.scalars()}
found = 0 found = 0
for stop_area_id, stop_id in stop_area_to_stop_ids: for stop_area_id, stop_id in stop_area_to_stop_ids:
@@ -140,5 +170,6 @@ class StopArea(_Stop):
else: else:
print(f"No stop area found for {stop_area_id}") print(f"No stop area found for {stop_area_id}")
await cls.db.session.commit() await session.commit()
return found return found

View File

@@ -1,5 +1,5 @@
from sqlalchemy import Column, ForeignKey, String, Table from sqlalchemy import Column, ForeignKey, String, Table
from sqlalchemy.orm import Mapped, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..db import Base, db from ..db import Base, db
from .stop import _Stop from .stop import _Stop
@@ -18,8 +18,8 @@ class UserLastStopSearchResults(Base):
__tablename__ = "user_last_stop_search_results" __tablename__ = "user_last_stop_search_results"
user_mxid = Column(String, primary_key=True) user_mxid = mapped_column(String, primary_key=True)
request_content = Column(String, nullable=False) request_content = mapped_column(String, nullable=False)
stops: Mapped[list[_Stop]] = relationship( stops: Mapped[_Stop] = relationship(
_Stop, secondary=user_last_stop_search_stops_associations_table _Stop, secondary=user_last_stop_search_stops_associations_table
) )

0
backend/backend/py.typed Normal file
View File

View File

@@ -1,3 +1,5 @@
from .line import Line, TransportMode from .line import Line, TransportMode
from .next_passage import NextPassage, NextPassages from .next_passage import NextPassage, NextPassages
from .stop import Stop, StopArea from .stop import Stop, StopArea
__all__ = ["Line", "NextPassage", "NextPassages", "Stop", "StopArea", "TransportMode"]

View File

@@ -1,5 +1,4 @@
from enum import StrEnum from enum import StrEnum
from typing import Self
from pydantic import BaseModel from pydantic import BaseModel
@@ -29,10 +28,11 @@ class TransportMode(StrEnum):
# idfm_types.TransportMode.rail + idfm_types.TransportSubMode.railShuttle # idfm_types.TransportMode.rail + idfm_types.TransportSubMode.railShuttle
val = "val" val = "val"
# Self return type replaced by "TransportMode" to fix following mypy error:
# Incompatible return value type (got "TransportMode", expected "Self")
# TODO: Is it the good fix ?
@classmethod @classmethod
def from_idfm_transport_mode( def from_idfm_transport_mode(cls, mode: str, sub_mode: str) -> "TransportMode":
cls, mode: IdfmTransportMode, sub_mode: IdfmTransportSubMode
) -> Self:
if mode == IdfmTransportMode.rail: if mode == IdfmTransportMode.rail:
if sub_mode == IdfmTransportSubMode.regionalRail: if sub_mode == IdfmTransportSubMode.regionalRail:
return cls.rail_ter return cls.rail_ter
@@ -42,7 +42,7 @@ class TransportMode(StrEnum):
return cls.rail_transilien return cls.rail_transilien
if sub_mode == IdfmTransportSubMode.railShuttle: if sub_mode == IdfmTransportSubMode.railShuttle:
return cls.val return cls.val
return TransportMode(mode) return cls(mode)
class Line(BaseModel): class Line(BaseModel):

View File

@@ -8,11 +8,11 @@ class NextPassage(BaseModel):
operator: str operator: str
destinations: list[str] destinations: list[str]
atStop: bool atStop: bool
aimedArrivalTs: None | int aimedArrivalTs: int | None
expectedArrivalTs: None | int expectedArrivalTs: int | None
arrivalPlatformName: None | str arrivalPlatformName: str | None
aimedDepartTs: None | int aimedDepartTs: int | None
expectedDepartTs: None | int expectedDepartTs: int | None
arrivalStatus: TrainStatus arrivalStatus: TrainStatus
departStatus: TrainStatus departStatus: TrainStatus

View File

@@ -1,6 +1,6 @@
from pydantic import BaseModel from pydantic import BaseModel
from ..idfm_interface import IdfmLineState, IdfmState, StopAreaType, TransportMode from ..idfm_interface import StopAreaType
class Stop(BaseModel): class Stop(BaseModel):

View File

@@ -1,10 +1,10 @@
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from os import environ from os import environ, EX_USAGE
from typing import Sequence
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from rich import print from rich import print
@@ -21,7 +21,9 @@ from backend.schemas import (
) )
API_KEY = environ.get("API_KEY") API_KEY = environ.get("API_KEY")
# TODO: Add error message if no key is given. if API_KEY is None:
print('No "API_KEY" environment variable set... abort.')
exit(EX_USAGE)
# TODO: Remove postgresql+asyncpg from environ variable # TODO: Remove postgresql+asyncpg from environ variable
DB_PATH = "postgresql+asyncpg://cer_user:cer_password@127.0.0.1:5438/cer_db" DB_PATH = "postgresql+asyncpg://cer_user:cer_password@127.0.0.1:5438/cer_db"
@@ -44,9 +46,9 @@ idfm_interface = IdfmInterface(API_KEY, db)
@app.on_event("startup") @app.on_event("startup")
async def startup(): async def startup():
# await db.connect(DB_PATH, clear_static_data=True) await db.connect(DB_PATH, clear_static_data=True)
# await idfm_interface.startup() await idfm_interface.startup()
await db.connect(DB_PATH, clear_static_data=False) # await db.connect(DB_PATH, clear_static_data=False)
print("Connected") print("Connected")
@@ -61,12 +63,12 @@ STATIC_ROOT = "../frontend/"
app.mount("/widget", StaticFiles(directory=STATIC_ROOT, html=True), name="widget") app.mount("/widget", StaticFiles(directory=STATIC_ROOT, html=True), name="widget")
def optional_datetime_to_ts(dt: datetime) -> int | None: def optional_datetime_to_ts(dt: datetime | None) -> int | None:
return dt.timestamp() if dt else None return int(dt.timestamp()) if dt else None
@app.get("/line/{line_id}", response_model=LineSchema) @app.get("/line/{line_id}", response_model=LineSchema)
async def get_line(line_id: str) -> JSONResponse: async def get_line(line_id: str) -> LineSchema:
line: Line | None = await Line.get_by_id(line_id) line: Line | None = await Line.get_by_id(line_id)
if line is None: if line is None:
@@ -91,7 +93,7 @@ async def get_line(line_id: str) -> JSONResponse:
def _format_stop(stop: Stop) -> StopSchema: def _format_stop(stop: Stop) -> StopSchema:
print(stop.__dict__) # print(stop.__dict__)
return StopSchema( return StopSchema(
id=stop.id, id=stop.id,
name=stop.name, name=stop.name,
@@ -103,15 +105,17 @@ def _format_stop(stop: Stop) -> StopSchema:
lines=[line.id for line in stop.lines], lines=[line.id for line in stop.lines],
) )
# châtelet # châtelet
@app.get("/stop/") @app.get("/stop/")
async def get_stop( async def get_stop(
name: str = "", limit: int = 10 name: str = "", limit: int = 10
) -> list[StopAreaSchema | StopSchema]: ) -> Sequence[StopAreaSchema | StopSchema]:
# TODO: Add limit support # TODO: Add limit support
formatted = [] formatted: list[StopAreaSchema | StopSchema] = []
matching_stops = await Stop.get_by_name(name) matching_stops = await Stop.get_by_name(name)
# print(matching_stops, flush=True) # print(matching_stops, flush=True)
@@ -153,15 +157,17 @@ async def get_stop(
# TODO: Cache response for 30 secs ? # TODO: Cache response for 30 secs ?
@app.get("/stop/nextPassages/{stop_id}") @app.get("/stop/nextPassages/{stop_id}")
async def get_next_passages(stop_id: str) -> JSONResponse: async def get_next_passages(stop_id: str) -> NextPassagesSchema | None:
res = await idfm_interface.get_next_passages(stop_id) res = await idfm_interface.get_next_passages(stop_id)
if res is None:
# print(res) return None
service_delivery = res.Siri.ServiceDelivery service_delivery = res.Siri.ServiceDelivery
stop_monitoring_deliveries = service_delivery.StopMonitoringDelivery stop_monitoring_deliveries = service_delivery.StopMonitoringDelivery
by_line_by_dst_passages = defaultdict(lambda: defaultdict(list)) by_line_by_dst_passages: dict[
str, dict[str, list[NextPassageSchema]]
] = defaultdict(lambda: defaultdict(list))
for delivery in stop_monitoring_deliveries: for delivery in stop_monitoring_deliveries:
for stop_visit in delivery.MonitoredStopVisit: for stop_visit in delivery.MonitoredStopVisit:
@@ -190,7 +196,9 @@ async def get_next_passages(stop_id: str) -> JSONResponse:
atStop=call.VehicleAtStop, atStop=call.VehicleAtStop,
aimedArrivalTs=optional_datetime_to_ts(call.AimedArrivalTime), aimedArrivalTs=optional_datetime_to_ts(call.AimedArrivalTime),
expectedArrivalTs=optional_datetime_to_ts(call.ExpectedArrivalTime), expectedArrivalTs=optional_datetime_to_ts(call.ExpectedArrivalTime),
arrivalPlatformName=call.ArrivalPlatformName.value if call.ArrivalPlatformName else None, arrivalPlatformName=call.ArrivalPlatformName.value
if call.ArrivalPlatformName
else None,
aimedDepartTs=optional_datetime_to_ts(call.AimedDepartureTime), aimedDepartTs=optional_datetime_to_ts(call.AimedDepartureTime),
expectedDepartTs=optional_datetime_to_ts(call.ExpectedDepartureTime), expectedDepartTs=optional_datetime_to_ts(call.ExpectedDepartureTime),
arrivalStatus=call.ArrivalStatus.value, arrivalStatus=call.ArrivalStatus.value,

View File

@@ -11,7 +11,7 @@ python = "^3.11"
aiohttp = "^3.8.3" aiohttp = "^3.8.3"
rich = "^12.6.0" rich = "^12.6.0"
aiofiles = "^22.1.0" aiofiles = "^22.1.0"
sqlalchemy = {extras = ["asyncio"], version = "^1.4.46"} sqlalchemy = {extras = ["asyncio"], version = "^2.0.1"}
fastapi = "^0.88.0" fastapi = "^0.88.0"
uvicorn = "^0.20.0" uvicorn = "^0.20.0"
asyncpg = "^0.27.0" asyncpg = "^0.27.0"
@@ -21,38 +21,32 @@ msgspec = "^0.12.0"
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.poetry.dev-dependencies] [tool.poetry.group.dev.dependencies]
mypy = "^0.971"
pylsp-mypy = "^0.6.2" pylsp-mypy = "^0.6.2"
autopep8 = "^1.7.0"
mccabe = "^0.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" rope = "^1.3.0"
python-lsp-server = {extras = ["yapf"], version = "^1.5.0"}
python-lsp-black = "^1.2.1" python-lsp-black = "^1.2.1"
black = "^22.10.0" black = "^22.10.0"
whatthepatch = "^1.0.2"
[tool.poetry.group.dev.dependencies]
types-aiofiles = "^22.1.0.2" types-aiofiles = "^22.1.0.2"
sqlalchemy-stubs = "^0.4"
wrapt = "^1.14.1" wrapt = "^1.14.1"
pydocstyle = "^6.2.2" pydocstyle = "^6.2.2"
pylint = "^2.15.9"
dill = "^0.3.6" dill = "^0.3.6"
python-lsp-ruff = "^1.0.5"
python-lsp-server = "^1.7.1"
autopep8 = "^2.0.1"
pyflakes = "^3.0.1"
yapf = "^0.32.0"
whatthepatch = "^1.0.4"
sqlalchemy = {extras = ["mypy"], version = "^2.0.1"}
mypy = "^1.0.0"
[tool.pylsp-mypy] [tool.mypy]
enabled = true plugins = "sqlalchemy.ext.mypy.plugin"
[mypy] [tool.black]
plugins = "sqlmypy" target-version = ['py311']
[pycodestyle]
max_line_length = 100
[pylint]
max-line-length = 100
[tool.ruff]
line-length = 88
[too.ruff.per-file-ignores]
"__init__.py" = ["E401"]

View File

@@ -11,6 +11,7 @@
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/leaflet": "^1.9.0",
"@vitejs/plugin-basic-ssl": "^1.0.1", "@vitejs/plugin-basic-ssl": "^1.0.1",
"eslint": "^8.32.0", "eslint": "^8.32.0",
"eslint-plugin-solid": "^0.9.3", "eslint-plugin-solid": "^0.9.3",

View File

@@ -1,12 +1,10 @@
import { Component } from 'solid-js'; import { Component } from 'solid-js';
import { MatrixCapabilities, WidgetApi, WidgetApiToWidgetAction, CustomEvent, IVisibilityActionRequest } from 'matrix-widget-api'; import { IVisibilityActionRequest, MatrixCapabilities, WidgetApi, WidgetApiToWidgetAction } from 'matrix-widget-api';
import { HopeProvider } from "@hope-ui/solid"; import { HopeProvider } from "@hope-ui/solid";
import { BusinessDataProvider } from './businessData'; import { BusinessDataProvider } from './businessData';
import { SearchProvider } from './search'; import { SearchProvider } from './search';
import { NextPassagesDisplay } from './nextPassagesDisplay'; import { PassagesDisplay } from './passagesDisplay';
import { StopsManager } from './stopsManager'; import { StopsManager } from './stopsManager';
import styles from './App.module.css'; import styles from './App.module.css';
@@ -28,7 +26,7 @@ const App: Component = () => {
console.log("App: widgetId:" + widgetId); console.log("App: widgetId:" + widgetId);
console.log("App: userId:" + userId); console.log("App: userId:" + userId);
const api = new WidgetApi(widgetId); const api = new WidgetApi(widgetId != null ? widgetId : undefined);
api.requestCapability(MatrixCapabilities.AlwaysOnScreen); api.requestCapability(MatrixCapabilities.AlwaysOnScreen);
api.start(); api.start();
api.on("ready", function() { api.on("ready", function() {
@@ -53,7 +51,7 @@ const App: Component = () => {
<StopsManager /> <StopsManager />
</div> </div>
<div class={styles.panel}> <div class={styles.panel}>
<NextPassagesDisplay /> <PassagesDisplay />
</div> </div>
</div> </div>
</HopeProvider> </HopeProvider>

View File

@@ -1,28 +1,37 @@
import { createContext, createSignal } from 'solid-js'; import { createContext, createSignal, JSX } from 'solid-js';
import { createStore } from 'solid-js/store'; import { createStore } from 'solid-js/store';
import { Passages, Stops } from './types'; import { Line, Lines, Passage, Passages, Stop, Stops } from './types';
interface Store { export interface BusinessDataStore {
getLine: (lineId: string) => Promise<Line>;
getLinePassages: (lineId: string) => Record<string, Passage[]>;
passages: () => Passages; passages: () => Passages;
getLinePassages?: (lineId: string) => Passages; refreshPassages: (stopId: number) => Promise<void>;
addPassages?: (passages) => void; addPassages: (passages: Passages) => void;
clearPassages?: () => void; clearPassages: () => void;
stops: () => Stops; getStop: (stopId: number) => Stop | undefined;
addStops?: (stops) => void; searchStopByName: (name: string) => Promise<Stops>;
}; };
export const BusinessDataContext = createContext<Store>(); export const BusinessDataContext = createContext<BusinessDataStore>();
export function BusinessDataProvider(props: { children: JSX.Element }) { export function BusinessDataProvider(props: { children: JSX.Element }) {
const [serverUrl, setServerUrl] = createSignal<string>("https://localhost:4443"); const [serverUrl] = createSignal<string>("https://localhost:4443");
const [store, setStore] = createStore({ lines: {}, passages: {}, stops: {} }); type Store = {
lines: Lines;
passages: Passages;
stops: Stops;
};
async function getLine(lineId: number) { const [store, setStore] = createStore<Store>({ lines: {}, passages: {}, stops: {} });
const getLine = async (lineId: string): Promise<Line> => {
let line = store.lines[lineId]; let line = store.lines[lineId];
if (line === undefined) { if (line === undefined) {
console.log(`${lineId} not found... fetch it from backend.`); console.log(`${lineId} not found... fetch it from backend.`);
@@ -35,45 +44,84 @@ export function BusinessDataProvider(props: { children: JSX.Element }) {
return line; return line;
} }
const passages = () => { const getLinePassages = (lineId: string): Record<string, Passage[]> => {
return store.passages;
};
const getLinePassages = (lineId: string) => {
return store.passages[lineId]; return store.passages[lineId];
}; };
const addPassages = (passages) => { const passages = (): Passages => {
setStore((s) => { return store.passages;
// console.log("s=", s); }
setStore('passages', passages);
// console.log("s=", s); const refreshPassages = async (stopId: number): Promise<void> => {
const httpOptions = { headers: { "Content-Type": "application/json" } };
console.log(`Fetching data for ${stopId}`);
const data = await fetch(`${serverUrl()}/stop/nextPassages/${stopId}`, httpOptions);
const response = await data.json();
addPassages(response.passages);
}
const addPassages = (passages: Passages): void => {
const storePassages = store.passages;
for (const lineId of Object.keys(passages)) {
const newLinePassages = passages[lineId];
const linePassages = storePassages[lineId];
if (linePassages === undefined) {
setStore('passages', lineId, newLinePassages);
}
else {
for (const destination of Object.keys(newLinePassages)) {
const newLinePassagesDestination = newLinePassages[destination];
setStore('passages', lineId, destination, newLinePassagesDestination);
}
}
}
}
const clearPassages = (): void => {
setStore((s: Store): Store => {
for (const lineId of Object.keys(s.passages)) {
setStore('passages', lineId, undefined);
}
return s;
}); });
} }
const clearPassages = () => { const getStop = (stopId: number): Stop | undefined => {
setStore((s) => { return store.stops[stopId];
// TODO: Really need to set to undefined to reset ? }
console.log("s=", s);
console.log("s.passages=", s.passages); const searchStopByName = async (name: string): Promise<Stops> => {
// setStore('passages', undefined); const data = await fetch(`${serverUrl()}/stop/?name=${name}`, {
// setStore('passages', {}); headers: { 'Content-Type': 'application/json' }
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); const stops = await data.json();
// setStore('passages', {});
// } const byIdStops: Stops = {};
console.log("passages=", store.passages); for (const stop of stops) {
byIdStops[stop.id] = stop;
setStore('stops', stop.id, stop);
}
return byIdStops;
} }
return ( return (
<BusinessDataContext.Provider value={{ getLine, passages, getLinePassages, addPassages, clearPassages, serverUrl }}> <BusinessDataContext.Provider value={{
getLine, getLinePassages, passages, refreshPassages, addPassages, clearPassages, getStop, searchStopByName
}}>
{props.children} {props.children}
</BusinessDataContext.Provider> </BusinessDataContext.Provider>
); );
} }
export interface BusinessDataStore {
getLine: (lineId: string) => Promise<Line>;
getLinePassages: (lineId: string) => Record<string, Passage[]>;
passages: () => Passages;
refreshPassages: (stopId: number) => Promise<void>;
addPassages: (passages: Passages) => void;
clearPassages: () => void;
getStop: (stopId: number) => Stop | undefined;
searchStopByName: (name: string) => Promise<Stops>;
};

View File

@@ -16,6 +16,7 @@
body { body {
aspect-ratio: 16/9; aspect-ratio: 16/9;
width: 100vw; width: 100vw;
height: none;
margin: 0; margin: 0;
@@ -23,6 +24,5 @@ body {
} }
#root { #root {
height: inherit;
width: inherit; width: inherit;
} }

View File

@@ -1,253 +0,0 @@
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<number>(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 (
<div class={styles.header}>
<For each={Array.from(transportModes)}>
{(transportMode) => {
return (
<div class={styles.transportMode}>
<img src={transportMode} />
</div>
);
}}
</For>
<div class={styles.title}>
<svg viewbox="0 0 1260 50">
<text
x="0"
y="50%"
dominant-baseline="middle"
font-size="50"
style="fill: #ffffff"
>
{title}
</text>
</svg>
</div>
<div class={styles.clock}>
<svg viewbox="0 0 115 43">
<text
x="50%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="43"
style="fill: #ffffff"
>
{format(dateNow(), "HH:mm")}
</text>
</svg>
</div>
</div>
);
}
function _computeFooter(): JSX.Element {
return (
<div class={styles.footer}>
<For each={panels}>
{(positioned) => {
const { position, panel } = positioned;
const circleStyle = {
fill: `var(--idfm-${position == displayedPanelId() ? "white" : "black"
})`,
};
return (
<div>
<svg viewBox="0 0 29 29">
<circle
cx="50%"
cy="50%"
r="13"
stroke="#ffffff"
stroke-width="3"
style={circleStyle}
/>
</svg>
</div>
);
}}
</For>
</div>
);
}
const mainDivClasses = `${styles.NextPassagesDisplay} ${styles.ar16x9}`;
return (
<div class={mainDivClasses}>
{_computeHeader("Prochains passages")}
<div class={styles.panelsContainer}>
{() => {
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 = (
<NextPassagesPanel
show={panelid == displayedPanelId()}
nextPassages={store}
lines={_lines}
/>
);
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 = (
<NextPassagesPanel
show={panelId == displayedPanelId()}
nextPassages={store}
lines={_lines}
/>
);
newPanels.push(panel);
positioneds.push({ position: panelId, panel });
}
setPanels(positioneds);
return newPanels;
}}
</div>
{_computeFooter()}
</div>
);
};

View File

@@ -1,121 +0,0 @@
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, string>([
[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 (
<div class={class_}>
<svg viewBox={`0 0 215 ${fontSize}`}>
<Motion.text
x="100%" y="55%"
dominant-baseline="middle" text-anchor="end"
font-size={fontSize} style={{ fill: "#000000" }}
initial={isApproaching}
animate={{ opacity: [1, 0, 1] }}
transition={{ duration: 3, repeat: Infinity }}>
{Math.floor(ttwSec / 60)} min
</Motion.text>
</svg>
</div>
);
}
function _computeUnavailablePassage(class_) {
const textStyle = { fill: "#000000" };
return (
<div class={class_}>
<svg viewbox="0 0 230 110">
<text x="100%" y="26" font-size="25" text-anchor="end" style={textStyle}>Information</text>
<text x="100%" y="63" font-size="25" text-anchor="end" style={textStyle}>non</text>
<text x="100%" y="100" font-size="25" text-anchor="end" style={textStyle}>disponible</text>
</svg>
</div>
);
}
function _computeSecondPassage(passage): JSX.Element {
return (
<Show when={passage !== undefined} fallback={_computeUnavailablePassage(styles.unavailableSecondPassage)}>
{_computeTtwPassage(styles.secondPassage, passage, 45)}
</Show>
);
}
function _computeFirstPassage(passage): JSX.Element {
return (
<Show when={passage !== undefined} fallback={_computeUnavailablePassage(styles.unavailableFirstPassage)}>
{_computeTtwPassage(styles.firstPassage, passage, 50)}
</Show>
);
}
/* 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 (
<div class={styles.line}>
<div class={styles.transportMode}>
{renderLineTransportMode(line)}
</div>
{renderLinePicto(line, styles)}
<div class={styles.destination}>
<svg viewbox="0 0 600 40">
<text x="0" y="50%" dominant-baseline="middle" font-size="40" style={{ fill: "#000000" }}>
{destination}
</text>
</svg>
</div>
<div class={styles.trafficStatus}>
<svg viewBox="0 0 51 51">
<circle cx="50%" cy="50%" r="24" stroke="#231f20" stroke-width="3" style={trafficStatusStyle} />
</svg>
</div>
{firstPassage ? _computeFirstPassage(firstPassage) : null}
{secondPassage ? _computeSecondPassage(secondPassage) : null}
</div>
);
}
return (
<div classList={{ [styles.nextPassagesContainer]: true, [styles.displayed]: props.show }} style={{ "top": `${100 * props.position}%` }}>
{() => {
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;
}}
</div>
);
}

View File

@@ -0,0 +1,91 @@
/* Idfm: 1860x1080px */
.passagesDisplay {
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 {
aspect-ratio: 1/1;
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%);
}
/* 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%);
}

View File

@@ -0,0 +1,200 @@
import { createEffect, createResource, createSignal, For, JSX, ParentComponent, Show, useContext, VoidComponent } from "solid-js";
import { createStore } from "solid-js/store";
import { createDateNow } from "@solid-primitives/date";
import { format } from "date-fns";
import { BusinessDataContext, BusinessDataStore } from "./businessData";
import { SearchContext, SearchStore } from "./search";
import { Passage, Passages } from "./types";
import { getTransportModeSrc } from "./utils";
import { PassagesPanel } from "./passagesPanel";
import styles from "./passagesDisplay.module.css";
export const PassagesDisplay: ParentComponent = () => {
const maxPassagePerPanel = 5;
const syncPeriodMsec = 20 * 1000;
const panelSwitchPeriodMsec = 4 * 1000;
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
// TODO: Use props instead
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined)
return <div />;
const { passages, getLine, getLinePassages, refreshPassages, clearPassages } = businessDataStore;
const { getDisplayedStops } = searchStore;
const [displayedPanelId, setDisplayedPanelId] = createSignal<number>(0);
type PositionnedPanel = {
position: number;
// TODO: Should be PassagesPanelComponent ?
panel: JSX.Element;
};
const [panels, setPanels] = createStore<PositionnedPanel[]>([]);
const [dateNow] = createDateNow(1000);
setInterval(() => {
let nextPanelId = displayedPanelId() + 1;
if (nextPanelId >= panels.length) {
nextPanelId = 0;
}
setDisplayedPanelId(nextPanelId);
}, panelSwitchPeriodMsec);
createEffect(() => {
console.log("######### onStopIdUpdate #########");
// Track local.stopIp to force dependency.
console.log("getDisplayedStop=", getDisplayedStops());
clearPassages();
});
createEffect(async () => {
console.log(`## OnPassageUpdate ${passages()} ##`);
const stops = getDisplayedStops();
if (stops.length > 0) {
refreshPassages(stops[0].id);
}
});
setInterval(
async () => {
const stops = getDisplayedStops();
if (stops.length > 0) {
refreshPassages(stops[0].id);
}
},
syncPeriodMsec
);
// TODO: Sort transport modes by weight
const Header: VoidComponent<{ passages: Passages, title: string }> = (props) => {
const computeTransportModes = async (lineIds: string[]): Promise<string[]> => {
const lines = await Promise.all(lineIds.map((lineId) => getLine(lineId)));
const urls: Set<string> = new Set();
for (const line of lines) {
const src = getTransportModeSrc(line.transportMode, false);
if (src !== undefined) {
urls.add(src);
}
}
return Array.from(urls);
}
const [linesIds, setLinesIds] = createSignal<string[]>([]);
const [transportModeUrls] = createResource<string[], string[]>(linesIds, computeTransportModes);
createEffect(() => {
setLinesIds(Object.keys(props.passages));
});
return (
<div class={styles.header}>
<Show when={transportModeUrls() !== undefined} >
<For each={transportModeUrls()}>
{(url) =>
<div class={styles.transportMode}>
<img src={url} />
</div>
}
</For>
</Show>
<div class={styles.title}>
<svg viewBox="0 0 1260 50">
<text x="0" y="50%" dominant-baseline="middle" font-size="50" style="fill: #ffffff">
{props.title}
</text>
</svg>
</div>
<div class={styles.clock}>
<svg viewBox="0 0 115 43">
<text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle" font-size="43" style="fill: #ffffff">
{format(dateNow(), "HH:mm")}
</text>
</svg>
</div>
</div >
);
};
const Footer: VoidComponent<{ panels: PositionnedPanel[] }> = (props) => {
return (
<div class={styles.footer}>
<For each={props.panels}>
{(panel) => {
const position = panel.position;
return (
<div>
<svg viewBox="0 0 29 29">
<circle cx="50%" cy="50%" r="13" stroke="#ffffff" stroke-width="3"
style={{ fill: `var(--idfm-${position == displayedPanelId() ? "white" : "black"})` }}
/>
</svg>
</div>
);
}}
</For>
</div>
);
}
return (
<div class={styles.passagesDisplay}>
<Header title="Prochains passages" passages={passages()} />
<div class={styles.panelsContainer}>
{() => {
setPanels([]);
let newPanels = [];
let positioneds: PositionnedPanel[] = [];
let index = 0;
let chunk: Record<string, Record<string, Passage[]>> = {};
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 {
const panelid = index++;
const panel = <PassagesPanel show={panelid == displayedPanelId()} passages={chunk} />;
newPanels.push(panel);
positioneds.push({ position: panelid, panel: panel });
chunk = {};
chunk[lineId] = byLinePassages;
chunkSize = byLinePassagesKeys.length;
}
}
if (chunkSize) {
const panelId = index++;
const panel = <PassagesPanel show={panelId == displayedPanelId()} passages={chunk} />;
newPanels.push(panel);
positioneds.push({ position: panelId, panel: panel });
}
setPanels(positioneds);
return newPanels;
}}
</div>
<Footer panels={panels} />
</div>
);
};

View File

@@ -1,101 +1,22 @@
.passagesContainer {
/* 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%; height: 100%;
width: 100%; width: 100%;
display: none; display: none;
position: relative;
}
.nextPassagesContainer .line:last-child { position: relative;
border-bottom: 0;
/* To make up for the bottom border deletion */
padding-bottom: calc(2px);
} }
.displayed { .displayed {
display: block; display: block;
} }
/* TODO: Remove the bottom border only if there are 5 displayed lines. */
.passagesContainer .line:last-child {
border-bottom: 0;
/* To make up for the bottom border deletion */
padding-bottom: calc(2px);
}
/* Idfm: 1880x176px (margin: 0px 20px) */ /* Idfm: 1880x176px (margin: 0px 20px) */
.line { .line {
@@ -120,7 +41,7 @@
/* Idfm: 100x100px (margin: 0px 15px) */ /* Idfm: 100x100px (margin: 0px 15px) */
.transportMode { .transportMode {
aspect-ratio : 1 / 1; aspect-ratio : 1 / 1;
height: calc(100/176*100%); height: calc(100/176*100%);
margin: 0 calc(15/1920*100%); margin: 0 calc(15/1920*100%);
} }
@@ -136,10 +57,16 @@
margin-right: calc(23/1920*100%); margin-right: calc(23/1920*100%);
} }
.metroLinePicto {
aspect-ratio : 1 / 1;
height: calc(100/176*100%);
margin-right: calc(23/1920*100%);
}
.destination { .destination {
height: calc(60/176*100%); height: calc(60/176*100%);
width: 50%; width: 50%;
font-family: IDFVoyageur-bold; font-family: IDFVoyageur-bold;
text-align: left; text-align: left;
} }
@@ -162,7 +89,7 @@
.firstPassage { .firstPassage {
height: calc(100/176*100%); height: calc(100/176*100%);
aspect-ratio: 2.5; aspect-ratio: 2.5;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -203,24 +130,6 @@
margin-right: calc(30/1920*100%); margin-right: calc(30/1920*100%);
} }
.unavailableSecondNextPassage svg { .unavailableSecondPassage svg {
font-family: IDFVoyageur-regular; 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%);
}

View File

@@ -0,0 +1,140 @@
import { VoidComponent, createEffect, createResource, createSignal, ParentComponent, ParentProps, Show, useContext, For } from 'solid-js';
import { createDateNow, getTime } from '@solid-primitives/date';
import { AnimationOptions } from '@motionone/types';
import { Motion } from "@motionone/solid";
import { Line, Passage, Passages, TrafficStatus } from './types';
import { renderLineTransportMode, renderLinePicto } from './utils';
import { BusinessDataContext, BusinessDataStore } from "./businessData";
import styles from './passagesPanel.module.css';
const TtwPassage: VoidComponent<{ passage: Passage, style: string, fontSize: number }> = (props) => {
const [dateNow] = createDateNow(5000);
const refTs = props.passage.expectedDepartTs !== null ? props.passage.expectedDepartTs : props.passage.expectedArrivalTs;
const ttwSec = refTs - (getTime(dateNow()) / 1000);
const isApproaching = ttwSec <= 60;
const transition: AnimationOptions = { duration: 3, repeat: Infinity };
return (
<div class={props.style}>
<svg viewBox={`0 0 215 ${props.fontSize}`}>
<Motion.text
x="100%" y="55%"
dominant-baseline="middle" text-anchor="end"
font-size={props.fontSize} style={{ fill: "#000000" }}
initial={isApproaching ? undefined : false}
animate={{ opacity: [1, 0, 1] }}
transition={transition}>
{Math.floor(ttwSec / 60)} min
</Motion.text>
</svg>
</div>
);
}
const UnavailablePassage: VoidComponent<{ style: string }> = (props) => {
const textStyle = { fill: "#000000" };
return (
<div class={props.style}>
<svg viewBox="0 0 230 110">
<text x="100%" y="26" font-size="25" text-anchor="end" style={textStyle}>Information</text>
<text x="100%" y="63" font-size="25" text-anchor="end" style={textStyle}>non</text>
<text x="100%" y="100" font-size="25" text-anchor="end" style={textStyle}>disponible</text>
</svg>
</div>
);
}
/* TODO: Manage end of service */
const DestinationPassages: VoidComponent<{ passages: Passage[], line: Line, destination: string }> = (props) => {
/* TODO: Find where to get data to compute traffic status. */
const trafficStatusColor = new Map<TrafficStatus, string>([
[TrafficStatus.UNKNOWN, "#ffffff"],
[TrafficStatus.FLUID, "#00643c"],
[TrafficStatus.DISRUPTED, "#ffbe00"],
[TrafficStatus.VERY_DISRUPTED, "#ff5a00"],
[TrafficStatus.BYPASSED, "#ffffff"]
]);
const passagesLength = props.passages.length;
const firstPassage = passagesLength > 0 ? props.passages[0] : undefined;
const secondPassage = passagesLength > 1 ? props.passages[1] : undefined;
// TODO: Manage traffic status
// const trafficStatusStyle = { fill: trafficStatusColor.get(props.line.trafficStatus) };
const trafficStatusStyle = { fill: trafficStatusColor.get(TrafficStatus.UNKNOWN) };
return (
<div class={styles.line}>
<div class={styles.transportMode}>
{renderLineTransportMode(props.line)}
</div>
{renderLinePicto(props.line, styles)}
<div class={styles.destination}>
<svg viewBox="0 0 600 40">
<text x="0" y="50%" dominant-baseline="middle" font-size="40" style={{ fill: "#000000" }}>
{props.destination}
</text>
</svg>
</div>
<div class={styles.trafficStatus}>
<svg viewBox="0 0 51 51">
<circle cx="50%" cy="50%" r="24" stroke="#231f20" stroke-width="3" style={trafficStatusStyle} />
</svg>
</div>
<Show when={firstPassage !== undefined} fallback=<UnavailablePassage style={styles.unavailableFirstPassage} />>
<TtwPassage style={styles.firstPassage} passage={firstPassage} fontSize={50} />
</Show>
<Show when={secondPassage !== undefined} fallback=<UnavailablePassage style={styles.unavailableSecondPassage} />>
<TtwPassage style={styles.secondPassage} passage={secondPassage} fontSize={45} />
</Show>
</div >
);
}
export type PassagesPanelComponentProps = ParentProps & { passages: Passages, show: boolean };
export type PassagesPanelComponent = ParentComponent<PassagesPanelComponentProps>;
export const PassagesPanel: PassagesPanelComponent = (props) => {
const businessDataContext: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataContext === undefined)
return <div />;
const { getLine } = businessDataContext;
const getLines = async (lineIds: string[]): Promise<Line[]> => {
const lines = await Promise.all<Promise<Line>[]>(lineIds.map((lineId) => getLine(lineId)));
return lines;
}
const [lineIds, setLinesIds] = createSignal<string[]>([]);
const [lines] = createResource<Line[], string[]>(lineIds, getLines);
createEffect(async () => {
setLinesIds(Object.keys(props.passages));
});
return (
<div classList={{ [styles.passagesContainer]: true, [styles.displayed]: props.show }} >
<Show when={lines() !== undefined} >
<For each={lines()}>
{(line) =>
<Show when={props.passages[line.id]}>
<For each={Object.keys(props.passages[line.id])}>
{(destination) =>
<DestinationPassages passages={props.passages[line.id][destination]} line={line} destination={destination} />
}
</For>
</Show>
}
</For>
</Show>
</div >
);
}

View File

@@ -1,75 +1,49 @@
import { batch, createContext, createSignal } from 'solid-js'; import { createContext, JSX } from 'solid-js';
import { createStore } from 'solid-js/store'; import { createStore } from 'solid-js/store';
import { Marker as LeafletMarker } from 'leaflet';
import { Stop, Stops } from './types'; import { Stop } from './types';
interface Store { export type ByStopIdMarkers = Record<number, LeafletMarker[] | undefined>;
getMarkers: () => Markers;
addMarkers?: (stopId, markers) => void;
setMarkers?: (markers) => void;
getStops: () => Stops; export interface SearchStore {
setStops?: (stops) => void; getDisplayedStops: () => Stop[];
removeStops?: (stopIds) => void; setDisplayedStops: (stops: Stop[]) => void;
getDisplayedStop: () => Stop; addMarkers: (stopId: number, markers: LeafletMarker[]) => void;
setDisplayedStop: (stop: Stop) => void;
}; };
export const SearchContext = createContext<Store>(); export const SearchContext = createContext<SearchStore>();
export function SearchProvider(props: { children: JSX.Element }) { export function SearchProvider(props: { children: JSX.Element }) {
const [store, setStore] = createStore({stops: {}, markers: {}, displayedStop: []}); type Store = {
stops: Record<number, Stop | undefined>;
markers: ByStopIdMarkers;
displayedStops: Stop[];
};
const getStops = () => { const [store, setStore] = createStore<Store>({ stops: {}, markers: {}, displayedStops: [] });
return store.stops;
};
const setStops = (stops) => { const getDisplayedStops = (): Stop[] => {
setStore((s) => { return store.displayedStops;
setStore('stops', stops); }
});
};
const removeStops = (stopIds) => { const setDisplayedStops = (stops: Stop[]): void => {
batch(() => { setStore((s: Store) => {
for(const stopId of stopIds) { setStore('displayedStops', stops);
setStore('stops', stopId, undefined); return s;
setStore('markers', stopId, undefined); });
} }
});
};
const getMarkers = () => { const addMarkers = (stopId: number, markers: L.Marker[]): void => {
return store.markers; setStore('markers', stopId, markers);
}; }
const addMarkers = (stopId, markers) => { return (
setStore('markers', stopId, markers); <SearchContext.Provider value={{ getDisplayedStops, setDisplayedStops, addMarkers }}>
}; {props.children}
</SearchContext.Provider>
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 (
<SearchContext.Provider value={{addMarkers, getMarkers, setMarkers, getStops, removeStops, setStops, getDisplayedStop, setDisplayedStop}}>
{props.children}
</SearchContext.Provider>
);
} }

View File

@@ -1,27 +1,31 @@
import { batch, Component, createEffect, createResource, createSignal, onMount, Show, useContext } from 'solid-js'; import { createEffect, createResource, createSignal, For, JSX, onMount, Show, useContext, VoidComponent } 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 { import {
Box, Button, Input, InputLeftAddon, InputGroup, HStack, List, ListItem, Progress, featureGroup as leafletFeatureGroup, LatLngLiteral as LeafletLatLngLiteral, Map as LeafletMap,
ProgressIndicator, VStack Marker as LeafletMarker, tileLayer as leafletTileLayer
} from "@hope-ui/solid"; } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
import { BusinessDataContext } from './businessData'; import { BusinessDataContext, BusinessDataStore } from "./businessData";
import { SearchContext } from './search'; import { SearchContext, SearchStore } from './search';
import { Stop } from './types';
import { renderLineTransportMode, renderLinePicto, TransportModeWeights } from './utils'; import { renderLineTransportMode, renderLinePicto, TransportModeWeights } from './utils';
import styles from './stopManager.module.css'; import styles from './stopManager.module.css';
const StopRepr: Component = (props) => { const StopRepr: VoidComponent<{ stop: Stop }> = (props) => {
const { getLine } = useContext(BusinessDataContext); const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataStore === undefined)
return <div />;
const [lineReprs] = createResource(props.stop.lines, fetchLinesRepr); const { getLine } = businessDataStore;
async function fetchLinesRepr(lineIds) { const fetchLinesRepr = async (lineIds: string[]): Promise<JSX.Element[]> => {
const reprs = []; const reprs = [];
for (const lineId of lineIds) { for (const lineId of lineIds) {
const line = await getLine(lineId); const line = await getLine(lineId);
@@ -33,28 +37,37 @@ const StopRepr: Component = (props) => {
return reprs; return reprs;
} }
const [lineReprs] = createResource<JSX.Element[], string[]>(props.stop.lines, fetchLinesRepr);
return ( return (
<HStack height="100%"> <HStack height="100%">
{props.stop.name} {props.stop.name}
<For each={lineReprs()}>{(line) => line}</For> <For each={lineReprs()}>{(line: JSX.Element) => line}</For>
</HStack> </HStack>
); );
} }
const StopAreaRepr: Component = (props) => { const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
const { getLine } = useContext(BusinessDataContext); const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataStore === undefined)
return <div />;
const [lineReprs] = createResource(props.stop, fetchLinesRepr); const { getLine } = businessDataStore;
async function fetchLinesRepr(stop) { type ByTransportModeReprs = {
mode: JSX.Element | undefined;
[key: string]: JSX.Element | JSX.Element[] | undefined;
};
const fetchLinesRepr = async (stop: Stop): Promise<JSX.Element[]> => {
const lineIds = new Set(stop.lines); const lineIds = new Set(stop.lines);
const stops = stop.stops; const stops = stop.stops;
for (const stop of stops) { for (const stop of stops) {
stop.lines.forEach(lineIds.add, lineIds); stop.lines.forEach(lineIds.add, lineIds);
} }
const byModeReprs = {}; const byModeReprs: Record<string, ByTransportModeReprs> = {};
for (const lineId of lineIds) { for (const lineId of lineIds) {
const line = await getLine(lineId); const line = await getLine(lineId);
if (line !== undefined) { if (line !== undefined) {
@@ -68,7 +81,7 @@ const StopAreaRepr: Component = (props) => {
} }
const reprs = []; const reprs = [];
const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] < TransportModeWeights[y]); const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] < TransportModeWeights[y] ? 1 : -1);
for (const transportMode of sortedTransportModes) { for (const transportMode of sortedTransportModes) {
const lines = byModeReprs[transportMode]; const lines = byModeReprs[transportMode];
const repr = [lines.mode]; const repr = [lines.mode];
@@ -81,6 +94,8 @@ const StopAreaRepr: Component = (props) => {
return reprs; return reprs;
} }
const [lineReprs] = createResource(props.stop, fetchLinesRepr);
return ( return (
<HStack height="100%"> <HStack height="100%">
{props.stop.name} {props.stop.name}
@@ -90,29 +105,34 @@ const StopAreaRepr: Component = (props) => {
} }
const Map: Component = (props) => { const Map: VoidComponent<{ stops: Stop[] }> = (props) => {
const mapCenter = [48.853, 2.35]; const mapCenter: LeafletLatLngLiteral = { lat: 48.853, lng: 2.35 };
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined)
return <div />;
const { addMarkers } = searchStore;
const { addMarkers, getStops } = useContext(SearchContext);
let mapDiv: any; let mapDiv: any;
let map = null; let map: LeafletMap | undefined = undefined;
const stopsLayerGroup = L.featureGroup(); const stopsLayerGroup = leafletFeatureGroup();
function buildMap(div: HTMLDivElement) { const buildMap = (div: HTMLDivElement) => {
map = L.map(div).setView(mapCenter, 11); map = new LeafletMap(div).setView(mapCenter, 11);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { leafletTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map); }).addTo(map);
stopsLayerGroup.addTo(map); stopsLayerGroup.addTo(map);
} }
function setMarker(stop) { const setMarker = (stop: Stop): L.Marker[] => {
const markers = []; const markers = [];
if (stop.lat !== undefined && stop.lon !== undefined) { if (stop.lat !== undefined && stop.lon !== undefined) {
/* TODO: Add stop lines representation to popup. */ /* TODO: Add stop lines representation to popup. */
markers.push(L.marker([stop.lat, stop.lon]).bindPopup(`${stop.name}`).openPopup()); markers.push(new LeafletMarker([stop.lat, stop.lon]).bindPopup(`${stop.name}`).openPopup());
} }
else { else {
for (const _stop of stop.stops) { for (const _stop of stop.stops) {
@@ -124,11 +144,11 @@ const Map: Component = (props) => {
onMount(() => buildMap(mapDiv)); onMount(() => buildMap(mapDiv));
const onStopUpdate = createEffect(() => { createEffect(() => {
/* TODO: Avoid to clear all layers... */ /* TODO: Avoid to clear all layers... */
stopsLayerGroup.clearLayers(); stopsLayerGroup.clearLayers();
for (const stop of Object.values(getStops())) { for (const stop of props.stops) {
const markers = setMarker(stop); const markers = setMarker(stop);
addMarkers(stop.id, markers); addMarkers(stop.id, markers);
for (const marker of markers) { for (const marker of markers) {
@@ -137,7 +157,7 @@ const Map: Component = (props) => {
} }
const stopsBound = stopsLayerGroup.getBounds(); const stopsBound = stopsLayerGroup.getBounds();
if (Object.keys(stopsBound).length) { if (map !== undefined && Object.keys(stopsBound).length) {
map.fitBounds(stopsBound); map.fitBounds(stopsBound);
} }
}); });
@@ -145,42 +165,32 @@ const Map: Component = (props) => {
return <div ref={mapDiv} id='main-map' style={{ width: "100%", height: "100%" }} />; return <div ref={mapDiv} id='main-map' style={{ width: "100%", height: "100%" }} />;
} }
export const StopsManager: Component = (props) => { export const StopsManager: VoidComponent = () => {
const [minCharactersNb, setMinCharactersNb] = createSignal<int>(4); const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const [_inProgress, _setInProgress] = createSignal<bool>(false); const searchStore: SearchStore | undefined = useContext(SearchContext);
const { serverUrl } = useContext(BusinessDataContext); if (businessDataStore === undefined || searchStore === undefined)
const { getStops, removeStops, setStops, setDisplayedStop } = useContext(SearchContext); return <div />;
async function _fetchStopByName(name) { const { searchStopByName } = businessDataStore;
const data = await fetch(`${serverUrl()}/stop/?name=${name}`, { const { setDisplayedStops } = searchStore;
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)); // TODO: Use props instead
const [minCharactersNb] = createSignal<number>(4);
const byIdStops = {}; const [inProgress, setInProgress] = createSignal<boolean>(false);
for (const stop of stops) { const [foundStops, setFoundStops] = createSignal<Stop[]>([]);
byIdStops[stop.id] = stop;
}
batch(() => { const onStopNameInput: JSX.EventHandler<HTMLInputElement, InputEvent> = async (event): Promise<void> => {
removeStops(stopIdsToRemove);
setStops(byIdStops);
});
}
async function _onStopNameInput(event) {
/* TODO: Add a tempo before fetching stop for giving time to user to finish his request */ /* TODO: Add a tempo before fetching stop for giving time to user to finish his request */
const stopName = event.target.value; const stopName = event.currentTarget.value;
if (stopName.length >= minCharactersNb()) { if (stopName.length >= minCharactersNb()) {
console.log(`Fetching data for ${stopName}`); console.log(`Fetching data for ${stopName}`);
_setInProgress(true); setInProgress(true);
await _fetchStopByName(stopName); const stopsById = await searchStopByName(stopName);
_setInProgress(false); setFoundStops(Object.values(stopsById));
setInProgress(false);
} }
} }
@@ -188,36 +198,30 @@ export const StopsManager: Component = (props) => {
<VStack h="100%"> <VStack h="100%">
<InputGroup w="50%" h="5%"> <InputGroup w="50%" h="5%">
<InputLeftAddon>🚉 🚏</InputLeftAddon> <InputLeftAddon>🚉 🚏</InputLeftAddon>
<Input onInput={_onStopNameInput} readOnly={_inProgress()} placeholder="Stop name..." /> <Input onInput={onStopNameInput} readOnly={inProgress()} placeholder="Stop name..." />
</InputGroup> </InputGroup>
<Progress size="xs" w="50%" indeterminate={_inProgress()}> <Progress size="xs" w="50%" indeterminate={inProgress()}>
<ProgressIndicator striped animated /> <ProgressIndicator striped animated />
</Progress> </Progress>
<Box w="100%" h="40%" borderWidth="1px" borderColor="var(--idfm-black)" borderRadius="$lg" overflow="scroll" marginBottom="2px"> <Box w="100%" h="40%" borderWidth="1px" borderColor="var(--idfm-black)" borderRadius="$lg" overflow="scroll" marginBottom="2px">
<List width="100%" height="100%"> <List width="100%" height="100%">
{() => { <For each={foundStops().sort((x, y) => x.name.localeCompare(y.name))}>
const items = []; {(stop) =>
for (const stop of Object.values(getStops()).sort((x, y) => x.name.localeCompare(y.name))) { <ListItem borderWidth="1px" mb="0px" color="var(--idfm-black)" borderRadius="$lg">
items.push( <Button fullWidth="true" color="var(--idfm-black)" bg="white" onClick={() => setDisplayedStops([stop])}>
<ListItem h="10%" borderWidth="1px" mb="0px" color="var(--idfm-black)" borderRadius="$lg"> <Box w="100%" h="100%">
<Button fullWidth="true" color="var(--idfm-black)" bg="white" onClick={() => { <Show when={stop.stops !== undefined} fallback={<StopRepr stop={stop} />}>
console.log(`${stop.id} clicked !!!`); <StopAreaRepr stop={stop} />
setDisplayedStop(stop); </Show>
}}> </Box>
<Box w="100%" h="100%"> </Button>
<Show when={stop.stops !== undefined} fallback={<StopRepr stop={stop} />}> </ListItem>
<StopAreaRepr stop={stop} />
</Show>
</Box>
</Button>
</ListItem>);
} }
return items; </For>
}}
</List> </List>
</Box> </Box>
<Box borderWidth="1px" borderColor="var(--idfm-black)" borderRadius="$lg" h="55%" w="100%" overflow="scroll"> <Box borderWidth="1px" borderColor="var(--idfm-black)" borderRadius="$lg" h="55%" w="100%" overflow="scroll">
<Map /> <Map stops={foundStops()} />
</Box> </Box>
</VStack> </VStack>
); );

View File

@@ -1,13 +1,3 @@
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 { export enum TrafficStatus {
UNKNOWN = 0, UNKNOWN = 0,
FLUID, FLUID,
@@ -16,28 +6,93 @@ export enum TrafficStatus {
BYPASSED BYPASSED
} }
export interface Passages { }; export class Passage {
export interface Passage { line: number;
line: number, operator: string;
operator: string, destinations: string[];
destinations: Array<string>, atStop: boolean;
atStop: boolean, aimedArrivalTs: number;
aimedArrivalTs: number, expectedArrivalTs: number;
expectedArrivalTs: number, arrivalPlatformName: string;
arrivalPlatformName: string, aimedDepartTs: number;
aimedDepartTs: number, expectedDepartTs: number;
expectedDepartTs: number, arrivalStatus: string;
arrivalStatus: string, departStatus: string;
departStatus: string,
constructor(line: number, operator: string, destinations: string[], atStop: boolean, aimedArrivalTs: number,
expectedArrivalTs: number, arrivalPlatformName: string, aimedDepartTs: number, expectedDepartTs: number,
arrivalStatus: string, departStatus: string) {
this.line = line;
this.operator = operator;
this.destinations = destinations;
this.atStop = atStop;
this.aimedArrivalTs = aimedArrivalTs;
this.expectedArrivalTs = expectedArrivalTs;
this.arrivalPlatformName = arrivalPlatformName;
this.aimedDepartTs = aimedDepartTs;
this.expectedDepartTs = expectedDepartTs;
this.arrivalStatus = arrivalStatus;
this.departStatus = departStatus;
}
}; };
export type Passages = Record<string, Record<string, Passage[]>>;
export interface Stops { }; export class Stop {
export interface Stop { id: number;
id: number, name: string;
name: string, town: string;
town: string, lat: number;
lat: number, lon: number;
lon: number, stops: Stop[];
lines: Array<string> lines: string[];
constructor(id: number, name: string, town: string, lat: number, lon: number, stops: Stop[], lines: string[]) {
this.id = id;
this.name = name;
this.town = town;
this.lat = lat;
this.lon = lon;
this.stops = stops;
this.lines = lines;
for (const stop of this.stops) {
this.lines.push(...stop.lines);
}
}
}; };
export type Stops = Record<number, Stop>;
export class Line {
id: string;
shortName: string;
name: string;
status: string; // TODO: Use an enum
transportMode: string; // TODO: Use an enum
backColorHexa: string;
foreColorHexa: string;
operatorId: number;
accessibility: boolean;
visualSignsAvailable: string; // TODO: Use an enum
audibleSignsAvailable: string; // TODO: Use an enum
stopIds: number[];
constructor(id: string, shortName: string, name: string, status: string, transportMode: string, backColorHexa: string,
foreColorHexa: string, operatorId: number, accessibility: boolean, visualSignsAvailable: string,
audibleSignsAvailable: string, stopIds: number[]) {
this.id = id;
this.shortName = shortName;
this.name = name;
this.status = status;
this.transportMode = transportMode;
this.backColorHexa = backColorHexa;
this.foreColorHexa = foreColorHexa;
this.operatorId = operatorId;
this.accessibility = accessibility;
this.visualSignsAvailable = visualSignsAvailable;
this.audibleSignsAvailable = audibleSignsAvailable;
this.stopIds = stopIds;
}
};
export type Lines = Record<string, Line>;

View File

@@ -1,6 +1,10 @@
import { getTransportModeSrc } from './types'; import { JSX } from 'solid-js';
export const TransportModeWeights = { import { Line } from './types';
const validTransportModes = ["bus", "tram", "metro", "rer", "transilien", "funicular", "ter", "unknown"];
export const TransportModeWeights: Record<string, number> = {
bus: 1, bus: 1,
tram: 2, tram: 2,
val: 3, val: 3,
@@ -11,11 +15,19 @@ export const TransportModeWeights = {
ter: 8, ter: 8,
}; };
export function renderLineTransportMode(line): JSX.Element { export function getTransportModeSrc(mode: string, color: boolean = true): string | undefined {
let ret = undefined;
if (validTransportModes.includes(mode)) {
ret = `/public/symbole_${mode}_${color ? "" : "support_fonce_"}RVB.svg`;
}
return ret;
}
export function renderLineTransportMode(line: Line): JSX.Element {
return <img src={getTransportModeSrc(line.transportMode)} /> return <img src={getTransportModeSrc(line.transportMode)} />
} }
function renderBusLinePicto(line, styles): JSX.Element { function renderBusLinePicto(line: Line, styles: CSSModuleClasses): JSX.Element {
return ( return (
<div class={styles.busLinePicto}> <div class={styles.busLinePicto}>
<svg viewBox="0 0 31.5 14"> <svg viewBox="0 0 31.5 14">
@@ -33,7 +45,7 @@ function renderBusLinePicto(line, styles): JSX.Element {
); );
} }
function renderTramLinePicto(line, styles): JSX.Element { function renderTramLinePicto(line: Line, styles: CSSModuleClasses): JSX.Element {
const lineStyle = { fill: `#${line.backColorHexa}` }; const lineStyle = { fill: `#${line.backColorHexa}` };
return ( return (
<div class={styles.tramLinePicto}> <div class={styles.tramLinePicto}>
@@ -53,10 +65,10 @@ function renderTramLinePicto(line, styles): JSX.Element {
); );
} }
function renderMetroLinePicto(line, styles): JSX.Element { function renderMetroLinePicto(line: Line, styles: CSSModuleClasses): JSX.Element {
return ( return (
<div class={styles.metroLinePicto}> <div class={styles.metroLinePicto}>
<svg viewbox="0 0 20 20"> <svg viewBox="0 0 20 20">
<circle cx="10" cy="10" r="10" style={{ fill: `#${line.backColorHexa}` }} /> <circle cx="10" cy="10" r="10" style={{ fill: `#${line.backColorHexa}` }} />
<text x="50%" <text x="50%"
y="55%" y="55%"
@@ -70,10 +82,10 @@ function renderMetroLinePicto(line, styles): JSX.Element {
); );
} }
function renderTrainLinePicto(line, styles): JSX.Element { function renderTrainLinePicto(line: Line, styles: CSSModuleClasses): JSX.Element {
return ( return (
<div class={styles.trainLinePicto}> <div class={styles.trainLinePicto}>
<svg viewbox="0 0 20 20"> <svg viewBox="0 0 20 20">
<rect x="0" y="0" width="20" height="20" rx="4.5" ry="4.5" style={{ fill: `#${line.backColorHexa}` }} /> <rect x="0" y="0" width="20" height="20" rx="4.5" ry="4.5" style={{ fill: `#${line.backColorHexa}` }} />
<text x="50%" <text x="50%"
y="55%" y="55%"
@@ -88,7 +100,7 @@ function renderTrainLinePicto(line, styles): JSX.Element {
); );
} }
export function renderLinePicto(line, styles): JSX.Element { export function renderLinePicto(line: Line, styles: CSSModuleClasses): JSX.Element {
switch (line.transportMode) { switch (line.transportMode) {
case "bus": case "bus":
case "funicular": case "funicular":

View File

@@ -15,7 +15,8 @@
{ {
"name": "typescript-eslint-language-service" "name": "typescript-eslint-language-service"
} }
] ],
"lib": ["ES2021", "DOM"],
}, },
"include": ["src"] "include": ["src"]
} }