27 Commits

Author SHA1 Message Date
40b2704a15 🎨 Factorize tramLinePicto CSS class with metroLinePicto one 2023-02-12 22:44:22 +01:00
04053e25ed 💄 Animate destination text when they´re too long for panel 2023-02-12 22:43:09 +01:00
5d566648e5 💄 Fix no picto displayed for train and RER lines 2023-02-12 21:21:45 +01:00
5c08780f98 ️ Reduce the refresh on passages update to the TtwPassage component 2023-02-12 19:01:32 +01:00
3913209b28 Add button to PassagesDisplay to disable passages fetching 2023-02-11 18:12:26 +01:00
7294f35622 🐛 Filter old passages
Only passages with a expectedDepartTs > now - 60s are kept.
2023-02-09 22:36:42 +01:00
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
35 changed files with 1411 additions and 1029 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 .base_class import Base
__all__ = ["Base"]
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.exc import IntegrityError
from sqlalchemy.orm import declarative_base
from typing import Iterable, Self
from sqlalchemy.orm import DeclarativeBase
Base = declarative_base()
Base.db = None
if TYPE_CHECKING:
from .db import Database
async def base_add(cls, stops: Self | Iterable[Self]) -> bool:
class Base(DeclarativeBase):
db: Database | None = None
@classmethod
async def add(cls, stops: Self | Iterable[Self]) -> bool:
try:
method = (
cls.db.session.add_all
if isinstance(stops, Iterable)
else cls.db.session.add
)
method(stops)
await cls.db.session.commit()
except IntegrityError as err:
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
return True
Base.add = classmethod(base_add)
async def base_get_by_id(cls, id_: int | str) -> None | Base:
res = await cls.db.session.execute(select(cls).where(cls.id == id_))
@classmethod
async def get_by_id(cls, id_: int | str) -> Self | None:
try:
stmt = select(cls).where(cls.id == id_) # type: ignore
res = await cls.db.session.execute(stmt) # type: ignore
element = res.scalar_one_or_none()
except AttributeError as err:
print(err)
element = None
return element
Base.get_by_id = classmethod(base_get_by_id)

View File

@@ -1,80 +1,48 @@
from asyncio import gather as asyncio_gather
from functools import wraps
from pathlib import Path
from time import time
from typing import Callable, Iterable, Optional
from rich import print
from sqlalchemy import event, select, tuple_
from sqlalchemy.engine import Engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import (
selectinload,
sessionmaker,
with_polymorphic,
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
async_sessionmaker,
AsyncEngine,
AsyncSession,
create_async_engine,
)
from sqlalchemy.orm.attributes import set_committed_value
from .base_class import Base
# import logging
# logging.basicConfig()
# logger = logging.getLogger("bot.sqltime")
# logger.setLevel(logging.DEBUG)
# @event.listens_for(Engine, "before_cursor_execute")
# def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
# conn.info.setdefault("query_start_time", []).append(time())
# logger.debug("Start Query: %s", statement)
# @event.listens_for(Engine, "after_cursor_execute")
# def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
# total = time() - conn.info["query_start_time"].pop(-1)
# logger.debug("Query Complete!")
# logger.debug("Total Time: %f", total)
class Database:
def __init__(self) -> None:
self._engine = None
self._session_maker = None
self._session = None
self._engine: AsyncEngine | None = None
self._session_maker: async_sessionmaker[AsyncSession] | None = None
self._session: AsyncSession | None = None
@property
def session(self) -> None:
if self._session is None:
self._session = self._session_maker()
def session(self) -> AsyncSession | None:
if self._session is None and (session_maker := self._session_maker) is not None:
self._session = session_maker()
return self._session
def use_session(func: Callable):
@wraps(func)
async def wrapper(self, *args, **kwargs):
if self._check_session() is not None:
return await func(self, *args, **kwargs)
# TODO: Raise an exception ?
async def connect(self, db_path: str, clear_static_data: bool = False) -> bool:
return wrapper
async def connect(self, db_path: str, clear_static_data: bool = False) -> None:
# TODO: Preserve UserLastStopSearchResults table from drop.
self._engine = create_async_engine(db_path)
self._session_maker = sessionmaker(
if self._engine is not None:
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:
if clear_static_data:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
return True
async def disconnect(self) -> None:
if self._session is not None:
await self._session.close()
self._session = None
if self._engine is not None:
await self._engine.dispose()

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,5 +1,5 @@
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 .stop import _Stop
@@ -18,8 +18,8 @@ class UserLastStopSearchResults(Base):
__tablename__ = "user_last_stop_search_results"
user_mxid = Column(String, primary_key=True)
request_content = Column(String, nullable=False)
stops: Mapped[list[_Stop]] = relationship(
user_mxid = mapped_column(String, primary_key=True)
request_content = mapped_column(String, nullable=False)
stops: Mapped[_Stop] = relationship(
_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 .next_passage import NextPassage, NextPassages
from .stop import Stop, StopArea
__all__ = ["Line", "NextPassage", "NextPassages", "Stop", "StopArea", "TransportMode"]

View File

@@ -1,5 +1,4 @@
from enum import StrEnum
from typing import Self
from pydantic import BaseModel
@@ -29,10 +28,11 @@ class TransportMode(StrEnum):
# idfm_types.TransportMode.rail + idfm_types.TransportSubMode.railShuttle
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
def from_idfm_transport_mode(
cls, mode: IdfmTransportMode, sub_mode: IdfmTransportSubMode
) -> Self:
def from_idfm_transport_mode(cls, mode: str, sub_mode: str) -> "TransportMode":
if mode == IdfmTransportMode.rail:
if sub_mode == IdfmTransportSubMode.regionalRail:
return cls.rail_ter
@@ -42,7 +42,7 @@ class TransportMode(StrEnum):
return cls.rail_transilien
if sub_mode == IdfmTransportSubMode.railShuttle:
return cls.val
return TransportMode(mode)
return cls(mode)
class Line(BaseModel):

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
from collections import defaultdict
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.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from rich import print
@@ -21,7 +21,9 @@ from backend.schemas import (
)
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
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")
async def startup():
# await db.connect(DB_PATH, clear_static_data=True)
# await idfm_interface.startup()
await db.connect(DB_PATH, clear_static_data=False)
await db.connect(DB_PATH, clear_static_data=True)
await idfm_interface.startup()
# await db.connect(DB_PATH, clear_static_data=False)
print("Connected")
@@ -61,12 +63,12 @@ STATIC_ROOT = "../frontend/"
app.mount("/widget", StaticFiles(directory=STATIC_ROOT, html=True), name="widget")
def optional_datetime_to_ts(dt: datetime) -> int | None:
return dt.timestamp() if dt else None
def optional_datetime_to_ts(dt: datetime | None) -> int | None:
return int(dt.timestamp()) if dt else None
@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)
if line is None:
@@ -91,7 +93,7 @@ async def get_line(line_id: str) -> JSONResponse:
def _format_stop(stop: Stop) -> StopSchema:
print(stop.__dict__)
# print(stop.__dict__)
return StopSchema(
id=stop.id,
name=stop.name,
@@ -103,15 +105,17 @@ def _format_stop(stop: Stop) -> StopSchema:
lines=[line.id for line in stop.lines],
)
# châtelet
@app.get("/stop/")
async def get_stop(
name: str = "", limit: int = 10
) -> list[StopAreaSchema | StopSchema]:
) -> Sequence[StopAreaSchema | StopSchema]:
# TODO: Add limit support
formatted = []
formatted: list[StopAreaSchema | StopSchema] = []
matching_stops = await Stop.get_by_name(name)
# print(matching_stops, flush=True)
@@ -153,15 +157,17 @@ async def get_stop(
# TODO: Cache response for 30 secs ?
@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)
# print(res)
if res is None:
return None
service_delivery = res.Siri.ServiceDelivery
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 stop_visit in delivery.MonitoredStopVisit:
@@ -190,7 +196,9 @@ async def get_next_passages(stop_id: str) -> JSONResponse:
atStop=call.VehicleAtStop,
aimedArrivalTs=optional_datetime_to_ts(call.AimedArrivalTime),
expectedArrivalTs=optional_datetime_to_ts(call.ExpectedArrivalTime),
arrivalPlatformName=call.ArrivalPlatformName.value if call.ArrivalPlatformName else None,
arrivalPlatformName=call.ArrivalPlatformName.value
if call.ArrivalPlatformName
else None,
aimedDepartTs=optional_datetime_to_ts(call.AimedDepartureTime),
expectedDepartTs=optional_datetime_to_ts(call.ExpectedDepartureTime),
arrivalStatus=call.ArrivalStatus.value,

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,40 @@
import { createContext, createSignal } from 'solid-js';
import { batch, createContext, createSignal, JSX } from 'solid-js';
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[]>;
getLineDestinations: (lineId: string) => string[];
getDestinationPassages: (lineId: string, destination: string) => Passage[];
passages: () => Passages;
getLinePassages?: (lineId: string) => Passages;
addPassages?: (passages) => void;
clearPassages?: () => void;
getPassagesLineIds: () => string[];
refreshPassages: (stopId: number) => Promise<void>;
addPassages: (passages: Passages) => void;
clearPassages: () => void;
stops: () => Stops;
addStops?: (stops) => void;
getStop: (stopId: number) => Stop | undefined;
searchStopByName: (name: string) => Promise<Stops>;
};
export const BusinessDataContext = createContext<Store>();
export const BusinessDataContext = createContext<BusinessDataStore>();
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];
if (line === undefined) {
console.log(`${lineId} not found... fetch it from backend.`);
@@ -35,45 +47,139 @@ export function BusinessDataProvider(props: { children: JSX.Element }) {
return line;
}
const passages = () => {
return store.passages;
};
const getLinePassages = (lineId: string) => {
const getLinePassages = (lineId: string): Record<string, Passage[]> => {
return store.passages[lineId];
};
}
const addPassages = (passages) => {
setStore((s) => {
// console.log("s=", s);
setStore('passages', passages);
// console.log("s=", s);
const getLineDestinations = (lineId: string): string[] => {
return Object.keys(store.passages[lineId]);
}
const getDestinationPassages = (lineId: string, destination: string): Passage[] => {
return store.passages[lineId][destination];
}
const passages = (): Passages => {
return store.passages;
}
const getPassagesLineIds = (): string[] => {
return Object.keys(store.passages);
}
const _cleanupPassages = (passages: Passages): void => {
const deadline = Math.floor(Date.now() / 1000) - 60;
for (const linePassages of Object.values(passages)) {
for (const destination of Object.keys(linePassages)) {
const destinationPassages = linePassages[destination];
const cleaned = [];
for (const passage of destinationPassages) {
if (passage.expectedDepartTs > deadline) {
cleaned.push(passage);
}
}
linePassages[destination] = cleaned;
}
}
}
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();
_cleanupPassages(response.passages);
addPassages(response.passages);
}
const addPassages = (passages: Passages): void => {
batch(() => {
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];
const linePassagesDestination = linePassages[destination];
if (linePassagesDestination === undefined) {
setStore('passages', lineId, destination, newLinePassagesDestination);
}
else {
if (linePassagesDestination.length - newLinePassagesDestination.length != 0) {
console.log(`Server provides ${newLinePassagesDestination.length} passages, \
${linePassagesDestination.length} here... refresh all them.`);
setStore('passages', lineId, destination, newLinePassagesDestination);
}
else {
linePassagesDestination.forEach((passage, index) => {
const newPassage = newLinePassagesDestination[index];
if (passage.expectedDepartTs != newPassage.expectedDepartTs) {
console.log(`Refresh expectedDepartTs (${passage.expectedDepartTs} -> ${newPassage.expectedDepartTs}`);
setStore('passages', lineId, destination, index, 'expectedDepartTs', newPassage.expectedDepartTs);
}
if (passage.expectedArrivalTs != newPassage.expectedArrivalTs) {
console.log(`Refresh expectedArrivalTs (${passage.expectedArrivalTs} -> ${newPassage.expectedArrivalTs}`);
setStore('passages', lineId, destination, index, 'expectedArrivalTs', newPassage.expectedArrivalTs);
}
});
}
}
}
}
}
});
}
const clearPassages = () => {
setStore((s) => {
// TODO: Really need to set to undefined to reset ?
console.log("s=", s);
console.log("s.passages=", s.passages);
// setStore('passages', undefined);
// setStore('passages', {});
console.log("Object.keys(s.passages)=", Object.keys(s.passages));
const clearPassages = (): void => {
setStore((s: Store): Store => {
for (const lineId of Object.keys(s.passages)) {
console.log("lineId=", lineId);
setStore('passages', lineId, undefined);
}
console.log("s=", s);
return s;
});
// setStore('passages', undefined);
// setStore('passages', {});
// }
console.log("passages=", store.passages);
}
const getStop = (stopId: number): Stop | undefined => {
return store.stops[stopId];
}
const searchStopByName = async (name: string): Promise<Stops> => {
const data = await fetch(`${serverUrl()}/stop/?name=${name}`, {
headers: { 'Content-Type': 'application/json' }
});
const stops = await data.json();
const byIdStops: Stops = {};
for (const stop of stops) {
byIdStops[stop.id] = stop;
setStore('stops', stop.id, stop);
}
return byIdStops;
}
return (
<BusinessDataContext.Provider value={{ getLine, passages, getLinePassages, addPassages, clearPassages, serverUrl }}>
<BusinessDataContext.Provider value={{
getLine, getLinePassages, getLineDestinations, getDestinationPassages, passages, getPassagesLineIds,
refreshPassages, addPassages, clearPassages, getStop, searchStopByName
}}>
{props.children}
</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

@@ -0,0 +1,19 @@
import { createIcon } from "@hope-ui/solid";
// From https://github.com/hope-ui/hope-ui/blob/main/apps/docs/src/icons/IconHamburgerMenu.tsx
export const IconHamburgerMenu = createIcon({
viewBox: "0 0 15 15",
path: () => (
<path
d="M1.5 3C1.22386 3 1 3.22386 1 3.5C1 3.77614 1.22386 4 1.5 4H13.5C13.7761 4 14 3.77614 14 3.5C14 3.22386
13.7761 3 13.5 3H1.5ZM1 7.5C1 7.22386 1.22386 7 1.5 7H13.5C13.7761 7 14 7.22386 14 7.5C14 7.77614 13.7761 8 13.5
8H1.5C1.22386 8 1 7.77614 1 7.5ZM1 11.5C1 11.2239 1.22386 11 1.5 11H13.5C13.7761 11 14 11.2239 14 11.5C14 11.7761
13.7761 12 13.5 12H1.5C1.22386 12 1 11.7761 1 11.5Z"
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
/>
),
});

View File

@@ -16,6 +16,7 @@
body {
aspect-ratio: 16/9;
width: 100vw;
height: none;
margin: 0;
@@ -23,6 +24,5 @@ body {
}
#root {
height: 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,106 @@
/* 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 .menu {
height: calc(80/100*100%);
aspect-ratio: 1;
margin-right: calc(30/1920*100%);
}
.header .menu button {
height: 100%;
aspect-ratio: 1;
background-color: var(--idfm-black);
border: solid var(--idfm-white) 3px;
border-radius: calc(9/86*100%);
}
.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,308 @@
import { createContext, createEffect, createResource, createSignal, For, JSX, ParentComponent, Show, useContext, VoidComponent, VoidProps } from "solid-js";
import { createStore } from "solid-js/store";
import { createDateNow } from "@solid-primitives/date";
import { IconButton, Menu, MenuTrigger, MenuContent, MenuItem } from "@hope-ui/solid";
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 { IconHamburgerMenu } from './extra/iconHamburgerMenu';
import styles from "./passagesDisplay.module.css";
type PositionnedPanel = {
position: number;
// TODO: Should be PassagesPanelComponent ?
panel: JSX.Element;
};
interface PassagesDisplayStore {
isPassagesRefreshEnabled: () => boolean;
enablePassagesRefresh: () => void;
disablePassagesRefresh: () => void;
togglePassagesRefresh: () => void;
getPanels: () => Array<PositionnedPanel>;
setPanels: (panels: Array<PositionnedPanel>) => void;
getDisplayedPanelId: () => number;
setDisplayedPanelId: (panelId: number) => void;
};
const PassagesDisplayContext = createContext<PassagesDisplayStore>();
function PassagesDisplayProvider(props: { children: JSX.Element }) {
type Store = {
refreshEnabled: boolean;
panels: Array<PositionnedPanel>;
displayedPanelId: number;
};
const [store, setStore] = createStore<Store>({ refreshEnabled: true, panels: [], displayedPanelId: 0 });
const isPassagesRefreshEnabled = (): boolean => {
return store.refreshEnabled;
}
const enablePassagesRefresh = (): void => {
setStore('refreshEnabled', true);
}
const disablePassagesRefresh = (): void => {
setStore('refreshEnabled', false);
}
const togglePassagesRefresh = (): void => {
setStore('refreshEnabled', !store.refreshEnabled);
}
const getPanels = (): Array<PositionnedPanel> => {
return store.panels;
}
const setPanels = (panels: Array<PositionnedPanel>): void => {
setStore('panels', panels);
}
const getDisplayedPanelId = (): number => {
return store.displayedPanelId;
}
const setDisplayedPanelId = (panelId: number): void => {
setStore('displayedPanelId', panelId);
}
return (
<PassagesDisplayContext.Provider value={{
isPassagesRefreshEnabled, enablePassagesRefresh,
disablePassagesRefresh, togglePassagesRefresh,
getPanels, setPanels,
getDisplayedPanelId, setDisplayedPanelId
}}>
{props.children}
</PassagesDisplayContext.Provider>
);
}
// TODO: Sort transport modes by weight
const Header: VoidComponent<{ title: string }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const passagesDisplayStore: PassagesDisplayStore | undefined = useContext(PassagesDisplayContext);
if (businessDataStore === undefined || passagesDisplayStore === undefined)
return <div />;
const { getLine, passages } = businessDataStore;
const { isPassagesRefreshEnabled, togglePassagesRefresh } = passagesDisplayStore;
const [dateNow] = createDateNow(1000);
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(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.menu}>
<Menu>
<MenuTrigger
as={IconButton}
icon=<IconHamburgerMenu />
/>
<MenuContent>
<MenuItem onSelect={() => togglePassagesRefresh()}>{isPassagesRefreshEnabled() ? "Disable" : "Enable"}</MenuItem>
</MenuContent>
</Menu>
</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<{}> = () => {
const passagesDisplayStore: PassagesDisplayStore | undefined = useContext(PassagesDisplayContext);
if (passagesDisplayStore === undefined)
return <div />;
const { getDisplayedPanelId, getPanels } = passagesDisplayStore;
return (
<div class={styles.footer}>
<For each={getPanels()}>
{(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 == getDisplayedPanelId() ? "white" : "black"})` }}
/>
</svg>
</div>
);
}}
</For>
</div>
);
}
const Body: ParentComponent<{ maxPassagesPerPanel: number, syncPeriodMsec: number, panelSwitchPeriodMsec: number }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const passagesDisplayStore: PassagesDisplayStore | undefined = useContext(PassagesDisplayContext);
// TODO: Use props instead
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || passagesDisplayStore === undefined || searchStore === undefined) {
return <div />;
}
const { getLinePassages, getLineDestinations, passages, getPassagesLineIds, clearPassages, refreshPassages } = businessDataStore;
const { isPassagesRefreshEnabled, getDisplayedPanelId, setDisplayedPanelId, getPanels, setPanels } = passagesDisplayStore;
const { getDisplayedStops } = searchStore;
setInterval(() => {
let nextPanelId = getDisplayedPanelId() + 1;
if (nextPanelId >= getPanels().length) {
nextPanelId = 0;
}
setDisplayedPanelId(nextPanelId);
}, props.panelSwitchPeriodMsec);
setInterval(
async () => {
if (isPassagesRefreshEnabled()) {
const stops = getDisplayedStops();
if (stops.length > 0) {
refreshPassages(stops[0].id);
}
}
else {
console.log("Passages refresh disabled... skip it.");
}
},
props.syncPeriodMsec
);
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);
}
});
return (
<div class={styles.panelsContainer}>
{() => {
setPanels([]);
let newPanels = [];
let positioneds: PositionnedPanel[] = [];
let index = 0;
let lineIds: string[] = [];
let destinationsNb = 0;
for (const lineId of getPassagesLineIds()) {
const lineDestinations = getLineDestinations(lineId);
if (lineDestinations.length <= props.maxPassagesPerPanel - destinationsNb) {
lineIds.push(lineId);
destinationsNb += lineDestinations.length;
}
else {
const panelid = index++;
const panel = <PassagesPanel stopId={getDisplayedStops()[0].id} lineIds={lineIds} show={panelid == getDisplayedPanelId()} />;
newPanels.push(panel);
positioneds.push({ position: panelid, panel: panel });
lineIds = [lineId];
destinationsNb = lineDestinations.length;
}
}
if (destinationsNb) {
const panelId = index++;
const panel = <PassagesPanel stopId={getDisplayedStops()[0].id} lineIds={lineIds} show={panelId == getDisplayedPanelId()} />;
newPanels.push(panel);
positioneds.push({ position: panelId, panel: panel });
}
setPanels(positioneds);
return newPanels;
}}
</div>
);
}
export const PassagesDisplay: ParentComponent = () => {
const MAX_PASSAGES_PER_PANEL = 5;
// TODO: Use props.
const syncPeriodMsec = 20 * 1000;
const panelSwitchPeriodMsec = 4 * 1000;
return (
<div class={styles.passagesDisplay}>
<PassagesDisplayProvider>
<Header title="Prochains passages" />
<Body maxPassagesPerPanel={MAX_PASSAGES_PER_PANEL} syncPeriodMsec={syncPeriodMsec} panelSwitchPeriodMsec={panelSwitchPeriodMsec} />
<Footer />
</PassagesDisplayProvider>
</div>
);
};

View File

@@ -1,83 +1,4 @@
/* 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 {
.passagesContainer {
height: 100%;
width: 100%;
@@ -86,16 +7,16 @@
position: relative;
}
.nextPassagesContainer .line:last-child {
border-bottom: 0;
/* To make up for the bottom border deletion */
padding-bottom: calc(2px);
}
.displayed {
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) */
.line {
@@ -124,18 +45,18 @@
margin: 0 calc(15/1920*100%);
}
.tramLinePicto {
aspect-ratio : 1 / 1;
height: calc(100/176*100%);
margin-right: calc(23/1920*100%);
}
.busLinePicto {
aspect-ratio : 2.25;
height: calc(70/176*100%);
margin-right: calc(23/1920*100%);
}
.metroLinePicto, .tramLinePicto, .trainLinePicto {
aspect-ratio : 1 / 1;
height: calc(100/176*100%);
margin-right: calc(23/1920*100%);
}
.destination {
height: calc(60/176*100%);
width: 50%;
@@ -144,7 +65,6 @@
text-align: left;
}
.trafficStatus {
height: calc(50/176*100%);
aspect-ratio: 35/50;
@@ -203,24 +123,6 @@
margin-right: calc(30/1920*100%);
}
.unavailableSecondNextPassage svg {
.unavailableSecondPassage svg {
font-family: IDFVoyageur-regular;
}
/* Idfm: 1800x54px (margin: 0px 50px) */
.footer {
width: calc(1820/1920*100%);
height: calc(54/1080*100%);
margin: 0 calc(50/1920*100%);
display: flex;
align-items: center;
justify-content: right;
}
.footer div {
aspect-ratio: 1;
height: 50%;
margin-left: calc(42/1920*100%);
}

View File

@@ -0,0 +1,164 @@
import { VoidComponent, createResource, onMount, ParentComponent, ParentProps, Show, useContext, For } from 'solid-js';
import { createDateNow, getTime } from '@solid-primitives/date';
import { timeline } from '@motionone/dom';
import { AnimationOptions } from '@motionone/types';
import { Motion } from "@motionone/solid";
import { Line, TrafficStatus } from './types';
import { renderLineTransportMode, renderLinePicto } from './utils';
import { BusinessDataContext, BusinessDataStore } from "./businessData";
import styles from './passagesPanel.module.css';
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>
);
}
const TtwPassage: VoidComponent<{ line: Line, destination: string, index: number, style: string, fontSize: number, fallbackStyle: string }> = (props) => {
const businessDataContext: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataContext === undefined)
return <div />;
const { getDestinationPassages } = businessDataContext;
const [dateNow] = createDateNow(10000);
const transition: AnimationOptions = { duration: 3, repeat: Infinity };
return (() => {
const passage = getDestinationPassages(props.line.id, props.destination)[props.index];
const refTs = passage !== undefined ? (passage.expectedDepartTs !== null ? passage.expectedDepartTs : passage.expectedArrivalTs) : 0;
const ttwSec = refTs - (getTime(dateNow()) / 1000);
const isApproaching = ttwSec <= 60;
return (
<Show when={passage !== undefined} fallback=<UnavailablePassage style={props.fallbackStyle} />>
<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>
</Show>
);
});
}
/* TODO: Manage end of service */
const DestinationPassages: VoidComponent<{ 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"]
]);
// TODO: Manage traffic status
// const trafficStatusStyle = { fill: trafficStatusColor.get(props.line.trafficStatus) };
const trafficStatusStyle = { fill: trafficStatusColor.get(TrafficStatus.UNKNOWN) };
let destinationViewboxRef: SVGSVGElement | undefined = undefined;
let destinationTextRef: SVGTextElement | undefined = undefined;
onMount(() => {
if (destinationViewboxRef !== undefined && destinationTextRef !== undefined) {
const overlap = destinationTextRef.getComputedTextLength() - destinationViewboxRef.viewBox.baseVal.width;
if (overlap > 0) {
timeline(
[
[destinationTextRef, { x: [-overlap] }, { duration: 5 }],
[destinationTextRef, { x: [0] }, { duration: 2 }],
],
{ repeat: Infinity },
);
}
}
});
return (
<div class={styles.line}>
<div class={styles.transportMode}>
{renderLineTransportMode(props.line)}
</div>
{renderLinePicto(props.line, styles)}
<div class={styles.destination}>
<svg ref={destinationViewboxRef} viewBox="0 0 600 40">
<text ref={destinationTextRef} 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>
<TtwPassage line={props.line} destination={props.destination} index={0} style={styles.firstPassage}
fontSize={50} fallbackStyle={styles.unavailableFirstPassage} />
<TtwPassage line={props.line} destination={props.destination} index={1} style={styles.secondPassage}
fontSize={45} fallbackStyle={styles.unavailableSecondPassage} />
</div >
);
}
export type PassagesPanelComponentProps = ParentProps & { stopId: number, lineIds: string[], 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, getLineDestinations } = businessDataContext;
const getLines = async (lineIds: string[]): Promise<Line[]> => {
const lines = await Promise.all<Promise<Line>[]>(lineIds.map((lineId) => getLine(lineId)));
return lines;
}
const [lines] = createResource<Line[], string[]>(props.lineIds, getLines);
return (
<div classList={{ [styles.passagesContainer]: true, [styles.displayed]: props.show }} >
<Show when={lines() !== undefined} >
<For each={lines()}>
{(line) =>
<Show when={getLineDestinations(line.id) !== undefined}>
<For each={getLineDestinations(line.id)}>
{(destination) =>
<DestinationPassages line={line} destination={destination} />
}
</For>
</Show>
}
</For>
</Show>
</div >
);
}

View File

@@ -1,74 +1,48 @@
import { batch, createContext, createSignal } from 'solid-js';
import { createContext, JSX } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Marker as LeafletMarker } from 'leaflet';
import { Stop, Stops } from './types';
import { Stop } from './types';
interface Store {
getMarkers: () => Markers;
addMarkers?: (stopId, markers) => void;
setMarkers?: (markers) => void;
export type ByStopIdMarkers = Record<number, LeafletMarker[] | undefined>;
getStops: () => Stops;
setStops?: (stops) => void;
removeStops?: (stopIds) => void;
export interface SearchStore {
getDisplayedStops: () => Stop[];
setDisplayedStops: (stops: Stop[]) => void;
getDisplayedStop: () => Stop;
setDisplayedStop: (stop: Stop) => void;
addMarkers: (stopId: number, markers: LeafletMarker[]) => void;
};
export const SearchContext = createContext<Store>();
export const SearchContext = createContext<SearchStore>();
export function SearchProvider(props: { children: JSX.Element }) {
const [store, setStore] = createStore({stops: {}, markers: {}, displayedStop: []});
const getStops = () => {
return store.stops;
type Store = {
stops: Record<number, Stop | undefined>;
markers: ByStopIdMarkers;
displayedStops: Stop[];
};
const setStops = (stops) => {
setStore((s) => {
setStore('stops', stops);
});
};
const [store, setStore] = createStore<Store>({ stops: {}, markers: {}, displayedStops: [] });
const removeStops = (stopIds) => {
batch(() => {
for(const stopId of stopIds) {
setStore('stops', stopId, undefined);
setStore('markers', stopId, undefined);
const getDisplayedStops = (): Stop[] => {
return store.displayedStops;
}
const setDisplayedStops = (stops: Stop[]): void => {
setStore((s: Store) => {
setStore('displayedStops', stops);
return s;
});
};
}
const getMarkers = () => {
return store.markers;
};
const addMarkers = (stopId, markers) => {
const addMarkers = (stopId: number, markers: L.Marker[]): void => {
setStore('markers', stopId, markers);
};
const setMarkers = (markers) => {
setStore('markers', markers);
};
const getDisplayedStop = () => {
/* console.log(store.displayedStop); */
return store.displayedStop;
};
const setDisplayedStop = (stop: Stop) => {
/* console.log(stop); */
setStore((s) => {
console.log("s.displayedStop=", s.displayedStop);
setStore('displayedStop', [stop]);
});
/* console.log(store.displayedStop); */
};
}
return (
<SearchContext.Provider value={{addMarkers, getMarkers, setMarkers, getStops, removeStops, setStops, getDisplayedStop, setDisplayedStop}}>
<SearchContext.Provider value={{ getDisplayedStops, setDisplayedStops, addMarkers }}>
{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 {
Box, Button, Input, InputLeftAddon, InputGroup, HStack, List, ListItem, Progress,
ProgressIndicator, VStack
} from "@hope-ui/solid";
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
featureGroup as leafletFeatureGroup, LatLngLiteral as LeafletLatLngLiteral, Map as LeafletMap,
Marker as LeafletMarker, tileLayer as leafletTileLayer
} from 'leaflet';
import { BusinessDataContext } from './businessData';
import { SearchContext } from './search';
import { BusinessDataContext, BusinessDataStore } from "./businessData";
import { SearchContext, SearchStore } from './search';
import { Stop } from './types';
import { renderLineTransportMode, renderLinePicto, TransportModeWeights } from './utils';
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 = [];
for (const lineId of lineIds) {
const line = await getLine(lineId);
@@ -33,28 +37,37 @@ const StopRepr: Component = (props) => {
return reprs;
}
const [lineReprs] = createResource<JSX.Element[], string[]>(props.stop.lines, fetchLinesRepr);
return (
<HStack height="100%">
{props.stop.name}
<For each={lineReprs()}>{(line) => line}</For>
<For each={lineReprs()}>{(line: JSX.Element) => line}</For>
</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 stops = stop.stops;
for (const stop of stops) {
stop.lines.forEach(lineIds.add, lineIds);
}
const byModeReprs = {};
const byModeReprs: Record<string, ByTransportModeReprs> = {};
for (const lineId of lineIds) {
const line = await getLine(lineId);
if (line !== undefined) {
@@ -68,7 +81,7 @@ const StopAreaRepr: Component = (props) => {
}
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) {
const lines = byModeReprs[transportMode];
const repr = [lines.mode];
@@ -81,6 +94,8 @@ const StopAreaRepr: Component = (props) => {
return reprs;
}
const [lineReprs] = createResource(props.stop, fetchLinesRepr);
return (
<HStack height="100%">
{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 map = null;
const stopsLayerGroup = L.featureGroup();
let map: LeafletMap | undefined = undefined;
const stopsLayerGroup = leafletFeatureGroup();
function buildMap(div: HTMLDivElement) {
map = L.map(div).setView(mapCenter, 11);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
const buildMap = (div: HTMLDivElement) => {
map = new LeafletMap(div).setView(mapCenter, 11);
leafletTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
stopsLayerGroup.addTo(map);
}
function setMarker(stop) {
const setMarker = (stop: Stop): L.Marker[] => {
const markers = [];
if (stop.lat !== undefined && stop.lon !== undefined) {
/* TODO: Add stop lines representation to popup. */
markers.push(L.marker([stop.lat, stop.lon]).bindPopup(`${stop.name}`).openPopup());
markers.push(new LeafletMarker([stop.lat, stop.lon]).bindPopup(`${stop.name}`).openPopup());
}
else {
for (const _stop of stop.stops) {
@@ -124,11 +144,11 @@ const Map: Component = (props) => {
onMount(() => buildMap(mapDiv));
const onStopUpdate = createEffect(() => {
createEffect(() => {
/* TODO: Avoid to clear all layers... */
stopsLayerGroup.clearLayers();
for (const stop of Object.values(getStops())) {
for (const stop of props.stops) {
const markers = setMarker(stop);
addMarkers(stop.id, markers);
for (const marker of markers) {
@@ -137,7 +157,7 @@ const Map: Component = (props) => {
}
const stopsBound = stopsLayerGroup.getBounds();
if (Object.keys(stopsBound).length) {
if (map !== undefined && Object.keys(stopsBound).length) {
map.fitBounds(stopsBound);
}
});
@@ -145,42 +165,32 @@ const Map: Component = (props) => {
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 [_inProgress, _setInProgress] = createSignal<bool>(false);
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
const { serverUrl } = useContext(BusinessDataContext);
const { getStops, removeStops, setStops, setDisplayedStop } = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined)
return <div />;
async function _fetchStopByName(name) {
const data = await fetch(`${serverUrl()}/stop/?name=${name}`, {
headers: { 'Content-Type': 'application/json' }
});
const stops = await data.json();
const stopIds = stops.map((stop) => stop.id);
const { searchStopByName } = businessDataStore;
const { setDisplayedStops } = searchStore;
const stopIdsToRemove = Object.keys(getStops()).filter(stopId => !(stopId in stopIds));
// TODO: Use props instead
const [minCharactersNb] = createSignal<number>(4);
const byIdStops = {};
for (const stop of stops) {
byIdStops[stop.id] = stop;
}
const [inProgress, setInProgress] = createSignal<boolean>(false);
const [foundStops, setFoundStops] = createSignal<Stop[]>([]);
batch(() => {
removeStops(stopIdsToRemove);
setStops(byIdStops);
});
}
async function _onStopNameInput(event) {
const onStopNameInput: JSX.EventHandler<HTMLInputElement, InputEvent> = async (event): Promise<void> => {
/* 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()) {
console.log(`Fetching data for ${stopName}`);
_setInProgress(true);
await _fetchStopByName(stopName);
_setInProgress(false);
setInProgress(true);
const stopsById = await searchStopByName(stopName);
setFoundStops(Object.values(stopsById));
setInProgress(false);
}
}
@@ -188,36 +198,30 @@ export const StopsManager: Component = (props) => {
<VStack h="100%">
<InputGroup w="50%" h="5%">
<InputLeftAddon>🚉 🚏</InputLeftAddon>
<Input onInput={_onStopNameInput} readOnly={_inProgress()} placeholder="Stop name..." />
<Input onInput={onStopNameInput} readOnly={inProgress()} placeholder="Stop name..." />
</InputGroup>
<Progress size="xs" w="50%" indeterminate={_inProgress()}>
<Progress size="xs" w="50%" indeterminate={inProgress()}>
<ProgressIndicator striped animated />
</Progress>
<Box w="100%" h="40%" borderWidth="1px" borderColor="var(--idfm-black)" borderRadius="$lg" overflow="scroll" marginBottom="2px">
<List width="100%" height="100%">
{() => {
const items = [];
for (const stop of Object.values(getStops()).sort((x, y) => x.name.localeCompare(y.name))) {
items.push(
<ListItem h="10%" borderWidth="1px" mb="0px" color="var(--idfm-black)" borderRadius="$lg">
<Button fullWidth="true" color="var(--idfm-black)" bg="white" onClick={() => {
console.log(`${stop.id} clicked !!!`);
setDisplayedStop(stop);
}}>
<For each={foundStops().sort((x, y) => x.name.localeCompare(y.name))}>
{(stop) =>
<ListItem borderWidth="1px" mb="0px" color="var(--idfm-black)" borderRadius="$lg">
<Button fullWidth="true" color="var(--idfm-black)" bg="white" onClick={() => setDisplayedStops([stop])}>
<Box w="100%" h="100%">
<Show when={stop.stops !== undefined} fallback={<StopRepr stop={stop} />}>
<StopAreaRepr stop={stop} />
</Show>
</Box>
</Button>
</ListItem>);
</ListItem>
}
return items;
}}
</For>
</List>
</Box>
<Box borderWidth="1px" borderColor="var(--idfm-black)" borderRadius="$lg" h="55%" w="100%" overflow="scroll">
<Map />
<Map stops={foundStops()} />
</Box>
</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 {
UNKNOWN = 0,
FLUID,
@@ -16,28 +6,93 @@ export enum TrafficStatus {
BYPASSED
}
export interface Passages { };
export interface Passage {
line: number,
operator: string,
destinations: Array<string>,
atStop: boolean,
aimedArrivalTs: number,
expectedArrivalTs: number,
arrivalPlatformName: string,
aimedDepartTs: number,
expectedDepartTs: number,
arrivalStatus: string,
departStatus: string,
export class Passage {
line: number;
operator: string;
destinations: string[];
atStop: boolean;
aimedArrivalTs: number;
expectedArrivalTs: number;
arrivalPlatformName: string;
aimedDepartTs: number;
expectedDepartTs: number;
arrivalStatus: 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 interface Stop {
id: number,
name: string,
town: string,
lat: number,
lon: number,
lines: Array<string>
export class Stop {
id: number;
name: string;
town: string;
lat: number;
lon: number;
stops: Stop[];
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,
tram: 2,
val: 3,
@@ -11,11 +15,19 @@ export const TransportModeWeights = {
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)} />
}
function renderBusLinePicto(line, styles): JSX.Element {
function renderBusLinePicto(line: Line, styles: CSSModuleClasses): JSX.Element {
return (
<div class={styles.busLinePicto}>
<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}` };
return (
<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 (
<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}` }} />
<text x="50%"
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 (
<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}` }} />
<text x="50%"
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) {
case "bus":
case "funicular":

View File

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