41 Commits

Author SHA1 Message Date
ee14d60db7 🐛 Old stops still displayed on map once the stop search narrowed 2023-04-15 18:24:47 +02:00
a2728cfc0c 💄 Redesign StopSearchMenu (map panel)
- Replace leaflet with openlayers
- Add stop areas shape to map
- Display stop destinations sub-panel on click
2023-04-14 11:56:41 +02:00
0a7d74a215 ♻️ Store StopArea Stops in BusinessDataStore when looking for stops 2023-04-14 11:36:54 +02:00
1b713dbc0e Add StopDestinations to the frontend business data 2023-04-14 11:33:29 +02:00
1ffd3cbe94 Add StopShape to the frontend business data 2023-04-14 11:29:25 +02:00
42817f7b0c 🚚 Fix the transport mode location issue 2023-04-14 11:20:19 +02:00
440a5faf3c 🗃️ Update StopArea db models: StopArea can´t be composed of another StopAreas 2023-04-13 21:40:35 +02:00
61097fe9e2 Add /stop_shape/{stop_id} endpoint 2023-04-13 21:35:41 +02:00
62a9000ec2 Add /stop/{stop_id}/destinations endpoint 2023-04-13 21:35:41 +02:00
62b6425255 ♻️ Update stop destinations returned value 2023-04-13 21:35:41 +02:00
ac06df9f87 Handle IDFM stop areas shapes 2023-04-13 21:35:29 +02:00
ecfb3c8cb3 Handle IDFM connection areas 2023-04-13 20:57:15 +02:00
293a1391bc Add ConnectionArea and StopShape models + Stop-ConnectionArea relationship 2023-04-13 20:55:56 +02:00
71e2530c01 💄 Delete unused CSS rules and reformat PassagePanel ones 2023-03-05 21:11:19 +01:00
65f284bc25 💄 Fix first passage misalignment when it's unavailable 2023-03-05 21:07:40 +01:00
d3a689cefc 🐛 Add forgotten @solid-primitives/scroll dependency 2023-03-05 19:42:18 +01:00
726efd8e8c 💄 Add ScrollingText component and use it in StopAreaRepr component
Update DestinationPassages component to use it.
2023-03-05 19:20:40 +01:00
546ec5a89f Merge branch 'idfm-style-for-search-display' into develop 2023-03-05 13:51:03 +01:00
4a2fadb5b3 💄 Redesign stop search menu to follow the passage display style 2023-03-05 13:46:25 +01:00
f09ba4cc58 💄 Replace css files with scss and factorize CSS rules 2023-03-05 12:49:20 +01:00
a9d918fb0f 🐛 Time to wait shall be displayed in 2 digits 2023-02-18 15:38:25 +01:00
033e27fb56 Add platform id to passages panels 2023-02-18 14:50:24 +01:00
11c62e5795 💄 Fix passage delay format when waiting time > 1h
When waiting time is less than 1h, the duration shall be displayed, the arrival time otherwise.
2023-02-12 22:54:52 +01:00
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
42 changed files with 2795 additions and 1075 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:
try:
method = (
cls.db.session.add_all
if isinstance(stops, Iterable)
else cls.db.session.add
)
method(stops)
await cls.db.session.commit()
except IntegrityError as err:
print(err)
class Base(DeclarativeBase):
db: Database | None = None
@classmethod
async def add(cls, stops: Self | Iterable[Self]) -> bool:
try:
if isinstance(stops, Iterable):
cls.db.session.add_all(stops) # type: ignore
else:
cls.db.session.add(stops) # type: ignore
await cls.db.session.commit() # type: ignore
except (AttributeError, IntegrityError) as err:
print(err)
return False
Base.add = classmethod(base_add)
return True
async def base_get_by_id(cls, id_: int | str) -> None | Base:
res = await cls.db.session.execute(select(cls).where(cls.id == id_))
element = res.scalar_one_or_none()
return element
Base.get_by_id = classmethod(base_get_by_id)
@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

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(
self._engine, expire_on_commit=False, class_=AsyncSession
)
await self.session.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
if self._engine is not None:
self._session_maker = async_sessionmaker(
self._engine, expire_on_commit=False, class_=AsyncSession
)
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)
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
await self._engine.dispose()
if self._engine is not None:
await self._engine.dispose()

View File

@@ -1,2 +1,70 @@
from .idfm_interface import IdfmInterface
from .idfm_types import *
from .idfm_types import (
Coordinate,
Destinations,
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",
"Destinations",
"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,14 @@
from pathlib import Path
from collections import defaultdict
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
@@ -9,21 +16,24 @@ from aiohttp import ClientSession
from msgspec import ValidationError
from msgspec.json import Decoder
from rich import print
from shapefile import Reader as ShapeFileReader, ShapeRecord
from ..db import Database
from ..models import Line, LinePicto, Stop, StopArea
from ..models import ConnectionArea, Line, LinePicto, Stop, StopArea, StopShape
from .idfm_types import (
ConnectionArea as IdfmConnectionArea,
Destinations as IdfmDestinations,
IdfmLineState,
IdfmResponse,
Line as IdfmLine,
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 +50,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::([^:]+):")
@@ -53,6 +66,7 @@ class IdfmInterface:
self._json_stops_decoder = Decoder(type=List[IdfmStop])
self._json_stop_areas_decoder = Decoder(type=List[IdfmStopArea])
self._json_connection_areas_decoder = Decoder(type=List[IdfmConnectionArea])
self._json_lines_decoder = Decoder(type=List[IdfmLine])
self._json_stops_lines_assos_decoder = Decoder(type=List[IdfmStopLineAsso])
self._json_ratp_pictos_decoder = Decoder(type=List[RatpPicto])
@@ -64,7 +78,24 @@ class IdfmInterface:
async def startup(self) -> None:
BATCH_SIZE = 10000
STEPS = (
STEPS: tuple[
tuple[
Type[ConnectionArea] | Type[Stop] | Type[StopArea] | Type[StopShape],
Callable,
Callable,
],
...,
] = (
(
StopShape,
self._request_stop_shapes,
IdfmInterface._format_idfm_stop_shapes,
),
(
ConnectionArea,
self._request_idfm_connection_areas,
IdfmInterface._format_idfm_connection_areas,
),
(
StopArea,
self._request_idfm_stop_areas,
@@ -102,7 +133,7 @@ class IdfmInterface:
print(f"Link Stops to Lines: {time() - begin_ts}s")
begin_ts = time()
await self._load_stop_areas_stops_assos()
await self._load_stop_assos()
print(f"Link Stops to StopAreas: {time() - begin_ts}s")
async def _load_lines(self, batch_size: int = 5000) -> None:
@@ -132,13 +163,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:
@@ -165,25 +196,53 @@ class IdfmInterface:
print(f"{total_found_nb} line <-> stop ({total_assos_nb = } found)")
async def _load_stop_areas_stops_assos(self, batch_size: int = 5000) -> None:
total_assos_nb = total_found_nb = 0
assos = []
async def _load_stop_assos(self, batch_size: int = 5000) -> None:
total_assos_nb = area_stop_assos_nb = conn_stop_assos_nb = 0
area_stop_assos = []
connection_stop_assos = []
async for asso in self._request_idfm_stop_area_stop_associations():
fields = asso.fields
assos.append((int(fields.zdaid), int(fields.arrid)))
if len(assos) == batch_size:
stop_id = int(fields.arrid)
area_stop_assos.append((int(fields.zdaid), stop_id))
connection_stop_assos.append((int(fields.zdcid), stop_id))
if len(area_stop_assos) == batch_size:
total_assos_nb += batch_size
total_found_nb += await StopArea.add_stops(assos)
assos.clear()
if assos:
total_assos_nb += len(assos)
total_found_nb += await StopArea.add_stops(assos)
if (found_nb := await StopArea.add_stops(area_stop_assos)) is not None:
area_stop_assos_nb += found_nb
area_stop_assos.clear()
print(f"{total_found_nb} stop area <-> stop ({total_assos_nb = } found)")
if (
found_nb := await ConnectionArea.add_stops(connection_stop_assos)
) is not None:
conn_stop_assos_nb += found_nb
connection_stop_assos.clear()
async def _request_idfm_stops(self):
if area_stop_assos:
total_assos_nb += len(area_stop_assos)
if (found_nb := await StopArea.add_stops(area_stop_assos)) is not None:
area_stop_assos_nb += found_nb
if (
found_nb := await ConnectionArea.add_stops(connection_stop_assos)
) is not None:
conn_stop_assos_nb += found_nb
print(f"{area_stop_assos_nb} stop area <-> stop ({total_assos_nb = } found)")
print(f"{conn_stop_assos_nb} stop area <-> stop ({total_assos_nb = } found)")
# TODO: This method is synchronous due to the shapefile library.
# It's not a blocking issue but it could be nice to find an alternative.
async def _request_stop_shapes(self) -> AsyncIterator[ShapeRecord]:
# TODO: Use HTTP
with ShapeFileReader("./tests/datasets/REF_LDA.zip") as reader:
for record in reader.shapeRecords():
yield record
async def _request_idfm_stops(self) -> AsyncIterator[IdfmStop]:
# headers = {"Accept": "application/json", "apikey": self._api_key}
# async with ClientSession(headers=headers) as session:
# async with session.get(self.STOPS_URL) as response:
@@ -196,19 +255,28 @@ 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_connection_areas(self) -> AsyncIterator[IdfmConnectionArea]:
async with async_open(
"./tests/datasets/zones-de-correspondance.json", "rb"
) as raw:
for element in self._json_connection_areas_decoder.decode(await raw.read()):
yield element
async def _request_idfm_lines(self) -> AsyncIterator[IdfmLine]:
# TODO: Use HTTP
async with async_open("./tests/datasets/lines_dataset.json", "rb") as raw:
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 +284,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 +294,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,20 +324,25 @@ 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(
id=fields.id_,
mime_type=f"image/{fields.format.lower()}",
height_px=fields.height,
width_px=fields.width,
filename=fields.filename,
url=f"{cls.RATP_PICTO_URL}/{fields.id_}/download",
thumbnail=fields.thumbnail,
format=fields.format,
ret.append(
(
picto.fields.indices_commerciaux,
LinePicto(
id=fields.id_,
mime_type=f"image/{fields.format.lower()}",
height_px=fields.height,
width_px=fields.width,
filename=fields.filename,
url=f"{cls.RATP_PICTO_URL}/{fields.id_}/download",
thumbnail=fields.thumbnail,
format=fields.format,
),
)
)
return ret
@@ -289,7 +364,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 +375,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 +396,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 +408,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 +424,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,12 +434,40 @@ 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()),
)
@staticmethod
def _format_idfm_connection_areas(
*connection_areas: IdfmConnectionArea,
) -> Iterable[ConnectionArea]:
for connection_area in connection_areas:
yield ConnectionArea(
id=int(connection_area.zdcid),
name=connection_area.zdcname,
town_name=connection_area.zdctown,
postal_region=connection_area.zdcpostalregion,
xepsg2154=connection_area.zdcxepsg2154,
yepsg2154=connection_area.zdcyepsg2154,
transport_mode=StopAreaType(connection_area.zdctype.value),
version=connection_area.zdcversion,
created_ts=int(connection_area.zdccreated.timestamp()),
changed_ts=int(connection_area.zdcchanged.timestamp()),
)
@staticmethod
def _format_idfm_stop_shapes(*shape_records: ShapeRecord) -> Iterable[StopShape]:
for shape_record in shape_records:
yield StopShape(
id=shape_record.record[1],
type=shape_record.shape.shapeType,
bounding_box=list(shape_record.shape.bbox),
points=shape_record.shape.points,
)
async def render_line_picto(self, line: Line) -> tuple[None | str, None | str]:
begin_ts = time()
line_picto_path = line_picto_format = None
@@ -368,22 +475,22 @@ class IdfmInterface:
picto = line.picto
if picto is not None:
picto_data = await self._get_line_picto(line)
async with async_open(target, "wb") as fd:
await fd.write(picto_data)
line_picto_path = target
line_picto_format = picto.mime_type
if (picto_data := await self._get_line_picto(line)) is not None:
async with async_open(target, "wb") as fd:
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,47 +508,54 @@ 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]:
# TODO: Store in database the destination for the given stop and line id.
async def get_destinations(self, stop_id: int) -> IdfmDestinations | None:
begin_ts = time()
destinations: dict[str, str] = {}
if (res := await self.get_next_passages(stop_point_id)) is not None:
destinations: IdfmDestinations = defaultdict(set)
if (stop := await Stop.get_by_id(stop_id)) is not None:
expected_stop_ids = {stop.id}
elif (stop_area := await StopArea.get_by_id(stop_id)) is not None:
expected_stop_ids = {stop.id for stop in stop_area.stops}
else:
return None
if (res := await self.get_next_passages(stop_id)) is not None:
for delivery in res.Siri.ServiceDelivery.StopMonitoringDelivery:
if delivery.Status == IdfmState.true:
for stop_visit in delivery.MonitoredStopVisit:
monitoring_ref = stop_visit.MonitoringRef.value
try:
monitored_stop_id = int(monitoring_ref.split(":")[-2])
except (IndexError, ValueError):
print(f"Unable to get stop id from {monitoring_ref}")
continue
journey = stop_visit.MonitoredVehicleJourney
if (destination_name := journey.DestinationName) and (
line_ref := journey.LineRef
):
line_id = line_ref.value.replace("STIF:Line::", "")[:-1]
print(f"{line_id = }")
destinations[line_id] = destination_name[0].value
if (
dst_names := journey.DestinationName
) and monitored_stop_id in expected_stop_ids:
line_id = journey.LineRef.value.split(":")[-2]
destinations[line_id].add(dst_names[0].value)
print(f"get_next_passages: {time() - begin_ts}")
return destinations

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
@@ -116,15 +116,28 @@ class StopArea(Struct):
record_timestamp: datetime
class ConnectionArea(Struct):
zdcid: str
zdcversion: str
zdccreated: datetime
zdcchanged: datetime
zdcname: str
zdcxepsg2154: int
zdcyepsg2154: int
zdctown: str
zdcpostalregion: str
zdctype: StopAreaType
class StopAreaStopAssociationFields(Struct, kw_only=True):
arrid: str # TODO: use int ?
artid: 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 +166,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):
@@ -184,6 +197,8 @@ class Line(Struct):
Lines = dict[str, Line]
Destinations = dict[str, set[str]]
# TODO: Set structs frozen
class StopLineAssoFields(Struct):
@@ -220,17 +235,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 +255,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,14 @@
from .line import Line, LinePicto
from .stop import Stop, StopArea
from .stop import ConnectionArea, Stop, StopArea, StopShape
from .user import UserLastStopSearchResults
__all__ = [
"ConnectionArea",
"Line",
"LinePicto",
"Stop",
"StopArea",
"StopShape",
"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, Sequence, TYPE_CHECKING
from sqlalchemy import (
BigInteger,
@@ -6,16 +8,28 @@ from sqlalchemy import (
Enum,
Float,
ForeignKey,
Integer,
JSON,
select,
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,27 +42,34 @@ 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",
# lazy="joined",
lazy="selectin",
)
areas: Mapped[list["StopArea"]] = relationship(
"StopArea", secondary=stop_area_stop_association_table, back_populates="stops"
)
connection_area_id: Mapped[int] = mapped_column(
ForeignKey("connection_areas.id"), nullable=True
)
connection_area: Mapped["ConnectionArea"] = relationship(
back_populates="stops", lazy="selectin"
)
__tablename__ = "_stops"
__mapper_args__ = {"polymorphic_identity": "_stops", "polymorphic_on": kind}
@@ -65,7 +86,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 +100,26 @@ 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,36 +127,47 @@ 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",
# lazy="joined",
)
__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.scalars(
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.all()
}
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 +180,81 @@ class StopArea(_Stop):
else:
print(f"No stop area found for {stop_area_id}")
await cls.db.session.commit()
await session.commit()
return found
class StopShape(Base):
db = db
id = mapped_column(BigInteger, primary_key=True) # Same id than ConnectionArea
type = mapped_column(Integer, nullable=False)
bounding_box = mapped_column(JSON)
points = mapped_column(JSON)
__tablename__ = "stop_shapes"
class ConnectionArea(Base):
db = db
id = mapped_column(BigInteger, primary_key=True)
name = mapped_column(String, nullable=False)
town_name = mapped_column(String, nullable=False)
postal_region = mapped_column(String, nullable=False)
xepsg2154 = mapped_column(BigInteger, nullable=False)
yepsg2154 = mapped_column(BigInteger, nullable=False)
transport_mode = mapped_column(Enum(StopAreaType), nullable=False)
version = mapped_column(String, nullable=False)
created_ts = mapped_column(BigInteger)
changed_ts = mapped_column(BigInteger, nullable=False)
stops: Mapped[list["_Stop"]] = relationship(back_populates="connection_area")
__tablename__ = "connection_areas"
# TODO: Merge with StopArea.add_stops
@classmethod
async def add_stops(
cls, conn_area_to_stop_ids: Iterable[tuple[int, int]]
) -> int | None:
session = cls.db.session
if session is None:
return None
conn_area_ids, stop_ids = set(), set()
for conn_area_id, stop_id in conn_area_to_stop_ids:
conn_area_ids.add(conn_area_id)
stop_ids.add(stop_id)
conn_area_res = await session.execute(
select(ConnectionArea)
.where(ConnectionArea.id.in_(conn_area_ids))
.options(selectinload(ConnectionArea.stops))
)
conn_areas: dict[int, ConnectionArea] = {
conn.id: conn for conn in conn_area_res.scalars()
}
stop_res = await session.execute(select(_Stop).where(_Stop.id.in_(stop_ids)))
stops: dict[int, _Stop] = {stop.id: stop for stop in stop_res.scalars()}
found = 0
for conn_area_id, stop_id in conn_area_to_stop_ids:
if (conn_area := conn_areas.get(conn_area_id)) is not None:
if (stop := stops.get(stop_id)) is not None:
conn_area.stops.append(stop)
found += 1
else:
print(f"No stop found for {stop_id} id")
else:
print(f"No connection area found for {conn_area_id}")
await session.commit()
return found

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,14 @@
from .line import Line, TransportMode
from .next_passage import NextPassage, NextPassages
from .stop import Stop, StopArea
from .stop import Stop, StopArea, StopShape
__all__ = [
"Line",
"NextPassage",
"NextPassages",
"Stop",
"StopArea",
"StopShape",
"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):
@@ -23,3 +23,10 @@ class StopArea(BaseModel):
type: StopAreaType
lines: list[str] # SNCF lines are linked to stop areas and not stops.
stops: list[Stop]
class StopShape(BaseModel):
id: int
type: int
bbox: list[float]
points: list[tuple[float, float]]

View File

@@ -1,16 +1,16 @@
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
from backend.db import db
from backend.idfm_interface import IdfmInterface
from backend.models import Line, Stop, StopArea
from backend.idfm_interface import Destinations as IdfmDestinations, IdfmInterface
from backend.models import Line, Stop, StopArea, StopShape
from backend.schemas import (
Line as LineSchema,
TransportMode,
@@ -18,10 +18,13 @@ from backend.schemas import (
NextPassages as NextPassagesSchema,
Stop as StopSchema,
StopArea as StopAreaSchema,
StopShape as StopShapeSchema,
)
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 +47,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 +64,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 +94,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 +106,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 +158,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 +197,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,
@@ -206,3 +215,43 @@ async def get_next_passages(stop_id: str) -> JSONResponse:
ts=service_delivery.ResponseTimestamp.timestamp(),
passages=by_line_by_dst_passages,
)
@app.get("/stop/{stop_id}/destinations")
async def get_stop_destinations(
stop_id: int,
) -> IdfmDestinations | None:
destinations = await idfm_interface.get_destinations(stop_id)
return destinations
# TODO: Rename endpoint -> /stop/{stop_id}/shape
@app.get("/stop_shape/{stop_id}")
async def get_stop_shape(stop_id: int) -> StopShapeSchema | None:
connection_area = None
if (stop := await Stop.get_by_id(stop_id)) is not None:
connection_area = stop.connection_area
elif (stop_area := await StopArea.get_by_id(stop_id)) is not None:
connection_areas = {stop.connection_area for stop in stop_area.stops}
connection_areas_len = len(connection_areas)
if connection_areas_len == 1:
connection_area = connection_areas.pop()
else:
prefix = "More than one" if connection_areas_len else "No"
msg = f"{prefix} connection area has been found for stop area #{stop_id}"
raise HTTPException(status_code=500, detail=msg)
if (
connection_area is not None
and (shape := await StopShape.get_by_id(connection_area.id)) is not None
):
return StopShapeSchema(
id=shape.id, type=shape.type, bbox=shape.bounding_box, points=shape.points
)
msg = f"No shape found for stop {stop_id}"
raise HTTPException(status_code=404, detail=msg)

View File

@@ -11,48 +11,43 @@ 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"
msgspec = "^0.12.0"
pyshp = "^2.3.1"
[build-system]
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,9 +11,12 @@
},
"license": "MIT",
"devDependencies": {
"@types/leaflet": "^1.9.0",
"@types/proj4": "^2.5.2",
"@vitejs/plugin-basic-ssl": "^1.0.1",
"eslint": "^8.32.0",
"eslint-plugin-solid": "^0.9.3",
"sass": "^1.62.0",
"typescript": "^4.9.4",
"typescript-eslint-language-service": "^5.0.0",
"vite": "^4.0.3",
@@ -23,10 +26,12 @@
"@hope-ui/solid": "^0.6.7",
"@motionone/solid": "^10.15.5",
"@solid-primitives/date": "^2.0.5",
"@solid-primitives/scroll": "^2.0.10",
"@stitches/core": "^1.2.8",
"date-fns": "^2.29.3",
"leaflet": "^1.9.3",
"matrix-widget-api": "^1.1.1",
"ol": "^7.3.0",
"proj4": "^2.9.0",
"solid-js": "^1.6.6",
"solid-transition-group": "^0.0.10"
}

View File

@@ -10,12 +10,14 @@
display: flex;
text-align: center;
}
.panel {
min-width: 100%;
height: inherit;
width: inherit;
.panel {
min-width: 100%;
height: inherit;
width: inherit;
scroll-snap-align: center;
scroll-snap-align: center;
background-color: var(--idfm-black);
}
}

View File

@@ -1,15 +1,14 @@
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 { AppContextProvider } from './appContext';
import { SearchProvider } from './search';
import { PassagesDisplay } from './passagesDisplay';
import { StopsManager } from './stopsManager';
import { StopsSearchMenu } from './stopsSearchMenu';
import styles from './App.module.css';
import "./App.scss";
function parseFragment() {
@@ -28,7 +27,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() {
@@ -46,18 +45,18 @@ const App: Component = () => {
return (
<BusinessDataProvider>
<SearchProvider>
<AppContextProvider>
<HopeProvider>
<div class={styles.App} data-panelsnap-id="1">
<div class={styles.panel}>
<StopsManager />
<div class="App">
<div class="panel">
<StopsSearchMenu />
</div>
<div class={styles.panel}>
<div class="panel">
<PassagesDisplay />
</div>
</div>
</HopeProvider>
</SearchProvider>
</AppContextProvider>
</BusinessDataProvider>
);
};

View File

@@ -1,5 +1,5 @@
/* Idfm: 1860x1080px */
.passagesDisplay {
%widget {
aspect-ratio: 16/9;
--reverse-aspect-ratio: 9/16;
/* height is set according to the aspect-ratio, don´t touch it */
@@ -7,12 +7,10 @@
display: flex;
flex-direction: column;
background-color: var(--idfm-black);
}
/* Idfm: 1800x100px (margin: 17px 60px) */
.header {
%header {
width: calc(1800/1920*100%);
height: calc(100/1080*100%);
/*Percentage margin are computed relatively to the nearest block container's width, not height */
@@ -25,38 +23,19 @@
font-family: IDFVoyageur-bold;
}
.header .transportMode {
height: 100%;
margin: 0;
margin-right: calc(23/1920*100%);
.header {
@extend %header;
}
.header .title {
%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 {
%body {
width: calc(1860/1920*100%);
height: calc(892/1080*100%);
margin: 0 calc(30/1920*100%);
@@ -67,12 +46,13 @@
background-color: white;
border-collapse:separate;
border:solid var(--idfm-black) 1px;
// border:solid var(--idfm-black) 1px;
border-radius: calc(15/1920*100%);
}
/* Idfm: 1800x54px (margin: 0px 50px) */
.footer {
%footer {
width: calc(1820/1920*100%);
height: calc(54/1080*100%);
margin: 0 calc(50/1920*100%);
@@ -82,6 +62,10 @@
justify-content: right;
}
.footer {
@extend %footer;
}
.footer div {
aspect-ratio: 1;
height: 50%;

31
frontend/src/_utils.scss Normal file
View File

@@ -0,0 +1,31 @@
%transportMode {
aspect-ratio : 1 / 1;
}
%linePicto {
font-family: IDFVoyageur-bold;
}
%tramLinePicto {
@extend %linePicto;
aspect-ratio : 1 / 1;
}
%trainLinePicto {
@extend %linePicto;
aspect-ratio : 1 / 1;
}
%metroLinePicto {
@extend %linePicto;
aspect-ratio : 1 / 1;
}
%busLinePicto {
@extend %linePicto;
aspect-ratio : 2.25;
}

View File

@@ -0,0 +1,43 @@
import { createContext, JSX } from 'solid-js';
import { createStore } from "solid-js/store";
import { Stop } from './types';
export interface AppContextStore {
getDisplayedStops: () => Stop[];
setDisplayedStops: (stops: Stop[]) => void;
};
export const AppContextContext = createContext<AppContextStore>();
export function AppContextProvider(props: { children: JSX.Element }) {
type Store = {
displayedStops: Stop[];
};
const [store, setStore] = createStore<Store>({
displayedStops: [],
});
const getDisplayedStops = (): Stop[] => {
return store.displayedStops;
}
const setDisplayedStops = (stops: Stop[]): void => {
console.log("setDisplayedStops=", stops);
// setStore((s: Store) => {
setStore('displayedStops', stops);
// return s;
// });
}
return (
<AppContextContext.Provider value={{
getDisplayedStops, setDisplayedStops,
}}>
{props.children}
</AppContextContext.Provider>
);
};

View File

@@ -1,28 +1,46 @@
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, StopShape, StopShapes, Stops } from './types';
interface Store {
export type StopDestinations = Record<string, string[]>;
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: Passages) => void;
clearPassages?: () => void;
getPassagesLineIds: () => string[];
refreshPassages: (stopId: number) => Promise<void>;
addPassages: (passages: Passages) => void;
clearPassages: () => void;
stops: () => Stops;
addStops?: (stops: Stops) => void;
getStop: (stopId: number) => Stop | undefined;
searchStopByName: (name: string) => Promise<Stops>;
getStopDestinations: (stopId: number) => Promise<StopDestinations>;
getStopShape: (stopId: number) => Promise<StopShape | undefined>;
};
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;
stopShapes: StopShapes;
};
const getLine: Line = async (lineId: string) => {
const [store, setStore] = createStore<Store>({ lines: {}, passages: {}, stops: {}, stopShapes: {} });
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,60 +53,165 @@ export function BusinessDataProvider(props: { children: JSX.Element }) {
return line;
}
const getLinePassages = (lineId: string) => {
const getLinePassages = (lineId: string): Record<string, Passage[]> => {
return store.passages[lineId];
};
}
const passages = () => {
const getLineDestinations = (lineId: string): string[] => {
return Object.keys(store.passages[lineId]);
}
// TODO: Remove this method: it's based on the next passages and return nothing until the refreshPassages is called.
const getDestinationPassages = (lineId: string, destination: string): Passage[] => {
return store.passages[lineId][destination];
}
const passages = (): Passages => {
return store.passages;
}
const refreshPassages = async (stopId: number) => {
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) => {
setStore((s) => {
setStore('passages', passages);
});
}
const clearPassages = () => {
setStore((s) => {
for (const lineId of Object.keys(s.passages)) {
setStore('passages', lineId, undefined);
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 getStop = (stopId: int) => {
const clearPassages = (): void => {
setStore((s: Store): Store => {
for (const lineId of Object.keys(s.passages)) {
setStore('passages', lineId, undefined);
}
return s;
});
}
const getStop = (stopId: number): Stop | undefined => {
return store.stops[stopId];
}
const searchStopByName = async (name: string) => {
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 = {};
const byIdStops: Stops = {};
for (const stop of stops) {
byIdStops[stop.id] = stop;
setStore('stops', stop.id, stop);
for (const innerStop of stop.stops) {
setStore('stops', innerStop.id, innerStop);
}
}
return byIdStops;
}
const getStopDestinations = async (stopId: number): Promise<StopDestinations> => {
const data = await fetch(`${serverUrl()}/stop/${stopId}/destinations`, {
headers: { 'Content-Type': 'application/json' }
});
const response = await data.json();
return response;
}
const getStopShape = async (stopId: number): Promise<StopShape | undefined> => {
let shape = store.stopShapes[stopId];
if (shape === undefined) {
console.log(`No shape found for ${stopId} stop... fetch it from backend.`);
const data = await fetch(`${serverUrl()}/stop_shape/${stopId}`, {
headers: { 'Content-Type': 'application/json' }
});
shape = await data.json();
setStore('stopShapes', stopId, shape);
}
return shape;
}
return (
<BusinessDataContext.Provider value={{
getLine, getLinePassages, passages, refreshPassages, clearPassages,
getStop, searchStopByName
getLine, getLinePassages, getLineDestinations, getDestinationPassages, passages, getPassagesLineIds,
refreshPassages, addPassages, clearPassages,
getStop, getStopDestinations, getStopShape, 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,7 +1,7 @@
/* @refresh reload */
import { render } from 'solid-js/web';
import './index.css';
import './index.scss';
import App from './App';
render(() => (<App/>), document.getElementById('root') as HTMLElement);

View File

@@ -0,0 +1,60 @@
@use "_common";
@use "_utils";
.passagesDisplay {
@extend %widget;
.header {
$header-element-height: calc(80/100*100%);
$component-border: solid var(--idfm-white) calc(0.25*1vh);
$component-border-radius: calc(9/86*100%);
.transportMode {
@extend %transportMode;
height: 100%;
margin: 0;
margin-right: calc(23/1920*100%);
}
.title {
@extend %title;
}
.menu {
height: $header-element-height;
aspect-ratio: 1;
margin-right: calc(30/1920*100%);
margin-left: auto;
button {
height: 100%;
aspect-ratio: 1;
background-color: var(--idfm-black);
border: $component-border;
border-radius: $component-border-radius;
}
}
.clock {
width: calc(175/1920*100%);
height: $header-element-height;
display: flex;
align-items: center;
justify-content: center;
border: $component-border;
border-radius: $component-border-radius;
svg {
aspect-ratio: 2.45;
height: calc(0.7*100%);
}
}
}
.body {
@extend %body
}
}

View File

@@ -1,38 +1,223 @@
import { Component, createEffect, createResource, createSignal, useContext } from "solid-js";
import { createContext, createEffect, createResource, createSignal, For, JSX, ParentComponent, Show, useContext, VoidComponent } from "solid-js";
import { createStore } from "solid-js/store";
import { createDateNow } from "@solid-primitives/date";
import { IconButton, Menu, MenuTrigger, MenuContent, MenuItem } from "@hope-ui/solid";
import { format } from "date-fns";
import { BusinessDataContext } from "./businessData";
import { SearchContext } from "./search";
import { BusinessDataContext, BusinessDataStore } from "./businessData";
import { AppContextContext, AppContextStore } from "./appContext";
import { getTransportModeSrc, PositionedPanel } from "./utils";
import { PassagesPanel } from "./passagesPanel";
import { getTransportModeSrc } from "./utils";
import { IconHamburgerMenu } from './extra/iconHamburgerMenu';
import styles from "./passagesDisplay.module.css";
import "./passagesDisplay.scss";
export const PassagesDisplay: Component = () => {
const maxPassagePerPanel = 5;
const syncPeriodMsec = 20 * 1000;
interface PassagesDisplayStore {
isPassagesRefreshEnabled: () => boolean;
enablePassagesRefresh: () => void;
disablePassagesRefresh: () => void;
togglePassagesRefresh: () => void;
const { passages, getLine, getLinePassages, refreshPassages, clearPassages } = useContext(BusinessDataContext);
getPanels: () => PositionedPanel[];
setPanels: (panels: PositionedPanel[]) => void;
// TODO: Use props instead
const { getDisplayedStops } = useContext(SearchContext);
getDisplayedPanelId: () => number;
setDisplayedPanelId: (panelId: number) => void;
};
const [displayedPanelId, setDisplayedPanelId] = createSignal<number>(0);
const [panels, setPanels] = createStore([]);
const PassagesDisplayContext = createContext<PassagesDisplayStore>();
function PassagesDisplayProvider(props: { children: JSX.Element }) {
type Store = {
refreshEnabled: boolean;
panels: PositionedPanel[];
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 = (): PositionedPanel[] => {
return store.panels;
}
const setPanels = (panels: PositionedPanel[]): 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="header">
<Show when={transportModeUrls() !== undefined} >
<For each={transportModeUrls()}>
{(url) =>
<div class="transportMode">
<img src={url} />
</div>
}
</For>
</Show>
<div class="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="menu">
<Menu>
<MenuTrigger
as={IconButton}
icon=<IconHamburgerMenu />
/>
<MenuContent>
<MenuItem onSelect={() => togglePassagesRefresh()}>{isPassagesRefreshEnabled() ? "Disable" : "Enable"}</MenuItem>
</MenuContent>
</Menu>
</div>
<div class="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="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 appContextStore: AppContextStore | undefined = useContext(AppContextContext);
const passagesDisplayStore: PassagesDisplayStore | undefined = useContext(PassagesDisplayContext);
if (businessDataStore === undefined || appContextStore === undefined || passagesDisplayStore === undefined) {
return <div />;
}
const { getLineDestinations, passages, getPassagesLineIds, clearPassages, refreshPassages } = businessDataStore;
const { isPassagesRefreshEnabled, getDisplayedPanelId, setDisplayedPanelId, getPanels, setPanels } = passagesDisplayStore;
const { getDisplayedStops } = appContextStore;
setInterval(() => {
let nextPanelId = displayedPanelId() + 1;
if (nextPanelId >= panels.length) {
let nextPanelId = getDisplayedPanelId() + 1;
if (nextPanelId >= getPanels().length) {
nextPanelId = 0;
}
setDisplayedPanelId(nextPanelId);
}, 4000);
}, 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 #########");
@@ -49,131 +234,65 @@ export const PassagesDisplay: Component = () => {
}
});
setInterval(
async () => {
const stops = getDisplayedStops();
if (stops.length > 0) {
refreshPassages(stops[0].id);
}
},
syncPeriodMsec
return (
<div class="body">
{() => {
setPanels([]);
let newPanels = [];
let positioneds: PositionedPanel[] = [];
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>
);
}
// TODO: Sort transport modes by weight
const Header: Component = (props) => {
const computeTransportModes = async (lineIds: Array<number>) => {
const lines = await Promise.all(lineIds.map((lineId) => getLine(lineId)));
return new Set(lines.map((line) => getTransportModeSrc(line.transportMode, false)));
}
export const PassagesDisplay: ParentComponent = () => {
const [linesIds, setLinesIds] = createSignal([]);
const [transportModeUrls] = createResource(linesIds, computeTransportModes);
const MAX_PASSAGES_PER_PANEL = 5;
createEffect(() => {
setLinesIds(Object.keys(props.passages));
});
return (
<div class={styles.header}>
<Show when={transportModeUrls() !== undefined} >
<For each={Array.from(transportModeUrls())}>
{(url) =>
<div class={styles.transportMode}>
<img src={url} />
</div>
}
</For>
</Show>
<div class={styles.title}>
<svg viewbox="0 0 1260 50">
<text x="0" y="50%" dominant-baseline="middle" font-size="50" style="fill: #ffffff">
{props.title}
</text>
</svg>
</div>
<div class={styles.clock}>
<svg viewbox="0 0 115 43">
<text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle" font-size="43" style="fill: #ffffff">
{format(dateNow(), "HH:mm")}
</text>
</svg>
</div>
</div >
);
};
const Footer: Component = (props) => {
return (
<div class={styles.footer}>
<For each={props.panels}>
{(positioned) => {
const { position } = positioned;
return (
<div>
<svg viewBox="0 0 29 29">
<circle cx="50%" cy="50%" r="13" stroke="#ffffff" stroke-width="3"
style={{ fill: `var(--idfm-${position == displayedPanelId() ? "white" : "black"})` }}
/>
</svg>
</div>
);
}}
</For>
</div>
);
}
// TODO: Use props.
const syncPeriodMsec = 20 * 1000;
const panelSwitchPeriodMsec = 4 * 1000;
return (
<div class={styles.passagesDisplay}>
<Header title="Prochains passages" passages={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 {
const [store] = createStore(chunk);
const panelid = index++;
const panel = <PassagesPanel show={panelid == displayedPanelId()} passages={store} />;
newPanels.push(panel);
positioneds.push({ position: panelid, panel });
chunk = {};
chunk[lineId] = byLinePassages;
chunkSize = byLinePassagesKeys.length;
}
}
if (chunkSize) {
const panelId = index++;
const [store] = createStore(chunk);
const panel = <PassagesPanel show={panelId == displayedPanelId()} passages={store} />;
newPanels.push(panel);
positioneds.push({ position: panelId, panel });
}
setPanels(positioneds);
return newPanels;
}}
</div>
<Footer panels={panels} />
<div class="passagesDisplay">
<PassagesDisplayProvider>
<Header title="Prochains passages" />
<Body maxPassagesPerPanel={MAX_PASSAGES_PER_PANEL} syncPeriodMsec={syncPeriodMsec} panelSwitchPeriodMsec={panelSwitchPeriodMsec} />
<Footer />
</PassagesDisplayProvider>
</div>
);
};

View File

@@ -1,129 +0,0 @@
.passagesContainer {
height: 100%;
width: 100%;
display: none;
position: relative;
}
.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 {
width: calc(1880/1920*100%);
height: calc(100% / 5);
margin: 0 calc(20/1920*100%);
display: flex;
align-items: center;
/* TODO: compute the border weight according to the parent height */
/* TODO: Disable border-bottom for the last .line */
border-bottom: solid calc(2px);
}
.line svg {
font-family: IDFVoyageur-bold;
max-width: 100%;
max-height: 100%;
}
/* Idfm: 100x100px (margin: 0px 15px) */
.transportMode {
aspect-ratio : 1 / 1;
height: calc(100/176*100%);
margin: 0 calc(15/1920*100%);
}
.tramLinePicto {
aspect-ratio : 1 / 1;
height: calc(100/176*100%);
margin-right: calc(23/1920*100%);
}
.busLinePicto {
aspect-ratio : 2.25;
height: calc(70/176*100%);
margin-right: calc(23/1920*100%);
}
.destination {
height: calc(60/176*100%);
width: 50%;
font-family: IDFVoyageur-bold;
text-align: left;
}
.trafficStatus {
height: calc(50/176*100%);
aspect-ratio: 35/50;
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
}
.trafficStatus svg {
width: 100%;
}
.firstPassage {
height: calc(100/176*100%);
aspect-ratio: 2.5;
display: flex;
align-items: center;
justify-content: center;
padding-right: calc(30/1920*100%);
/* TODO: compute the border weight according to the parent width */
border-right: solid calc(5px);
}
.unavailableFirstPassage {
height: calc(100/176*100%);
aspect-ratio: calc(230/100);
margin-right: calc(30/1920*100%);
/* TODO: compute the border weight according to the parent width */
border-right: solid calc(5px);
}
.firstPassage svg {
aspect-ratio: 215/50;
height: calc(1/2*100%);
}
.secondPassage {
height: calc(45/176*100%);
aspect-ratio: calc(230/45);
margin-right: calc(30/1920*100%);
}
.secondPassage svg {
font-family: IDFVoyageur-regular;
}
.unavailableSecondPassage {
height: calc(100/176*100%);
aspect-ratio: calc(230/100);
margin-right: calc(30/1920*100%);
}
.unavailableSecondPassage svg {
font-family: IDFVoyageur-regular;
}

View File

@@ -0,0 +1,208 @@
@use "_utils.scss";
.body {
.passagesContainer {
height: 100%;
width: 100%;
display: none;
position: relative;
/* TODO: Remove the bottom border only if there are 5 displayed lines. */
.line:last-child {
border-bottom: 0;
/* To make up for the bottom border deletion */
padding-bottom: calc(2px);
}
/* Idfm: 1880x176px (margin: 0px 20px) */
.line {
width: calc(1880/1920*100%);
height: calc(100% / 5);
margin: 0 calc(20/1920*100%);
display: flex;
align-items: center;
/* TODO: compute the border weight according to the parent height */
/* TODO: Disable border-bottom for the last .line */
border-bottom: solid calc(2px);
svg {
font-family: IDFVoyageur-bold;
max-width: 100%;
max-height: 100%;
}
}
/* Idfm: 100x100px (margin: 0px 15px) */
.transportMode {
@extend %transportMode;
height: calc(100/176*100%);
margin: 0 calc(15/1920*100%);
}
.busLinePicto {
@extend %busLinePicto;
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%;
font-family: IDFVoyageur-bold;
text-align: left;
}
.trafficStatus {
height: calc(50/176*100%);
aspect-ratio: 35/50;
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 100%;
}
}
.firstPassage {
height: calc(100/176*100%);
aspect-ratio: 2.5;
display: flex;
align-items: center;
justify-content: center;
padding-right: calc(30/1920*100%);
/* TODO: compute the border weight according to the parent width */
border-right: solid calc(5px);
svg {
aspect-ratio: 215/50;
height: calc(50%);
}
}
.unavailableFirstPassage {
height: calc(100/176*100%);
aspect-ratio: calc(230/100);
/* TODO: compute the border weight according to the parent width */
border-right: solid calc(5px);
}
.secondPassage {
height: calc(45/176*100%);
aspect-ratio: calc(230/45);
margin-right: calc(30/1920*100%);
svg {
font-family: IDFVoyageur-regular;
}
}
.unavailableSecondPassage {
height: calc(100/176*100%);
aspect-ratio: calc(230/100);
margin-right: calc(30/1920*100%);
svg {
font-family: IDFVoyageur-regular;
}
}
%withPlatformPassage {
height: calc(120/176*100%);
display: flex;
flex-direction: column;
}
.withPlatformFirstPassage {
@extend %withPlatformPassage;
aspect-ratio: 250/120;
padding-right: calc(30/1920*100%);
/* TODO: compute the border weight according to the parent width */
border-right: solid calc(5px);
.passage {
aspect-ratio: 215/50;
height: calc(1/2*100%);
font-family: IDFVoyageur-bold;
margin-top: calc(5/176*100%);
}
.platform {
margin-top: auto;
margin-bottom: calc(5/176*100%);
rect {
background-color: var(--idfm-black);
}
text {
vertical-align: middle;
font-family: IDFVoyageur-bold;
}
}
}
.withPlatformSecondPassage {
@extend %withPlatformPassage;
aspect-ratio: 215/120;
align-items: end;
justify-content: center;
margin-right: calc(30/1920*100%);
.passage {
aspect-ratio: 215/45;
height: calc(45/120*100%);
/* 5px + (first passage font size - second passage font size/2) to align passages... */
/* There must exist a better way to align them. */
margin-top: calc(7.5/176*100%);
}
svg {
font-family: IDFVoyageur-regular;
}
.platform {
rect {
background-color: var(--idfm-black);
}
text {
vertical-align: middle;
font-family: IDFVoyageur-bold;
}
}
}
}
.displayed {
display: block;
}
}

View File

@@ -1,46 +1,22 @@
import { Component, createEffect, createResource, createSignal, useContext } from 'solid-js';
import { VoidComponent, createResource, onMount, ParentComponent, ParentProps, Show, useContext, For } from 'solid-js';
import { createDateNow, getTime } from '@solid-primitives/date';
import { AnimationOptions } from '@motionone/types';
import { Motion } from "@motionone/solid";
import { format } from "date-fns";
import { TrafficStatus } from './types';
import { renderLineTransportMode, renderLinePicto } from './utils';
import { Line, TrafficStatus } from './types';
import { renderLineTransportMode, renderLinePicto, ScrollingText } from './utils';
import { BusinessDataContext, BusinessDataStore } from "./businessData";
import { BusinessDataContext } from "./businessData";
import styles from "./passagesPanel.module.css";
import "./passagesPanel.scss";
const TtwPassage: Component = (props) => {
const [dateNow] = createDateNow(5000);
const refTs = props.passage.expectedDepartTs !== null ? props.passage.expectedDepartTs : props.passage.expectedArrivalTs;
const ttwSec = refTs - (getTime(dateNow()) / 1000);
const isApproaching = ttwSec <= 60;
return (
<div class={props.style}>
<svg viewBox={`0 0 215 ${props.fontSize}`}>
<Motion.text
x="100%" y="55%"
dominant-baseline="middle" text-anchor="end"
font-size={props.fontSize} style={{ fill: "#000000" }}
initial={isApproaching}
animate={{ opacity: [1, 0, 1] }}
transition={{ duration: 3, repeat: Infinity }}>
{Math.floor(ttwSec / 60)} min
</Motion.text>
</svg>
</div>
);
}
const UnavailablePassage: Component = (props) => {
const UnavailablePassage: VoidComponent<{ style: string }> = (props) => {
const textStyle = { fill: "#000000" };
return (
<div class={props.style}>
<svg viewbox="0 0 230 110">
<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>
@@ -49,8 +25,89 @@ const UnavailablePassage: Component = (props) => {
);
}
const Platform: VoidComponent<{ name: string }> = (props) => {
const platformTextPaddingPx: number = 20;
const viewBoxWidthPx: number = 215;
let rectRef: SVGSVGElement | undefined = undefined;
let textRef: SVGTextElement | undefined = undefined;
onMount(() => {
if (rectRef !== undefined && textRef !== undefined) {
const textWidth = textRef.getComputedTextLength();
const rectWidth = textWidth + platformTextPaddingPx * 2;
rectRef.setAttribute("width", `${rectWidth}px`);
rectRef.setAttribute("x", `${viewBoxWidthPx - rectWidth}px`);
textRef.setAttribute("x", `${viewBoxWidthPx - platformTextPaddingPx}px`);
}
});
return (
<svg class="platform" viewBox={`0 0 ${viewBoxWidthPx} 40`}>
<rect ref={rectRef} x="0" y="0" height="100%" rx="9" ry="9" />
<text ref={textRef} x="100%" y="55%" dominant-baseline="middle" text-anchor="end" font-size="25" style={{ fill: "#ffffff" }}>
QUAI {props.name}
</text>
</svg>
);
}
const TtwPassage: VoidComponent<{
line: Line, destination: string, index: number, style: string,
withPlatformStyle: 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 ttwRepr = ttwSec < 3600 ? `${Math.floor(ttwSec / 60).toString().padStart(2, "0")} min` : format(refTs * 1000, "HH:mm");
const isApproaching = ttwSec <= 60;
const text = <svg class="passage" 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}>
{ttwRepr}
</Motion.text>
</svg>;
return (
<Show when={passage !== undefined} fallback=<UnavailablePassage style={props.fallbackStyle} />>
<Show
when={passage.arrivalPlatformName !== null}
fallback={
<div class={props.style}>
{text}
</div>}>
<div class={props.withPlatformStyle}>
{text}
<Platform name={passage.arrivalPlatformName} />
</div>
</Show >
</Show >
);
});
}
/* TODO: Manage end of service */
const Passages: Component = (props) => {
const DestinationPassages: VoidComponent<{ line: Line, destination: string }> = (props) => {
/* TODO: Find where to get data to compute traffic status. */
const trafficStatusColor = new Map<TrafficStatus, string>([
@@ -61,70 +118,62 @@ const Passages: Component = (props) => {
[TrafficStatus.BYPASSED, "#ffffff"]
]);
const passagesLength = props.passages.length;
const firstPassage = passagesLength > 0 ? props.passages[0] : undefined;
const secondPassage = passagesLength > 1 ? props.passages[1] : undefined;
const trafficStatusStyle = { fill: trafficStatusColor.get(props.line.trafficStatus) };
// TODO: Manage traffic status
// const trafficStatusStyle = { fill: trafficStatusColor.get(props.line.trafficStatus) };
const trafficStatusStyle = { fill: trafficStatusColor.get(TrafficStatus.UNKNOWN) };
return (
<div class={styles.line}>
<div class={styles.transportMode}>
<div class="line">
<div class="transportMode">
{renderLineTransportMode(props.line)}
</div>
{renderLinePicto(props.line, styles)}
<div class={styles.destination}>
<svg viewbox="0 0 600 40">
<text x="0" y="50%" dominant-baseline="middle" font-size="40" style={{ fill: "#000000" }}>
{props.destination}
</text>
</svg>
{renderLinePicto(props.line)}
<div class="destination">
<ScrollingText height={40} width={600} content={props.destination} />
</div>
<div class={styles.trafficStatus}>
<div class="trafficStatus">
<svg viewBox="0 0 51 51">
<circle cx="50%" cy="50%" r="24" stroke="#231f20" stroke-width="3" style={trafficStatusStyle} />
</svg>
</div>
<Show when={firstPassage !== undefined} fallback=<UnavailablePassage style={styles.unavailableFirstPassage} />>
<TtwPassage style={styles.firstPassage} passage={firstPassage} fontSize="50" />
</Show>
<Show when={secondPassage !== undefined} fallback=<UnavailablePassage style={styles.unavailableSecondPassage} />>
<TtwPassage style={styles.secondPassage} passage={secondPassage} fontSize="45" />
</Show>
<TtwPassage line={props.line} destination={props.destination} index={0}
style="firstPassage" withPlatformStyle="withPlatformFirstPassage"
fontSize={50} fallbackStyle="unavailableFirstPassage" />
<TtwPassage line={props.line} destination={props.destination} index={1}
style="secondPassage" withPlatformStyle="withPlatformSecondPassage"
fontSize={45} fallbackStyle="unavailableSecondPassage" />
</div >
);
}
export const PassagesPanel: Component = (props) => {
export type PassagesPanelComponentProps = ParentProps & { stopId: number, lineIds: string[], show: boolean };
export type PassagesPanelComponent = ParentComponent<PassagesPanelComponentProps>;
export const PassagesPanel: PassagesPanelComponent = (props) => {
const { getLine } = useContext(BusinessDataContext);
const businessDataContext: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataContext === undefined)
return <div />;
const getLines = async (lineIds: Array<number>) => {
const lines = await Promise.all(lineIds.map((lineId) => getLine(lineId)));
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 [lineIds, setLinesIds] = createSignal([]);
const [lines] = createResource(lineIds, getLines);
createEffect(async () => {
setLinesIds(Object.keys(props.passages));
});
const [lines] = createResource<Line[], string[]>(props.lineIds, getLines);
return (
<div classList={{ [styles.passagesContainer]: true, [styles.displayed]: props.show }} style={{ "top": `${100 * props.position}%` }}>
<div classList={{ ["passagesContainer"]: true, ["displayed"]: props.show }} >
<Show when={lines() !== undefined} >
{() => {
const ret = [];
for (const line of lines()) {
const byLinePassages = props.passages[line.id];
if (byLinePassages !== undefined) {
for (const destination of Object.keys(byLinePassages)) {
ret.push(<Passages passages={byLinePassages[destination]} line={line} destination={destination} />);
}
}
<For each={lines()}>
{(line) =>
<Show when={getLineDestinations(line.id) !== undefined}>
<For each={getLineDestinations(line.id)}>
{(destination) => <DestinationPassages line={line} destination={destination} />}
</For>
</Show>
}
return ret;
}}
</For>
</Show>
</div >
);

View File

@@ -1,61 +1,59 @@
import { batch, createContext } from 'solid-js';
import { batch, createContext, JSX } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Marker as LeafletMarker } from 'leaflet';
import { Stop, Stops } 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: Array<number>) => void;
export interface SearchStore {
getFoundStops: () => Stop[];
setFoundStops: (stops: Stop[]) => void;
getDisplayedStops: () => Array<Stop>;
setDisplayedStops: (stops: Array<Stop>) => void;
getDisplayedStops: () => Stop[];
setDisplayedStops: (stops: 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: {}, displayedStops: [] });
type Store = {
foundStops: Stop[];
markers: ByStopIdMarkers;
displayedStops: Stop[];
};
const getDisplayedStops = () => {
const [store, setStore] = createStore<Store>({ foundStops: [], markers: {}, displayedStops: [] });
const getFoundStops = (): Stop[] => {
return store.foundStops;
}
const setFoundStops = (stops: Stop[]): void => {
setStore('foundStops', stops);
}
const getDisplayedStops = (): Stop[] => {
return store.displayedStops;
}
const setDisplayedStops = (stops: Array<Stop>) => {
setStore((s) => {
const setDisplayedStops = (stops: Stop[]): void => {
setStore((s: Store) => {
setStore('displayedStops', stops);
return s;
});
}
const removeStops = (stopIds: Array<number>) => {
batch(() => {
for (const stopId of stopIds) {
setStore('stops', stopId, undefined);
setStore('markers', stopId, undefined);
}
});
}
const getMarkers = () => {
return store.markers;
}
const addMarkers = (stopId: number, markers) => {
const addMarkers = (stopId: number, markers: L.Marker[]): void => {
setStore('markers', stopId, markers);
}
const setMarkers = (markers) => {
setStore('markers', markers);
}
return (
<SearchContext.Provider value={{ getDisplayedStops, setDisplayedStops, removeStops, getMarkers, addMarkers, setMarkers }}>
<SearchContext.Provider value={{ getFoundStops, setFoundStops, getDisplayedStops, setDisplayedStops, addMarkers }}>
{props.children}
</SearchContext.Provider>
);

View File

@@ -1,33 +0,0 @@
svg {
font-family: IDFVoyageur-bold;
}
.transportMode {
aspect-ratio : 1 / 1;
height: 70%;
margin-left: 1%;
}
.tramLinePicto {
height: 70%;
margin-left: 1%;
aspect-ratio : 1 / 1;
}
.trainLinePicto {
height: 70%;
margin-left: 1%;
aspect-ratio : 1 / 1;
}
.metroLinePicto {
height: 70%;
margin-left: 1%;
aspect-ratio : 1 / 1;
}
.busLinePicto {
height: 70%;
margin-left: 1%;
aspect-ratio : 2.25;
}

View File

@@ -1,204 +0,0 @@
import { Component, createEffect, createResource, createSignal, onMount, Show, useContext } from 'solid-js';
import { Box, Button, Input, InputLeftAddon, InputGroup, HStack, List, ListItem, Progress, ProgressIndicator, VStack } from "@hope-ui/solid";
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
import { BusinessDataContext } from './businessData';
import { SearchContext } from './search';
import { Stop } from './types';
import { renderLineTransportMode, renderLinePicto, TransportModeWeights } from './utils';
import styles from './stopManager.module.css';
const StopRepr: Component = (props) => {
const { getLine } = useContext(BusinessDataContext);
const [lineReprs] = createResource(props.stop.lines, fetchLinesRepr);
const fetchLinesRepr = async (lineIds: Array<string>) => {
const reprs = [];
for (const lineId of lineIds) {
const line = await getLine(lineId);
if (line !== undefined) {
reprs.push(<div class={styles.transportMode}>{renderLineTransportMode(line)}</div>);
reprs.push(renderLinePicto(line, styles));
}
}
return reprs;
}
return (
<HStack height="100%">
{props.stop.name}
<For each={lineReprs()}>{(line) => line}</For>
</HStack>
);
}
const StopAreaRepr: Component = (props) => {
const { getLine } = useContext(BusinessDataContext);
const fetchLinesRepr = async (stop: Stop) => {
const lineIds = new Set(stop.lines);
const stops = stop.stops;
for (const stop of stops) {
stop.lines.forEach(lineIds.add, lineIds);
}
const byModeReprs = {};
for (const lineId of lineIds) {
const line = await getLine(lineId);
if (line !== undefined) {
if (!(line.transportMode in byModeReprs)) {
byModeReprs[line.transportMode] = {
mode: <div class={styles.transportMode}>{renderLineTransportMode(line)}</div>
};
}
byModeReprs[line.transportMode][line.shortName] = renderLinePicto(line, styles);
}
}
const reprs = [];
const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] < TransportModeWeights[y]);
for (const transportMode of sortedTransportModes) {
const lines = byModeReprs[transportMode];
const repr = [lines.mode];
delete lines.mode;
for (const lineId of Object.keys(lines).sort((x, y) => x.localeCompare(y))) {
repr.push(lines[lineId]);
}
reprs.push(repr);
}
return reprs;
}
const [lineReprs] = createResource(props.stop, fetchLinesRepr);
return (
<HStack height="100%">
{props.stop.name}
<For each={lineReprs()}>{(line) => line}</For>
</HStack>
);
}
const Map: Component = (props) => {
const mapCenter = [48.853, 2.35];
const { addMarkers } = useContext(SearchContext);
let mapDiv: any;
let map = null;
const stopsLayerGroup = L.featureGroup();
const buildMap = (div: HTMLDivElement) => {
map = L.map(div).setView(mapCenter, 11);
L.tileLayer('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);
}
const setMarker = (stop: Stop): Array<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());
}
else {
for (const _stop of stop.stops) {
markers.push(...setMarker(_stop));
}
}
return markers;
}
onMount(() => buildMap(mapDiv));
createEffect(() => {
/* TODO: Avoid to clear all layers... */
stopsLayerGroup.clearLayers();
for (const stop of props.stops) {
const markers = setMarker(stop);
addMarkers(stop.id, markers);
for (const marker of markers) {
stopsLayerGroup.addLayer(marker);
}
}
const stopsBound = stopsLayerGroup.getBounds();
if (Object.keys(stopsBound).length) {
map.fitBounds(stopsBound);
}
});
return <div ref={mapDiv} id='main-map' style={{ width: "100%", height: "100%" }} />;
}
export const StopsManager: Component = () => {
const [minCharactersNb, setMinCharactersNb] = createSignal<number>(4);
const [inProgress, setInProgress] = createSignal<boolean>(false);
const [foundStops, setFoundStops] = createSignal<Array<number>>([]);
const { getStop, searchStopByName } = useContext(BusinessDataContext);
const { setDisplayedStops } = useContext(SearchContext);
const onStopNameInput = async (event) => {
/* TODO: Add a tempo before fetching stop for giving time to user to finish his request */
const stopName = event.target.value;
if (stopName.length >= minCharactersNb()) {
console.log(`Fetching data for ${stopName}`);
setInProgress(true);
const stopsById = await searchStopByName(stopName);
setFoundStops(Object.values(stopsById));
setInProgress(false);
}
}
return (
<VStack h="100%">
<InputGroup w="50%" h="5%">
<InputLeftAddon>🚉 🚏</InputLeftAddon>
<Input onInput={onStopNameInput} readOnly={inProgress()} placeholder="Stop name..." />
</InputGroup>
<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 foundStops().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 !!!`);
setDisplayedStops([stop]);
}}>
<Box w="100%" h="100%">
<Show when={stop.stops !== undefined} fallback={<StopRepr stop={stop} />}>
<StopAreaRepr stop={stop} />
</Show>
</Box>
</Button>
</ListItem>);
}
return items;
}}
</List>
</Box>
<Box borderWidth="1px" borderColor="var(--idfm-black)" borderRadius="$lg" h="55%" w="100%" overflow="scroll">
<Map stops={foundStops()} />
</Box>
</VStack>
);
};

View File

@@ -0,0 +1,222 @@
@use "_common";
@use "_utils";
.stopSearchMenu {
@extend %widget;
.inputGroup {
width: 50%;
// height: 5%;
// TODO: Setup hop-ui to avoid to have to overrride rules.
input {
color: var(--idfm-white);
font-family: IDFVoyageur-regular;
}
}
.title {
@extend %title;
display: flex;
justify-content: center;
}
.body {
@extend %body;
flex-direction: row;
.stopsPanels {
width: 50%;
height: 100%;
scroll-snap-type: y mandatory;
overflow-y: scroll;
.stopPanel {
scroll-snap-align: center;
.stop {
width: calc(1880/1920*100%);
height: calc(100% / 5);
// margin: 0 calc(20/1920*100%);
margin: 0 calc(10/1920*100%);
display: flex;
align-items: center;
flex-direction: row;
/* TODO: compute the border weight according to the parent height */
/* TODO: Disable border-bottom for the last .line */
border-bottom: solid calc(2px);
cursor: default;
.name {
margin-left: calc(40/1920*100%);
width: 60%;
aspect-ratio: 2.5;
display: flex;
align-items: center;
font-family: IDFVoyageur-bold;
}
.lineRepr {
width: 40%;
aspect-ratio: 2.5;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
.transportMode {
@extend %transportMode;
height: 50%;
}
.linesRepresentationMatrix {
@extend %busLinePicto; // Use the larger picto aspect-ratio
width: 75%;
aspect-ratio: 3;
display: flex;
flex-flow: row;
flex-wrap: wrap;
%picto {
margin-left: 1%;
align-self: center;
justify-self: center;
}
%singleLinePicto {
@extend %picto;
height: 80%;
}
.transportMode {
@extend %transportMode;
@extend %picto;
}
.tramLinePicto {
@extendnd %tramLinePicto;
@extend %singleLinePicto;
}
.trainLinePicto {
@extend %trainLinePicto;
@extend %singleLinePicto;
}
.metroLinePicto {
@extend %metroLinePicto;
@extend %singleLinePicto;
}
.busLinePicto {
@extend %busLinePicto;
@extend %picto;
height: 40%;
}
}
}
}
}
.displayed {
display: block;
}
}
.map {
position: relative;
height: 100%;
width: 50%;
.ol-viewport {
@extend %body;
position: absolute;
margin: 0;
}
.popup {
@extend %body;
margin: 0;
position: absolute;
width: 100%;
height: 35%;
border: solid var(--idfm-white) calc(0.2*1vh);
background-color: var(--idfm-black);
z-index: 1;
visibility: hidden;
.header {
@extend %header;
color: var(--idfm-white);
}
.body {
@extend %body;
scroll-snap-type: y mandatory;
overflow-y: scroll;
.line {
scroll-snap-align: center;
height: calc(100% / 3);
margin: 0 calc(10/1920*100%);
display: flex;
flex-direction: row;
align-items: center;
font-family: IDFVoyageur-bold;
.busLinePicto {
@extend %busLinePicto;
height: 80%;
width: 30%;
}
.name {
width: 100%;
height: 60%;
}
div {
height: 100%;
svg {
max-width: 100%;
max-height: 100%;
}
}
}
}
.footer {
@extend %footer;
}
}
.displayed {
visibility: visible;
}
}
}
}

View File

@@ -0,0 +1,770 @@
import { createContext, createEffect, createResource, createSignal, For, JSX, onMount, ParentComponent, Show, useContext, VoidComponent } from 'solid-js';
import { createStore } from "solid-js/store";
import { createScrollPosition } from "@solid-primitives/scroll";
import { Input, InputLeftAddon, InputGroup } from "@hope-ui/solid";
import OlFeature from 'ol/Feature';
import OlMap from 'ol/Map';
import OlView from 'ol/View';
import { isEmpty as isEmptyExtend } from 'ol/extent';
import { FeatureLike as OlFeatureLike } from 'ol/Feature';
import OlOSM from 'ol/source/OSM';
import OlOverlay from 'ol/Overlay';
import OlPoint from 'ol/geom/Point';
import OlPolygon from 'ol/geom/Polygon';
import OlVectorSource from 'ol/source/Vector';
import { fromLonLat, toLonLat } from 'ol/proj';
import { Tile as OlTileLayer, Vector as OlVectorLayer } from 'ol/layer';
import { Circle, Fill, Stroke, Style } from 'ol/style';
import { easeOut } from 'ol/easing';
import { getVectorContext } from 'ol/render';
import { unByKey } from 'ol/Observable';
import { register } from 'ol/proj/proj4';
import proj4 from 'proj4';
import { Stop, StopShape } from './types';
import { PositionedPanel, renderLineTransportMode, renderLinePicto, ScrollingText, TransportModeWeights } from './utils';
import { AppContextContext, AppContextStore } from "./appContext";
import { BusinessDataContext, BusinessDataStore } from "./businessData";
import "./stopsSearchMenu.scss";
proj4.defs("EPSG:2154", "+proj=lcc +lat_1=49 +lat_2=44 +lat_0=46.5 +lon_0=3 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs");
register(proj4);
type ByStopIdMapFeatures = Record<number, OlFeature>;
interface SearchStore {
getSearchText: () => string;
setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise<void>;
isSearchInProgress: () => boolean;
getFoundStops: () => Stop[];
setFoundStops: (stops: Stop[]) => void;
getDisplayedPanelId: () => number;
setDisplayedPanelId: (panelId: number) => void;
getPanels: () => PositionedPanel[];
setPanels: (panels: PositionedPanel[]) => void;
getHighlightedStop: () => Stop | undefined;
setHighlightedStop: (stop: Stop) => void;
resetHighlightedStop: () => void;
getMapFeature: (stopId: number) => OlFeature | undefined;
getAllMapFeatures: () => ByStopIdMapFeatures;
setMapFeature: (stopId: number, feature: OlFeature) => void;
};
const SearchContext = createContext<SearchStore>();
function SearchProvider(props: { children: JSX.Element }) {
type Store = {
searchText: string;
searchInProgress: boolean;
foundStops: Stop[];
displayedPanelId: number;
panels: PositionedPanel[];
highlightedStop: Stop | undefined;
mapFeatures: ByStopIdMapFeatures;
};
const [store, setStore] = createStore<Store>({
searchText: "",
searchInProgress: false,
foundStops: [],
displayedPanelId: 0,
panels: [],
highlightedStop: undefined,
mapFeatures: {},
});
const getSearchText = (): string => {
return store.searchText;
}
const setSearchText = async (text: string, businessDataStore: BusinessDataStore): Promise<void> => {
setStore('searchInProgress', true);
setStore('searchText', text);
const { searchStopByName } = businessDataStore;
console.log("store.searchText=", store.searchText);
const stopsById = await searchStopByName(store.searchText);
console.log("stopsById=", stopsById);
setFoundStops(Object.values(stopsById));
setStore('searchInProgress', false);
}
const isSearchInProgress = (): boolean => {
return store.searchInProgress;
}
const getFoundStops = (): Stop[] => {
return store.foundStops;
}
const setFoundStops = (stops: Stop[]): void => {
setStore('foundStops', stops);
}
const getDisplayedPanelId = (): number => {
return store.displayedPanelId;
}
const setDisplayedPanelId = (panelId: number): void => {
setStore('displayedPanelId', panelId);
}
const getPanels = (): PositionedPanel[] => {
return store.panels;
}
const setPanels = (panels: PositionedPanel[]): void => {
setStore('panels', panels);
}
const getHighlightedStop = (): Stop | undefined => {
return store.highlightedStop;
}
const setHighlightedStop = (stop: Stop): void => {
setStore('highlightedStop', stop);
}
const resetHighlightedStop = (): void => {
setStore('highlightedStop', undefined);
}
const getAllMapFeatures = (): ByStopIdMapFeatures => {
return store.mapFeatures;
}
const getMapFeature = (stopId: number): OlFeature | undefined => {
return store.mapFeatures[stopId];
}
const setMapFeature = (stopId: number, feature: OlFeature): void => {
setStore('mapFeatures', stopId, feature);
};
return (
<SearchContext.Provider value={{
getSearchText, setSearchText, isSearchInProgress,
getFoundStops, setFoundStops,
getDisplayedPanelId, setDisplayedPanelId,
getPanels, setPanels,
getHighlightedStop, setHighlightedStop, resetHighlightedStop,
getMapFeature, getAllMapFeatures, setMapFeature,
}}>
{props.children}
</SearchContext.Provider>
);
}
const Header: VoidComponent<{ title: string, minCharsNb: number }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined)
return <div />;
const { isSearchInProgress, setSearchText } = searchStore;
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.currentTarget.value;
if (stopName.length >= props.minCharsNb) {
console.log(`Fetching data for "${stopName}" stop name`);
await setSearchText(stopName, businessDataStore);
}
}
return (
<div class="header">
<div class="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="inputGroup">
<InputGroup >
<InputLeftAddon>🚉 🚏</InputLeftAddon>
<Input onInput={onStopNameInput} readOnly={isSearchInProgress()} placeholder="Stop name..." />
</InputGroup>
</div>
</div >
);
};
const StopRepr: VoidComponent<{ stop: Stop }> = (props) => {
const fontSize: number = 40;
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataStore === undefined)
return <div />;
const { getLine } = businessDataStore;
const fetchLinesRepr = async (lineIds: string[]): Promise<JSX.Element[]> => {
const reprs = [];
for (const lineId of lineIds) {
const line = await getLine(lineId);
if (line !== undefined) {
reprs.push(<div class="transportMode">{renderLineTransportMode(line)}</div>);
reprs.push(renderLinePicto(line));
}
}
return reprs;
}
const [lineReprs] = createResource<JSX.Element[], string[]>(props.stop.lines, fetchLinesRepr);
return (
<div class="stop">
<svg class="name" viewBox={`0 0 215 ${fontSize}`}>
<text
x="100%" y="55%"
dominant-baseline="middle" text-anchor="end"
font-size={fontSize}>
{props.stop.name}
</text>
</svg>
<For each={lineReprs()}>{(line: JSX.Element) => line}</For>
</div>
);
}
type ByTransportModeReprs = {
mode: JSX.Element | undefined;
lines: Record<string, JSX.Element | JSX.Element[] | undefined>;
}
const StopAreaRepr: VoidComponent<{ stop: Stop }> = (props) => {
const fontSize: number = 10;
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const appContextStore: AppContextStore | undefined = useContext(AppContextContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || appContextStore === undefined || searchStore === undefined)
return <div />;
const { getLine } = businessDataStore;
const { setDisplayedStops } = appContextStore;
const { setHighlightedStop, resetHighlightedStop } = searchStore;
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: Record<string, ByTransportModeReprs> = {};
for (const lineId of lineIds) {
const line = await getLine(lineId);
if (line !== undefined) {
if (!(line.transportMode in byModeReprs)) {
byModeReprs[line.transportMode] = {
mode: <div class="transportMode">{renderLineTransportMode(line)}</div>,
lines: {}
};
}
byModeReprs[line.transportMode].lines[line.shortName] = renderLinePicto(line);
}
}
const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] <
TransportModeWeights[y] ? 1 : -1);
return (
<div class="lineRepr">
<For each={sortedTransportModes}>{(transportMode) => {
const reprs = byModeReprs[transportMode];
const lineNames = Object.keys(reprs.lines).sort((x, y) => x.localeCompare(y));
return <>
{reprs.mode}
<div class="linesRepresentationMatrix">
<For each={lineNames}>{(lineName) => reprs.lines[lineName]}</For>
</div>
</>
}}
</For>
</div >
);
}
const [lineReprs] = createResource(props.stop, fetchLinesRepr);
return (
<div
class="stop"
onClick={() => setDisplayedStops([props.stop])}
onMouseEnter={() => setHighlightedStop(props.stop)}
onMouseLeave={resetHighlightedStop}
>
<div class="name" >
<ScrollingText height={fontSize} width={100} content={props.stop.name} />
</div>
{lineReprs()}
</div>
);
}
const StopsPanel: ParentComponent<{ stops: Stop[], show: boolean }> = (props) => {
return (
<div classList={{ "stopPanel": true, "displayed": props.show }}>
<For each={props.stops.sort((x, y) => x.name.localeCompare(y.name))}>
{(stop) => {
return <Show when={stop.stops !== undefined} fallback={<StopRepr stop={stop} />}>
<StopAreaRepr stop={stop} />
</Show>;
}}
</For>
</div>
);
}
const StopsPanels: ParentComponent<{ maxStopsPerPanel: number }> = (props) => {
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) {
return <div />;
}
const { getDisplayedPanelId, getFoundStops, getPanels, setDisplayedPanelId, setPanels } = searchStore;
let stopsPanelsRef: HTMLDivElement | undefined = undefined
const stopsPanelsScroll = createScrollPosition(() => stopsPanelsRef);
const yStopsPanelsScroll = () => stopsPanelsScroll.y;
createEffect(() => {
yStopsPanelsScroll();
for (const panel of getPanels()) {
const panelDiv = panel.panel();
const panelDivClientRect = panelDiv.getBoundingClientRect();
if (panelDivClientRect.y > 0) {
setDisplayedPanelId(panel.position);
break;
}
}
});
return (
<div ref={stopsPanelsRef} class="stopsPanels">
{() => {
setPanels([]);
let newPanels = [];
let positioneds: PositionedPanel[] = [];
let stops: Stop[] = [];
for (const stop of getFoundStops()) {
if (stops.length < props.maxStopsPerPanel) {
stops.push(stop);
}
else {
const panelId = newPanels.length;
const panel = <StopsPanel stops={stops} show={panelId == getDisplayedPanelId()} />;
newPanels.push(panel);
positioneds.push({ position: panelId, panel: panel });
stops = [stop];
}
}
if (stops.length) {
const panelId = newPanels.length;
const panel = <StopsPanel stops={stops} show={panelId == getDisplayedPanelId()} />;
newPanels.push(panel);
positioneds.push({ position: panelId, panel: panel });
}
setPanels(positioneds);
return newPanels;
}}
</div>
);
}
const StopPopup: ParentComponent<{ stop: Stop, show: boolean }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
if (businessDataStore === undefined)
return <div />;
const { getLine, getStopDestinations } = businessDataStore;
let popupDiv: HTMLDivElement | undefined = undefined;
const getDestinations = async (stop: Stop): Promise<{ lineId: string, destinations: string[] }[]> => {
let ret = [];
if (stop !== undefined) {
const result = await getStopDestinations(stop.id);
for (const [lineId, destinations] of Object.entries(result)) {
const line = await getLine(lineId);
const linePicto = renderLinePicto(line);
ret.push({ lineId: linePicto, destinations: destinations });
}
}
return ret;
}
const [destinations] = createResource(() => props.stop, getDestinations);
return (
<div ref={popupDiv} classList={{ "popup": true, "displayed": props.show }}>
<div class="header">{props.stop?.name}</div>
<div class="body">
<For each={destinations()}>
{(dst) => {
return <div class='line'>
{dst.lineId}
<div class="name">
<ScrollingText height={10} width={130} content={dst.destinations.join('/')} />
</div>
</div>;
}}
</For>
</div>
</div >
);
}
// TODO: Use boolean to set MapStop selected
const MapStop: VoidComponent<{ stop: Stop, selected: Stop | undefined }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined)
return <div />;
const { getStopShape } = businessDataStore;
const { setMapFeature } = searchStore;
const stopStyle = new Style({
image: new Circle({
fill: undefined,
stroke: new Stroke({ color: '#3399CC', width: 1.5 }),
radius: 10,
}),
});
const selectedStopStyle = new Style({
image: new Circle({
fill: undefined,
stroke: new Stroke({ color: 'purple', width: 2 }),
radius: 10,
}),
});
const stopAreaStyle = new Style({
stroke: new Stroke({ color: 'red' }),
fill: new Fill({ color: 'rgba(255,255,255,0.2)' }),
});
const getShape = async (stopId: number): Promise<StopShape | undefined> => {
return await getStopShape(stopId);
};
const [shape] = createResource<StopShape | undefined, number>(props.stop.id, getShape);
createEffect(() => {
const shape_ = shape();
if (shape_ === undefined) {
return;
}
let feature = undefined;
if (props.stop.lat !== undefined && props.stop.lon !== undefined) {
const selectStopStyle = () => {
return (props.selected?.id === props.stop.id ? selectedStopStyle : stopStyle);
}
feature = new OlFeature({
geometry: new OlPoint(fromLonLat([props.stop.lon, props.stop.lat])),
});
feature.setStyle(selectStopStyle);
}
else {
let geometry = undefined;
const areaShape = shape();
if (areaShape !== undefined) {
const transformed = areaShape.points.map(point => fromLonLat(toLonLat(point, 'EPSG:2154')));
geometry = new OlPolygon([transformed.slice(0, -1)]);
}
else {
geometry = new OlPoint(fromLonLat([props.stop.lon, props.stop.lat]));
}
feature = new OlFeature({ geometry: geometry });
feature.setStyle(stopAreaStyle);
}
feature.setId(props.stop.id);
setMapFeature(props.stop.id, feature);
});
return <For each={props.stop.stops}>{stop => <MapStop stop={stop} selected={props.selected} />}</For>;
}
const Map: ParentComponent<{}> = () => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (businessDataStore === undefined || searchStore === undefined)
return <div />;
const { getStop } = businessDataStore;
const { getAllMapFeatures, getFoundStops, getHighlightedStop } = searchStore;
const [selectedMapStop, setSelectedMapStop] = createSignal<Stop | undefined>(undefined);
const [isPopupDisplayed, setPopupDisplayed] = createSignal<boolean>(false);
const mapCenter = [260769.80336542107, 6250587.867330259]; // EPSG:3857
const fitDurationMs = 1500;
const flashDurationMs = 2000;
// TODO: Set padding according to the marker design.
const fitPointsPadding = [50, 50, 50, 50];
let mapDiv: HTMLDivElement | undefined = undefined;
let popup: StopPopup | undefined = undefined;
const stopVectorSource = new OlVectorSource({ features: [] });
const stopVectorLayer = new OlVectorLayer({ source: stopVectorSource });
let overlay: OlOverlay | undefined = undefined;
let map: OlMap | undefined = undefined;
const displayedFeatures: Record<number, OlFeature> = {};
const buildMap = (div: HTMLDivElement): void => {
overlay = new OlOverlay({
element: popup,
autoPan: {
animation: {
duration: 250,
},
},
});
map = new OlMap({
target: div,
controls: [], // remove controls
view: new OlView({
center: mapCenter,
zoom: 10,
}),
layers: [
new OlTileLayer({
source: new OlOSM(),
}),
stopVectorLayer,
],
overlays: [overlay],
});
map.on('singleclick', onClickedMap);
}
const onClickedMap = async (event): Promise<void> => {
const features = await stopVectorLayer.getFeatures(event.pixel);
// Handle only the first feature
if (features.length > 0) {
await onClickedFeature(features[0]);
}
else {
setPopupDisplayed(false);
setSelectedMapStop(undefined);
}
}
const onClickedFeature = async (feature: OlFeatureLike): Promise<void> => {
const stopId: number = feature.getId();
const stop = getStop(stopId);
// TODO: Handle StopArea (use center given by the backend)
if (stop?.lat !== undefined && stop?.lon !== undefined) {
setSelectedMapStop(stop);
map?.getView().animate(
{
center: fromLonLat([stop.lon, stop.lat]),
duration: 1000
},
// Display the popup once the animation finished
() => setPopupDisplayed(true)
);
}
}
onMount(() => buildMap(mapDiv));
// Filling the map with stops shape
createEffect(() => {
const stops = getFoundStops();
const foundStopIds = new Set();
for (const foundStop of stops) {
foundStopIds.add(foundStop.id);
foundStop.stops.forEach(s => foundStopIds.add(s.id));
}
for (const [stopIdStr, feature] of Object.entries(displayedFeatures)) {
const stopId = parseInt(stopIdStr);
if (!foundStopIds.has(stopId)) {
console.log(`Remove feature for ${stopId}`);
stopVectorSource.removeFeature(feature);
delete displayedFeatures[stopId];
}
}
const features = getAllMapFeatures();
for (const [stopIdStr, feature] of Object.entries(features)) {
const stopId = parseInt(stopIdStr);
if (foundStopIds.has(stopId) && !(stopId in displayedFeatures)) {
console.log(`Add feature for ${stopId}`);
stopVectorSource.addFeature(feature);
displayedFeatures[stopId] = feature;
}
}
const extend = stopVectorSource.getExtent();
if (map !== undefined && !isEmptyExtend(extend)) {
map.getView().fit(extend, { duration: fitDurationMs, padding: fitPointsPadding });
}
});
// Flashing effect
createEffect(() => {
const highlightedStopId = getHighlightedStop()?.id;
if (highlightedStopId !== undefined) {
const stop = getStop(highlightedStopId);
if (stop !== undefined) {
const stops = stop.stops ? stop.stops : [stop];
stops.forEach((s) => {
const feature = displayedFeatures[s.id];
if (feature !== undefined) {
flash(feature);
}
});
}
}
});
const flash = (feature: OlFeature) => {
const start = Date.now();
const flashGeom = feature.getGeometry()?.clone();
const listenerKey = stopVectorLayer.on('postrender', animate);
// Force postrender raising.
feature.changed();
function animate(event) {
const frameState = event.frameState;
const elapsed = frameState.time - start;
const vectorContext = getVectorContext(event);
if (elapsed >= flashDurationMs) {
unByKey(listenerKey);
return;
}
if (flashGeom !== undefined && map !== undefined) {
const elapsedRatio = elapsed / flashDurationMs;
// radius will be 5 at start and 30 at end.
const radius = easeOut(elapsedRatio) * 25 + 5;
const opacity = easeOut(1 - elapsedRatio);
const style = new Style({
image: new Circle({
radius: radius,
stroke: new Stroke({
color: `rgba(255, 0, 0, ${opacity})`,
width: 0.25 + opacity,
}),
}),
});
vectorContext.setStyle(style);
vectorContext.drawGeometry(flashGeom);
// tell OpenLayers to continue postrender animation
map.render();
}
}
}
return <>
<div ref={mapDiv} class="map">
<StopPopup ref={popup} stop={selectedMapStop()} show={isPopupDisplayed()} />
</div>
<For each={getFoundStops()}>{(stop) => <MapStop stop={stop} selected={selectedMapStop()} />}</For>
</>;
}
const Footer: VoidComponent<{}> = () => {
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) {
return <div />;
}
const { getDisplayedPanelId, getPanels } = searchStore;
return (
<div class="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>
);
}
export const StopsSearchMenu: VoidComponent = () => {
const maxStopsPerPanel = 5;
return (
<div class="stopSearchMenu">
<SearchProvider>
<Header title="Recherche de l'arrêt..." minCharsNb={4} />
<div class="body">
<StopsPanels maxStopsPerPanel={maxStopsPerPanel} />
<Map />
</div>
<Footer />
</SearchProvider>
</div>
);
};

View File

@@ -6,12 +6,10 @@ export enum TrafficStatus {
BYPASSED
}
export class Passages { };
export class Passage {
line: number;
operator: string;
destinations: Array<string>;
destinations: string[];
atStop: boolean;
aimedArrivalTs: number;
expectedArrivalTs: number;
@@ -21,7 +19,7 @@ export class Passage {
arrivalStatus: string;
departStatus: string;
constructor(line: number, operator: string, destinations: Array<string>, atStop: boolean, aimedArrivalTs: number,
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;
@@ -38,7 +36,7 @@ export class Passage {
}
};
export class Stops { };
export type Passages = Record<string, Record<string, Passage[]>>;
export class Stop {
id: number;
@@ -46,10 +44,10 @@ export class Stop {
town: string;
lat: number;
lon: number;
stops: Array<Stop>;
lines: Array<string>;
stops: Stop[];
lines: string[];
constructor(id: number, name: string, town: string, lat: number, lon: number, stops: Array<Stop>, lines: Array<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;
@@ -63,6 +61,26 @@ export class Stop {
}
};
export type Stops = Record<number, Stop>;
export type Points = [number, number][];
export class StopShape {
stop_id: number;
type_: number;
bounding_box: number[];
points: Points;
constructor(stop_id: number, type_: number, bounding_box: number[], points: Points) {
this.stop_id = stop_id;
this.type_ = type_;
this.bounding_box = bounding_box;
this.points = points;
}
};
export type StopShapes = Record<number, StopShape>;
export class Line {
id: string;
shortName: string;
@@ -75,11 +93,11 @@ export class Line {
accessibility: boolean;
visualSignsAvailable: string; // TODO: Use an enum
audibleSignsAvailable: string; // TODO: Use an enum
stopIds: Array<number>;
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: Array<number>) {
audibleSignsAvailable: string, stopIds: number[]) {
this.id = id;
this.shortName = shortName;
this.name = name;
@@ -94,3 +112,5 @@ export class Line {
this.stopIds = stopIds;
}
};
export type Lines = Record<string, Line>;

View File

@@ -1,8 +1,18 @@
import { JSX, onMount, VoidComponent } from 'solid-js';
import { timeline } from '@motionone/dom';
import { Line } from './types';
// Thanks to https://dev.to/ycmjason/how-to-create-range-in-javascript-539i
export function* range(start: number, end: number): Generator<number> {
for (let i = start; i <= end; i++) {
yield i;
}
}
const validTransportModes = ["bus", "tram", "metro", "rer", "transilien", "funicular", "ter", "unknown"];
export const TransportModeWeights = {
export const TransportModeWeights: Record<string, number> = {
bus: 1,
tram: 2,
val: 3,
@@ -13,22 +23,21 @@ export const TransportModeWeights = {
ter: 8,
};
export function renderLineTransportMode(line: Line): JSX.Element {
return <img src={getTransportModeSrc(line.transportMode)} />
}
export function getTransportModeSrc(mode: string, color: boolean = true): string | null {
let ret = null;
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 `/carrramba-encore-rate/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: Line, styles): JSX.Element {
function renderBusLinePicto(line: Line): JSX.Element {
return (
<div class={styles.busLinePicto}>
<div class="busLinePicto">
<svg viewBox="0 0 31.5 14">
<rect x="0" y="0" width="31.5" height="14" rx="1.5" ry="1.5" style={{ fill: `#${line.backColorHexa}` }} />
<text x="50%"
@@ -44,10 +53,10 @@ function renderBusLinePicto(line: Line, styles): JSX.Element {
);
}
function renderTramLinePicto(line: Line, styles): JSX.Element {
function renderTramLinePicto(line: Line): JSX.Element {
const lineStyle = { fill: `#${line.backColorHexa}` };
return (
<div class={styles.tramLinePicto}>
<div class="tramLinePicto">
<svg viewBox="0 0 20 20">
<rect x="0" y="0" width="20" height="3" rx="1" ry="1" style={lineStyle} />
<rect x="0" y="17" width="20" height="3" rx="1" ry="1" style={lineStyle} />
@@ -64,10 +73,10 @@ function renderTramLinePicto(line: Line, styles): JSX.Element {
);
}
function renderMetroLinePicto(line: Line, styles): JSX.Element {
function renderMetroLinePicto(line: Line): JSX.Element {
return (
<div class={styles.metroLinePicto}>
<svg viewbox="0 0 20 20">
<div class="metroLinePicto">
<svg viewBox="0 0 20 20">
<circle cx="10" cy="10" r="10" style={{ fill: `#${line.backColorHexa}` }} />
<text x="50%"
y="55%"
@@ -81,10 +90,10 @@ function renderMetroLinePicto(line: Line, styles): JSX.Element {
);
}
function renderTrainLinePicto(line: Line, styles): JSX.Element {
function renderTrainLinePicto(line: Line): JSX.Element {
return (
<div class={styles.trainLinePicto}>
<svg viewbox="0 0 20 20">
<div class="trainLinePicto">
<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%"
@@ -99,19 +108,59 @@ function renderTrainLinePicto(line: Line, styles): JSX.Element {
);
}
export function renderLinePicto(line: Line, styles): JSX.Element {
export function renderLinePicto(line: Line): JSX.Element {
switch (line.transportMode) {
case "bus":
case "funicular":
return renderBusLinePicto(line, styles);
return renderBusLinePicto(line);
case "tram":
return renderTramLinePicto(line, styles);
return renderTramLinePicto(line);
/* case "val": */
case "metro":
return renderMetroLinePicto(line, styles);
return renderMetroLinePicto(line);
case "transilien":
case "rer":
case "ter":
return renderTrainLinePicto(line, styles);
return renderTrainLinePicto(line);
}
}
export type PositionedPanel = {
position: number;
// TODO: Should be PassagesPanelComponent ?
panel: JSX.Element;
};
export const ScrollingText: VoidComponent<{ height: number, width: number, content: string }> = (props) => {
let viewBoxRef: SVGSVGElement | undefined = undefined;
let textRef: SVGTextElement | undefined = undefined;
onMount(() => {
if (viewBoxRef !== undefined && textRef !== undefined) {
const overlap = textRef.getComputedTextLength() - viewBoxRef.viewBox.baseVal.width;
if (overlap > 0) {
timeline(
[
[textRef, { x: [-overlap] }, { duration: 5 }],
[textRef, { x: [0] }, { duration: 2 }],
],
{ repeat: Infinity },
);
}
}
});
return (
<svg ref={viewBoxRef} viewBox={`0 0 ${props.width} ${props.height}`}>
<text
ref={textRef}
x="0%" y="55%"
dominant-baseline="middle"
font-size={`${props.height}px`}>
{props.content}
</text>
</svg >
);
}

View File

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