41 Commits

Author SHA1 Message Date
b894d68a7a ♻️ Use of pydantic to manage config+env variables
FastAPI release has been updated allowing to use lifespan parameter
to prepare/shutdown sub components.
2023-05-10 22:30:30 +02:00
ef26509b87 🐛 Fix invalid line id returned by /stop/{stop_id}/nextPassages endpoint 2023-05-09 23:25:30 +02:00
de82eb6c55 🔊 Reformat error logs generated by frontend BusinessData class 2023-05-08 17:25:14 +02:00
0ba4c1e6fa ️ Stop.postal_region and Line.id/operator_id can be integer values 2023-05-08 17:15:21 +02:00
0f1c16ab53 💄 Force App to use 100% of the shortest side 2023-05-08 16:17:56 +02:00
5692bc96d5 🐛 Check backend return codes before handling data 2023-05-08 15:05:43 +02:00
d15fee75ca ♻️ Fix /stop/ endpoints inconsistency 2023-05-08 13:44:44 +02:00
93047c8706 ♻️ Use declarative table configuration to define backend db tables 2023-05-08 13:17:09 +02:00
c84b78d3e2 🚑️ /stop responses didn't return StopArea.stops fields 2023-05-07 12:36:38 +02:00
c6e3881966 Add sqlalchemy-utils types dependency 2023-05-07 12:20:28 +02:00
b713042359 🗃️ Use of dedicated db sessions 2023-05-07 12:18:12 +02:00
5505209760 ️ Replace asyncpg with psycopg 2023-05-07 11:24:02 +02:00
6aa28f7bfb 🔨 Add OTel/Jeager to start HTTP/SQL requests monitoring 2023-05-02 23:02:09 +02:00
07d43bfcb4 ⬆️ Add sqlalchemy-utils dep 2023-05-01 23:22:51 +02:00
6eb78d7307 ️ /stop API endpoint uses stop and towns names to resolve queries 2023-05-01 22:42:02 +02:00
bcedf32bec Merge branch 'reduce-frontend-bundle-size' into develop 2023-05-01 22:36:37 +02:00
e9a651e47e ♻️ Refactor StopsSearchMenu + load Map lazily + placeholder 2023-04-25 21:47:10 +02:00
245bc4d261 ️ All provided location are in EPSG:3857, remove proj4 from frontend 2023-04-23 11:20:44 +02:00
d94027da9a 💥 All location points provided by backend are in EPSG:3857 2023-04-23 11:14:11 +02:00
8fafdb3dde ️ Replace hope-ui based IconHamburgerMenu with a custom one 2023-04-22 17:06:02 +02:00
da2fb1f41c ️ Replace hope-ui Input with a custom component in StopSearchMenu 2023-04-22 16:56:52 +02:00
e81f81b7a7 🚸 Add debounce mechanism to stop name input 2023-04-22 12:31:26 +02:00
61610fa2ba Merge branch 'add-stop-area-repr-and-stop-destinations' into develop 2023-04-22 12:31:01 +02:00
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
40 changed files with 2389 additions and 1307 deletions

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from logging import getLogger
from typing import Iterable, Self, TYPE_CHECKING from typing import Iterable, Self, TYPE_CHECKING
from sqlalchemy import select from sqlalchemy import select
@@ -9,31 +10,36 @@ from sqlalchemy.orm import DeclarativeBase
if TYPE_CHECKING: if TYPE_CHECKING:
from .db import Database from .db import Database
logger = getLogger(__name__)
class Base(DeclarativeBase): class Base(DeclarativeBase):
db: Database | None = None db: Database | None = None
@classmethod @classmethod
async def add(cls, stops: Self | Iterable[Self]) -> bool: async def add(cls, objs: Self | Iterable[Self]) -> bool:
try: if cls.db is not None and (session := await cls.db.get_session()) is not None:
if isinstance(stops, Iterable):
cls.db.session.add_all(stops) # type: ignore async with session.begin():
else: try:
cls.db.session.add(stops) # type: ignore if isinstance(objs, Iterable):
await cls.db.session.commit() # type: ignore session.add_all(objs)
except (AttributeError, IntegrityError) as err: else:
print(err) session.add(objs)
return False
except (AttributeError, IntegrityError) as err:
logger.error(err)
return False
return True return True
@classmethod @classmethod
async def get_by_id(cls, id_: int | str) -> Self | None: async def get_by_id(cls, id_: int | str) -> Self | None:
try: if cls.db is not None and (session := await cls.db.get_session()) is not None:
stmt = select(cls).where(cls.id == id_) # type: ignore
res = await cls.db.session.execute(stmt) # type: ignore async with session.begin():
element = res.scalar_one_or_none() stmt = select(cls).where(cls.id == id_)
except AttributeError as err: res = await session.execute(stmt)
print(err) return res.scalar_one_or_none()
element = None
return element return None

View File

@@ -1,4 +1,10 @@
from logging import getLogger
from typing import Annotated, AsyncIterator
from fastapi import Depends
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
async_sessionmaker, async_sessionmaker,
AsyncEngine, AsyncEngine,
@@ -7,42 +13,57 @@ from sqlalchemy.ext.asyncio import (
) )
from .base_class import Base from .base_class import Base
from ..settings import DatabaseSettings
logger = getLogger(__name__)
class Database: class Database:
def __init__(self) -> None: def __init__(self) -> None:
self._engine: AsyncEngine | None = None self._async_engine: AsyncEngine | None = None
self._session_maker: async_sessionmaker[AsyncSession] | None = None self._async_session_local: async_sessionmaker[AsyncSession] | None = None
self._session: AsyncSession | None = None
@property async def get_session(self) -> AsyncSession | None:
def session(self) -> AsyncSession | None: try:
if self._session is None and (session_maker := self._session_maker) is not None: return self._async_session_local() # type: ignore
self._session = session_maker()
return self._session
async def connect(self, db_path: str, clear_static_data: bool = False) -> bool: except (SQLAlchemyError, AttributeError) as e:
logger.exception(e)
# TODO: Preserve UserLastStopSearchResults table from drop. return None
self._engine = create_async_engine(db_path)
if self._engine is not None: # TODO: Preserve UserLastStopSearchResults table from drop.
self._session_maker = async_sessionmaker( async def connect(
self._engine, expire_on_commit=False, class_=AsyncSession self, settings: DatabaseSettings, clear_static_data: bool = False
) -> bool:
password = settings.password
path = (
f"{settings.driver}://{settings.user}:"
f"{password.get_secret_value() if password is not None else ''}"
f"@{settings.host}:{settings.port}/{settings.name}"
)
self._async_engine = create_async_engine(
path, pool_pre_ping=True, pool_size=10, max_overflow=20
)
if self._async_engine is not None:
SQLAlchemyInstrumentor().instrument(engine=self._async_engine.sync_engine)
self._async_session_local = async_sessionmaker(
bind=self._async_engine,
# autoflush=False,
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: async with self._async_engine.begin() as session:
await session.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm;"))
if clear_static_data: if clear_static_data:
await conn.run_sync(Base.metadata.drop_all) await session.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all) await session.run_sync(Base.metadata.create_all)
return True return True
async def disconnect(self) -> None: async def disconnect(self) -> None:
if self._session is not None: if self._async_engine is not None:
await self._session.close() await self._async_engine.dispose()
self._session = None
if self._engine is not None:
await self._engine.dispose()

View File

@@ -2,6 +2,7 @@ from .idfm_interface import IdfmInterface
from .idfm_types import ( from .idfm_types import (
Coordinate, Coordinate,
Destinations,
FramedVehicleJourney, FramedVehicleJourney,
IdfmLineState, IdfmLineState,
IdfmOperator, IdfmOperator,
@@ -35,6 +36,7 @@ from .idfm_types import (
__all__ = [ __all__ = [
"Coordinate", "Coordinate",
"Destinations",
"FramedVehicleJourney", "FramedVehicleJourney",
"IdfmInterface", "IdfmInterface",
"IdfmLineState", "IdfmLineState",

View File

@@ -1,17 +1,28 @@
from collections import defaultdict
from logging import getLogger
from re import compile as re_compile from re import compile as re_compile
from time import time from time import time
from typing import AsyncIterator, ByteString, Callable, Iterable, List, Type from typing import (
AsyncIterator,
ByteString,
Callable,
Iterable,
List,
Type,
)
from aiofiles import open as async_open from aiofiles import open as async_open
from aiohttp import ClientSession from aiohttp import ClientSession
from msgspec import ValidationError from msgspec import ValidationError
from msgspec.json import Decoder from msgspec.json import Decoder
from rich import print from pyproj import Transformer
from shapefile import Reader as ShapeFileReader, ShapeRecord # type: ignore
from ..db import Database from ..db import Database
from ..models import Line, LinePicto, Stop, StopArea from ..models import ConnectionArea, Line, LinePicto, Stop, StopArea, StopShape
from .idfm_types import ( from .idfm_types import (
ConnectionArea as IdfmConnectionArea,
Destinations as IdfmDestinations,
IdfmLineState, IdfmLineState,
IdfmResponse, IdfmResponse,
Line as IdfmLine, Line as IdfmLine,
@@ -27,6 +38,9 @@ from .idfm_types import (
from .ratp_types import Picto as RatpPicto from .ratp_types import Picto as RatpPicto
logger = getLogger(__name__)
class IdfmInterface: class IdfmInterface:
IDFM_ROOT_URL = "https://prim.iledefrance-mobilites.fr/marketplace" IDFM_ROOT_URL = "https://prim.iledefrance-mobilites.fr/marketplace"
@@ -45,7 +59,7 @@ class IdfmInterface:
) )
OPERATOR_RE = re_compile(r"[^:]+:Operator::([^:]+):") OPERATOR_RE = re_compile(r"[^:]+:Operator::([^:]+):")
LINE_RE = re_compile(r"[^:]+:Line::([^:]+):") LINE_RE = re_compile(r"[^:]+:Line::C([^:]+):")
def __init__(self, api_key: str, database: Database) -> None: def __init__(self, api_key: str, database: Database) -> None:
self._api_key = api_key self._api_key = api_key
@@ -53,8 +67,11 @@ class IdfmInterface:
self._http_headers = {"Accept": "application/json", "apikey": self._api_key} self._http_headers = {"Accept": "application/json", "apikey": self._api_key}
self._epsg2154_epsg3857_transformer = Transformer.from_crs(2154, 3857)
self._json_stops_decoder = Decoder(type=List[IdfmStop]) self._json_stops_decoder = Decoder(type=List[IdfmStop])
self._json_stop_areas_decoder = Decoder(type=List[IdfmStopArea]) 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_lines_decoder = Decoder(type=List[IdfmLine])
self._json_stops_lines_assos_decoder = Decoder(type=List[IdfmStopLineAsso]) self._json_stops_lines_assos_decoder = Decoder(type=List[IdfmStopLineAsso])
self._json_ratp_pictos_decoder = Decoder(type=List[RatpPicto]) self._json_ratp_pictos_decoder = Decoder(type=List[RatpPicto])
@@ -66,13 +83,30 @@ class IdfmInterface:
async def startup(self) -> None: async def startup(self) -> None:
BATCH_SIZE = 10000 BATCH_SIZE = 10000
STEPS: tuple[tuple[Type[Stop] | Type[StopArea], Callable, Callable], ...] = ( STEPS: tuple[
tuple[
Type[ConnectionArea] | Type[Stop] | Type[StopArea] | Type[StopShape],
Callable,
Callable,
],
...,
] = (
(
StopShape,
self._request_stop_shapes,
self._format_idfm_stop_shapes,
),
(
ConnectionArea,
self._request_idfm_connection_areas,
self._format_idfm_connection_areas,
),
( (
StopArea, StopArea,
self._request_idfm_stop_areas, self._request_idfm_stop_areas,
IdfmInterface._format_idfm_stop_areas, self._format_idfm_stop_areas,
), ),
(Stop, self._request_idfm_stops, IdfmInterface._format_idfm_stops), (Stop, self._request_idfm_stops, self._format_idfm_stops),
) )
for model, get_method, format_method in STEPS: for model, get_method, format_method in STEPS:
@@ -104,7 +138,7 @@ class IdfmInterface:
print(f"Link Stops to Lines: {time() - begin_ts}s") print(f"Link Stops to Lines: {time() - begin_ts}s")
begin_ts = time() 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") print(f"Link Stops to StopAreas: {time() - begin_ts}s")
async def _load_lines(self, batch_size: int = 5000) -> None: async def _load_lines(self, batch_size: int = 5000) -> None:
@@ -167,25 +201,51 @@ class IdfmInterface:
print(f"{total_found_nb} line <-> stop ({total_assos_nb = } found)") print(f"{total_found_nb} line <-> stop ({total_assos_nb = } found)")
async def _load_stop_areas_stops_assos(self, batch_size: int = 5000) -> None: async def _load_stop_assos(self, batch_size: int = 5000) -> None:
total_assos_nb = total_found_nb = 0 total_assos_nb = area_stop_assos_nb = conn_stop_assos_nb = 0
assos = [] area_stop_assos = []
connection_stop_assos = []
async for asso in self._request_idfm_stop_area_stop_associations(): async for asso in self._request_idfm_stop_area_stop_associations():
fields = asso.fields fields = asso.fields
assos.append((int(fields.zdaid), int(fields.arrid))) stop_id = int(fields.arrid)
if len(assos) == batch_size:
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_assos_nb += batch_size
if (found_nb := await StopArea.add_stops(assos)) is not None:
total_found_nb += found_nb
assos.clear()
if assos: if (found_nb := await StopArea.add_stops(area_stop_assos)) is not None:
total_assos_nb += len(assos) area_stop_assos_nb += found_nb
if (found_nb := await StopArea.add_stops(assos)) is not None: area_stop_assos.clear()
total_found_nb += found_nb
print(f"{total_found_nb} stop area <-> stop ({total_assos_nb = } found)") if (
found_nb := await ConnectionArea.add_stops(connection_stop_assos)
) is not None:
conn_stop_assos_nb += found_nb
connection_stop_assos.clear()
if area_stop_assos:
total_assos_nb += len(area_stop_assos)
if (found_nb := await StopArea.add_stops(area_stop_assos)) is not None:
area_stop_assos_nb += found_nb
if (
found_nb := await ConnectionArea.add_stops(connection_stop_assos)
) is not None:
conn_stop_assos_nb += found_nb
print(f"{area_stop_assos_nb} stop area <-> stop ({total_assos_nb = } found)")
print(f"{conn_stop_assos_nb} stop area <-> stop ({total_assos_nb = } found)")
# TODO: This method is synchronous due to the shapefile library.
# It's not a blocking issue but it could be nice to find an alternative.
async def _request_stop_shapes(self) -> AsyncIterator[ShapeRecord]:
# TODO: Use HTTP
with ShapeFileReader("./tests/datasets/REF_LDA.zip") as reader:
for record in reader.shapeRecords():
yield record
async def _request_idfm_stops(self) -> AsyncIterator[IdfmStop]: async def _request_idfm_stops(self) -> AsyncIterator[IdfmStop]:
# headers = {"Accept": "application/json", "apikey": self._api_key} # headers = {"Accept": "application/json", "apikey": self._api_key}
@@ -206,6 +266,13 @@ class IdfmInterface:
for element in self._json_stop_areas_decoder.decode(await raw.read()): for element in self._json_stop_areas_decoder.decode(await raw.read()):
yield element yield element
async def _request_idfm_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]: async def _request_idfm_lines(self) -> AsyncIterator[IdfmLine]:
# TODO: Use HTTP # TODO: Use HTTP
async with async_open("./tests/datasets/lines_dataset.json", "rb") as raw: async with async_open("./tests/datasets/lines_dataset.json", "rb") as raw:
@@ -294,11 +361,23 @@ class IdfmInterface:
fields = line.fields fields = line.fields
picto_id = fields.picto.id_ if fields.picto is not None else None picto_id = fields.picto.id_ if fields.picto is not None else None
picto = await LinePicto.get_by_id(picto_id) if picto_id else None
line_id = fields.id_line
try:
formatted_line_id = int(line_id[1:] if line_id[0] == "C" else line_id)
except ValueError:
logger.warning("Unable to format %s line id.", line_id)
continue
try:
operator_id = int(fields.operatorref) # type: ignore
except (ValueError, TypeError):
logger.warning("Unable to format %s operator id.", fields.operatorref)
operator_id = 0
ret.append( ret.append(
Line( Line(
id=fields.id_line, id=formatted_line_id,
short_name=fields.shortname_line, short_name=fields.shortname_line,
name=fields.name_line, name=fields.name_line,
status=IdfmLineState(fields.status.value), status=IdfmLineState(fields.status.value),
@@ -311,7 +390,7 @@ class IdfmInterface:
), ),
colour_web_hexa=fields.colourweb_hexa, colour_web_hexa=fields.colourweb_hexa,
text_colour_hexa=fields.textcolourprint_hexa, text_colour_hexa=fields.textcolourprint_hexa,
operator_id=optional_value(fields.operatorref), operator_id=operator_id,
operator_name=optional_value(fields.operatorname), operator_name=optional_value(fields.operatorname),
accessibility=IdfmState(fields.accessibility.value), accessibility=IdfmState(fields.accessibility.value),
visual_signs_available=IdfmState( visual_signs_available=IdfmState(
@@ -321,7 +400,6 @@ class IdfmInterface:
fields.audiblesigns_available.value fields.audiblesigns_available.value
), ),
picto_id=fields.picto.id_ if fields.picto is not None else None, picto_id=fields.picto.id_ if fields.picto is not None else None,
picto=picto,
record_id=line.recordid, record_id=line.recordid,
record_ts=int(line.record_timestamp.timestamp()), record_ts=int(line.record_timestamp.timestamp()),
) )
@@ -329,23 +407,34 @@ class IdfmInterface:
return ret return ret
@staticmethod def _format_idfm_stops(self, *stops: IdfmStop) -> Iterable[Stop]:
def _format_idfm_stops(*stops: IdfmStop) -> Iterable[Stop]:
for stop in stops: for stop in stops:
fields = stop.fields fields = stop.fields
try: try:
created_ts = int(fields.arrcreated.timestamp()) # type: ignore created_ts = int(fields.arrcreated.timestamp()) # type: ignore
except AttributeError: except AttributeError:
created_ts = None created_ts = None
epsg3857_point = self._epsg2154_epsg3857_transformer.transform(
fields.arrxepsg2154, fields.arryepsg2154
)
try:
postal_region = int(fields.arrpostalregion)
except ValueError:
logger.warning(
"Unable to format %s postal region.", fields.arrpostalregion
)
continue
yield Stop( yield Stop(
id=int(fields.arrid), id=int(fields.arrid),
name=fields.arrname, name=fields.arrname,
latitude=fields.arrgeopoint.lat, epsg3857_x=epsg3857_point[0],
longitude=fields.arrgeopoint.lon, epsg3857_y=epsg3857_point[1],
town_name=fields.arrtown, town_name=fields.arrtown,
postal_region=fields.arrpostalregion, postal_region=postal_region,
xepsg2154=fields.arrxepsg2154,
yepsg2154=fields.arryepsg2154,
transport_mode=TransportMode(fields.arrtype.value), transport_mode=TransportMode(fields.arrtype.value),
version=fields.arrversion, version=fields.arrversion,
created_ts=created_ts, created_ts=created_ts,
@@ -357,27 +446,78 @@ class IdfmInterface:
record_ts=int(stop.record_timestamp.timestamp()), record_ts=int(stop.record_timestamp.timestamp()),
) )
@staticmethod def _format_idfm_stop_areas(self, *stop_areas: IdfmStopArea) -> Iterable[StopArea]:
def _format_idfm_stop_areas(*stop_areas: IdfmStopArea) -> Iterable[StopArea]:
for stop_area in stop_areas: for stop_area in stop_areas:
fields = stop_area.fields fields = stop_area.fields
try: try:
created_ts = int(fields.zdacreated.timestamp()) # type: ignore created_ts = int(fields.zdacreated.timestamp()) # type: ignore
except AttributeError: except AttributeError:
created_ts = None created_ts = None
epsg3857_point = self._epsg2154_epsg3857_transformer.transform(
fields.zdaxepsg2154, fields.zdayepsg2154
)
yield StopArea( yield StopArea(
id=int(fields.zdaid), id=int(fields.zdaid),
name=fields.zdaname, name=fields.zdaname,
town_name=fields.zdatown, town_name=fields.zdatown,
postal_region=fields.zdapostalregion, postal_region=fields.zdapostalregion,
xepsg2154=fields.zdaxepsg2154, epsg3857_x=epsg3857_point[0],
yepsg2154=fields.zdayepsg2154, epsg3857_y=epsg3857_point[1],
type=StopAreaType(fields.zdatype.value), type=StopAreaType(fields.zdatype.value),
version=fields.zdaversion, version=fields.zdaversion,
created_ts=created_ts, created_ts=created_ts,
changed_ts=int(fields.zdachanged.timestamp()), changed_ts=int(fields.zdachanged.timestamp()),
) )
def _format_idfm_connection_areas(
self,
*connection_areas: IdfmConnectionArea,
) -> Iterable[ConnectionArea]:
for connection_area in connection_areas:
epsg3857_point = self._epsg2154_epsg3857_transformer.transform(
connection_area.zdcxepsg2154, connection_area.zdcyepsg2154
)
yield ConnectionArea(
id=int(connection_area.zdcid),
name=connection_area.zdcname,
town_name=connection_area.zdctown,
postal_region=connection_area.zdcpostalregion,
epsg3857_x=epsg3857_point[0],
epsg3857_y=epsg3857_point[1],
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()),
)
def _format_idfm_stop_shapes(
self, *shape_records: ShapeRecord
) -> Iterable[StopShape]:
for shape_record in shape_records:
epsg3857_points = [
self._epsg2154_epsg3857_transformer.transform(*point)
for point in shape_record.shape.points
]
bbox_it = iter(shape_record.shape.bbox)
epsg3857_bbox = [
self._epsg2154_epsg3857_transformer.transform(*point)
for point in zip(bbox_it, bbox_it)
]
yield StopShape(
id=shape_record.record[1],
type=shape_record.shape.shapeType,
epsg3857_bbox=epsg3857_bbox,
epsg3857_points=epsg3857_points,
)
async def render_line_picto(self, line: Line) -> tuple[None | str, None | str]: async def render_line_picto(self, line: Line) -> tuple[None | str, None | str]:
begin_ts = time() begin_ts = time()
line_picto_path = line_picto_format = None line_picto_path = line_picto_format = None
@@ -418,7 +558,7 @@ class IdfmInterface:
print("---------------------------------------------------------------------") print("---------------------------------------------------------------------")
return data return data
async def get_next_passages(self, stop_point_id: str) -> IdfmResponse | None: async def get_next_passages(self, stop_point_id: int) -> IdfmResponse | None:
ret = None ret = None
params = {"MonitoringRef": f"STIF:StopPoint:Q:{stop_point_id}:"} params = {"MonitoringRef": f"STIF:StopPoint:Q:{stop_point_id}:"}
async with ClientSession(headers=self._http_headers) as session: async with ClientSession(headers=self._http_headers) as session:
@@ -432,20 +572,40 @@ class IdfmInterface:
return ret return ret
async def get_destinations(self, stop_point_id: str) -> Iterable[str]: async def get_destinations(self, stop_id: int) -> IdfmDestinations | None:
# TODO: Store in database the destination for the given stop and line id.
begin_ts = time() 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: for delivery in res.Siri.ServiceDelivery.StopMonitoringDelivery:
if delivery.Status == IdfmState.true: if delivery.Status == IdfmState.true:
for stop_visit in delivery.MonitoredStopVisit: 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 journey = stop_visit.MonitoredVehicleJourney
if (destination_name := journey.DestinationName) and ( if (
line_ref := journey.LineRef dst_names := journey.DestinationName
): ) and monitored_stop_id in expected_stop_ids:
line_id = line_ref.value.replace("STIF:Line::", "")[:-1]
print(f"{line_id = }") line_id = journey.LineRef.value.split(":")[-2]
destinations[line_id] = destination_name[0].value destinations[line_id].add(dst_names[0].value)
print(f"get_next_passages: {time() - begin_ts}") print(f"get_next_passages: {time() - begin_ts}")
return destinations return destinations

View File

@@ -116,6 +116,19 @@ class StopArea(Struct):
record_timestamp: datetime 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): class StopAreaStopAssociationFields(Struct, kw_only=True):
arrid: str # TODO: use int ? arrid: str # TODO: use int ?
artid: str | None = None artid: str | None = None
@@ -184,6 +197,8 @@ class Line(Struct):
Lines = dict[str, Line] Lines = dict[str, Line]
Destinations = dict[str, set[str]]
# TODO: Set structs frozen # TODO: Set structs frozen
class StopLineAssoFields(Struct): class StopLineAssoFields(Struct):

View File

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

View File

@@ -5,13 +5,11 @@ from typing import Iterable, Self, Sequence
from sqlalchemy import ( from sqlalchemy import (
BigInteger, BigInteger,
Boolean, Boolean,
Column,
Enum, Enum,
ForeignKey, ForeignKey,
Integer, Integer,
select, select,
String, String,
Table,
) )
from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload
from sqlalchemy.sql.expression import tuple_ from sqlalchemy.sql.expression import tuple_
@@ -25,12 +23,14 @@ from ..idfm_interface.idfm_types import (
) )
from .stop import _Stop from .stop import _Stop
line_stop_association_table = Table(
"line_stop_association_table", class LineStopAssociations(Base):
Base.metadata,
Column("line_id", ForeignKey("lines.id")), id = mapped_column(BigInteger, primary_key=True)
Column("stop_id", ForeignKey("_stops.id")), line_id = mapped_column(BigInteger, ForeignKey("lines.id"))
) stop_id = mapped_column(BigInteger, ForeignKey("_stops.id"))
__tablename__ = "line_stop_associations"
class LinePicto(Base): class LinePicto(Base):
@@ -53,7 +53,7 @@ class Line(Base):
db = db db = db
id = mapped_column(String, primary_key=True) id = mapped_column(BigInteger, primary_key=True)
short_name = mapped_column(String) short_name = mapped_column(String)
name = mapped_column(String, nullable=False) name = mapped_column(String, nullable=False)
@@ -68,7 +68,7 @@ class Line(Base):
colour_web_hexa = mapped_column(String, nullable=False) colour_web_hexa = mapped_column(String, nullable=False)
text_colour_hexa = mapped_column(String, nullable=False) text_colour_hexa = mapped_column(String, nullable=False)
operator_id = mapped_column(String) operator_id = mapped_column(Integer)
operator_name = mapped_column(String) operator_name = mapped_column(String)
accessibility = mapped_column(Enum(IdfmState), nullable=False) accessibility = mapped_column(Enum(IdfmState), nullable=False)
@@ -83,7 +83,7 @@ class Line(Base):
stops: Mapped[list[_Stop]] = relationship( stops: Mapped[list[_Stop]] = relationship(
"_Stop", "_Stop",
secondary=line_stop_association_table, secondary="line_stop_associations",
back_populates="lines", back_populates="lines",
lazy="selectin", lazy="selectin",
) )
@@ -94,23 +94,24 @@ class Line(Base):
async def get_by_name( async def get_by_name(
cls, name: str, operator_name: None | str = None cls, name: str, operator_name: None | str = None
) -> Sequence[Self] | None: ) -> Sequence[Self] | None:
session = cls.db.session if (session := await cls.db.get_session()) is not None:
if session is None:
return None
filters = {"name": name} async with session.begin():
if operator_name is not None: filters = {"name": name}
filters["operator_name"] = operator_name if operator_name is not None:
filters["operator_name"] = operator_name
stmt = ( stmt = (
select(cls) select(cls)
.filter_by(**filters) .filter_by(**filters)
.options(selectinload(cls.stops), selectinload(cls.picto)) .options(selectinload(cls.stops), selectinload(cls.picto))
) )
res = await session.execute(stmt) res = await session.execute(stmt)
lines = res.scalars().all() lines = res.scalars().all()
return lines return lines
return None
@classmethod @classmethod
async def _add_picto_to_line(cls, line: str | Self, picto: LinePicto) -> None: async def _add_picto_to_line(cls, line: str | Self, picto: LinePicto) -> None:
@@ -133,57 +134,63 @@ class Line(Base):
@classmethod @classmethod
async def add_pictos(cls, line_to_pictos: Iterable[tuple[str, LinePicto]]) -> bool: async def add_pictos(cls, line_to_pictos: Iterable[tuple[str, LinePicto]]) -> bool:
session = cls.db.session if (session := await cls.db.get_session()) is not None:
if session is None:
return False
await asyncio_gather( async with session.begin():
*[cls._add_picto_to_line(line, picto) for line, picto in line_to_pictos] await asyncio_gather(
) *[
cls._add_picto_to_line(line, picto)
for line, picto in line_to_pictos
]
)
await session.commit() return True
return True return False
@classmethod @classmethod
async def add_stops(cls, line_to_stop_ids: Iterable[tuple[str, str, int]]) -> int: async def add_stops(cls, line_to_stop_ids: Iterable[tuple[str, str, int]]) -> int:
session = cls.db.session if (session := await cls.db.get_session()) is not None:
if session is None:
return 0
line_names_ops, stop_ids = set(), set() async with session.begin():
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)
lines_res = await session.execute( line_names_ops, stop_ids = set(), set()
select(Line).where( for line_name, operator_name, stop_id in line_to_stop_ids:
tuple_(Line.name, Line.operator_name).in_(line_names_ops) line_names_ops.add((line_name, operator_name))
) stop_ids.add(stop_id)
)
lines = defaultdict(list) lines_res = await session.execute(
for line in lines_res.scalars(): select(Line).where(
lines[(line.name, line.operator_name)].append(line) tuple_(Line.name, Line.operator_name).in_(line_names_ops)
)
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:
for stop_line in stop_lines:
stop_line.stops.append(stop)
found += 1
else:
print(f"No line found for {line_name}/{operator_name}")
else:
print(
f"No stop found for {stop_id} id"
f"(used by {line_name}/{operator_name})"
) )
await session.commit() lines = defaultdict(list)
for line in lines_res.scalars():
lines[(line.name, line.operator_name)].append(line)
return found 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:
for stop_line in stop_lines:
stop_line.stops.append(stop)
found += 1
else:
print(f"No line found for {line_name}/{operator_name}")
else:
print(
f"No stop found for {stop_id} id"
f"(used by {line_name}/{operator_name})"
)
return found
return 0

View File

@@ -1,16 +1,20 @@
from __future__ import annotations from __future__ import annotations
from typing import Iterable, Self, Sequence, TYPE_CHECKING from logging import getLogger
from typing import Iterable, Sequence, TYPE_CHECKING
from sqlalchemy import ( from sqlalchemy import (
BigInteger, BigInteger,
Column, Computed,
desc,
Enum, Enum,
Float, Float,
ForeignKey, ForeignKey,
func,
Integer,
JSON,
select, select,
String, String,
Table,
) )
from sqlalchemy.orm import ( from sqlalchemy.orm import (
mapped_column, mapped_column,
@@ -20,6 +24,7 @@ from sqlalchemy.orm import (
with_polymorphic, with_polymorphic,
) )
from sqlalchemy.schema import Index from sqlalchemy.schema import Index
from sqlalchemy_utils.types.ts_vector import TSVectorType
from ..db import Base, db from ..db import Base, db
from ..idfm_interface.idfm_types import TransportMode, IdfmState, StopAreaType from ..idfm_interface.idfm_types import TransportMode, IdfmState, StopAreaType
@@ -28,12 +33,15 @@ if TYPE_CHECKING:
from .line import Line from .line import Line
stop_area_stop_association_table = Table( logger = getLogger(__name__)
"stop_area_stop_association_table",
Base.metadata, class StopAreaStopAssociations(Base):
Column("stop_id", ForeignKey("_stops.id")),
Column("stop_area_id", ForeignKey("stop_areas.id")), id = mapped_column(BigInteger, primary_key=True)
) stop_id = mapped_column(BigInteger, ForeignKey("_stops.id"))
stop_area_id = mapped_column(BigInteger, ForeignKey("stop_areas.id"))
__tablename__ = "stop_area_stop_associations"
class _Stop(Base): class _Stop(Base):
@@ -45,69 +53,82 @@ class _Stop(Base):
name = mapped_column(String, nullable=False, index=True) name = mapped_column(String, nullable=False, index=True)
town_name = mapped_column(String, nullable=False) town_name = mapped_column(String, nullable=False)
postal_region = mapped_column(String, nullable=False) postal_region = mapped_column(Integer, nullable=False)
xepsg2154 = mapped_column(BigInteger, nullable=False) epsg3857_x = mapped_column(Float, nullable=False)
yepsg2154 = mapped_column(BigInteger, nullable=False) epsg3857_y = mapped_column(Float, nullable=False)
version = mapped_column(String, nullable=False) version = mapped_column(String, nullable=False)
created_ts = mapped_column(BigInteger) created_ts = mapped_column(BigInteger)
changed_ts = mapped_column(BigInteger, nullable=False) changed_ts = mapped_column(BigInteger, nullable=False)
lines: Mapped[list[Line]] = relationship( lines: Mapped[list[Line]] = relationship(
"Line", "Line",
secondary="line_stop_association_table", secondary="line_stop_associations",
back_populates="stops", back_populates="stops",
# lazy="joined",
lazy="selectin", lazy="selectin",
) )
areas: Mapped[list["StopArea"]] = relationship( areas: Mapped[list["StopArea"]] = relationship(
"StopArea", secondary=stop_area_stop_association_table, back_populates="stops" "StopArea",
secondary="stop_area_stop_associations",
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"
)
names_tsv = mapped_column(
TSVectorType("name", "town_name", regconfig="french"),
Computed("to_tsvector('french', name || ' ' || town_name)", persisted=True),
) )
__tablename__ = "_stops" __tablename__ = "_stops"
__mapper_args__ = {"polymorphic_identity": "_stops", "polymorphic_on": kind} __mapper_args__ = {"polymorphic_identity": "_stops", "polymorphic_on": kind}
__table_args__ = ( __table_args__ = (
# To optimize the ilike requests
Index( Index(
"name_idx_gin", "names_tsv_idx",
name, names_tsv,
postgresql_ops={"name": "gin_trgm_ops"}, postgresql_ops={"name": "gin_trgm_ops"},
postgresql_using="gin", postgresql_using="gin",
), ),
) )
# TODO: Test https://www.cybertec-postgresql.com/en/postgresql-more-performance-for-like-and-ilike-statements/
# TODO: Should be able to remove with_polymorphic ?
@classmethod @classmethod
async def get_by_name(cls, name: str) -> Sequence[type[_Stop]] | None: async def get_by_name(cls, name: str) -> Sequence[_Stop] | None:
session = cls.db.session if (session := await cls.db.get_session()) is not None:
if session is None:
return None
stop_stop_area = with_polymorphic(_Stop, [Stop, StopArea]) async with session.begin():
stmt = ( descendants = with_polymorphic(_Stop, "*")
select(stop_stop_area)
.where(stop_stop_area.name.ilike(f"%{name}%"))
.options(
selectinload(stop_stop_area.areas),
selectinload(stop_stop_area.lines),
)
)
res = await session.execute(stmt) match_stmt = descendants.names_tsv.match(
stops = res.scalars().all() name, postgresql_regconfig="french"
)
ranking_stmt = func.ts_rank_cd(
descendants.names_tsv, func.plainto_tsquery("french", name)
)
stmt = (
select(descendants).filter(match_stmt).order_by(desc(ranking_stmt))
)
return stops res = await session.execute(stmt)
stops = res.scalars().all()
return stops
return None
class Stop(_Stop): class Stop(_Stop):
id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True) id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True)
latitude = mapped_column(Float, nullable=False)
longitude = mapped_column(Float, nullable=False)
transport_mode = mapped_column(Enum(TransportMode), nullable=False) transport_mode = mapped_column(Enum(TransportMode), nullable=False)
accessibility = mapped_column(Enum(IdfmState), nullable=False) accessibility = mapped_column(Enum(IdfmState), nullable=False)
visual_signs_available = mapped_column(Enum(IdfmState), nullable=False) visual_signs_available = mapped_column(Enum(IdfmState), nullable=False)
audible_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_id = mapped_column(String, nullable=False)
record_ts = mapped_column(BigInteger, nullable=False) record_ts = mapped_column(BigInteger, nullable=False)
@@ -120,12 +141,12 @@ class StopArea(_Stop):
id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True) id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True)
type = mapped_column(Enum(StopAreaType), nullable=False) type = mapped_column(Enum(StopAreaType), nullable=False)
stops: Mapped[list["_Stop"]] = relationship(
"_Stop", stops: Mapped[list["Stop"]] = relationship(
secondary=stop_area_stop_association_table, "Stop",
secondary="stop_area_stop_associations",
back_populates="areas", back_populates="areas",
lazy="selectin", lazy="selectin",
# lazy="joined",
) )
__tablename__ = "stop_areas" __tablename__ = "stop_areas"
@@ -138,38 +159,117 @@ class StopArea(_Stop):
async def add_stops( async def add_stops(
cls, stop_area_to_stop_ids: Iterable[tuple[int, int]] cls, stop_area_to_stop_ids: Iterable[tuple[int, int]]
) -> int | None: ) -> int | None:
session = cls.db.session if (session := await cls.db.get_session()) is not None:
if session is None:
return None
stop_area_ids, stop_ids = set(), set() async with session.begin():
for stop_area_id, stop_id in stop_area_to_stop_ids:
stop_area_ids.add(stop_area_id)
stop_ids.add(stop_id)
stop_areas_res = await session.execute( stop_area_ids, stop_ids = set(), set()
select(StopArea) for stop_area_id, stop_id in stop_area_to_stop_ids:
.where(StopArea.id.in_(stop_area_ids)) stop_area_ids.add(stop_area_id)
.options(selectinload(StopArea.stops)) stop_ids.add(stop_id)
)
stop_areas: dict[int, StopArea] = {
stop_area.id: stop_area for stop_area in stop_areas_res.scalars()
}
stop_res = await session.execute(select(_Stop).where(_Stop.id.in_(stop_ids))) stop_areas_res = await session.scalars(
stops: dict[int, _Stop] = {stop.id: stop for stop in stop_res.scalars()} select(StopArea)
.where(StopArea.id.in_(stop_area_ids))
.options(selectinload(StopArea.stops))
)
stop_areas: dict[int, StopArea] = {
stop_area.id: stop_area for stop_area in stop_areas_res.all()
}
found = 0 stop_res = await session.execute(
for stop_area_id, stop_id in stop_area_to_stop_ids: select(Stop).where(Stop.id.in_(stop_ids))
if (stop_area := stop_areas.get(stop_area_id)) is not None: )
if (stop := stops.get(stop_id)) is not None: stops: dict[int, Stop] = {stop.id: stop for stop in stop_res.scalars()}
stop_area.stops.append(stop)
found += 1
else:
print(f"No stop found for {stop_id} id")
else:
print(f"No stop area found for {stop_area_id}")
await session.commit() found = 0
for stop_area_id, stop_id in stop_area_to_stop_ids:
if (stop_area := stop_areas.get(stop_area_id)) is not None:
if (stop := stops.get(stop_id)) is not None:
stop_area.stops.append(stop)
found += 1
else:
print(f"No stop found for {stop_id} id")
else:
print(f"No stop area found for {stop_area_id}")
return found return found
return None
class StopShape(Base):
db = db
id = mapped_column(BigInteger, primary_key=True) # Same id than ConnectionArea
type = mapped_column(Integer, nullable=False)
epsg3857_bbox = mapped_column(JSON)
epsg3857_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)
epsg3857_x = mapped_column(Float, nullable=False)
epsg3857_y = mapped_column(Float, 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:
if (session := await cls.db.get_session()) is not None:
async with session.begin():
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}")
return found
return None

View File

@@ -1,25 +1,27 @@
from sqlalchemy import Column, ForeignKey, String, Table from sqlalchemy import BigInteger, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..db import Base, db from ..db import Base, db
from .stop import _Stop from .stop import _Stop
user_last_stop_search_stops_associations_table = Table(
"user_last_stop_search_stops_associations_table", class UserLastStopSearchStopAssociations(Base):
Base.metadata, id = mapped_column(BigInteger, primary_key=True)
Column("user_mxid", ForeignKey("user_last_stop_search_results.user_mxid")), user_mxid = mapped_column(
Column("stop_id", ForeignKey("_stops.id")), String, ForeignKey("user_last_stop_search_results.user_mxid")
) )
stop_id = mapped_column(BigInteger, ForeignKey("_stops.id"))
__tablename__ = "user_last_stop_search_stop_associations"
class UserLastStopSearchResults(Base): class UserLastStopSearchResults(Base):
db = db db = db
__tablename__ = "user_last_stop_search_results"
user_mxid = mapped_column(String, primary_key=True) user_mxid = mapped_column(String, primary_key=True)
request_content = mapped_column(String, nullable=False) request_content = mapped_column(String, nullable=False)
stops: Mapped[_Stop] = relationship( stops: Mapped[_Stop] = relationship(
_Stop, secondary=user_last_stop_search_stops_associations_table _Stop, secondary="user_last_stop_search_stop_associations"
) )
__tablename__ = "user_last_stop_search_results"

View File

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

View File

@@ -46,7 +46,7 @@ class TransportMode(StrEnum):
class Line(BaseModel): class Line(BaseModel):
id: str id: int
shortName: str shortName: str
name: str name: str
status: IdfmLineState status: IdfmLineState

View File

@@ -7,10 +7,8 @@ class Stop(BaseModel):
id: int id: int
name: str name: str
town: str town: str
lat: float epsg3857_x: float
lon: float epsg3857_y: float
# xepsg2154: int
# yepsg2154: int
lines: list[str] lines: list[str]
@@ -18,8 +16,16 @@ class StopArea(BaseModel):
id: int id: int
name: str name: str
town: str town: str
# xepsg2154: int
# yepsg2154: int
type: StopAreaType type: StopAreaType
lines: list[str] # SNCF lines are linked to stop areas and not stops. lines: list[str] # SNCF lines are linked to stop areas and not stops.
stops: list[Stop] stops: list[Stop]
Point = tuple[float, float]
class StopShape(BaseModel):
id: int
type: int
epsg3857_bbox: list[Point]
epsg3857_points: list[Point]

View File

@@ -0,0 +1,26 @@
from pydantic import BaseModel, BaseSettings, Field, SecretStr
class HttpSettings(BaseModel):
host: str = "127.0.0.1"
port: int = 8080
cert: str | None = None
class DatabaseSettings(BaseModel):
name: str = "carrramba-encore-rate"
host: str = "127.0.0.1"
port: int = 5432
driver: str = "postgresql+psycopg"
user: str = "cer"
password: SecretStr | None = None
class Settings(BaseSettings):
app_name: str
idfm_api_key: SecretStr = Field(..., env="API_KEY")
clear_static_data: bool = Field(..., env="CLEAR_STATIC_DATA")
http: HttpSettings = HttpSettings()
db: DatabaseSettings = DatabaseSettings()

View File

@@ -0,0 +1,14 @@
app_name: carrramba-encore-rate
http:
host: 0.0.0.0
port: 4443
cert: ./config/cert.pem
db:
name: carrramba-encore-rate
host: 127.0.0.1
port: 5432
driver: postgresql+psycopg
user: cer
password: cer_password

View File

@@ -13,7 +13,62 @@ services:
max-size: 10m max-size: 10m
max-file: "3" max-file: "3"
ports: ports:
- '127.0.0.1:5438:5432' - "127.0.0.1:5432:5432"
volumes: volumes:
- ./docker/database/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d - ./docker/database/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
- ./docker/database/data:/var/lib/postgresql/data - ./docker/database/data:/var/lib/postgresql/data
jaeger-agent:
image: jaegertracing/jaeger-agent:latest
command:
- "--reporter.grpc.host-port=jaeger-collector:14250"
ports:
- "127.0.0.1:5775:5775/udp"
- "127.0.0.1:6831:6831/udp"
- "127.0.0.1:6832:6832/udp"
- "127.0.0.1:5778:5778"
restart: on-failure
depends_on:
- jaeger-collector
jaeger-collector:
image: jaegertracing/jaeger-collector:latest
command:
- "--cassandra.keyspace=jaeger_v1_dc1"
- "--cassandra.servers=cassandra"
- "--collector.zipkin.host-port=9411"
- "--sampling.initial-sampling-probability=.5"
- "--sampling.target-samples-per-second=.01"
environment:
- SAMPLING_CONFIG_TYPE=adaptive
- COLLECTOR_OTLP_ENABLED=true
ports:
- "127.0.0.1:4317:4317"
- "127.0.0.1:4318:4318"
# - "127.0.0.1:9411:9411"
# - "127.0.0.1:14250:14250"
# - "127.0.0.1:14268:14268"
# - "127.0.0.1:14269:14269"
restart: on-failure
depends_on:
- cassandra-schema
cassandra:
image: cassandra:latest
cassandra-schema:
image: jaegertracing/jaeger-cassandra-schema:latest
depends_on:
- cassandra
jaeger-query:
image: jaegertracing/jaeger-query:latest
command:
- "--cassandra.keyspace=jaeger_v1_dc1"
- "--cassandra.servers=cassandra"
ports:
- "127.0.0.1:16686:16686"
# - "127.0.0.1:16687:16687"
restart: on-failure
depends_on:
- cassandra-schema

169
backend/main.py Normal file → Executable file
View File

@@ -1,16 +1,27 @@
#!/usr/bin/env python3
import logging
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from os import environ, EX_USAGE from os import environ, EX_USAGE
from typing import Sequence from typing import Sequence
import uvicorn
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from rich import print from rich import print
from yaml import safe_load
from backend.db import db from backend.db import db
from backend.idfm_interface import IdfmInterface from backend.idfm_interface import Destinations as IdfmDestinations, IdfmInterface
from backend.models import Line, Stop, StopArea from backend.models import Line, Stop, StopArea, StopShape
from backend.schemas import ( from backend.schemas import (
Line as LineSchema, Line as LineSchema,
TransportMode, TransportMode,
@@ -18,49 +29,56 @@ from backend.schemas import (
NextPassages as NextPassagesSchema, NextPassages as NextPassagesSchema,
Stop as StopSchema, Stop as StopSchema,
StopArea as StopAreaSchema, StopArea as StopAreaSchema,
StopShape as StopShapeSchema,
) )
from backend.settings import Settings
API_KEY = environ.get("API_KEY")
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"
app = FastAPI() CONFIG_PATH = environ.get("CONFIG_PATH", "./config.sample.yaml")
def load_settings(path: str) -> Settings:
with open(path, "r") as config_file:
config = safe_load(config_file)
return Settings(**config)
settings = load_settings(CONFIG_PATH)
@asynccontextmanager
async def lifespan(app: FastAPI):
await db.connect(settings.db, settings.clear_static_data)
if settings.clear_static_data:
await idfm_interface.startup()
yield
await db.disconnect()
app = FastAPI(lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=["https://localhost:4443", "https://localhost:3000"],
"https://localhost:4443",
"https://localhost:3000",
],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
idfm_interface = IdfmInterface(API_KEY, db) app.mount("/widget", StaticFiles(directory="../frontend/", html=True), name="widget")
FastAPIInstrumentor.instrument_app(app)
@app.on_event("startup") trace.set_tracer_provider(
async def startup(): TracerProvider(resource=Resource.create({SERVICE_NAME: settings.app_name}))
await db.connect(DB_PATH, clear_static_data=True) )
await idfm_interface.startup() trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
# await db.connect(DB_PATH, clear_static_data=False) tracer = trace.get_tracer(settings.app_name)
print("Connected")
idfm_interface = IdfmInterface(settings.idfm_api_key.get_secret_value(), db)
@app.on_event("shutdown")
async def shutdown():
await db.disconnect()
# /addwidget https://localhost:4443/static/#?widgetId=$matrix_widget_id&userId=$matrix_user_id
# /addwidget https://localhost:3000/widget?widgetId=$matrix_widget_id&userId=$matrix_user_id
STATIC_ROOT = "../frontend/"
app.mount("/widget", StaticFiles(directory=STATIC_ROOT, html=True), name="widget")
def optional_datetime_to_ts(dt: datetime | None) -> int | None: def optional_datetime_to_ts(dt: datetime | None) -> int | None:
@@ -68,7 +86,7 @@ def optional_datetime_to_ts(dt: datetime | None) -> int | None:
@app.get("/line/{line_id}", response_model=LineSchema) @app.get("/line/{line_id}", response_model=LineSchema)
async def get_line(line_id: str) -> LineSchema: async def get_line(line_id: int) -> LineSchema:
line: Line | None = await Line.get_by_id(line_id) line: Line | None = await Line.get_by_id(line_id)
if line is None: if line is None:
@@ -93,22 +111,16 @@ async def get_line(line_id: str) -> LineSchema:
def _format_stop(stop: Stop) -> StopSchema: def _format_stop(stop: Stop) -> StopSchema:
# print(stop.__dict__)
return StopSchema( return StopSchema(
id=stop.id, id=stop.id,
name=stop.name, name=stop.name,
town=stop.town_name, town=stop.town_name,
# xepsg2154=stop.xepsg2154, epsg3857_x=stop.epsg3857_x,
# yepsg2154=stop.yepsg2154, epsg3857_y=stop.epsg3857_y,
lat=stop.latitude,
lon=stop.longitude,
lines=[line.id for line in stop.lines], lines=[line.id for line in stop.lines],
) )
# châtelet
@app.get("/stop/") @app.get("/stop/")
async def get_stop( async def get_stop(
name: str = "", limit: int = 10 name: str = "", limit: int = 10
@@ -141,23 +153,20 @@ async def get_stop(
id=stop_area.id, id=stop_area.id,
name=stop_area.name, name=stop_area.name,
town=stop_area.town_name, town=stop_area.town_name,
# xepsg2154=stop_area.xepsg2154,
# yepsg2154=stop_area.yepsg2154,
type=stop_area.type, type=stop_area.type,
lines=[line.id for line in stop_area.lines], lines=[line.id for line in stop_area.lines],
stops=formatted_stops, stops=formatted_stops,
) )
) )
# print(f"{stops = }", flush=True)
formatted.extend(_format_stop(stop) for stop in stops.values()) formatted.extend(_format_stop(stop) for stop in stops.values())
return formatted return formatted
# TODO: Cache response for 30 secs ? # TODO: Cache response for 30 secs ?
@app.get("/stop/nextPassages/{stop_id}") @app.get("/stop/{stop_id}/nextPassages")
async def get_next_passages(stop_id: str) -> NextPassagesSchema | None: async def get_next_passages(stop_id: int) -> NextPassagesSchema | None:
res = await idfm_interface.get_next_passages(stop_id) res = await idfm_interface.get_next_passages(stop_id)
if res is None: if res is None:
return None return None
@@ -176,18 +185,20 @@ async def get_next_passages(stop_id: str) -> NextPassagesSchema | None:
# re.match will return None if the given journey.LineRef.value is not valid. # re.match will return None if the given journey.LineRef.value is not valid.
try: try:
line_id = IdfmInterface.LINE_RE.match(journey.LineRef.value).group(1) line_id_match = IdfmInterface.LINE_RE.match(journey.LineRef.value)
except AttributeError as exc: line_id = int(line_id_match.group(1)) # type: ignore
except (AttributeError, TypeError, ValueError) as err:
raise HTTPException( raise HTTPException(
status_code=404, detail=f'Line "{journey.LineRef.value}" not found' status_code=404, detail=f'Line "{journey.LineRef.value}" not found'
) from exc ) from err
call = journey.MonitoredCall call = journey.MonitoredCall
dst_names = call.DestinationDisplay dst_names = call.DestinationDisplay
dsts = [dst.value for dst in dst_names] if dst_names else [] dsts = [dst.value for dst in dst_names] if dst_names else []
arrivalPlatformName = (
print(f"{call.ArrivalPlatformName = }") call.ArrivalPlatformName.value if call.ArrivalPlatformName else None
)
next_passage = NextPassageSchema( next_passage = NextPassageSchema(
line=line_id, line=line_id,
@@ -196,9 +207,7 @@ async def get_next_passages(stop_id: str) -> NextPassagesSchema | None:
atStop=call.VehicleAtStop, atStop=call.VehicleAtStop,
aimedArrivalTs=optional_datetime_to_ts(call.AimedArrivalTime), aimedArrivalTs=optional_datetime_to_ts(call.AimedArrivalTime),
expectedArrivalTs=optional_datetime_to_ts(call.ExpectedArrivalTime), expectedArrivalTs=optional_datetime_to_ts(call.ExpectedArrivalTime),
arrivalPlatformName=call.ArrivalPlatformName.value arrivalPlatformName=arrivalPlatformName,
if call.ArrivalPlatformName
else None,
aimedDepartTs=optional_datetime_to_ts(call.AimedDepartureTime), aimedDepartTs=optional_datetime_to_ts(call.AimedDepartureTime),
expectedDepartTs=optional_datetime_to_ts(call.ExpectedDepartureTime), expectedDepartTs=optional_datetime_to_ts(call.ExpectedDepartureTime),
arrivalStatus=call.ArrivalStatus.value, arrivalStatus=call.ArrivalStatus.value,
@@ -214,3 +223,55 @@ async def get_next_passages(stop_id: str) -> NextPassagesSchema | None:
ts=service_delivery.ResponseTimestamp.timestamp(), ts=service_delivery.ResponseTimestamp.timestamp(),
passages=by_line_by_dst_passages, 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
@app.get("/stop/{stop_id}/shape")
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,
epsg3857_bbox=shape.epsg3857_bbox,
epsg3857_points=shape.epsg3857_points,
)
msg = f"No shape found for stop {stop_id}"
raise HTTPException(status_code=404, detail=msg)
if __name__ == "__main__":
http_settings = settings.http
uvicorn.run(
app,
host=http_settings.host,
port=http_settings.port,
ssl_certfile=http_settings.cert,
)

View File

@@ -11,11 +11,21 @@ python = "^3.11"
aiohttp = "^3.8.3" aiohttp = "^3.8.3"
rich = "^12.6.0" rich = "^12.6.0"
aiofiles = "^22.1.0" aiofiles = "^22.1.0"
sqlalchemy = {extras = ["asyncio"], version = "^2.0.1"} fastapi = "^0.95.0"
fastapi = "^0.88.0"
uvicorn = "^0.20.0" uvicorn = "^0.20.0"
asyncpg = "^0.27.0"
msgspec = "^0.12.0" msgspec = "^0.12.0"
pyshp = "^2.3.1"
pyproj = "^3.5.0"
opentelemetry-instrumentation-fastapi = "^0.38b0"
sqlalchemy-utils = "^0.41.1"
opentelemetry-instrumentation-logging = "^0.38b0"
opentelemetry-sdk = "^1.17.0"
opentelemetry-api = "^1.17.0"
opentelemetry-exporter-otlp-proto-http = "^1.17.0"
opentelemetry-instrumentation-sqlalchemy = "^0.38b0"
sqlalchemy = "^2.0.12"
psycopg = "^3.1.9"
pyyaml = "^6.0"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
@@ -37,8 +47,9 @@ autopep8 = "^2.0.1"
pyflakes = "^3.0.1" pyflakes = "^3.0.1"
yapf = "^0.32.0" yapf = "^0.32.0"
whatthepatch = "^1.0.4" whatthepatch = "^1.0.4"
sqlalchemy = {extras = ["mypy"], version = "^2.0.1"}
mypy = "^1.0.0" mypy = "^1.0.0"
types-sqlalchemy-utils = "^1.0.1"
types-pyyaml = "^6.0.12.9"
[tool.mypy] [tool.mypy]
plugins = "sqlalchemy.ext.mypy.plugin" plugins = "sqlalchemy.ext.mypy.plugin"

View File

@@ -1,35 +1,37 @@
{ {
"name": "vite-template-solid", "name": "vite-template-solid",
"version": "0.0.0", "version": "0.0.0",
"engine": "19.3.0", "engine": "19.3.0",
"description": "", "description": "",
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"dev": "vite --debug", "dev": "vite --debug",
"build": "vite build", "build": "vite build",
"serve": "vite preview" "serve": "vite preview",
}, "bundle-visualizer": "npx vite-bundle-visualizer"
"license": "MIT", },
"devDependencies": { "license": "MIT",
"@types/leaflet": "^1.9.0", "devDependencies": {
"@vitejs/plugin-basic-ssl": "^1.0.1", "@types/leaflet": "^1.9.0",
"eslint": "^8.32.0", "@vitejs/plugin-basic-ssl": "^1.0.1",
"eslint-plugin-solid": "^0.9.3", "eslint": "^8.32.0",
"typescript": "^4.9.4", "eslint-plugin-solid": "^0.9.3",
"typescript-eslint-language-service": "^5.0.0", "sass": "^1.62.0",
"vite": "^4.0.3", "typescript": "^4.9.4",
"vite-plugin-solid": "^2.5.0" "typescript-eslint-language-service": "^5.0.0",
}, "vite": "^4.0.3",
"dependencies": { "vite-bundle-visualizer": "^0.6.0",
"@hope-ui/solid": "^0.6.7", "vite-plugin-solid": "^2.5.0"
"@motionone/solid": "^10.15.5", },
"@solid-primitives/date": "^2.0.5", "dependencies": {
"@stitches/core": "^1.2.8", "@motionone/solid": "^10.15.5",
"date-fns": "^2.29.3", "@solid-primitives/date": "^2.0.5",
"leaflet": "^1.9.3", "@solid-primitives/scroll": "^2.0.10",
"matrix-widget-api": "^1.1.1", "@stitches/core": "^1.2.8",
"sass": "^1.58.3", "date-fns": "^2.29.3",
"solid-js": "^1.6.6", "matrix-widget-api": "^1.1.1",
"solid-transition-group": "^0.0.10" "ol": "^7.3.0",
} "solid-js": "^1.6.6",
"solid-transition-group": "^0.0.10"
}
} }

View File

@@ -2,6 +2,10 @@
--idfm-black: #2c2e35; --idfm-black: #2c2e35;
--idfm-white: #ffffff; --idfm-white: #ffffff;
--neutral-color: #d7dbdf;
--border-radius: calc(15/1920*100%);
height: inherit; height: inherit;
width: inherit; width: inherit;

View File

@@ -1,4 +1,4 @@
import { Component } from 'solid-js'; import { Component, createSignal } from 'solid-js';
import { IVisibilityActionRequest, MatrixCapabilities, WidgetApi, WidgetApiToWidgetAction } from 'matrix-widget-api'; import { IVisibilityActionRequest, MatrixCapabilities, WidgetApi, WidgetApiToWidgetAction } from 'matrix-widget-api';
import { HopeProvider } from "@hope-ui/solid"; import { HopeProvider } from "@hope-ui/solid";
@@ -6,9 +6,10 @@ import { BusinessDataProvider } from './businessData';
import { AppContextProvider } from './appContext'; import { AppContextProvider } from './appContext';
import { PassagesDisplay } from './passagesDisplay'; import { PassagesDisplay } from './passagesDisplay';
import { StopsSearchMenu } from './stopsSearchMenu'; import { StopsSearchMenu } from './stopsSearchMenu/stopsSearchMenu';
import "./App.scss"; import "./App.scss";
import { onCleanup, onMount } from 'solid-js';
function parseFragment() { function parseFragment() {
@@ -43,6 +44,33 @@ const App: Component = () => {
api.transport.reply(ev.detail, {}); api.transport.reply(ev.detail, {});
}); });
createSignal({
height: window.innerHeight,
width: window.innerWidth
});
const onResize = () => {
const body = document.body;
if (window.innerWidth * 9 / 16 < window.innerHeight) {
body.style['height'] = 'auto';
body.style['width'] = '100vw';
}
else {
body.style['height'] = '100vh';
body.style['width'] = 'auto';
}
};
onMount(() => {
window.addEventListener('resize', onResize);
onResize();
});
onCleanup(() => {
window.removeEventListener('resize', onResize);
})
return ( return (
<BusinessDataProvider> <BusinessDataProvider>
<AppContextProvider> <AppContextProvider>

View File

@@ -10,7 +10,7 @@
} }
/* Idfm: 1800x100px (margin: 17px 60px) */ /* Idfm: 1800x100px (margin: 17px 60px) */
.header { %header {
width: calc(1800/1920*100%); width: calc(1800/1920*100%);
height: calc(100/1080*100%); height: calc(100/1080*100%);
/*Percentage margin are computed relatively to the nearest block container's width, not height */ /*Percentage margin are computed relatively to the nearest block container's width, not height */
@@ -23,7 +23,10 @@
font-family: IDFVoyageur-bold; font-family: IDFVoyageur-bold;
} }
// .header .title { .header {
@extend %header;
}
%title { %title {
height: 50%; height: 50%;
width: 70%; width: 70%;
@@ -31,8 +34,6 @@
margin-right: auto; margin-right: auto;
} }
/* Idfm: 1860x892px (margin: 0px 30px) */ /* Idfm: 1860x892px (margin: 0px 30px) */
%body { %body {
width: calc(1860/1920*100%); width: calc(1860/1920*100%);
@@ -50,10 +51,8 @@
} }
/* Idfm: 1800x54px (margin: 0px 50px) */ /* Idfm: 1800x54px (margin: 0px 50px) */
.footer { %footer {
width: calc(1820/1920*100%); width: calc(1820/1920*100%);
height: calc(54/1080*100%); height: calc(54/1080*100%);
margin: 0 calc(50/1920*100%); margin: 0 calc(50/1920*100%);
@@ -63,6 +62,10 @@
justify-content: right; justify-content: right;
} }
.footer {
@extend %footer;
}
.footer div { .footer div {
aspect-ratio: 1; aspect-ratio: 1;
height: 50%; height: 50%;

View File

@@ -1,9 +1,11 @@
import { batch, createContext, createSignal, JSX } from 'solid-js'; import { batch, createContext, createSignal, JSX } from 'solid-js';
import { createStore } from 'solid-js/store'; import { createStore } from 'solid-js/store';
import { Line, Lines, Passage, Passages, Stop, Stops } from './types'; import { Line, Lines, Passage, Passages, Stop, StopShape, StopShapes, Stops } from './types';
export type StopDestinations = Record<string, string[]>;
export interface BusinessDataStore { export interface BusinessDataStore {
getLine: (lineId: string) => Promise<Line>; getLine: (lineId: string) => Promise<Line>;
getLinePassages: (lineId: string) => Record<string, Passage[]>; getLinePassages: (lineId: string) => Record<string, Passage[]>;
@@ -18,6 +20,9 @@ export interface BusinessDataStore {
getStop: (stopId: number) => Stop | undefined; getStop: (stopId: number) => Stop | undefined;
searchStopByName: (name: string) => Promise<Stops>; searchStopByName: (name: string) => Promise<Stops>;
getStopDestinations: (stopId: number) => Promise<StopDestinations | undefined>;
getStopShape: (stopId: number) => Promise<StopShape | undefined>;
}; };
export const BusinessDataContext = createContext<BusinessDataStore>(); export const BusinessDataContext = createContext<BusinessDataStore>();
@@ -30,19 +35,29 @@ export function BusinessDataProvider(props: { children: JSX.Element }) {
lines: Lines; lines: Lines;
passages: Passages; passages: Passages;
stops: Stops; stops: Stops;
stopShapes: StopShapes;
}; };
const [store, setStore] = createStore<Store>({ lines: {}, passages: {}, stops: {} }); const [store, setStore] = createStore<Store>({ lines: {}, passages: {}, stops: {}, stopShapes: {} });
const getLine = async (lineId: string): Promise<Line> => { const getLine = async (lineId: string): Promise<Line> => {
let line = store.lines[lineId]; let line = store.lines[lineId];
if (line === undefined) { if (line === undefined) {
console.log(`${lineId} not found... fetch it from backend.`); console.log(`${lineId} not found... fetch it from backend.`);
const data = await fetch(`${serverUrl()}/line/${lineId}`, {
const response = await fetch(`${serverUrl()}/line/${lineId}`, {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
line = await data.json();
setStore('lines', lineId, line); const json = await response.json();
if (response.ok) {
setStore('lines', lineId, json);
line = json;
}
else {
console.warn(`No line found for ${lineId} line id:`, json);
}
} }
return line; return line;
} }
@@ -55,6 +70,7 @@ export function BusinessDataProvider(props: { children: JSX.Element }) {
return Object.keys(store.passages[lineId]); 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[] => { const getDestinationPassages = (lineId: string, destination: string): Passage[] => {
return store.passages[lineId][destination]; return store.passages[lineId][destination];
} }
@@ -84,12 +100,19 @@ export function BusinessDataProvider(props: { children: JSX.Element }) {
} }
const refreshPassages = async (stopId: number): Promise<void> => { const refreshPassages = async (stopId: number): Promise<void> => {
const httpOptions = { headers: { "Content-Type": "application/json" } };
console.log(`Fetching data for ${stopId}`); console.log(`Fetching data for ${stopId}`);
const data = await fetch(`${serverUrl()}/stop/nextPassages/${stopId}`, httpOptions); const httpOptions = { headers: { "Content-Type": "application/json" } };
const response = await data.json(); const response = await fetch(`${serverUrl()}/stop/${stopId}/nextPassages`, httpOptions);
_cleanupPassages(response.passages);
addPassages(response.passages); const json = await response.json();
if (response.ok) {
_cleanupPassages(json.passages);
addPassages(json.passages);
}
else {
console.warn(`No passage found for ${stopId} stop:`, json);
}
} }
const addPassages = (passages: Passages): void => { const addPassages = (passages: Passages): void => {
@@ -148,23 +171,67 @@ ${linePassagesDestination.length} here... refresh all them.`);
} }
const searchStopByName = async (name: string): Promise<Stops> => { const searchStopByName = async (name: string): Promise<Stops> => {
const data = await fetch(`${serverUrl()}/stop/?name=${name}`, { const byIdStops: Stops = {};
const response = await fetch(`${serverUrl()}/stop/?name=${name}`, {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
const stops = await data.json();
const byIdStops: Stops = {}; const json = await response.json();
for (const stop of stops) {
byIdStops[stop.id] = stop; if (response.ok) {
setStore('stops', stop.id, stop); for (const stop of json) {
byIdStops[stop.id] = stop;
setStore('stops', stop.id, stop);
if (stop.stops !== undefined) {
for (const innerStop of stop.stops) {
setStore('stops', innerStop.id, innerStop);
}
}
}
} }
else {
console.warn(`No stop found for '${name}' query:`, json);
}
return byIdStops; return byIdStops;
} }
const getStopDestinations = async (stopId: number): Promise<StopDestinations | undefined> => {
const response = await fetch(`${serverUrl()}/stop/${stopId}/destinations`, {
headers: { 'Content-Type': 'application/json' }
});
const destinations = response.ok ? await response.json() : undefined;
return destinations;
}
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 response = await fetch(`${serverUrl()}/stop/${stopId}/shape`, {
headers: { 'Content-Type': 'application/json' }
});
const json = await response.json();
if (response.ok) {
setStore('stopShapes', stopId, json);
shape = json;
}
else {
console.warn(`No shape found for ${stopId} stop:`, json);
}
}
return shape;
}
return ( return (
<BusinessDataContext.Provider value={{ <BusinessDataContext.Provider value={{
getLine, getLinePassages, getLineDestinations, getDestinationPassages, passages, getPassagesLineIds, getLine, getLinePassages, getLineDestinations, getDestinationPassages, passages, getPassagesLineIds,
refreshPassages, addPassages, clearPassages, getStop, searchStopByName refreshPassages, addPassages, clearPassages,
getStop, getStopDestinations, getStopShape, searchStopByName
}}> }}>
{props.children} {props.children}
</BusinessDataContext.Provider> </BusinessDataContext.Provider>

View File

@@ -1,19 +1,16 @@
import { createIcon } from "@hope-ui/solid"; import { VoidComponent } from "solid-js";
// Inspired by https://github.com/hope-ui/hope-ui/blob/main/apps/docs/src/icons/IconHamburgerMenu.tsx
// From https://github.com/hope-ui/hope-ui/blob/main/apps/docs/src/icons/IconHamburgerMenu.tsx export const IconHamburgerMenu: VoidComponent<{}> = () => {
return (
export const IconHamburgerMenu = createIcon({ <svg class="iconHamburgerMenu" viewBox="0 0 15 15">
viewBox: "0 0 15 15", <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
path: () => ( 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
<path 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
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 12 13.5 12H1.5C1.22386 12 1 11.7761 1 11.5Z"
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 fill="currentColor"
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 fill-rule="evenodd"
13.7761 12 13.5 12H1.5C1.22386 12 1 11.7761 1 11.5Z" clip-rule="evenodd"
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
/> />
), </svg>);
}); }

View File

@@ -13,10 +13,8 @@
src: url(/public/fonts/IDFVoyageur-Medium.otf); src: url(/public/fonts/IDFVoyageur-Medium.otf);
} }
body { html, body {
aspect-ratio: 16/9; aspect-ratio: 16/9;
width: 100vw;
height: none;
margin: 0; margin: 0;

View File

@@ -1,6 +1,7 @@
@use "_common"; @use "_common";
@use "_utils"; @use "_utils";
.passagesDisplay { .passagesDisplay {
@extend %widget; @extend %widget;
@@ -21,18 +22,24 @@
} }
.menu { .menu {
aspect-ratio: 0.75;
height: $header-element-height; height: $header-element-height;
aspect-ratio: 1;
margin-right: calc(30/1920*100%); margin-right: calc(30/1920*100%);
margin-left: auto; margin-left: auto;
border: $component-border;
border-radius: $component-border-radius;
button { button {
height: 100%; height: 100%;
aspect-ratio: 1;
background-color: var(--idfm-black); border: 0;
border: $component-border; color: var(--idfm-white);
border-radius: $component-border-radius; background-color: transparent;
.iconHamburgerMenu {
width: 75%;
}
} }
} }

View File

@@ -1,205 +1,208 @@
@use "_utils.scss"; @use "_utils.scss";
.passagesContainer { .body {
height: 100%; .passagesContainer {
width: 100%; height: 100%;
width: 100%;
display: none; display: none;
position: relative; position: relative;
}
.displayed { /* TODO: Remove the bottom border only if there are 5 displayed lines. */
display: block; .line:last-child {
} border-bottom: 0;
/* To make up for the bottom border deletion */
/* TODO: Remove the bottom border only if there are 5 displayed lines. */ padding-bottom: calc(2px);
.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 {
@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;
}
.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;
}
%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 { /* Idfm: 1880x176px (margin: 0px 20px) */
vertical-align: middle; .line {
font-family: IDFVoyageur-bold; width: calc(1880/1920*100%);
} height: calc(100% / 5);
} margin: 0 calc(20/1920*100%);
}
display: flex;
.withPlatformSecondPassage { align-items: center;
@extend %withPlatformPassage;
/* TODO: compute the border weight according to the parent height */
aspect-ratio: 215/120; /* TODO: Disable border-bottom for the last .line */
border-bottom: solid calc(2px);
align-items: end;
justify-content: center; svg {
font-family: IDFVoyageur-bold;
margin-right: calc(30/1920*100%); max-width: 100%;
max-height: 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. */ /* Idfm: 100x100px (margin: 0px 15px) */
margin-top: calc(7.5/176*100%); .transportMode {
} @extend %transportMode;
svg { height: calc(100/176*100%);
font-family: IDFVoyageur-regular; margin: 0 calc(15/1920*100%);
} }
.platform { .busLinePicto {
rect { @extend %busLinePicto;
background-color: var(--idfm-black);
} height: calc(70/176*100%);
margin-right: calc(23/1920*100%);
text { }
vertical-align: middle;
.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; 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,12 +1,11 @@
import { VoidComponent, createResource, onMount, ParentComponent, ParentProps, Show, useContext, For } from 'solid-js'; import { VoidComponent, createResource, onMount, ParentComponent, ParentProps, Show, useContext, For } from 'solid-js';
import { createDateNow, getTime } from '@solid-primitives/date'; import { createDateNow, getTime } from '@solid-primitives/date';
import { timeline } from '@motionone/dom';
import { AnimationOptions } from '@motionone/types'; import { AnimationOptions } from '@motionone/types';
import { Motion } from "@motionone/solid"; import { Motion } from "@motionone/solid";
import { format } from "date-fns"; import { format } from "date-fns";
import { Line, TrafficStatus } from './types'; import { Line, TrafficStatus } from './types';
import { renderLineTransportMode, renderLinePicto } from './utils'; import { renderLineTransportMode, renderLinePicto, ScrollingText } from './utils';
import { BusinessDataContext, BusinessDataStore } from "./businessData"; import { BusinessDataContext, BusinessDataStore } from "./businessData";
import "./passagesPanel.scss"; import "./passagesPanel.scss";
@@ -123,24 +122,6 @@ const DestinationPassages: VoidComponent<{ line: Line, destination: string }> =
// const trafficStatusStyle = { fill: trafficStatusColor.get(props.line.trafficStatus) }; // const trafficStatusStyle = { fill: trafficStatusColor.get(props.line.trafficStatus) };
const trafficStatusStyle = { fill: trafficStatusColor.get(TrafficStatus.UNKNOWN) }; const trafficStatusStyle = { fill: trafficStatusColor.get(TrafficStatus.UNKNOWN) };
let destinationViewboxRef: SVGSVGElement | undefined = undefined;
let destinationTextRef: SVGTextElement | undefined = undefined;
onMount(() => {
if (destinationViewboxRef !== undefined && destinationTextRef !== undefined) {
const overlap = destinationTextRef.getComputedTextLength() - destinationViewboxRef.viewBox.baseVal.width;
if (overlap > 0) {
timeline(
[
[destinationTextRef, { x: [-overlap] }, { duration: 5 }],
[destinationTextRef, { x: [0] }, { duration: 2 }],
],
{ repeat: Infinity },
);
}
}
});
return ( return (
<div class="line"> <div class="line">
<div class="transportMode"> <div class="transportMode">
@@ -148,14 +129,7 @@ const DestinationPassages: VoidComponent<{ line: Line, destination: string }> =
</div> </div>
{renderLinePicto(props.line)} {renderLinePicto(props.line)}
<div class="destination"> <div class="destination">
<svg ref={destinationViewboxRef} viewBox="0 0 600 40"> <ScrollingText height={40} width={600} content={props.destination} />
<text ref={destinationTextRef} x="0" y="50%"
dominant-baseline="middle"
font-size="40"
style={{ fill: "#000000" }}>
{props.destination}
</text>
</svg>
</div> </div>
<div class="trafficStatus"> <div class="trafficStatus">
<svg viewBox="0 0 51 51"> <svg viewBox="0 0 51 51">

View File

@@ -1,242 +0,0 @@
@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 {
// display: None;
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);
.name {
width: 60%;
font-family: IDFVoyageur-bold;
}
.lineRepr {
// height: 100%;
width: 40%;
aspect-ratio: 2.5;
// margin-left: auto;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
.transportMode {
@extend %transportMode;
height: 50%;
}
.break {
flex-basis: 100%;
height: 0;
}
.linesRepresentationMatrix {
@extend %busLinePicto; // Use the larger picto aspect-ratio
width: 75%;
aspect-ratio: 3;
display: flex;
flex-flow: row;
flex-wrap: wrap;
// justify-content: space-around;
// .break {
// flex-basis: 100%;
// height: 0;
// }
%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 {
@extendnd %metroLinePicto;
@extend %singleLinePicto;
}
.busLinePicto {
@extend %busLinePicto;
@extend %picto;
}
}
}
}
// .stop:last-child {
// border-bottom: 0;
// /* to make up for the bottom border deletion */
// padding-bottom: calc(2px);
// }
}
.displayed {
display: block;
}
}
.map {
height: 100%;
width: 50%;
}
}
// .lineTable {
// height: 100%;
// width: 50%;
// tr {
// height: 100%;
// display: flex;
// flex-flow: row;
// }
// td {
// height: 100%;
// display: flex;
// flex-flow: row;
// }
// }
// .stop {
// height: 100%;
// display: flex;
// flex-direction: row;
// align-items: center;
// .lineRepr {
// height: 100%;
// aspect-ratio: 5;
// display: flex;
// flex-direction: row;
// flex-wrap: wrap;
// align-items: center;
// .break {
// flex-basis: 100%;
// height: 0;
// }
// .linesRepresentationMatrix {
// @extend %busLinePicto; // Use the larger picto aspect-ratio
// height: 100%;
// aspect-ratio: 3;
// display: flex;
// flex-flow: row;
// flex-wrap: wrap;
// .break {
// flex-basis: 100%;
// height: 0;
// }
// %picto {
// margin-left: 1%;
// align-self: center;
// justify-self: center;
// }
// .transportMode {
// @extend %transportMode;
// @extend %picto;
// }
// .tramLinePicto {
// @extendnd %tramLinePicto;
// @extend %picto;
// }
// .trainLinePicto {
// @extend %trainLinePicto;
// @extend %picto;
// }
// .metroLinePicto {
// @extendnd %metroLinePicto;
// @extend %picto;
// }
// .busLinePicto {
// @extend %busLinePicto;
// @extend %picto;
// }
// }
// }
// }
}

View File

@@ -1,442 +0,0 @@
import { createContext, createEffect, createResource, For, JSX, onMount, ParentComponent, Show, useContext, VoidComponent } from 'solid-js';
import { createStore } from "solid-js/store";
import { createScrollPosition } from "@solid-primitives/scroll";
import { Input, InputLeftAddon, InputGroup } from "@hope-ui/solid";
import {
featureGroup as leafletFeatureGroup, LatLngLiteral as LeafletLatLngLiteral, Map as LeafletMap,
Marker as LeafletMarker, tileLayer as leafletTileLayer
} from 'leaflet';
import { Stop } from './types';
import { PositionedPanel, renderLineTransportMode, renderLinePicto, TransportModeWeights } from './utils';
import { AppContextContext, AppContextStore } from "./appContext";
import { BusinessDataContext, BusinessDataStore } from "./businessData";
import "leaflet/dist/leaflet.css";
import "./stopsSearchMenu.scss";
type ByStopIdMarkers = Record<number, LeafletMarker[] | undefined>;
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;
addMarkers: (stopId: number, markers: LeafletMarker[]) => void;
getPanels: () => PositionedPanel[];
setPanels: (panels: PositionedPanel[]) => void;
};
const SearchContext = createContext<SearchStore>();
function SearchProvider(props: { children: JSX.Element }) {
type Store = {
searchText: string;
searchInProgress: boolean;
foundStops: Stop[];
markers: ByStopIdMarkers;
displayedPanelId: number;
panels: PositionedPanel[];
};
const [store, setStore] = createStore<Store>({
searchText: "", searchInProgress: false, foundStops: [], markers: {}, displayedPanelId: 0, panels: []
});
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 addMarkers = (stopId: number, markers: L.Marker[]): void => {
setStore('markers', stopId, markers);
}
const getPanels = (): PositionedPanel[] => {
return store.panels;
}
const setPanels = (panels: PositionedPanel[]): void => {
setStore('panels', panels);
}
return (
<SearchContext.Provider value={{
getSearchText, setSearchText, isSearchInProgress,
getFoundStops, setFoundStops,
getDisplayedPanelId, setDisplayedPanelId,
addMarkers,
getPanels, setPanels
}}>
{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 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">
<div class="name">{props.stop.name}</div>
<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 businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const appContextStore: AppContextStore | undefined = useContext(AppContextContext);
if (businessDataStore === undefined || appContextStore === undefined)
return <div />;
const { getLine } = businessDataStore;
const { setDisplayedStops } = appContextStore;
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])}>
<div class="name">{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 Object.values(getPanels())) {
if (panel.panel) {
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 Map: VoidComponent<{}> = () => {
const mapCenter: LeafletLatLngLiteral = { lat: 48.853, lng: 2.35 };
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined)
return <div />;
const { addMarkers, getFoundStops } = searchStore;
let mapDiv: any;
let map: LeafletMap | undefined = undefined;
const stopsLayerGroup = leafletFeatureGroup();
const buildMap = (div: HTMLDivElement) => {
map = new LeafletMap(div).setView(mapCenter, 11);
leafletTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
stopsLayerGroup.addTo(map);
}
const setMarker = (stop: Stop): L.Marker[] => {
const markers = [];
if (stop.lat !== undefined && stop.lon !== undefined) {
/* TODO: Add stop lines representation to popup. */
markers.push(new LeafletMarker([stop.lat, stop.lon]).bindPopup(`${stop.name}`).openPopup());
}
else {
for (const _stop of stop.stops) {
markers.push(...setMarker(_stop));
}
}
return markers;
}
onMount(() => buildMap(mapDiv));
createEffect(() => {
/* TODO: Avoid to clear all layers... */
stopsLayerGroup.clearLayers();
for (const stop of getFoundStops()) {
const markers = setMarker(stop);
addMarkers(stop.id, markers);
for (const marker of markers) {
stopsLayerGroup.addLayer(marker);
}
}
const stopsBound = stopsLayerGroup.getBounds();
if (map !== undefined && Object.keys(stopsBound).length) {
map.fitBounds(stopsBound);
}
});
return <div ref={mapDiv} class="map" id="main-map" />;
}
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 MAX_STOPS_PER_PANEL = 5;
return (
<div class="stopSearchMenu">
<SearchProvider>
<Header title="Recherche de l'arrêt..." minCharsNb={4} />
<div class="body">
<StopsPanels maxStopsPerPanel={MAX_STOPS_PER_PANEL} />
<Map />
</div>
<Footer />
</SearchProvider>
</div>
);
};

View File

@@ -0,0 +1,87 @@
@use "../_common";
@use "../_utils";
.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,213 @@
import { createEffect, createSignal, For, onMount, ParentComponent, useContext } from 'solid-js';
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 OlVectorSource from 'ol/source/Vector';
import { Tile as OlTileLayer, Vector as OlVectorLayer } from 'ol/layer';
import { Circle, Stroke, Style } from 'ol/style';
import { easeOut } from 'ol/easing';
import { getVectorContext } from 'ol/render';
import { unByKey } from 'ol/Observable';
import { Stop } from '../types';
import { BusinessDataContext, BusinessDataStore } from "../businessData";
import { SearchContext, SearchStore } from "./searchStore";
import { MapStop } from "./mapStop";
import { StopPopup } from "./stopPopup";
import "./map.scss";
export 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?.epsg3857_x !== undefined && stop?.epsg3857_y !== undefined) {
setSelectedMapStop(stop);
map?.getView().animate(
{
center: [stop.epsg3857_x, stop.epsg3857_y],
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>
</>;
}

View File

@@ -0,0 +1,85 @@
import { createEffect, createResource, For, useContext, VoidComponent } from 'solid-js';
import { Circle, Fill, Stroke, Style } from 'ol/style';
import OlFeature from 'ol/Feature';
import OlPoint from 'ol/geom/Point';
import OlPolygon from 'ol/geom/Polygon';
import { Stop, StopShape } from '../types';
import { BusinessDataContext, BusinessDataStore } from "../businessData";
import { SearchContext, SearchStore } from "./searchStore";
// TODO: Use boolean to set MapStop selected
export 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.epsg3857_x !== undefined && props.stop.epsg3857_y !== undefined) {
const selectStopStyle = () => {
return (props.selected?.id === props.stop.id ? selectedStopStyle : stopStyle);
}
feature = new OlFeature({
geometry: new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]),
});
feature.setStyle(selectStopStyle);
}
else {
let geometry = undefined;
const areaShape = shape();
if (areaShape !== undefined) {
geometry = new OlPolygon([areaShape.epsg3857_points.slice(0, -1)]);
}
else {
geometry = new OlPoint([props.stop.epsg3857_x, props.stop.epsg3857_y]);
}
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>;
}

View File

@@ -0,0 +1,165 @@
import { createContext, JSX } from 'solid-js';
import { createStore } from "solid-js/store";
import OlFeature from 'ol/Feature';
import { BusinessDataStore } from "../businessData";
import { Stop } from '../types';
import { PositionedPanel } from '../utils';
type ByStopIdMapFeatures = Record<number, OlFeature>;
export interface SearchStore {
getSearchText: () => string;
setSearchText: (text: string, businessDataStore: BusinessDataStore) => Promise<void>;
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;
enableMap: (enable: boolean) => void;
isMapEnabled: () => boolean;
getMapFeature: (stopId: number) => OlFeature | undefined;
getAllMapFeatures: () => ByStopIdMapFeatures;
setMapFeature: (stopId: number, feature: OlFeature) => void;
};
export const SearchContext = createContext<SearchStore>();
export function SearchProvider(props: { children: JSX.Element }) {
const searchTextDelayMs = 1500;
type Store = {
searchText: string;
searchPromise: Promise<void> | undefined;
foundStops: Stop[];
displayedPanelId: number;
panels: PositionedPanel[];
highlightedStop: Stop | undefined;
mapEnabled: boolean;
mapFeatures: ByStopIdMapFeatures;
};
const [store, setStore] = createStore<Store>({
searchText: "",
searchPromise: undefined,
foundStops: [],
displayedPanelId: 0,
panels: [],
highlightedStop: undefined,
// mapEnabled: false,
mapFeatures: {},
});
const getSearchText = (): string => {
return store.searchText;
}
const debounce = async (fn: (...args: any[]) => Promise<void>, delayMs: number) => {
let timerId: number;
return new Promise((...args) => {
clearTimeout(timerId);
timerId = setTimeout(fn, delayMs, ...args);
});
}
const setSearchText = async (text: string, businessDataStore: BusinessDataStore): Promise<void> => {
setStore('searchText', text);
if (store.searchPromise === undefined) {
const { searchStopByName } = businessDataStore;
const promise: Promise<void> = debounce(async (onSuccess: () => void) => {
console.log(`Fetching data for "${store.searchText}" stop name`);
const stopsById = await searchStopByName(store.searchText);
console.log("stopsById=", stopsById);
setFoundStops(Object.values(stopsById));
onSuccess();
}, searchTextDelayMs).then(() => {
setStore('searchPromise', undefined);
});
setStore('searchPromise', promise);
}
}
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 enableMap = (enable: boolean): void => {
setStore("mapEnabled", enable);
}
const isMapEnabled = (): boolean => {
return store.mapEnabled;
}
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,
getFoundStops, setFoundStops,
getDisplayedPanelId, setDisplayedPanelId,
getPanels, setPanels,
getHighlightedStop, setHighlightedStop, resetHighlightedStop,
enableMap, isMapEnabled,
getMapFeature, getAllMapFeatures, setMapFeature,
}}>
{props.children}
</SearchContext.Provider>
);
}

View File

@@ -0,0 +1,100 @@
@use "../_common";
@use "../_utils";
.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 {
@extend %tramLinePicto;
@extend %singleLinePicto;
}
.trainLinePicto {
@extend %trainLinePicto;
@extend %singleLinePicto;
}
.metroLinePicto {
@extend %metroLinePicto;
@extend %singleLinePicto;
}
.busLinePicto {
@extend %busLinePicto;
@extend %picto;
height: 40%;
}
}
}
}
}

View File

@@ -0,0 +1,143 @@
import { createResource, For, JSX, ParentComponent, Show, useContext, VoidComponent } from 'solid-js';
import { Stop } from '../types';
import { renderLineTransportMode, renderLinePicto, ScrollingText, TransportModeWeights } from '../utils';
import { AppContextContext, AppContextStore } from "../appContext";
import { BusinessDataContext, BusinessDataStore } from "../businessData";
import { SearchContext, SearchStore } from "./searchStore";
import "./stopPanel.scss";
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>
);
}
export 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>
);
}

View File

@@ -0,0 +1,49 @@
import { createResource, For, ParentComponent, useContext } from 'solid-js';
import { Stop } from '../types';
import { BusinessDataContext, BusinessDataStore } from "../businessData";
import { renderLinePicto, ScrollingText } from '../utils';
export 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 >
);
}

View File

@@ -0,0 +1,85 @@
@use "../_common";
@use "../_utils";
.mapPlaceholder {
--border-width: 0.1vh;
height: calc(100% - 2*var(--border-width));
width: 50%;
display: flex;
align-items: center;
justify-content: center;
border: solid var(--neutral-color) var(--border-width);
border-radius: var(--border-radius);
background-color: var(--idfm-black);
font-family: IDFVoyageur-bold;
font-size: 2vh;
color: var(--idfm-white);
}
.stopNameInput {
width: 50%;
height: 60%;
display: flex;
flex-flow: row;
border: solid var(--neutral-color) calc(0.01vh);
border-radius: var(--border-radius);
background-color: transparent;
.leftAddon {
width: 17%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--idfm-white);
}
input {
width: 83%;
padding-left: 3%;
padding-right: 3%;
color: var(--idfm-white);
font-family: IDFVoyageur-regular;
background-color: transparent;
}
}
.title {
@extend %title;
display: flex;
justify-content: center;
}
.stopSearchMenu {
@extend %widget;
.body {
@extend %body;
flex-direction: row;
.stopsPanels {
width: 50%;
height: 100%;
scroll-snap-type: y mandatory;
overflow-y: scroll;
.displayed {
display: block;
}
}
}
}

View File

@@ -0,0 +1,200 @@
import { createEffect, For, JSX, lazy, ParentComponent, useContext, Show, VoidComponent } from 'solid-js';
import { lazily } from 'solidjs-lazily';
import { createScrollPosition } from "@solid-primitives/scroll";
import { Stop } from '../types';
import { PositionedPanel } from '../utils';
import { BusinessDataContext, BusinessDataStore } from "../businessData";
import { SearchContext, SearchProvider, SearchStore } from "./searchStore";
import { StopsPanel } from "./stopPanel";
const { Map } = lazily(() => import("./map"));
import "./stopsSearchMenu.scss";
const StopNameInput: VoidComponent<{ onInput: JSX.EventHandler<HTMLInputElement, InputEvent>, leftAddon: string, placeholder: string }> = (props) => {
return (
<div class="stopNameInput">
<div class="leftAddon">{props.leftAddon}</div>
<input type="text" oninput={props.onInput} placeholder={props.placeholder} />
</div>);
};
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 { setSearchText } = searchStore;
const onStopNameInput: JSX.EventHandler<HTMLInputElement, InputEvent> = async (event): Promise<void> => {
const stopName = event.currentTarget.value;
if (stopName.length >= props.minCharsNb) {
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>
<StopNameInput onInput={onStopNameInput} leftAddon="🚉 🚏" placeholder="Stop name..." />
</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 MapPlaceholder: VoidComponent<{}> = () => {
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) {
return <div />;
}
const { enableMap } = searchStore;
const onDoubleClick = (): void => {
console.log('!!! ON DOUBLE CLICK');
enableMap(true);
}
return <div
class="mapPlaceholder" ondblclick={() => onDoubleClick()}>
Double-clic pour activer la carte
</div>;
};
const Body: VoidComponent<{}> = () => {
const searchStore: SearchStore | undefined = useContext(SearchContext);
if (searchStore === undefined) {
return <div />;
}
const { isMapEnabled } = searchStore;
const maxStopsPerPanel = 5;
return <div class="body">
<StopsPanels maxStopsPerPanel={maxStopsPerPanel} />
<Show when={isMapEnabled()} fallback={<MapPlaceholder />}>
<Map />
</Show>
</div>;
};
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 = () => {
return (
<div class="stopSearchMenu">
<SearchProvider>
<Header title="Recherche de l'arrêt..." minCharsNb={4} />
<Body />
<Footer />
</SearchProvider>
</div>
);
};

View File

@@ -42,17 +42,17 @@ export class Stop {
id: number; id: number;
name: string; name: string;
town: string; town: string;
lat: number; epsg3857_x: number;
lon: number; epsg3857_y: number;
stops: Stop[]; stops: Stop[];
lines: string[]; lines: string[];
constructor(id: number, name: string, town: string, lat: number, lon: number, stops: Stop[], lines: string[]) { constructor(id: number, name: string, town: string, epsg3857_x: number, epsg3857_y: number, stops: Stop[], lines: string[]) {
this.id = id; this.id = id;
this.name = name; this.name = name;
this.town = town; this.town = town;
this.lat = lat; this.epsg3857_x = epsg3857_x;
this.lon = lon; this.epsg3857_y = epsg3857_y;
this.stops = stops; this.stops = stops;
this.lines = lines; this.lines = lines;
for (const stop of this.stops) { for (const stop of this.stops) {
@@ -63,6 +63,24 @@ export class Stop {
export type Stops = Record<number, Stop>; export type Stops = Record<number, Stop>;
export type Points = [number, number][];
export class StopShape {
stop_id: number;
type_: number;
epsg3857_bbox: number[];
epsg3857_points: Points;
constructor(stop_id: number, type_: number, epsg3857_bbox: number[], epsg3857_points: Points) {
this.stop_id = stop_id;
this.type_ = type_;
this.epsg3857_bbox = epsg3857_bbox;
this.epsg3857_points = epsg3857_points;
}
};
export type StopShapes = Record<number, StopShape>;
export class Line { export class Line {
id: string; id: string;
shortName: string; shortName: string;

View File

@@ -1,4 +1,5 @@
import { JSX } from 'solid-js'; import { JSX, onMount, VoidComponent } from 'solid-js';
import { timeline } from '@motionone/dom';
import { Line } from './types'; import { Line } from './types';
@@ -25,7 +26,7 @@ export const TransportModeWeights: Record<string, number> = {
export function getTransportModeSrc(mode: string, color: boolean = true): string | undefined { export function getTransportModeSrc(mode: string, color: boolean = true): string | undefined {
let ret = undefined; let ret = undefined;
if (validTransportModes.includes(mode)) { 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; return ret;
} }
@@ -129,3 +130,37 @@ export type PositionedPanel = {
// TODO: Should be PassagesPanelComponent ? // TODO: Should be PassagesPanelComponent ?
panel: JSX.Element; 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 >
);
}