108 Commits

Author SHA1 Message Date
fcd1ae78c3 🐛 Fix IP to bind in the api config file for local use 2023-09-20 22:24:02 +02:00
ec1b4100a3 🐛 Fix validation issue in /api/stop/{stop_id}/destinations responses 2023-09-20 22:22:13 +02:00
37ec05bf3b Merge branch 'k8s-integration' into develop 2023-09-20 22:14:56 +02:00
3434802b31 🎨 Reorganize back-end code 2023-09-20 22:08:32 +02:00
bdbc72ab39 🐛 Front: Fix URL used to fetch transport mode representation 2023-09-10 12:25:38 +02:00
4cc8f60076 🐛 Front: Use the public API server to fetch data 2023-09-10 12:17:48 +02:00
cf5c4c6224 🔒️ Fix CORS allowed origins and methods 2023-09-10 12:07:20 +02:00
f69aee1c9c 🔒️ Remove driver and password from configuration file
Password will be provided by vault using an env variable.
2023-09-10 12:04:25 +02:00
8c493f8fab ♻️ Remove pg_trgm creation from the db session init
The pg_trgm extension will be created during db init, by the db-updated image.
2023-09-10 11:46:24 +02:00
4fce832db5 ♻️ Rename docker file building api image 2023-09-10 11:45:08 +02:00
bfc669cd11 ♻️ Use pydantic-settings to handle config file 2023-09-09 23:35:18 +02:00
4056b3a739 🐛 Error raised by frontend Map component if no stop found 2023-09-09 23:18:03 +02:00
f7f0fdb980 ️ Use of integer to store Line and Stop id
Update Line and Stop schemas.
2023-09-09 23:05:18 +02:00
6c149e844b 💥 Remove /widget static endpoint
This endpoint shall be served by a dedicated static HTTP server.
2023-06-13 05:45:33 +02:00
f5529bba24 Merge branch 'remove-db-filling-from-backend' into develop 2023-06-13 05:44:00 +02:00
5da918c04b 👽️ Take the last IDFM format into account 2023-06-11 22:41:44 +02:00
2eaf0f4ed5 Use of db merge when adds fails due to single key violations 2023-06-11 22:28:15 +02:00
c42b687870 🐛 Fix IdfmInterface circular import issue 2023-06-11 22:24:09 +02:00
d8adb4f52d ♻️ Remove code in charge or db filling from IdfmInterface 2023-06-11 22:22:05 +02:00
5e7f440b54 ♻️ Add the db_updater package 2023-06-11 22:18:47 +02:00
824536ddbe 💥 Rename API_KEY to IDFM_API_KEY 2023-05-28 12:45:03 +02:00
7fbdd0606c ️ Reduce the size of the backend docker image 2023-05-28 12:40:10 +02:00
581f6b7b8f 🐛 Add workaround for fastapi-cache issue #144 2023-05-28 10:45:14 +02:00
404b228cbf 🔥 Remove env variables from backend dockerfile 2023-05-26 23:55:58 +02:00
e2ff90cd5f ️ Use Redis to cache REST responses 2023-05-26 18:10:47 +02:00
cd700ebd42 🐛 The backend shall serve requests once the database reachable 2023-05-26 18:09:24 +02:00
c44a52b7ae ♻️ Add backend and frontend to docker-compose 2023-05-26 18:01:04 +02:00
b3b36bc3de Replace rich with icecream for temporary tracing 2023-05-11 21:44:58 +02:00
5e0d7b174c 🏷️ Fix some type issues (mypy) 2023-05-11 21:40:38 +02:00
b437bbbf70 🎨 Split main into several APIRouters 2023-05-11 21:17:02 +02:00
85fdb28cc6 🐛 Set default value to Settings.clear_static_data 2023-05-11 20:31:24 +02:00
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
4a2fadb5b3 💄 Redesign stop search menu to follow the passage display style 2023-03-05 13:46:25 +01:00
f09ba4cc58 💄 Replace css files with scss and factorize CSS rules 2023-03-05 12:49:20 +01:00
a9d918fb0f 🐛 Time to wait shall be displayed in 2 digits 2023-02-18 15:38:25 +01:00
033e27fb56 Add platform id to passages panels 2023-02-18 14:50:24 +01:00
11c62e5795 💄 Fix passage delay format when waiting time > 1h
When waiting time is less than 1h, the duration shall be displayed, the arrival time otherwise.
2023-02-12 22:54:52 +01:00
40b2704a15 🎨 Factorize tramLinePicto CSS class with metroLinePicto one 2023-02-12 22:44:22 +01:00
04053e25ed 💄 Animate destination text when they´re too long for panel 2023-02-12 22:43:09 +01:00
5d566648e5 💄 Fix no picto displayed for train and RER lines 2023-02-12 21:21:45 +01:00
5c08780f98 ️ Reduce the refresh on passages update to the TtwPassage component 2023-02-12 19:01:32 +01:00
3913209b28 Add button to PassagesDisplay to disable passages fetching 2023-02-11 18:12:26 +01:00
7294f35622 🐛 Filter old passages
Only passages with a expectedDepartTs > now - 60s are kept.
2023-02-09 22:36:42 +01:00
0dd44372e8 ♻️ Don´t overwrite store.passages structure during its update by the addPassage method 2023-02-09 21:27:47 +01:00
f0fe3f8803 🎨 Remove not essential store from the rendering of the PassagesDisplay component 2023-02-09 21:17:58 +01:00
275954f52d 🎨 Replace for loop with a <For/> control flow for PassagesPanel component 2023-02-09 21:14:16 +01:00
e34355e8be 🏷️ Make python linters happy 2023-02-08 22:10:21 +01:00
d1db97554c 🧑‍💻 Use ruff/mypy/black linter/type-checker/formatter
* Use of python-lsp-server with emacs (cf. .dir-locals.el file)
 * Remove autopep8/pycodestyle/pydocstyle/pyflakes/pylint/yapf
2023-02-05 23:10:58 +01:00
aaab0a933b 🎨 Replace for loop with a <For/> control flow for StopsManager 2023-01-30 22:47:12 +01:00
bd8ccc5978 🔥 Remove unused functions from search module
removeStops, getMarkers and addMarkers are not used anymore... remove them.
2023-01-30 22:10:41 +01:00
d490236456 💄 Force root div to fit its content (all height before) 2023-01-30 22:08:54 +01:00
2fd6783534 🚨 Try to make TS linter happy 2023-01-30 22:07:20 +01:00
27f895ce0c 🔇 No need to log line stops 2023-01-28 17:58:43 +01:00
f4d6a3e684 💄 Fix the Metro picto rendering
No metro picto was displayed... lack of metroLinePicto CSS class.
2023-01-28 16:38:42 +01:00
79f4ad0c4c Merge branch 'frontend-file-reorg' into develop 2023-01-28 16:37:13 +01:00
43cbfc17b6 🚨 Make ts linter less depressed 2023-01-28 16:27:40 +01:00
29ba26e80b ♻️ Replace methods called to render Component with small Components 2023-01-28 16:26:50 +01:00
e141aa15e5 🚚 Remove business logic from Component instances 2023-01-28 16:18:55 +01:00
207fe12842 🚚 Move utils function from types.tsx to utils.tsx 2023-01-27 20:23:43 +01:00
cc5205c318 🐛 Fix PassageDisplay footer bullets behavior
The bullets was not updated according to the displayed panel.
2023-01-27 20:20:17 +01:00
495b2bafe2 🔥 Remove ar16x9 CSS class 2023-01-23 22:50:29 +01:00
e96e7aeae0 🚚 Rename NextPassagesDisplay/NextPassagesPanel (remove Next prefix) 2023-01-23 22:34:33 +01:00
b8984e455c 🚚 Add a dedicated CSS file for NextPassagePanel component 2023-01-23 21:16:47 +01:00
f8786fc863 Merge branch 'remove-idfm-extra-references' into develop 2023-01-23 21:09:20 +01:00
dea6b928e1 🗃️ Create and use a db dedicated user for backend
* Rename the db name (idfm_matrix_bot -> cer_db).
 * Remove unused bot database.
 * Create a dedicated user (cer_user/cer_password).
2023-01-22 19:02:54 +01:00
76d4c8a641 🚚 Rename backend project (idfm-matrix-widget -> carrramba-encore-rate)
Update the pyproject.toml file.
2023-01-22 19:01:15 +01:00
7423511a51 🚚 Rename backend component (idfm_matrix_backend -> backend) 2023-01-22 18:58:47 +01:00
c81d234426 🙈 Add __pycache__ and lock files 2023-01-22 17:25:44 +01:00
83 changed files with 4847 additions and 2355 deletions

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

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

12
backend/.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
.dir-locals.el
.dockerignore
.gitignore
**/.mypy_cache
**/.ruff_cache
.venv
**/__pycache__
config
docker
poetry.lock
tests
Dockerfile

3
backend/.gitignore vendored
View File

@@ -1 +1,2 @@
!**/__pycache__/
**/__pycache__/
poetry.lock

34
backend/Dockerfile.api Normal file
View File

@@ -0,0 +1,34 @@
FROM python:3.11-slim as builder
RUN pip install poetry
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
WORKDIR /app
COPY pyproject.toml /app
RUN poetry install --only=main --no-root && \
rm -rf ${POETRY_CACHE_DIR}
FROM python:3.11-slim as runtime
WORKDIR /app
RUN apt update && \
apt install -y --no-install-recommends libpq5 && \
apt clean && \
rm -rf /var/lib/apt/lists/*
env VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY api /app/api
CMD ["python", "./api/main.py"]

View File

@@ -0,0 +1,40 @@
FROM python:3.11-slim as builder
RUN apt update && \
apt install -y --no-install-recommends proj-bin && \
apt clean && \
rm -rf /var/lib/apt/lists/*
RUN pip install poetry
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
WORKDIR /app
COPY ./pyproject.toml /app
RUN poetry install --only=db_updater --no-root && \
rm -rf ${POETRY_CACHE_DIR}
FROM python:3.11-slim as runtime
WORKDIR /app
RUN apt update && \
apt install -y --no-install-recommends libpq5 && \
apt clean && \
rm -rf /var/lib/apt/lists/*
env VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY api /app/api
COPY db_updater /app/db_updater
CMD ["python", "-m", "db_updater.fill_db"]

0
backend/api/__init__.py Normal file
View File

View File

@@ -0,0 +1,21 @@
app_name: carrramba-encore-rate
clear_static_data: false
http:
host: 127.0.0.1
port: 8080
cert: ./config/cert.pem
db:
name: carrramba-encore-rate
host: 127.0.0.1
port: 5432
driver: postgresql+psycopg
user: cer
password: cer_password
cache:
enable: true
tracing:
enable: false

View File

@@ -0,0 +1,21 @@
app_name: carrramba-encore-rate
clear_static_data: false
http:
host: 0.0.0.0
port: 8080
# cert: ./config/cert.pem
db:
name: carrramba-encore-rate
host: postgres
port: 5432
user: cer
cache:
enable: true
host: redis
# TODO: Add user credentials
tracing:
enable: false

View File

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

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
from logging import getLogger
from typing import Self, Sequence, TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import DeclarativeBase
if TYPE_CHECKING:
from .db import Database
logger = getLogger(__name__)
class Base(DeclarativeBase):
db: Database | None = None
@classmethod
async def add(cls, objs: Sequence[Self]) -> bool:
if cls.db is not None and (session := await cls.db.get_session()) is not None:
try:
async with session.begin():
session.add_all(objs)
except IntegrityError as err:
logger.warning(err)
return await cls.merge(objs)
except AttributeError as err:
logger.error(err)
return False
return True
@classmethod
async def merge(cls, objs: Sequence[Self]) -> bool:
if cls.db is not None and (session := await cls.db.get_session()) is not None:
async with session.begin():
for obj in objs:
await session.merge(obj)
return True
return False
@classmethod
async def get_by_id(cls, id_: int | str) -> Self | None:
if cls.db is not None and (session := await cls.db.get_session()) is not None:
async with session.begin():
stmt = select(cls).where(cls.id == id_)
res = await session.execute(stmt)
return res.scalar_one_or_none()
return None

76
backend/api/db/db.py Normal file
View File

@@ -0,0 +1,76 @@
from asyncio import sleep
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.exc import OperationalError, SQLAlchemyError
from sqlalchemy.ext.asyncio import (
async_sessionmaker,
AsyncEngine,
AsyncSession,
create_async_engine,
)
from .base_class import Base
from settings import DatabaseSettings
logger = getLogger(__name__)
class Database:
def __init__(self) -> None:
self._async_engine: AsyncEngine | None = None
self._async_session_local: async_sessionmaker[AsyncSession] | None = None
async def get_session(self) -> AsyncSession | None:
try:
return self._async_session_local() # type: ignore
except (SQLAlchemyError, AttributeError) as e:
logger.exception(e)
return None
# TODO: Preserve UserLastStopSearchResults table from drop.
async def connect(
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,
)
ret = False
while not ret:
try:
async with self._async_engine.begin() as session:
if clear_static_data:
await session.run_sync(Base.metadata.drop_all)
await session.run_sync(Base.metadata.create_all)
ret = True
except OperationalError as err:
logger.error(err)
await sleep(1)
return True
async def disconnect(self) -> None:
if self._async_engine is not None:
await self._async_engine.dispose()

View File

@@ -0,0 +1,38 @@
from os import environ
from fastapi_cache.backends.redis import RedisBackend
from redis import asyncio as aioredis
from yaml import safe_load
from db import db
from idfm_interface.idfm_interface import IdfmInterface
from settings import CacheSettings, Settings
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)
idfm_interface = IdfmInterface(settings.idfm_api_key.get_secret_value(), db)
def init_redis_backend(settings: CacheSettings) -> RedisBackend:
login = f"{settings.user}:{settings.password}@" if settings.user is not None else ""
url = f"redis://{login}{settings.host}:{settings.port}"
redis_connections_pool = aioredis.from_url(
url, encoding="utf8", decode_responses=True
)
return RedisBackend(redis_connections_pool)
redis_backend = init_redis_backend(settings.cache)

View File

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

View File

@@ -0,0 +1,115 @@
from collections import defaultdict
from re import compile as re_compile
from typing import ByteString
from aiofiles import open as async_open
from aiohttp import ClientSession
from msgspec import ValidationError
from msgspec.json import Decoder
from .idfm_types import Destinations as IdfmDestinations, IdfmResponse, IdfmState
from db import Database
from models import Line, Stop, StopArea
class IdfmInterface:
IDFM_ROOT_URL = "https://prim.iledefrance-mobilites.fr/marketplace"
IDFM_STOP_MON_URL = f"{IDFM_ROOT_URL}/stop-monitoring"
OPERATOR_RE = re_compile(r"[^:]+:Operator::([^:]+):")
LINE_RE = re_compile(r"[^:]+:Line::C([^:]+):")
def __init__(self, api_key: str, database: Database) -> None:
self._api_key = api_key
self._database = database
self._http_headers = {"Accept": "application/json", "apikey": self._api_key}
self._response_json_decoder = Decoder(type=IdfmResponse)
async def startup(self) -> None:
...
@staticmethod
def _format_line_id(line_id: str) -> int:
return int(line_id[1:] if line_id[0] == "C" else line_id)
async def render_line_picto(self, line: Line) -> tuple[None | str, None | str]:
line_picto_path = line_picto_format = None
target = f"/tmp/{line.id}_repr"
picto = line.picto
if picto is not None:
if (picto_data := await self._get_line_picto(line)) is not None:
async with async_open(target, "wb") as fd:
await fd.write(bytes(picto_data))
line_picto_path = target
line_picto_format = picto.mime_type
return (line_picto_path, line_picto_format)
async def _get_line_picto(self, line: Line) -> ByteString | None:
data = None
picto = line.picto
if picto is not None and picto.url is not None:
headers = (
self._http_headers if picto.url.startswith(self.IDFM_ROOT_URL) else None
)
async with ClientSession(headers=headers) as session:
async with session.get(picto.url) as response:
data = await response.read()
return data
async def get_next_passages(self, stop_point_id: int) -> IdfmResponse | None:
ret = None
params = {"MonitoringRef": f"STIF:StopPoint:Q:{stop_point_id}:"}
async with ClientSession(headers=self._http_headers) as session:
async with session.get(self.IDFM_STOP_MON_URL, params=params) as response:
if response.status == 200:
data = await response.read()
try:
ret = self._response_json_decoder.decode(data)
except ValidationError as err:
print(err)
return ret
async def get_destinations(self, stop_id: int) -> IdfmDestinations | None:
destinations: IdfmDestinations = defaultdict(set)
if (stop := await Stop.get_by_id(stop_id)) is not None:
expected_stop_ids = {stop.id}
elif (stop_area := await StopArea.get_by_id(stop_id)) is not None:
expected_stop_ids = {stop.id for stop in stop_area.stops}
else:
return None
if (res := await self.get_next_passages(stop_id)) is not None:
for delivery in res.Siri.ServiceDelivery.StopMonitoringDelivery:
if delivery.Status == IdfmState.true:
for stop_visit in delivery.MonitoredStopVisit:
monitoring_ref = stop_visit.MonitoringRef.value
try:
monitored_stop_id = int(monitoring_ref.split(":")[-2])
except (IndexError, ValueError):
print(f"Unable to get stop id from {monitoring_ref}")
continue
journey = stop_visit.MonitoredVehicleJourney
if (
dst_names := journey.DestinationName
) and monitored_stop_id in expected_stop_ids:
raw_line_id = journey.LineRef.value.split(":")[-2]
line_id = IdfmInterface._format_line_id(raw_line_id)
destinations[line_id].add(dst_names[0].value)
return destinations

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime
from enum import Enum, StrEnum
from typing import Any, Literal, Optional, NamedTuple
from typing import Any, NamedTuple
from msgspec import Struct
@@ -88,7 +88,7 @@ class Stop(Struct):
Stops = dict[str, Stop]
class StopAreaType(Enum):
class StopAreaType(StrEnum):
metroStation = "metroStation"
onstreetBus = "onstreetBus"
onstreetTram = "onstreetTram"
@@ -101,7 +101,7 @@ class StopAreaFields(Struct, kw_only=True):
zdatown: str
zdaversion: str
zdaid: str
zdacreated: Optional[datetime] = None
zdacreated: datetime | None = None
zdatype: StopAreaType
zdayepsg2154: int
zdapostalregion: str
@@ -116,15 +116,35 @@ class StopArea(Struct):
record_timestamp: datetime
class ConnectionAreaFields(Struct, kw_only=True):
zdcid: str
zdcversion: str
zdccreated: datetime
zdcchanged: datetime
zdcname: str
zdcxepsg2154: int | None = None
zdcyepsg2154: int | None = None
zdctown: str
zdcpostalregion: str
zdctype: StopAreaType
class ConnectionArea(Struct):
datasetid: str
recordid: str
fields: ConnectionAreaFields
record_timestamp: datetime
class StopAreaStopAssociationFields(Struct, kw_only=True):
arrid: str # TODO: use int ?
artid: Optional[str] = None
artid: str | None = None
arrversion: str
zdcid: str
version: int
zdaid: str
zdaversion: str
artversion: Optional[str] = None
artversion: str | None = None
class StopAreaStopAssociation(Struct):
@@ -136,6 +156,7 @@ class StopAreaStopAssociation(Struct):
class IdfmLineState(Enum):
active = "active"
available_soon = "prochainement active"
class LinePicto(Struct, rename={"id_": "id"}):
@@ -153,20 +174,20 @@ class LineFields(Struct, kw_only=True):
name_line: str
status: IdfmLineState
accessibility: IdfmState
shortname_groupoflines: Optional[str] = None
shortname_groupoflines: str | None = None
transportmode: TransportMode
colourweb_hexa: str
textcolourprint_hexa: str
transportsubmode: Optional[TransportSubMode] = TransportSubMode.unknown
operatorref: Optional[str] = None
transportsubmode: TransportSubMode | None = TransportSubMode.unknown
operatorref: str | None = None
visualsigns_available: IdfmState
networkname: Optional[str] = None
networkname: str | None = None
id_line: str
id_groupoflines: Optional[str] = None
operatorname: Optional[str] = None
id_groupoflines: str | None = None
operatorname: str | None = None
audiblesigns_available: IdfmState
shortname_line: str
picto: Optional[LinePicto] = None
picto: LinePicto | None = None
class Line(Struct):
@@ -184,6 +205,8 @@ class Line(Struct):
Lines = dict[str, Line]
Destinations = dict[int, set[str]]
# TODO: Set structs frozen
class StopLineAssoFields(Struct):
@@ -220,17 +243,17 @@ class TrainNumber(Struct):
class MonitoredCall(Struct, kw_only=True):
Order: Optional[int] = None
Order: int | None = None
StopPointName: list[Value]
VehicleAtStop: bool
DestinationDisplay: list[Value]
AimedArrivalTime: Optional[datetime] = None
ExpectedArrivalTime: Optional[datetime] = None
ArrivalPlatformName: Optional[Value] = None
AimedDepartureTime: Optional[datetime] = None
ExpectedDepartureTime: Optional[datetime] = None
ArrivalStatus: TrainStatus = None
DepartureStatus: TrainStatus = None
AimedArrivalTime: datetime | None = None
ExpectedArrivalTime: datetime | None = None
ArrivalPlatformName: Value | None = None
AimedDepartureTime: datetime | None = None
ExpectedDepartureTime: datetime | None = None
ArrivalStatus: TrainStatus | None = None
DepartureStatus: TrainStatus | None = None
class MonitoredVehicleJourney(Struct, kw_only=True):
@@ -240,7 +263,7 @@ class MonitoredVehicleJourney(Struct, kw_only=True):
DestinationRef: Value
DestinationName: list[Value] | None = None
JourneyNote: list[Value] | None = None
TrainNumbers: Optional[TrainNumber] = None
TrainNumbers: TrainNumber | None = None
MonitoredCall: MonitoredCall

View File

@@ -0,0 +1,15 @@
from msgspec import Struct
class PictoFieldsFile(Struct, rename={"id_": "id"}):
id_: str
height: int
width: int
filename: str
thumbnail: bool
format: str
class Picto(Struct):
indices_commerciaux: str
noms_des_fichiers: PictoFieldsFile | None = None

89
backend/api/main.py Executable file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
import uvicorn
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi_cache import FastAPICache
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 db import db
from dependencies import idfm_interface, redis_backend, settings
from routers import line, stop
@asynccontextmanager
async def lifespan(app: FastAPI):
FastAPICache.init(redis_backend, prefix="api", enable=settings.cache.enable)
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(
CORSMiddleware,
allow_origins=["http://carrramba.adrien.run", "https://carrramba.adrien.run"],
allow_credentials=True,
allow_methods=["OPTIONS", "GET"],
allow_headers=["*"],
)
# The cache-control header entry is not managed properly by fastapi-cache:
# For now, a request with a cache-control set to no-cache
# is interpreted as disabling the use of the server cache.
# Cf. Improve Cache-Control header parsing and handling
# https://github.com/long2ice/fastapi-cache/issues/144 workaround
@app.middleware("http")
async def fastapi_cache_issue_144_workaround(request: Request, call_next):
entries = request.headers.__dict__["_list"]
new_entries = [
entry for entry in entries if entry[0].decode().lower() != "cache-control"
]
request.headers.__dict__["_list"] = new_entries
return await call_next(request)
app.include_router(line.router)
app.include_router(stop.router)
if settings.tracing.enable:
FastAPIInstrumentor.instrument_app(app)
trace.set_tracer_provider(
TracerProvider(resource=Resource.create({SERVICE_NAME: settings.app_name}))
)
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(OTLPSpanExporter())
)
tracer = trace.get_tracer(settings.app_name)
if __name__ == "__main__":
http_settings = settings.http
config = uvicorn.Config(
app=app,
host=http_settings.host,
port=http_settings.port,
ssl_certfile=http_settings.cert,
proxy_headers=True,
)
server = uvicorn.Server(config)
server.run()

View File

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

196
backend/api/models/line.py Normal file
View File

@@ -0,0 +1,196 @@
from asyncio import gather as asyncio_gather
from collections import defaultdict
from typing import Iterable, Self, Sequence
from sqlalchemy import (
BigInteger,
Boolean,
Enum,
ForeignKey,
Integer,
select,
String,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload
from sqlalchemy.sql.expression import tuple_
from db import Base, db
from idfm_interface.idfm_types import (
IdfmState,
IdfmLineState,
TransportMode,
TransportSubMode,
)
from .stop import _Stop
class LineStopAssociations(Base):
id = mapped_column(BigInteger, primary_key=True)
line_id = mapped_column(BigInteger, ForeignKey("lines.id"))
stop_id = mapped_column(BigInteger, ForeignKey("_stops.id"))
__tablename__ = "line_stop_associations"
class LinePicto(Base):
db = db
id = mapped_column(String, primary_key=True)
mime_type = mapped_column(String, nullable=False)
height_px = mapped_column(Integer, nullable=False)
width_px = mapped_column(Integer, nullable=False)
filename = mapped_column(String, nullable=False)
url = mapped_column(String, nullable=False)
thumbnail = mapped_column(Boolean, nullable=False)
format = mapped_column(String, nullable=False)
__tablename__ = "line_pictos"
class Line(Base):
db = db
id = mapped_column(BigInteger, primary_key=True)
short_name = mapped_column(String)
name = mapped_column(String, nullable=False)
status = mapped_column(Enum(IdfmLineState), nullable=False)
transport_mode = mapped_column(Enum(TransportMode), nullable=False)
transport_submode = mapped_column(Enum(TransportSubMode), nullable=False)
network_name = mapped_column(String)
group_of_lines_id = mapped_column(String)
group_of_lines_shortname = mapped_column(String)
colour_web_hexa = mapped_column(String, nullable=False)
text_colour_hexa = mapped_column(String, nullable=False)
operator_id = mapped_column(Integer)
operator_name = mapped_column(String)
accessibility = mapped_column(Enum(IdfmState), nullable=False)
visual_signs_available = mapped_column(Enum(IdfmState), nullable=False)
audible_signs_available = mapped_column(Enum(IdfmState), nullable=False)
picto_id = mapped_column(String, ForeignKey("line_pictos.id"))
picto: Mapped[LinePicto] = relationship(LinePicto, lazy="selectin")
record_id = mapped_column(String, nullable=False)
record_ts = mapped_column(BigInteger, nullable=False)
stops: Mapped[list[_Stop]] = relationship(
"_Stop",
secondary="line_stop_associations",
back_populates="lines",
lazy="selectin",
)
__tablename__ = "lines"
@classmethod
async def get_by_name(
cls, name: str, operator_name: None | str = None
) -> Sequence[Self] | None:
if (session := await cls.db.get_session()) is not None:
async with session.begin():
filters = {"name": name}
if operator_name is not None:
filters["operator_name"] = operator_name
stmt = (
select(cls)
.filter_by(**filters)
.options(selectinload(cls.stops), selectinload(cls.picto))
)
res = await session.execute(stmt)
lines = res.scalars().all()
return lines
return None
@classmethod
async def _add_picto_to_line(cls, line: str | Self, picto: LinePicto) -> None:
formatted_line: Self | None = None
if isinstance(line, str):
if (lines := await cls.get_by_name(line)) is not None:
if len(lines) == 1:
formatted_line = lines[0]
else:
for candidate_line in lines:
if candidate_line.operator_name == "RATP":
formatted_line = candidate_line
break
else:
formatted_line = line
if isinstance(formatted_line, Line) and formatted_line.picto is None:
formatted_line.picto = picto
formatted_line.picto_id = picto.id
@classmethod
async def add_pictos(cls, line_to_pictos: Iterable[tuple[str, LinePicto]]) -> bool:
if (session := await cls.db.get_session()) is not None:
async with session.begin():
await asyncio_gather(
*[
cls._add_picto_to_line(line, picto)
for line, picto in line_to_pictos
]
)
return True
return False
@classmethod
async def add_stops(cls, line_to_stop_ids: Iterable[tuple[str, str, int]]) -> int:
if (session := await cls.db.get_session()) is not None:
async with session.begin():
line_names_ops, stop_ids = set(), set()
for line_name, operator_name, stop_id in line_to_stop_ids:
line_names_ops.add((line_name, operator_name))
stop_ids.add(stop_id)
lines_res = await session.execute(
select(Line).where(
tuple_(Line.name, Line.operator_name).in_(line_names_ops)
)
)
lines = defaultdict(list)
for line in lines_res.scalars():
lines[(line.name, line.operator_name)].append(line)
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

275
backend/api/models/stop.py Normal file
View File

@@ -0,0 +1,275 @@
from __future__ import annotations
from logging import getLogger
from typing import Iterable, Sequence, TYPE_CHECKING
from sqlalchemy import (
BigInteger,
Computed,
desc,
Enum,
Float,
ForeignKey,
func,
Integer,
JSON,
select,
String,
)
from sqlalchemy.orm import (
mapped_column,
Mapped,
relationship,
selectinload,
with_polymorphic,
)
from sqlalchemy.schema import Index
from sqlalchemy_utils.types.ts_vector import TSVectorType
from db import Base, db
from idfm_interface.idfm_types import TransportMode, IdfmState, StopAreaType
if TYPE_CHECKING:
from .line import Line
logger = getLogger(__name__)
class StopAreaStopAssociations(Base):
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):
db = db
id = mapped_column(BigInteger, primary_key=True)
kind = mapped_column(String)
name = mapped_column(String, nullable=False, index=True)
town_name = mapped_column(String, nullable=False)
postal_region = mapped_column(Integer, nullable=False)
epsg3857_x = mapped_column(Float, nullable=False)
epsg3857_y = mapped_column(Float, nullable=False)
version = mapped_column(String, nullable=False)
created_ts = mapped_column(BigInteger)
changed_ts = mapped_column(BigInteger, nullable=False)
lines: Mapped[list[Line]] = relationship(
"Line",
secondary="line_stop_associations",
back_populates="stops",
lazy="selectin",
)
areas: Mapped[list["StopArea"]] = relationship(
"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"
__mapper_args__ = {"polymorphic_identity": "_stops", "polymorphic_on": kind}
__table_args__ = (
Index(
"names_tsv_idx",
names_tsv,
postgresql_ops={"name": "gin_trgm_ops"},
postgresql_using="gin",
),
)
@classmethod
async def get_by_name(cls, name: str) -> Sequence[_Stop] | None:
if (session := await cls.db.get_session()) is not None:
async with session.begin():
descendants = with_polymorphic(_Stop, "*")
match_stmt = descendants.names_tsv.match(
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))
)
res = await session.execute(stmt)
stops = res.scalars().all()
return stops
return None
class Stop(_Stop):
id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True)
transport_mode = mapped_column(Enum(TransportMode), nullable=False)
accessibility = mapped_column(Enum(IdfmState), nullable=False)
visual_signs_available = mapped_column(Enum(IdfmState), nullable=False)
audible_signs_available = mapped_column(Enum(IdfmState), nullable=False)
record_id = mapped_column(String, nullable=False)
record_ts = mapped_column(BigInteger, nullable=False)
__tablename__ = "stops"
__mapper_args__ = {"polymorphic_identity": "stops", "polymorphic_load": "inline"}
class StopArea(_Stop):
id = mapped_column(BigInteger, ForeignKey("_stops.id"), primary_key=True)
type = mapped_column(Enum(StopAreaType), nullable=False)
stops: Mapped[list["Stop"]] = relationship(
"Stop",
secondary="stop_area_stop_associations",
back_populates="areas",
lazy="selectin",
)
__tablename__ = "stop_areas"
__mapper_args__ = {
"polymorphic_identity": "stop_areas",
"polymorphic_load": "inline",
}
@classmethod
async def add_stops(
cls, stop_area_to_stop_ids: Iterable[tuple[int, int]]
) -> int | None:
if (session := await cls.db.get_session()) is not None:
async with session.begin():
stop_area_ids, stop_ids = set(), set()
for stop_area_id, stop_id in stop_area_to_stop_ids:
stop_area_ids.add(stop_area_id)
stop_ids.add(stop_id)
stop_areas_res = await session.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()
}
stop_res = await session.execute(
select(Stop).where(Stop.id.in_(stop_ids))
)
stops: dict[int, Stop] = {stop.id: stop for stop in stop_res.scalars()}
found = 0
for stop_area_id, stop_id in stop_area_to_stop_ids:
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 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

@@ -0,0 +1,27 @@
from sqlalchemy import BigInteger, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db import Base, db
from .stop import _Stop
class UserLastStopSearchStopAssociations(Base):
id = mapped_column(BigInteger, primary_key=True)
user_mxid = mapped_column(
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):
db = db
user_mxid = mapped_column(String, primary_key=True)
request_content = mapped_column(String, nullable=False)
stops: Mapped[_Stop] = relationship(
_Stop, secondary="user_last_stop_search_stop_associations"
)
__tablename__ = "user_last_stop_search_results"

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

View File

View File

@@ -0,0 +1,34 @@
from fastapi import APIRouter, HTTPException
from fastapi_cache.decorator import cache
from models import Line
from schemas import Line as LineSchema, TransportMode
router = APIRouter(prefix="/line", tags=["line"])
@router.get("/{line_id}", response_model=LineSchema)
@cache(namespace="line")
async def get_line(line_id: int) -> LineSchema:
line: Line | None = await Line.get_by_id(line_id)
if line is None:
raise HTTPException(status_code=404, detail=f'Line "{line_id}" not found')
return LineSchema(
id=line.id,
shortName=line.short_name,
name=line.name,
status=line.status,
transportMode=TransportMode.from_idfm_transport_mode(
line.transport_mode, line.transport_submode
),
backColorHexa=line.colour_web_hexa,
foreColorHexa=line.text_colour_hexa,
operatorId=line.operator_id,
accessibility=line.accessibility,
visualSignsAvailable=line.visual_signs_available,
audibleSignsAvailable=line.audible_signs_available,
stopIds=[stop.id for stop in line.stops],
)

176
backend/api/routers/stop.py Normal file
View File

@@ -0,0 +1,176 @@
from collections import defaultdict
from datetime import datetime
from typing import Sequence
from fastapi import APIRouter, HTTPException
from fastapi_cache.decorator import cache
from idfm_interface import Destinations as IdfmDestinations, TrainStatus
from models import Stop, StopArea, StopShape
from schemas import (
NextPassage as NextPassageSchema,
NextPassages as NextPassagesSchema,
Stop as StopSchema,
StopArea as StopAreaSchema,
StopShape as StopShapeSchema,
)
from dependencies import idfm_interface
router = APIRouter(prefix="/stop", tags=["stop"])
def _format_stop(stop: Stop) -> StopSchema:
return StopSchema(
id=stop.id,
name=stop.name,
town=stop.town_name,
epsg3857_x=stop.epsg3857_x,
epsg3857_y=stop.epsg3857_y,
lines=[line.id for line in stop.lines],
)
def optional_datetime_to_ts(dt: datetime | None) -> int | None:
return int(dt.timestamp()) if dt else None
# TODO: Add limit support
@router.get("/")
@cache(namespace="stop")
async def get_stop(
name: str = "", limit: int = 10
) -> Sequence[StopAreaSchema | StopSchema] | None:
matching_stops = await Stop.get_by_name(name)
if matching_stops is None:
return None
formatted: list[StopAreaSchema | StopSchema] = []
stop_areas: dict[int, StopArea] = {}
stops: dict[int, Stop] = {}
for stop in matching_stops:
if isinstance(stop, StopArea):
stop_areas[stop.id] = stop
elif isinstance(stop, Stop):
stops[stop.id] = stop
for stop_area in stop_areas.values():
formatted_stops = []
for stop in stop_area.stops:
formatted_stops.append(_format_stop(stop))
try:
del stops[stop.id]
except KeyError as err:
print(err)
formatted.append(
StopAreaSchema(
id=stop_area.id,
name=stop_area.name,
town=stop_area.town_name,
type=stop_area.type,
lines=[line.id for line in stop_area.lines],
stops=formatted_stops,
)
)
formatted.extend(_format_stop(stop) for stop in stops.values())
return formatted
@router.get("/{stop_id}/nextPassages")
@cache(namespace="stop-nextPassages", expire=30)
async def get_next_passages(stop_id: int) -> NextPassagesSchema | None:
res = await idfm_interface.get_next_passages(stop_id)
if res is None:
return None
service_delivery = res.Siri.ServiceDelivery
stop_monitoring_deliveries = service_delivery.StopMonitoringDelivery
by_line_by_dst_passages: dict[
int, dict[str, list[NextPassageSchema]]
] = defaultdict(lambda: defaultdict(list))
for delivery in stop_monitoring_deliveries:
for stop_visit in delivery.MonitoredStopVisit:
journey = stop_visit.MonitoredVehicleJourney
# re.match will return None if the given journey.LineRef.value is not valid.
try:
line_id_match = idfm_interface.LINE_RE.match(journey.LineRef.value)
line_id = int(line_id_match.group(1)) # type: ignore
except (AttributeError, TypeError, ValueError) as err:
raise HTTPException(
status_code=404, detail=f'Line "{journey.LineRef.value}" not found'
) from err
call = journey.MonitoredCall
dst_names = call.DestinationDisplay
dsts = [dst.value for dst in dst_names] if dst_names else []
arrivalPlatformName = (
call.ArrivalPlatformName.value if call.ArrivalPlatformName else None
)
next_passage = NextPassageSchema(
line=line_id,
operator=journey.OperatorRef.value,
destinations=dsts,
atStop=call.VehicleAtStop,
aimedArrivalTs=optional_datetime_to_ts(call.AimedArrivalTime),
expectedArrivalTs=optional_datetime_to_ts(call.ExpectedArrivalTime),
arrivalPlatformName=arrivalPlatformName,
aimedDepartTs=optional_datetime_to_ts(call.AimedDepartureTime),
expectedDepartTs=optional_datetime_to_ts(call.ExpectedDepartureTime),
arrivalStatus=call.ArrivalStatus
if call.ArrivalStatus is not None
else TrainStatus.unknown,
departStatus=call.DepartureStatus
if call.DepartureStatus is not None
else TrainStatus.unknown,
)
by_line_passages = by_line_by_dst_passages[line_id]
# TODO: by_line_passages[dst].extend(dsts) instead ?
for dst in dsts:
by_line_passages[dst].append(next_passage)
return NextPassagesSchema(
ts=int(service_delivery.ResponseTimestamp.timestamp()),
passages=by_line_by_dst_passages,
)
@router.get("/{stop_id}/destinations")
@cache(namespace="stop-destinations", expire=30)
async def get_stop_destinations(
stop_id: int,
) -> IdfmDestinations | None:
destinations = await idfm_interface.get_destinations(stop_id)
return destinations
@router.get("/{stop_id}/shape")
@cache(namespace="stop-shape")
async def get_stop_shape(stop_id: int) -> StopShapeSchema | None:
if (await Stop.get_by_id(stop_id)) is not None or (
await StopArea.get_by_id(stop_id)
) is not None:
shape_id = stop_id
if (shape := await StopShape.get_by_id(shape_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)

View File

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

View File

@@ -1,9 +1,8 @@
from enum import StrEnum
from typing import Self
from pydantic import BaseModel
from ..idfm_interface import (
from idfm_interface import (
IdfmLineState,
IdfmState,
TransportMode as IdfmTransportMode,
@@ -29,10 +28,11 @@ class TransportMode(StrEnum):
# idfm_types.TransportMode.rail + idfm_types.TransportSubMode.railShuttle
val = "val"
# Self return type replaced by "TransportMode" to fix following mypy error:
# Incompatible return value type (got "TransportMode", expected "Self")
# TODO: Is it the good fix ?
@classmethod
def from_idfm_transport_mode(
cls, mode: IdfmTransportMode, sub_mode: IdfmTransportSubMode
) -> Self:
def from_idfm_transport_mode(cls, mode: str, sub_mode: str) -> "TransportMode":
if mode == IdfmTransportMode.rail:
if sub_mode == IdfmTransportSubMode.regionalRail:
return cls.rail_ter
@@ -42,19 +42,19 @@ class TransportMode(StrEnum):
return cls.rail_transilien
if sub_mode == IdfmTransportSubMode.railShuttle:
return cls.val
return TransportMode(mode)
return cls(mode)
class Line(BaseModel):
id: str
id: int
shortName: str
name: str
status: IdfmLineState
transportMode: TransportMode
backColorHexa: str
foreColorHexa: str
operatorId: str
operatorId: int
accessibility: IdfmState
visualSignsAvailable: IdfmState
audibleSignsAvailable: IdfmState
stopIds: list[str]
stopIds: list[int]

View File

@@ -0,0 +1,22 @@
from pydantic import BaseModel
from idfm_interface.idfm_types import TrainStatus
class NextPassage(BaseModel):
line: int
operator: str
destinations: list[str]
atStop: bool
aimedArrivalTs: int | None
expectedArrivalTs: int | None
arrivalPlatformName: str | None
aimedDepartTs: int | None
expectedDepartTs: int | None
arrivalStatus: TrainStatus
departStatus: TrainStatus
class NextPassages(BaseModel):
ts: int
passages: dict[int, dict[str, list[NextPassage]]]

View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel
from idfm_interface import StopAreaType
class Stop(BaseModel):
id: int
name: str
town: str
epsg3857_x: float
epsg3857_y: float
lines: list[int]
class StopArea(BaseModel):
id: int
name: str
town: str
type: StopAreaType
lines: list[int] # SNCF lines are linked to stop areas and not stops.
stops: list[Stop]
Point = tuple[float, float]
class StopShape(BaseModel):
id: int
type: int
epsg3857_bbox: list[Point]
epsg3857_points: list[Point]

74
backend/api/settings.py Normal file
View File

@@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Annotated
from pydantic import BaseModel, SecretStr
from pydantic.functional_validators import model_validator
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
class HttpSettings(BaseModel):
host: str = "127.0.0.1"
port: int = 8080
cert: str | None = None
class DatabaseSettings(BaseModel):
name: str
host: str
port: int
driver: str = "postgresql+psycopg"
user: str
password: Annotated[SecretStr, check_user_password]
class CacheSettings(BaseModel):
enable: bool = False
host: str = "127.0.0.1"
port: int = 6379
user: str | None = None
password: Annotated[SecretStr | None, check_user_password] = None
@model_validator(mode="after")
def check_user_password(self) -> DatabaseSettings | CacheSettings:
if self.user is not None and self.password is None:
raise ValueError("user is set, password shall be set too.")
if self.password is not None and self.user is None:
raise ValueError("password is set, user shall be set too.")
return self
class TracingSettings(BaseModel):
enable: bool = False
class Settings(BaseSettings):
app_name: str
idfm_api_key: SecretStr
clear_static_data: bool
http: HttpSettings
db: DatabaseSettings
cache: CacheSettings
tracing: TracingSettings
model_config = SettingsConfigDict(env_prefix="CER__", env_nested_delimiter="__")
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
return env_settings, init_settings, file_secret_settings

575
backend/db_updater/fill_db.py Executable file
View File

@@ -0,0 +1,575 @@
#!/usr/bin/env python3
from asyncio import run, gather
from logging import getLogger, INFO, Handler as LoggingHandler, NOTSET
from itertools import islice
from time import time
from os import environ
from typing import Callable, Iterable, List, Type
from aiofiles.tempfile import NamedTemporaryFile
from aiohttp import ClientSession
from msgspec import ValidationError
from msgspec.json import Decoder
from pyproj import Transformer
from shapefile import Reader as ShapeFileReader, ShapeRecord # type: ignore
from tqdm import tqdm
from yaml import safe_load
from api.db import Base, db, Database
from api.models import ConnectionArea, Line, LinePicto, Stop, StopArea, StopShape
from api.idfm_interface.idfm_types import (
ConnectionArea as IdfmConnectionArea,
IdfmLineState,
Line as IdfmLine,
LinePicto as IdfmPicto,
IdfmState,
Stop as IdfmStop,
StopArea as IdfmStopArea,
StopAreaStopAssociation,
StopAreaType,
StopLineAsso as IdfmStopLineAsso,
TransportMode,
)
from api.idfm_interface.ratp_types import Picto as RatpPicto
from api.settings import Settings
CONFIG_PATH = environ.get("CONFIG_PATH", "./config.sample.yaml")
BATCH_SIZE = 1000
IDFM_ROOT_URL = "https://data.iledefrance-mobilites.fr/explore/dataset"
IDFM_CONNECTION_AREAS_URL = (
f"{IDFM_ROOT_URL}/zones-de-correspondance/download/?format=json"
)
IDFM_LINES_URL = f"{IDFM_ROOT_URL}/referentiel-des-lignes/download/?format=json"
IDFM_PICTO_URL = f"{IDFM_ROOT_URL}/referentiel-des-lignes/files"
IDFM_STOP_AREAS_URL = f"{IDFM_ROOT_URL}/zones-d-arrets/download/?format=json"
IDFM_STOP_SHAPES_URL = "https://eu.ftp.opendatasoft.com/stif/Reflex/REF_ArR.zip"
IDFM_STOP_AREA_SHAPES_URL = "https://eu.ftp.opendatasoft.com/stif/Reflex/REF_ZdA.zip"
IDFM_STOP_STOP_AREAS_ASSOS_URL = f"{IDFM_ROOT_URL}/relations/download/?format=json"
IDFM_STOPS_LINES_ASSOS_URL = f"{IDFM_ROOT_URL}/arrets-lignes/download/?format=json"
IDFM_STOPS_URL = f"{IDFM_ROOT_URL}/arrets/download/?format=json"
RATP_ROOT_URL = "https://data.ratp.fr/api/explore/v2.1/catalog/datasets"
RATP_PICTOS_URL = (
f"{RATP_ROOT_URL}"
"/pictogrammes-des-lignes-de-metro-rer-tramway-bus-et-noctilien/exports/json?lang=fr"
)
# From https://stackoverflow.com/a/38739634
class TqdmLoggingHandler(LoggingHandler):
def __init__(self, level=NOTSET):
super().__init__(level)
def emit(self, record):
try:
msg = self.format(record)
tqdm.write(msg)
self.flush()
except Exception:
self.handleError(record)
logger = getLogger(__name__)
logger.setLevel(INFO)
logger.addHandler(TqdmLoggingHandler())
epsg2154_epsg3857_transformer = Transformer.from_crs(2154, 3857)
json_stops_decoder = Decoder(type=List[IdfmStop])
json_stop_areas_decoder = Decoder(type=List[IdfmStopArea])
json_connection_areas_decoder = Decoder(type=List[IdfmConnectionArea])
json_lines_decoder = Decoder(type=List[IdfmLine])
json_stops_lines_assos_decoder = Decoder(type=List[IdfmStopLineAsso])
json_ratp_pictos_decoder = Decoder(type=List[RatpPicto])
json_stop_area_stop_asso_decoder = Decoder(type=List[StopAreaStopAssociation])
def format_idfm_pictos(*pictos: IdfmPicto) -> Iterable[LinePicto]:
ret = []
for picto in pictos:
ret.append(
LinePicto(
id=picto.id_,
mime_type=picto.mimetype,
height_px=picto.height,
width_px=picto.width,
filename=picto.filename,
url=f"{IDFM_PICTO_URL}/{picto.id_}/download",
thumbnail=picto.thumbnail,
format=picto.format,
)
)
return ret
def format_ratp_pictos(*pictos: RatpPicto) -> Iterable[tuple[str, LinePicto]]:
ret = []
for picto in pictos:
if (fields := picto.noms_des_fichiers) is not None:
ret.append(
(
picto.indices_commerciaux,
LinePicto(
id=fields.id_,
mime_type=f"image/{fields.format.lower()}",
height_px=fields.height,
width_px=fields.width,
filename=fields.filename,
url=f"{RATP_PICTOS_URL}/{fields.id_}/download",
thumbnail=fields.thumbnail,
format=fields.format,
),
)
)
return ret
def format_idfm_lines(*lines: IdfmLine) -> Iterable[Line]:
ret = []
optional_value = IdfmLine.optional_value
for line in lines:
fields = line.fields
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(
Line(
id=formatted_line_id,
short_name=fields.shortname_line,
name=fields.name_line,
status=IdfmLineState(fields.status.value),
transport_mode=TransportMode(fields.transportmode.value),
transport_submode=optional_value(fields.transportsubmode),
network_name=optional_value(fields.networkname),
group_of_lines_id=optional_value(fields.id_groupoflines),
group_of_lines_shortname=optional_value(fields.shortname_groupoflines),
colour_web_hexa=fields.colourweb_hexa,
text_colour_hexa=fields.textcolourprint_hexa,
operator_id=operator_id,
operator_name=optional_value(fields.operatorname),
accessibility=IdfmState(fields.accessibility.value),
visual_signs_available=IdfmState(fields.visualsigns_available.value),
audible_signs_available=IdfmState(fields.audiblesigns_available.value),
picto_id=fields.picto.id_ if fields.picto is not None else None,
record_id=line.recordid,
record_ts=int(line.record_timestamp.timestamp()),
)
)
return ret
def format_idfm_stops(*stops: IdfmStop) -> Iterable[Stop]:
for stop in stops:
fields = stop.fields
try:
created_ts = int(fields.arrcreated.timestamp()) # type: ignore
except AttributeError:
created_ts = None
epsg3857_point = 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(
id=int(fields.arrid),
name=fields.arrname,
epsg3857_x=epsg3857_point[0],
epsg3857_y=epsg3857_point[1],
town_name=fields.arrtown,
postal_region=postal_region,
transport_mode=TransportMode(fields.arrtype.value),
version=fields.arrversion,
created_ts=created_ts,
changed_ts=int(fields.arrchanged.timestamp()),
accessibility=IdfmState(fields.arraccessibility.value),
visual_signs_available=IdfmState(fields.arrvisualsigns.value),
audible_signs_available=IdfmState(fields.arraudiblesignals.value),
record_id=stop.recordid,
record_ts=int(stop.record_timestamp.timestamp()),
)
def format_idfm_stop_areas(*stop_areas: IdfmStopArea) -> Iterable[StopArea]:
for stop_area in stop_areas:
fields = stop_area.fields
try:
created_ts = int(fields.zdacreated.timestamp()) # type: ignore
except AttributeError:
created_ts = None
epsg3857_point = epsg2154_epsg3857_transformer.transform(
fields.zdaxepsg2154, fields.zdayepsg2154
)
yield StopArea(
id=int(fields.zdaid),
name=fields.zdaname,
town_name=fields.zdatown,
postal_region=fields.zdapostalregion,
epsg3857_x=epsg3857_point[0],
epsg3857_y=epsg3857_point[1],
type=StopAreaType(fields.zdatype.value),
version=fields.zdaversion,
created_ts=created_ts,
changed_ts=int(fields.zdachanged.timestamp()),
)
def format_idfm_connection_areas(
*connection_areas: IdfmConnectionArea,
) -> Iterable[ConnectionArea]:
for connection_area in connection_areas:
fields = connection_area.fields
epsg3857_point = epsg2154_epsg3857_transformer.transform(
fields.zdcxepsg2154, fields.zdcyepsg2154
)
yield ConnectionArea(
id=int(fields.zdcid),
name=fields.zdcname,
town_name=fields.zdctown,
postal_region=fields.zdcpostalregion,
epsg3857_x=epsg3857_point[0],
epsg3857_y=epsg3857_point[1],
transport_mode=StopAreaType(fields.zdctype.value),
version=fields.zdcversion,
created_ts=int(fields.zdccreated.timestamp()),
changed_ts=int(fields.zdcchanged.timestamp()),
)
def format_idfm_stop_shapes(*shape_records: ShapeRecord) -> Iterable[StopShape]:
for shape_record in shape_records:
epsg3857_points = [
epsg2154_epsg3857_transformer.transform(*point)
for point in shape_record.shape.points
]
try:
bbox_it = iter(shape_record.shape.bbox)
epsg3857_bbox = [
epsg2154_epsg3857_transformer.transform(*point)
for point in zip(bbox_it, bbox_it)
]
except AttributeError:
# Handle stop shapes for which no bbox is provided
epsg3857_bbox = []
yield StopShape(
id=shape_record.record[1],
type=shape_record.shape.shapeType,
epsg3857_bbox=epsg3857_bbox,
epsg3857_points=epsg3857_points,
)
async def http_get(url: str) -> str | None:
chunks = []
headers = {"Accept": "application/json"}
async with ClientSession(headers=headers) as session:
async with session.get(url) as response:
size = int(response.headers.get("content-length", 0)) or None
progress_bar = tqdm(desc=f"Downloading {url}", total=size)
if response.status == 200:
async for chunk in response.content.iter_chunked(1024 * 1024):
chunks.append(chunk.decode())
progress_bar.update(len(chunk))
else:
return None
return "".join(chunks)
async def http_request(
url: str, decode: Callable, format_method: Callable, model: Type[Base]
) -> bool:
elements = []
data = await http_get(url)
if data is None:
return False
try:
for element in decode(data):
elements.append(element)
if len(elements) == BATCH_SIZE:
await model.add(format_method(*elements))
elements.clear()
if elements:
await model.add(format_method(*elements))
except ValidationError as err:
logger.warning(err)
return False
return True
async def load_idfm_stops() -> bool:
return await http_request(
IDFM_STOPS_URL, json_stops_decoder.decode, format_idfm_stops, Stop
)
async def load_idfm_stop_areas() -> bool:
return await http_request(
IDFM_STOP_AREAS_URL,
json_stop_areas_decoder.decode,
format_idfm_stop_areas,
StopArea,
)
async def load_idfm_connection_areas() -> bool:
return await http_request(
IDFM_CONNECTION_AREAS_URL,
json_connection_areas_decoder.decode,
format_idfm_connection_areas,
ConnectionArea,
)
async def load_idfm_stop_shapes(url: str) -> None:
async with ClientSession(headers={"Accept": "application/zip"}) as session:
async with session.get(url) as response:
size = int(response.headers.get("content-length", 0)) or None
dl_progress_bar = tqdm(desc=f"Downloading {url}", total=size)
if response.status == 200:
async with NamedTemporaryFile(suffix=".zip") as tmp_file:
async for chunk in response.content.iter_chunked(1024 * 1024):
await tmp_file.write(chunk)
dl_progress_bar.update(len(chunk))
with ShapeFileReader(tmp_file.name) as reader:
step_begin_ts = time()
shapes = reader.shapeRecords()
shapes_len = len(shapes)
db_progress_bar = tqdm(
desc=f"Filling db with {shapes_len} StopShapes",
total=shapes_len,
)
begin, end, finished = 0, BATCH_SIZE, False
while not finished:
elements = islice(shapes, begin, end)
formatteds = list(format_idfm_stop_shapes(*elements))
await StopShape.add(formatteds)
begin = end
end = begin + BATCH_SIZE
finished = begin > len(shapes)
db_progress_bar.update(BATCH_SIZE)
logger.info(
f"Add {StopShape.__name__}s: {time() - step_begin_ts}s"
)
async def load_idfm_lines() -> None:
data = await http_get(IDFM_LINES_URL)
if data is None:
return None
lines, pictos = [], []
picto_ids = set()
for line in json_lines_decoder.decode(data):
if (picto := line.fields.picto) is not None and picto.id_ not in picto_ids:
picto_ids.add(picto.id_)
pictos.append(picto)
lines.append(line)
if len(lines) == BATCH_SIZE:
await LinePicto.add(list(format_idfm_pictos(*pictos)))
await Line.add(list(format_idfm_lines(*lines)))
lines.clear()
pictos.clear()
if pictos:
await LinePicto.add(list(format_idfm_pictos(*pictos)))
if lines:
await Line.add(list(format_idfm_lines(*lines)))
async def load_ratp_pictos(batch_size: int = 5) -> None:
data = await http_get(RATP_PICTOS_URL)
if data is None:
return None
pictos = []
for picto in json_ratp_pictos_decoder.decode(data):
pictos.append(picto)
if len(pictos) == batch_size:
formatteds = format_ratp_pictos(*pictos)
await LinePicto.add([picto[1] for picto in formatteds])
await Line.add_pictos(formatteds)
pictos.clear()
if pictos:
formatteds = format_ratp_pictos(*pictos)
await LinePicto.add([picto[1] for picto in formatteds])
await Line.add_pictos(formatteds)
async def load_lines_stops_assos(batch_size: int = 5000) -> None:
data = await http_get(IDFM_STOPS_LINES_ASSOS_URL)
if data is None:
return None
total_assos_nb = total_found_nb = 0
assos = []
for asso in json_stops_lines_assos_decoder.decode(data):
fields = asso.fields
try:
stop_id = int(fields.stop_id.rsplit(":", 1)[-1])
except ValueError as err:
logger.error(err)
logger.error(f"{fields.stop_id = }")
continue
assos.append((fields.route_long_name, fields.operatorname, stop_id))
if len(assos) == batch_size:
total_assos_nb += batch_size
total_found_nb += await Line.add_stops(assos)
assos.clear()
if assos:
total_assos_nb += len(assos)
total_found_nb += await Line.add_stops(assos)
logger.info(f"{total_found_nb} line <-> stop ({total_assos_nb = } found)")
async def load_stop_assos(batch_size: int = 5000) -> None:
data = await http_get(IDFM_STOP_STOP_AREAS_ASSOS_URL)
if data is None:
return None
total_assos_nb = area_stop_assos_nb = conn_stop_assos_nb = 0
area_stop_assos = []
connection_stop_assos = []
for asso in json_stop_area_stop_asso_decoder.decode(data):
fields = asso.fields
stop_id = int(fields.arrid)
area_stop_assos.append((int(fields.zdaid), stop_id))
connection_stop_assos.append((int(fields.zdcid), stop_id))
if len(area_stop_assos) == batch_size:
total_assos_nb += batch_size
if (found_nb := await StopArea.add_stops(area_stop_assos)) is not None:
area_stop_assos_nb += found_nb
area_stop_assos.clear()
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
logger.info(f"{area_stop_assos_nb} stop area <-> stop ({total_assos_nb = } found)")
logger.info(f"{conn_stop_assos_nb} stop area <-> stop ({total_assos_nb = } found)")
async def prepare(db: Database) -> None:
await load_idfm_lines()
await gather(
*(
load_idfm_stops(),
load_idfm_stop_areas(),
load_idfm_connection_areas(),
load_ratp_pictos(),
)
)
await gather(
*(
load_idfm_stop_shapes(IDFM_STOP_SHAPES_URL),
load_idfm_stop_shapes(IDFM_STOP_AREA_SHAPES_URL),
load_lines_stops_assos(),
load_stop_assos(),
)
)
def load_settings(path: str) -> Settings:
with open(path, "r") as config_file:
config = safe_load(config_file)
return Settings(**config)
async def main() -> None:
settings = load_settings(CONFIG_PATH)
await db.connect(settings.db, True)
begin_ts = time()
await prepare(db)
logger.info(f"Elapsed time: {time() - begin_ts}s")
await db.disconnect()
if __name__ == "__main__":
run(main())

View File

@@ -1,19 +0,0 @@
version: '3.7'
services:
database:
image: postgres:15.1-alpine
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
logging:
options:
max-size: 10m
max-file: "3"
ports:
- '127.0.0.1:5438:5432'
volumes:
- ./docker/database/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
- ./docker/database/data:/var/lib/postgresql/data

View File

@@ -2,10 +2,10 @@
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE USER idfm_matrix_bot;
CREATE DATABASE bot;
CREATE DATABASE idfm;
GRANT ALL PRIVILEGES ON DATABASE bot TO idfm_matrix_bot;
GRANT ALL PRIVILEGES ON DATABASE idfm TO idfm_matrix_bot;
CREATE USER cer_user WITH ENCRYPTED password 'cer_password';
CREATE DATABASE cer_db;
\c cer_db;
CREATE EXTENSION IF NOT EXISTS pg_trgm SCHEMA "public";
GRANT CREATE ON SCHEMA public to cer_user;
EOSQL

View File

@@ -1,34 +0,0 @@
from collections.abc import Iterable
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import declarative_base
from typing import Iterable, Self
Base = declarative_base()
Base.db = None
async def base_add(cls, stops: Self | Iterable[Self]) -> bool:
try:
method = (
cls.db.session.add_all
if isinstance(stops, Iterable)
else cls.db.session.add
)
method(stops)
await cls.db.session.commit()
except IntegrityError as err:
print(err)
Base.add = classmethod(base_add)
async def base_get_by_id(cls, id_: int | str) -> None | Base:
res = await cls.db.session.execute(select(cls).where(cls.id == id_))
element = res.scalar_one_or_none()
return element
Base.get_by_id = classmethod(base_get_by_id)

View File

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

View File

@@ -1,2 +0,0 @@
from .idfm_interface import IdfmInterface
from .idfm_types import *

View File

@@ -1,447 +0,0 @@
from pathlib import Path
from re import compile as re_compile
from time import time
from typing import ByteString, Iterable, List, Optional
from aiofiles import open as async_open
from aiohttp import ClientSession
from msgspec import ValidationError
from msgspec.json import Decoder
from rich import print
from ..db import Database
from ..models import Line, LinePicto, Stop, StopArea
from .idfm_types import (
IdfmLineState,
IdfmResponse,
Line as IdfmLine,
MonitoredVehicleJourney,
LinePicto as IdfmPicto,
IdfmState,
Stop as IdfmStop,
StopArea as IdfmStopArea,
StopAreaStopAssociation,
StopLineAsso as IdfmStopLineAsso,
Stops,
)
from .ratp_types import Picto as RatpPicto
class IdfmInterface:
IDFM_ROOT_URL = "https://prim.iledefrance-mobilites.fr/marketplace"
IDFM_STOP_MON_URL = f"{IDFM_ROOT_URL}/stop-monitoring"
IDFM_ROOT_URL = "https://data.iledefrance-mobilites.fr/explore/dataset"
IDFM_STOPS_URL = (
f"{IDFM_ROOT_URL}/arrets/download/?format=json&timezone=Europe/Berlin"
)
IDFM_PICTO_URL = f"{IDFM_ROOT_URL}/referentiel-des-lignes/files"
RATP_ROOT_URL = "https://data.ratp.fr/explore/dataset"
RATP_PICTO_URL = f"{RATP_ROOT_URL}/pictogrammes-des-lignes-de-metro-rer-tramway-bus-et-noctilien/files"
OPERATOR_RE = re_compile(r"[^:]+:Operator::([^:]+):")
LINE_RE = re_compile(r"[^:]+:Line::([^:]+):")
def __init__(self, api_key: str, database: Database) -> None:
self._api_key = api_key
self._database = database
self._http_headers = {"Accept": "application/json", "apikey": self._api_key}
self._json_stops_decoder = Decoder(type=List[IdfmStop])
self._json_stop_areas_decoder = Decoder(type=List[IdfmStopArea])
self._json_lines_decoder = Decoder(type=List[IdfmLine])
self._json_stops_lines_assos_decoder = Decoder(type=List[IdfmStopLineAsso])
self._json_ratp_pictos_decoder = Decoder(type=List[RatpPicto])
self._json_stop_area_stop_asso_decoder = Decoder(
type=List[StopAreaStopAssociation]
)
self._response_json_decoder = Decoder(type=IdfmResponse)
async def startup(self) -> None:
BATCH_SIZE = 10000
STEPS = (
(
StopArea,
self._request_idfm_stop_areas,
IdfmInterface._format_idfm_stop_areas,
),
(Stop, self._request_idfm_stops, IdfmInterface._format_idfm_stops),
)
for model, get_method, format_method in STEPS:
step_begin_ts = time()
elements = []
async for element in get_method():
elements.append(element)
if len(elements) == BATCH_SIZE:
await model.add(format_method(*elements))
elements.clear()
if elements:
await model.add(format_method(*elements))
print(f"Add {model.__name__}s: {time() - step_begin_ts}s")
begin_ts = time()
await self._load_lines()
print(f"Add Lines and IDFM LinePictos: {time() - begin_ts}s")
begin_ts = time()
await self._load_ratp_pictos(30)
print(f"Add RATP LinePictos: {time() - begin_ts}s")
begin_ts = time()
await self._load_lines_stops_assos()
print(f"Link Stops to Lines: {time() - begin_ts}s")
begin_ts = time()
await self._load_stop_areas_stops_assos()
print(f"Link Stops to StopAreas: {time() - begin_ts}s")
async def _load_lines(self, batch_size: int = 5000) -> None:
lines, pictos = [], []
picto_ids = set()
async for line in self._request_idfm_lines():
if (picto := line.fields.picto) is not None and picto.id_ not in picto_ids:
picto_ids.add(picto.id_)
pictos.append(picto)
lines.append(line)
if len(lines) == batch_size:
await LinePicto.add(IdfmInterface._format_idfm_pictos(*pictos))
await Line.add(await self._format_idfm_lines(*lines))
lines.clear()
pictos.clear()
if pictos:
await LinePicto.add(IdfmInterface._format_idfm_pictos(*pictos))
if lines:
await Line.add(await self._format_idfm_lines(*lines))
async def _load_ratp_pictos(self, batch_size: int = 5) -> None:
pictos = []
async for picto in self._request_ratp_pictos():
pictos.append(picto)
if len(pictos) == batch_size:
formatted_pictos = IdfmInterface._format_ratp_pictos(*pictos)
await LinePicto.add(formatted_pictos.values())
await Line.add_pictos(formatted_pictos)
pictos.clear()
if pictos:
formatted_pictos = IdfmInterface._format_ratp_pictos(*pictos)
await LinePicto.add(formatted_pictos.values())
await Line.add_pictos(formatted_pictos)
async def _load_lines_stops_assos(self, batch_size: int = 5000) -> None:
total_assos_nb = total_found_nb = 0
assos = []
async for asso in self._request_idfm_stops_lines_associations():
fields = asso.fields
try:
stop_id = int(fields.stop_id.rsplit(":", 1)[-1])
except ValueError as err:
print(err)
print(f"{fields.stop_id = }")
continue
assos.append((fields.route_long_name, fields.operatorname, stop_id))
if len(assos) == batch_size:
total_assos_nb += batch_size
total_found_nb += await Line.add_stops(assos)
assos.clear()
if assos:
total_assos_nb += len(assos)
total_found_nb += await Line.add_stops(assos)
print(f"{total_found_nb} line <-> stop ({total_assos_nb = } found)")
async def _load_stop_areas_stops_assos(self, batch_size: int = 5000) -> None:
total_assos_nb = total_found_nb = 0
assos = []
async for asso in self._request_idfm_stop_area_stop_associations():
fields = asso.fields
assos.append((int(fields.zdaid), int(fields.arrid)))
if len(assos) == batch_size:
total_assos_nb += batch_size
total_found_nb += await StopArea.add_stops(assos)
assos.clear()
if assos:
total_assos_nb += len(assos)
total_found_nb += await StopArea.add_stops(assos)
print(f"{total_found_nb} stop area <-> stop ({total_assos_nb = } found)")
async def _request_idfm_stops(self):
# headers = {"Accept": "application/json", "apikey": self._api_key}
# async with ClientSession(headers=headers) as session:
# async with session.get(self.STOPS_URL) as response:
# # print("Status:", response.status)
# if response.status == 200:
# for point in self._json_stops_decoder.decode(await response.read()):
# yield point
# TODO: Use HTTP
async with async_open("./tests/datasets/stops_dataset.json", "rb") as raw:
for element in self._json_stops_decoder.decode(await raw.read()):
yield element
async def _request_idfm_stop_areas(self):
# TODO: Use HTTP
async with async_open("./tests/datasets/zones-d-arrets.json", "rb") as raw:
for element in self._json_stop_areas_decoder.decode(await raw.read()):
yield element
async def _request_idfm_lines(self):
# TODO: Use HTTP
async with async_open("./tests/datasets/lines_dataset.json", "rb") as raw:
for element in self._json_lines_decoder.decode(await raw.read()):
yield element
async def _request_idfm_stops_lines_associations(self):
# TODO: Use HTTP
async with async_open("./tests/datasets/arrets-lignes.json", "rb") as raw:
for element in self._json_stops_lines_assos_decoder.decode(
await raw.read()
):
yield element
async def _request_idfm_stop_area_stop_associations(self):
# TODO: Use HTTP
async with async_open("./tests/datasets/relations.json", "rb") as raw:
for element in self._json_stop_area_stop_asso_decoder.decode(
await raw.read()
):
yield element
async def _request_ratp_pictos(self):
# TODO: Use HTTP
async with async_open(
"./tests/datasets/pictogrammes-des-lignes-de-metro-rer-tramway-bus-et-noctilien.json",
"rb",
) as fd:
for element in self._json_ratp_pictos_decoder.decode(await fd.read()):
yield element
@classmethod
def _format_idfm_pictos(cls, *pictos: IdfmPicto) -> Iterable[LinePicto]:
ret = []
for picto in pictos:
ret.append(
LinePicto(
id=picto.id_,
mime_type=picto.mimetype,
height_px=picto.height,
width_px=picto.width,
filename=picto.filename,
url=f"{cls.IDFM_PICTO_URL}/{picto.id_}/download",
thumbnail=picto.thumbnail,
format=picto.format,
)
)
return ret
@classmethod
def _format_ratp_pictos(cls, *pictos: RatpPicto) -> dict[str, None | LinePicto]:
ret = {}
for picto in pictos:
if (fields := picto.fields.noms_des_fichiers) is not None:
ret[picto.fields.indices_commerciaux] = LinePicto(
id=fields.id_,
mime_type=f"image/{fields.format.lower()}",
height_px=fields.height,
width_px=fields.width,
filename=fields.filename,
url=f"{cls.RATP_PICTO_URL}/{fields.id_}/download",
thumbnail=fields.thumbnail,
format=fields.format,
)
return ret
async def _format_idfm_lines(self, *lines: IdfmLine) -> Iterable[Line]:
ret = []
optional_value = IdfmLine.optional_value
for line in lines:
fields = line.fields
picto_id = fields.picto.id_ if fields.picto is not None else None
picto = await LinePicto.get_by_id(picto_id) if picto_id else None
ret.append(
Line(
id=fields.id_line,
short_name=fields.shortname_line,
name=fields.name_line,
status=IdfmLineState(fields.status.value),
transport_mode=fields.transportmode.value,
transport_submode=optional_value(fields.transportsubmode),
network_name=optional_value(fields.networkname),
group_of_lines_id=optional_value(fields.id_groupoflines),
group_of_lines_shortname=optional_value(
fields.shortname_groupoflines
),
colour_web_hexa=fields.colourweb_hexa,
text_colour_hexa=fields.textcolourprint_hexa,
operator_id=optional_value(fields.operatorref),
operator_name=optional_value(fields.operatorname),
accessibility=fields.accessibility.value,
visual_signs_available=fields.visualsigns_available.value,
audible_signs_available=fields.audiblesigns_available.value,
picto_id=fields.picto.id_ if fields.picto is not None else None,
picto=picto,
record_id=line.recordid,
record_ts=int(line.record_timestamp.timestamp()),
)
)
return ret
@staticmethod
def _format_idfm_stops(*stops: IdfmStop) -> Iterable[Stop]:
for stop in stops:
fields = stop.fields
try:
created_ts = int(fields.arrcreated.timestamp())
except AttributeError:
created_ts = None
yield Stop(
id=int(fields.arrid),
name=fields.arrname,
latitude=fields.arrgeopoint.lat,
longitude=fields.arrgeopoint.lon,
town_name=fields.arrtown,
postal_region=fields.arrpostalregion,
xepsg2154=fields.arrxepsg2154,
yepsg2154=fields.arryepsg2154,
transport_mode=fields.arrtype.value,
version=fields.arrversion,
created_ts=created_ts,
changed_ts=int(fields.arrchanged.timestamp()),
accessibility=fields.arraccessibility.value,
visual_signs_available=fields.arrvisualsigns.value,
audible_signs_available=fields.arraudiblesignals.value,
record_id=stop.recordid,
record_ts=int(stop.record_timestamp.timestamp()),
)
@staticmethod
def _format_idfm_stop_areas(*stop_areas: IdfmStopArea) -> Iterable[StopArea]:
for stop_area in stop_areas:
fields = stop_area.fields
try:
created_ts = int(fields.arrcreated.timestamp())
except AttributeError:
created_ts = None
yield StopArea(
id=int(fields.zdaid),
name=fields.zdaname,
town_name=fields.zdatown,
postal_region=fields.zdapostalregion,
xepsg2154=fields.zdaxepsg2154,
yepsg2154=fields.zdayepsg2154,
type=fields.zdatype.value,
version=fields.zdaversion,
created_ts=created_ts,
changed_ts=int(fields.zdachanged.timestamp()),
)
async def render_line_picto(self, line: Line) -> tuple[None | str, None | str]:
begin_ts = time()
line_picto_path = line_picto_format = None
target = f"/tmp/{line.id}_repr"
picto = line.picto
if picto is not None:
picto_data = await self._get_line_picto(line)
async with async_open(target, "wb") as fd:
await fd.write(picto_data)
line_picto_path = target
line_picto_format = picto.mime_type
print(f"render_line_picto: {time() - begin_ts}")
return (line_picto_path, line_picto_format)
async def _get_line_picto(self, line: Line) -> Optional[ByteString]:
print("---------------------------------------------------------------------")
begin_ts = time()
data = None
picto = line.picto
if picto is not None:
headers = (
self._http_headers if picto.url.startswith(self.IDFM_ROOT_URL) else None
)
session_begin_ts = time()
async with ClientSession(headers=headers) as session:
session_creation_ts = time()
print(f"Session creation {session_creation_ts - session_begin_ts}")
async with session.get(picto.url) as response:
get_end_ts = time()
print(f"GET {get_end_ts - session_creation_ts}")
data = await response.read()
print(f"read {time() - get_end_ts}")
print(f"render_line_picto: {time() - begin_ts}")
print("---------------------------------------------------------------------")
return data
async def get_next_passages(self, stop_point_id: str) -> Optional[IdfmResponse]:
# print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
begin_ts = time()
ret = None
params = {"MonitoringRef": f"STIF:StopPoint:Q:{stop_point_id}:"}
session_begin_ts = time()
async with ClientSession(headers=self._http_headers) as session:
session_creation_ts = time()
# print(f"Session creation {session_creation_ts - session_begin_ts}")
async with session.get(self.IDFM_STOP_MON_URL, params=params) as response:
get_end_ts = time()
# print(f"GET {get_end_ts - session_creation_ts}")
if response.status == 200:
get_end_ts = time()
# print(f"GET {get_end_ts - session_creation_ts}")
data = await response.read()
# print(data)
try:
ret = self._response_json_decoder.decode(data)
except ValidationError as err:
print(err)
# print(f"read {time() - get_end_ts}")
# print(f"get_next_passages: {time() - begin_ts}")
# print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
return ret
async def get_destinations(self, stop_point_id: str) -> Iterable[str]:
# TODO: Store in database the destination for the given stop and line id.
begin_ts = time()
destinations: dict[str, str] = {}
if (res := await self.get_next_passages(stop_point_id)) is not None:
for delivery in res.Siri.ServiceDelivery.StopMonitoringDelivery:
if delivery.Status == IdfmState.true:
for stop_visit in delivery.MonitoredStopVisit:
journey = stop_visit.MonitoredVehicleJourney
if (destination_name := journey.DestinationName) and (
line_ref := journey.LineRef
):
line_id = line_ref.value.replace("STIF:Line::", "")[:-1]
print(f"{line_id = }")
destinations[line_id] = destination_name[0].value
print(f"get_next_passages: {time() - begin_ts}")
return destinations

View File

@@ -1,25 +0,0 @@
from datetime import datetime
from typing import Optional
from msgspec import Struct
class PictoFieldsFile(Struct, rename={"id_": "id"}):
id_: str
height: int
width: int
filename: str
thumbnail: bool
format: str
class PictoFields(Struct):
indices_commerciaux: str
noms_des_fichiers: Optional[PictoFieldsFile] = None
class Picto(Struct):
datasetid: str
recordid: str
fields: PictoFields
record_timestamp: datetime

View File

@@ -1,3 +0,0 @@
from .line import Line, LinePicto
from .stop import Stop, StopArea
from .user import UserLastStopSearchResults

View File

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

View File

@@ -1,144 +0,0 @@
from typing import Iterable, Self
from sqlalchemy import (
BigInteger,
Column,
Enum,
Float,
ForeignKey,
select,
String,
Table,
)
from sqlalchemy.orm import Mapped, relationship, selectinload, with_polymorphic
from sqlalchemy.schema import Index
from ..db import Base, db
from ..idfm_interface.idfm_types import TransportMode, IdfmState, StopAreaType
stop_area_stop_association_table = Table(
"stop_area_stop_association_table",
Base.metadata,
Column("stop_id", ForeignKey("_stops.id")),
Column("stop_area_id", ForeignKey("stop_areas.id")),
)
class _Stop(Base):
db = db
id = Column(BigInteger, primary_key=True)
kind = Column(String)
name = Column(String, nullable=False, index=True)
town_name = Column(String, nullable=False)
postal_region = Column(String, nullable=False)
xepsg2154 = Column(BigInteger, nullable=False)
yepsg2154 = Column(BigInteger, nullable=False)
version = Column(String, nullable=False)
created_ts = Column(BigInteger)
changed_ts = Column(BigInteger, nullable=False)
lines: Mapped[list["Line"]] = relationship(
"Line",
secondary="line_stop_association_table",
back_populates="stops",
# lazy="joined",
lazy="selectin",
)
areas: Mapped[list["StopArea"]] = relationship(
"StopArea", secondary=stop_area_stop_association_table, back_populates="stops"
)
__tablename__ = "_stops"
__mapper_args__ = {"polymorphic_identity": "_stops", "polymorphic_on": kind}
__table_args__ = (
# To optimize the ilike requests
Index(
"name_idx_gin",
name,
postgresql_ops={"name": "gin_trgm_ops"},
postgresql_using="gin",
),
)
# TODO: Test https://www.cybertec-postgresql.com/en/postgresql-more-performance-for-like-and-ilike-statements/
# TODO: Should be able to remove with_polymorphic ?
@classmethod
async def get_by_name(cls, name: str) -> list[Self]:
stop_stop_area = with_polymorphic(_Stop, [Stop, StopArea])
stmt = (
select(stop_stop_area)
.where(stop_stop_area.name.ilike(f"%{name}%"))
.options(
selectinload(stop_stop_area.areas),
selectinload(stop_stop_area.lines),
)
)
res = await cls.db.session.execute(stmt)
return res.scalars()
class Stop(_Stop):
id = Column(BigInteger, ForeignKey("_stops.id"), primary_key=True)
latitude = Column(Float, nullable=False)
longitude = Column(Float, nullable=False)
transport_mode = Column(Enum(TransportMode), nullable=False)
accessibility = Column(Enum(IdfmState), nullable=False)
visual_signs_available = Column(Enum(IdfmState), nullable=False)
audible_signs_available = Column(Enum(IdfmState), nullable=False)
record_id = Column(String, nullable=False)
record_ts = Column(BigInteger, nullable=False)
__tablename__ = "stops"
__mapper_args__ = {"polymorphic_identity": "stops", "polymorphic_load": "inline"}
class StopArea(_Stop):
id = Column(BigInteger, ForeignKey("_stops.id"), primary_key=True)
type = Column(Enum(StopAreaType), nullable=False)
stops: Mapped[list[_Stop]] = relationship(
_Stop,
secondary=stop_area_stop_association_table,
back_populates="areas",
lazy="selectin",
# lazy="joined",
)
__tablename__ = "stop_areas"
__mapper_args__ = {"polymorphic_identity": "stop_areas", "polymorphic_load": "inline"}
@classmethod
async def add_stops(cls, stop_area_to_stop_ids: Iterable[tuple[str, str]]) -> int:
stop_area_ids, stop_ids = set(), set()
for stop_area_id, stop_id in stop_area_to_stop_ids:
stop_area_ids.add(stop_area_id)
stop_ids.add(stop_id)
res = await cls.db.session.execute(
select(StopArea)
.where(StopArea.id.in_(stop_area_ids))
.options(selectinload(StopArea.stops))
)
stop_areas = {stop_area.id: stop_area for stop_area in res.scalars()}
res = await cls.db.session.execute(select(_Stop).where(_Stop.id.in_(stop_ids)))
stops = {stop.id: stop for stop in res.scalars()}
found = 0
for stop_area_id, stop_id in stop_area_to_stop_ids:
if (stop_area := stop_areas.get(stop_area_id)) is not None:
if (stop := stops.get(stop_id)) is not None:
stop_area.stops.append(stop)
found += 1
else:
print(f"No stop found for {stop_id} id")
else:
print(f"No stop area found for {stop_area_id}")
await cls.db.session.commit()
return found

View File

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

View File

@@ -1,3 +0,0 @@
from .line import Line, TransportMode
from .next_passage import NextPassage, NextPassages
from .stop import Stop, StopArea

View File

@@ -1,22 +0,0 @@
from pydantic import BaseModel
from ..idfm_interface.idfm_types import TrainStatus
class NextPassage(BaseModel):
line: str
operator: str
destinations: list[str]
atStop: bool
aimedArrivalTs: None | int
expectedArrivalTs: None | int
arrivalPlatformName: None | str
aimedDepartTs: None | int
expectedDepartTs: None | int
arrivalStatus: TrainStatus
departStatus: TrainStatus
class NextPassages(BaseModel):
ts: int
passages: dict[str, dict[str, list[NextPassage]]]

View File

@@ -1,25 +0,0 @@
from pydantic import BaseModel
from ..idfm_interface import IdfmLineState, IdfmState, StopAreaType, TransportMode
class Stop(BaseModel):
id: int
name: str
town: str
lat: float
lon: float
# xepsg2154: int
# yepsg2154: int
lines: list[str]
class StopArea(BaseModel):
id: int
name: str
town: str
# xepsg2154: int
# yepsg2154: int
type: StopAreaType
lines: list[str] # SNCF lines are linked to stop areas and not stops.
stops: list[Stop]

View File

@@ -1,208 +0,0 @@
from collections import defaultdict
from datetime import datetime
from os import environ
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from rich import print
from idfm_matrix_backend.db import db
from idfm_matrix_backend.idfm_interface import IdfmInterface
from idfm_matrix_backend.models import Line, Stop, StopArea
from idfm_matrix_backend.schemas import (
Line as LineSchema,
TransportMode,
NextPassage as NextPassageSchema,
NextPassages as NextPassagesSchema,
Stop as StopSchema,
StopArea as StopAreaSchema,
)
API_KEY = environ.get("API_KEY")
# TODO: Add error message if no key is given.
# TODO: Remove postgresql+asyncpg from environ variable
DB_PATH = "postgresql+asyncpg://postgres:postgres@127.0.0.1:5438/idfm"
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://localhost:4443",
"https://localhost:3000",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
idfm_interface = IdfmInterface(API_KEY, db)
@app.on_event("startup")
async def startup():
# await db.connect(DB_PATH, clear_static_data=True)
# await idfm_interface.startup()
await db.connect(DB_PATH, clear_static_data=False)
print("Connected")
@app.on_event("shutdown")
async def shutdown():
await db.disconnect()
# /addwidget https://localhost:4443/static/#?widgetId=$matrix_widget_id&userId=$matrix_user_id
# /addwidget https://localhost:3000/widget?widgetId=$matrix_widget_id&userId=$matrix_user_id
STATIC_ROOT = "../frontend/"
app.mount("/widget", StaticFiles(directory=STATIC_ROOT, html=True), name="widget")
def optional_datetime_to_ts(dt: datetime) -> int | None:
return dt.timestamp() if dt else None
@app.get("/line/{line_id}", response_model=LineSchema)
async def get_line(line_id: str) -> JSONResponse:
line: Line | None = await Line.get_by_id(line_id)
if line is None:
raise HTTPException(status_code=404, detail=f'Line "{line_id}" not found')
return LineSchema(
id=line.id,
shortName=line.short_name,
name=line.name,
status=line.status,
transportMode=TransportMode.from_idfm_transport_mode(
line.transport_mode, line.transport_submode
),
backColorHexa=line.colour_web_hexa,
foreColorHexa=line.text_colour_hexa,
operatorId=line.operator_id,
accessibility=line.accessibility,
visualSignsAvailable=line.visual_signs_available,
audibleSignsAvailable=line.audible_signs_available,
stopIds=[stop.id for stop in line.stops],
)
def _format_stop(stop: Stop) -> StopSchema:
print(stop.__dict__)
return StopSchema(
id=stop.id,
name=stop.name,
town=stop.town_name,
# xepsg2154=stop.xepsg2154,
# yepsg2154=stop.yepsg2154,
lat=stop.latitude,
lon=stop.longitude,
lines=[line.id for line in stop.lines],
)
# châtelet
@app.get("/stop/")
async def get_stop(
name: str = "", limit: int = 10
) -> list[StopAreaSchema | StopSchema]:
# TODO: Add limit support
formatted = []
matching_stops = await Stop.get_by_name(name)
# print(matching_stops, flush=True)
stop_areas: dict[int, StopArea] = {}
stops: dict[int, Stop] = {}
for stop in matching_stops:
# print(f"{stop.__dict__ = }", flush=True)
dst = stop_areas if isinstance(stop, StopArea) else stops
dst[stop.id] = stop
for stop_area in stop_areas.values():
formatted_stops = []
for stop in stop_area.stops:
formatted_stops.append(_format_stop(stop))
try:
del stops[stop.id]
except KeyError as err:
print(err)
formatted.append(
StopAreaSchema(
id=stop_area.id,
name=stop_area.name,
town=stop_area.town_name,
# xepsg2154=stop_area.xepsg2154,
# yepsg2154=stop_area.yepsg2154,
type=stop_area.type,
lines=[line.id for line in stop_area.lines],
stops=formatted_stops,
)
)
# print(f"{stops = }", flush=True)
formatted.extend(_format_stop(stop) for stop in stops.values())
return formatted
# TODO: Cache response for 30 secs ?
@app.get("/stop/nextPassages/{stop_id}")
async def get_next_passages(stop_id: str) -> JSONResponse:
res = await idfm_interface.get_next_passages(stop_id)
# print(res)
service_delivery = res.Siri.ServiceDelivery
stop_monitoring_deliveries = service_delivery.StopMonitoringDelivery
by_line_by_dst_passages = defaultdict(lambda: defaultdict(list))
for delivery in stop_monitoring_deliveries:
for stop_visit in delivery.MonitoredStopVisit:
journey = stop_visit.MonitoredVehicleJourney
# re.match will return None if the given journey.LineRef.value is not valid.
try:
line_id = IdfmInterface.LINE_RE.match(journey.LineRef.value).group(1)
except AttributeError as exc:
raise HTTPException(
status_code=404, detail=f'Line "{journey.LineRef.value}" not found'
) from exc
call = journey.MonitoredCall
dst_names = call.DestinationDisplay
dsts = [dst.value for dst in dst_names] if dst_names else []
print(f"{call.ArrivalPlatformName = }")
next_passage = NextPassageSchema(
line=line_id,
operator=journey.OperatorRef.value,
destinations=dsts,
atStop=call.VehicleAtStop,
aimedArrivalTs=optional_datetime_to_ts(call.AimedArrivalTime),
expectedArrivalTs=optional_datetime_to_ts(call.ExpectedArrivalTime),
arrivalPlatformName=call.ArrivalPlatformName.value if call.ArrivalPlatformName else None,
aimedDepartTs=optional_datetime_to_ts(call.AimedDepartureTime),
expectedDepartTs=optional_datetime_to_ts(call.ExpectedDepartureTime),
arrivalStatus=call.ArrivalStatus.value,
departStatus=call.DepartureStatus.value,
)
by_line_passages = by_line_by_dst_passages[line_id]
# TODO: by_line_passages[dst].extend(dsts) instead ?
for dst in dsts:
by_line_passages[dst].append(next_passage)
return NextPassagesSchema(
ts=service_delivery.ResponseTimestamp.timestamp(),
passages=by_line_by_dst_passages,
)

View File

@@ -1,58 +1,84 @@
[tool.poetry]
name = "idfm-matrix-widget"
name = "carrramba-encore-rate"
version = "0.1.0"
description = ""
authors = ["Adrien SUEUR <me@adrien.run>"]
readme = "README.md"
packages = [{include = "idfm_matrix_backend"}]
[tool.poetry.dependencies]
python = "^3.11"
aiohttp = "^3.8.3"
rich = "^12.6.0"
aiofiles = "^22.1.0"
sqlalchemy = {extras = ["asyncio"], version = "^1.4.46"}
fastapi = "^0.88.0"
fastapi = "^0.103.0"
uvicorn = "^0.20.0"
asyncpg = "^0.27.0"
msgspec = "^0.12.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"
fastapi-cache2 = {extras = ["redis"], version = "^0.2.1"}
pydantic-settings = "^2.0.3"
[tool.poetry.group.db_updater.dependencies]
aiofiles = "^22.1.0"
aiohttp = "^3.8.3"
fastapi = "^0.103.0"
msgspec = "^0.12.0"
opentelemetry-instrumentation-fastapi = "^0.38b0"
opentelemetry-instrumentation-sqlalchemy = "^0.38b0"
opentelemetry-sdk = "^1.17.0"
opentelemetry-api = "^1.17.0"
psycopg = "^3.1.9"
pyproj = "^3.5.0"
pyshp = "^2.3.1"
python = "^3.11"
pyyaml = "^6.0"
sqlalchemy = "^2.0.12"
sqlalchemy-utils = "^0.41.1"
tqdm = "^4.65.0"
pydantic-settings = "^2.0.3"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.dev-dependencies]
mypy = "^0.971"
pylsp-mypy = "^0.6.2"
autopep8 = "^1.7.0"
mccabe = "^0.7.0"
pycodestyle = "^2.9.1"
pydocstyle = "^6.1.1"
pyflakes = "^2.5.0"
pylint = "^2.14.5"
rope = "^1.3.0"
python-lsp-server = {extras = ["yapf"], version = "^1.5.0"}
python-lsp-black = "^1.2.1"
black = "^22.10.0"
whatthepatch = "^1.0.2"
[tool.poetry.group.dev.dependencies]
pylsp-mypy = "^0.6.2"
mccabe = "^0.7.0"
rope = "^1.3.0"
python-lsp-black = "^1.2.1"
black = "^22.10.0"
types-aiofiles = "^22.1.0.2"
sqlalchemy-stubs = "^0.4"
wrapt = "^1.14.1"
pydocstyle = "^6.2.2"
pylint = "^2.15.9"
dill = "^0.3.6"
python-lsp-ruff = "^1.0.5"
python-lsp-server = "^1.7.1"
autopep8 = "^2.0.1"
pyflakes = "^3.0.1"
yapf = "^0.32.0"
whatthepatch = "^1.0.4"
mypy = "^1.0.0"
icecream = "^2.1.3"
types-sqlalchemy-utils = "^1.0.1"
types-pyyaml = "^6.0.12.9"
types-tqdm = "^4.65.0.1"
[tool.pylsp-mypy]
enabled = true
[tool.mypy]
plugins = "sqlalchemy.ext.mypy.plugin"
[mypy]
plugins = "sqlmypy"
[pycodestyle]
max_line_length = 100
[pylint]
max-line-length = 100
[tool.black]
target-version = ['py311']
[tool.ruff]
line-length = 88
[too.ruff.per-file-ignores]
"__init__.py" = ["E401"]

99
docker-compose.yml Normal file
View File

@@ -0,0 +1,99 @@
version: '3.7'
services:
database:
image: postgres:15.1-alpine
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
logging:
options:
max-size: 10m
max-file: "3"
ports:
- "127.0.0.1:5432:5432"
volumes:
- ./backend/docker/database/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
- ./backend/docker/database/data:/var/lib/postgresql/data
redis:
image: redis:latest
restart: always
command: redis-server --loglevel warning
ports:
- "127.0.0.1:6379:6379"
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"
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"
restart: on-failure
depends_on:
- cassandra-schema
carrramba-encore-rate-api:
build:
context: ./backend/
dockerfile: Dockerfile.backend
environment:
- CONFIG_PATH=./config.local.yaml
- IDFM_API_KEY=set_your_idfm_key_here
ports:
- "127.0.0.1:8080:8080"
carrramba-encore-rate-frontend:
build:
context: ./frontend/
ports:
- "127.0.0.1:80:8081"
carrramba-encore-rate-db-updater:
build:
context: ./backend/
dockerfile: Dockerfile.db_updater
environment:
- CONFIG_PATH=./config.local.yaml

1
frontend/.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules
dist
package-lock.json
pnpm-lock.yaml

4
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
# pull the latest official nginx image
FROM nginx:mainline-alpine-slim
COPY dist /usr/share/nginx/html

View File

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

View File

@@ -1,21 +0,0 @@
.App {
--idfm-black: #2c2e35;
--idfm-white: #ffffff;
height: inherit;
width: inherit;
scroll-snap-type: x mandatory;
overflow-x: scroll;
display: flex;
text-align: center;
}
.panel {
min-width: 100%;
height: inherit;
width: inherit;
scroll-snap-align: center;
}

27
frontend/src/App.scss Normal file
View File

@@ -0,0 +1,27 @@
.App {
--idfm-black: #2c2e35;
--idfm-white: #ffffff;
--neutral-color: #d7dbdf;
--border-radius: calc(15/1920*100%);
height: inherit;
width: inherit;
scroll-snap-type: x mandatory;
overflow-x: scroll;
display: flex;
text-align: center;
.panel {
min-width: 100%;
height: inherit;
width: inherit;
scroll-snap-align: center;
background-color: var(--idfm-black);
}
}

View File

@@ -1,15 +1,15 @@
import { Component } from 'solid-js';
import { MatrixCapabilities, WidgetApi, WidgetApiToWidgetAction, CustomEvent, IVisibilityActionRequest } from 'matrix-widget-api';
import { Component, createSignal } from 'solid-js';
import { IVisibilityActionRequest, MatrixCapabilities, WidgetApi, WidgetApiToWidgetAction } from 'matrix-widget-api';
import { HopeProvider } from "@hope-ui/solid";
import { BusinessDataProvider } from './businessData';
import { AppContextProvider } from './appContext';
import { SearchProvider } from './search';
import { NextPassagesDisplay } from './nextPassagesDisplay';
import { StopsManager } from './stopsManager';
import { PassagesDisplay } from './passagesDisplay';
import { StopsSearchMenu } from './stopsSearchMenu/stopsSearchMenu';
import styles from './App.module.css';
import "./App.scss";
import { onCleanup, onMount } from 'solid-js';
function parseFragment() {
@@ -28,7 +28,7 @@ const App: Component = () => {
console.log("App: widgetId:" + widgetId);
console.log("App: userId:" + userId);
const api = new WidgetApi(widgetId);
const api = new WidgetApi(widgetId != null ? widgetId : undefined);
api.requestCapability(MatrixCapabilities.AlwaysOnScreen);
api.start();
api.on("ready", function() {
@@ -44,20 +44,47 @@ const App: Component = () => {
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 (
<BusinessDataProvider>
<SearchProvider>
<AppContextProvider>
<HopeProvider>
<div class={styles.App} data-panelsnap-id="1">
<div class={styles.panel}>
<StopsManager />
<div class="App">
<div class="panel">
<StopsSearchMenu />
</div>
<div class={styles.panel}>
<NextPassagesDisplay />
<div class="panel">
<PassagesDisplay />
</div>
</div>
</HopeProvider>
</SearchProvider>
</AppContextProvider>
</BusinessDataProvider>
);
};

74
frontend/src/_common.scss Normal file
View File

@@ -0,0 +1,74 @@
/* Idfm: 1860x1080px */
%widget {
aspect-ratio: 16/9;
--reverse-aspect-ratio: 9/16;
/* height is set according to the aspect-ratio, don´t touch it */
width: 100%;
display: flex;
flex-direction: column;
}
/* Idfm: 1800x100px (margin: 17px 60px) */
%header {
width: calc(1800/1920*100%);
height: calc(100/1080*100%);
/*Percentage margin are computed relatively to the nearest block container's width, not height */
/* cf. https://developer.mozilla.org/en-US/docs/Web/CSS/margin-bottom */
margin: calc(17/1080*var(--reverse-aspect-ratio)*100%) calc(60/1920*100%);
display: flex;
align-items: center;
font-family: IDFVoyageur-bold;
}
.header {
@extend %header;
}
%title {
height: 50%;
width: 70%;
margin-right: auto;
}
/* Idfm: 1860x892px (margin: 0px 30px) */
%body {
width: calc(1860/1920*100%);
height: calc(892/1080*100%);
margin: 0 calc(30/1920*100%);
display: flex;
flex-direction: column;
background-color: white;
border-collapse:separate;
// border:solid var(--idfm-black) 1px;
border-radius: calc(15/1920*100%);
}
/* Idfm: 1800x54px (margin: 0px 50px) */
%footer {
width: calc(1820/1920*100%);
height: calc(54/1080*100%);
margin: 0 calc(50/1920*100%);
display: flex;
align-items: center;
justify-content: right;
}
.footer {
@extend %footer;
}
.footer div {
aspect-ratio: 1;
height: 50%;
margin-left: calc(42/1920*100%);
}

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

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

View File

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

View File

@@ -1,79 +1,251 @@
import { createContext, createSignal } from 'solid-js';
import { batch, createContext, createSignal, JSX } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Passages, Stops } from './types';
import { Line, Lines, Passage, Passages, Stop, StopShape, StopShapes, Stops } from './types';
interface Store {
export type StopDestinations = Record<string, string[]>;
export interface BusinessDataStore {
getLine: (lineId: string) => Promise<Line>;
getLinePassages: (lineId: string) => Record<string, Passage[]>;
getLineDestinations: (lineId: string) => string[];
getDestinationPassages: (lineId: string, destination: string) => Passage[];
passages: () => Passages;
getLinePassages?: (lineId: string) => Passages;
addPassages?: (passages) => void;
clearPassages?: () => void;
getPassagesLineIds: () => string[];
refreshPassages: (stopId: number) => Promise<void>;
addPassages: (passages: Passages) => void;
clearPassages: () => void;
stops: () => Stops;
addStops?: (stops) => void;
getStop: (stopId: number) => Stop | undefined;
searchStopByName: (name: string) => Promise<Stops>;
getStopDestinations: (stopId: number) => Promise<StopDestinations | undefined>;
getStopShape: (stopId: number) => Promise<StopShape | undefined>;
};
export const BusinessDataContext = createContext<Store>();
export const BusinessDataContext = createContext<BusinessDataStore>();
export function BusinessDataProvider(props: { children: JSX.Element }) {
const [serverUrl] = createSignal<string>("https://carrramba.adrien.run/api");
const [serverUrl, setServerUrl] = createSignal<string>("https://localhost:4443");
type Store = {
lines: Lines;
passages: Passages;
stops: Stops;
stopShapes: StopShapes;
};
const [store, setStore] = createStore({ lines: {}, passages: {}, stops: {} });
const [store, setStore] = createStore<Store>({ lines: {}, passages: {}, stops: {}, stopShapes: {} });
async function getLine(lineId: number) {
const getLine = async (lineId: string): Promise<Line> => {
let line = store.lines[lineId];
if (line === undefined) {
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' }
});
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;
}
const passages = () => {
return store.passages;
};
const getLinePassages = (lineId: string) => {
const getLinePassages = (lineId: string): Record<string, Passage[]> => {
return store.passages[lineId];
};
}
const addPassages = (passages) => {
setStore((s) => {
// console.log("s=", s);
setStore('passages', passages);
// console.log("s=", s);
const getLineDestinations = (lineId: string): string[] => {
return Object.keys(store.passages[lineId]);
}
// TODO: Remove this method: it's based on the next passages and return nothing until the refreshPassages is called.
const getDestinationPassages = (lineId: string, destination: string): Passage[] => {
return store.passages[lineId][destination];
}
const passages = (): Passages => {
return store.passages;
}
const getPassagesLineIds = (): string[] => {
return Object.keys(store.passages);
}
const _cleanupPassages = (passages: Passages): void => {
const deadline = Math.floor(Date.now() / 1000) - 60;
for (const linePassages of Object.values(passages)) {
for (const destination of Object.keys(linePassages)) {
const destinationPassages = linePassages[destination];
const cleaned = [];
for (const passage of destinationPassages) {
if (passage.expectedDepartTs > deadline) {
cleaned.push(passage);
}
}
linePassages[destination] = cleaned;
}
}
}
const refreshPassages = async (stopId: number): Promise<void> => {
console.log(`Fetching data for ${stopId}`);
const httpOptions = { headers: { "Content-Type": "application/json" } };
const response = await fetch(`${serverUrl()}/stop/${stopId}/nextPassages`, httpOptions);
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 => {
batch(() => {
const storePassages = store.passages;
for (const lineId of Object.keys(passages)) {
const newLinePassages = passages[lineId];
const linePassages = storePassages[lineId];
if (linePassages === undefined) {
setStore('passages', lineId, newLinePassages);
}
else {
for (const destination of Object.keys(newLinePassages)) {
const newLinePassagesDestination = newLinePassages[destination];
const linePassagesDestination = linePassages[destination];
if (linePassagesDestination === undefined) {
setStore('passages', lineId, destination, newLinePassagesDestination);
}
else {
if (linePassagesDestination.length - newLinePassagesDestination.length != 0) {
console.log(`Server provides ${newLinePassagesDestination.length} passages, \
${linePassagesDestination.length} here... refresh all them.`);
setStore('passages', lineId, destination, newLinePassagesDestination);
}
else {
linePassagesDestination.forEach((passage, index) => {
const newPassage = newLinePassagesDestination[index];
if (passage.expectedDepartTs != newPassage.expectedDepartTs) {
console.log(`Refresh expectedDepartTs (${passage.expectedDepartTs} -> ${newPassage.expectedDepartTs}`);
setStore('passages', lineId, destination, index, 'expectedDepartTs', newPassage.expectedDepartTs);
}
if (passage.expectedArrivalTs != newPassage.expectedArrivalTs) {
console.log(`Refresh expectedArrivalTs (${passage.expectedArrivalTs} -> ${newPassage.expectedArrivalTs}`);
setStore('passages', lineId, destination, index, 'expectedArrivalTs', newPassage.expectedArrivalTs);
}
});
}
}
}
}
}
});
}
const clearPassages = () => {
setStore((s) => {
// TODO: Really need to set to undefined to reset ?
console.log("s=", s);
console.log("s.passages=", s.passages);
// setStore('passages', undefined);
// setStore('passages', {});
console.log("Object.keys(s.passages)=", Object.keys(s.passages));
const clearPassages = (): void => {
setStore((s: Store): Store => {
for (const lineId of Object.keys(s.passages)) {
console.log("lineId=", lineId);
setStore('passages', lineId, undefined);
}
console.log("s=", s);
return s;
});
// setStore('passages', undefined);
// setStore('passages', {});
// }
console.log("passages=", store.passages);
}
const getStop = (stopId: number): Stop | undefined => {
return store.stops[stopId];
}
const searchStopByName = async (name: string): Promise<Stops> => {
const byIdStops: Stops = {};
const response = await fetch(`${serverUrl()}/stop/?name=${name}`, {
headers: { 'Content-Type': 'application/json' }
});
const json = await response.json();
if (response.ok) {
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;
}
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 (
<BusinessDataContext.Provider value={{ getLine, passages, getLinePassages, addPassages, clearPassages, serverUrl }}>
<BusinessDataContext.Provider value={{
getLine, getLinePassages, getLineDestinations, getDestinationPassages, passages, getPassagesLineIds,
refreshPassages, addPassages, clearPassages,
getStop, getStopDestinations, getStopShape, searchStopByName
}}>
{props.children}
</BusinessDataContext.Provider>
);
}
export interface BusinessDataStore {
getLine: (lineId: string) => Promise<Line>;
getLinePassages: (lineId: string) => Record<string, Passage[]>;
passages: () => Passages;
refreshPassages: (stopId: number) => Promise<void>;
addPassages: (passages: Passages) => void;
clearPassages: () => void;
getStop: (stopId: number) => Stop | undefined;
searchStopByName: (name: string) => Promise<Stops>;
};

View File

@@ -0,0 +1,16 @@
import { VoidComponent } from "solid-js";
// Inspired by https://github.com/hope-ui/hope-ui/blob/main/apps/docs/src/icons/IconHamburgerMenu.tsx
export const IconHamburgerMenu: VoidComponent<{}> = () => {
return (
<svg class="iconHamburgerMenu" 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
13.7761 3 13.5 3H1.5ZM1 7.5C1 7.22386 1.22386 7 1.5 7H13.5C13.7761 7 14 7.22386 14 7.5C14 7.77614 13.7761 8 13.5
8H1.5C1.22386 8 1 7.77614 1 7.5ZM1 11.5C1 11.2239 1.22386 11 1.5 11H13.5C13.7761 11 14 11.2239 14 11.5C14 11.7761
13.7761 12 13.5 12H1.5C1.22386 12 1 11.7761 1 11.5Z"
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
/>
</svg>);
}

View File

@@ -13,9 +13,8 @@
src: url(/public/fonts/IDFVoyageur-Medium.otf);
}
body {
html, body {
aspect-ratio: 16/9;
width: 100vw;
margin: 0;
@@ -23,6 +22,5 @@ body {
}
#root {
height: inherit;
width: inherit;
}

View File

@@ -1,7 +1,7 @@
/* @refresh reload */
import { render } from 'solid-js/web';
import './index.css';
import './index.scss';
import App from './App';
render(() => (<App/>), document.getElementById('root') as HTMLElement);

View File

@@ -1,226 +0,0 @@
/* TODO: Remove this class */
.ar16x9 {
aspect-ratio: 16 / 9;
}
/* Idfm: 1860x1080px */
.NextPassagesDisplay {
aspect-ratio: 16/9;
--reverse-aspect-ratio: 9/16;
/* height is set according to the aspect-ratio, don´t touch it */
width: 100%;
display: flex;
flex-direction: column;
background-color: var(--idfm-black);
}
/* Idfm: 1800x100px (margin: 17px 60px) */
.header {
width: calc(1800/1920*100%);
height: calc(100/1080*100%);
/*Percentage margin are computed relatively to the nearest block container's width, not height */
/* cf. https://developer.mozilla.org/en-US/docs/Web/CSS/margin-bottom */
margin: calc(17/1080*var(--reverse-aspect-ratio)*100%) calc(60/1920*100%);
display: flex;
align-items: center;
font-family: IDFVoyageur-bold;
}
.header .transportMode {
height: 100%;
margin: 0;
margin-right: calc(23/1920*100%);
}
.header .title {
height: 50%;
width: 70%;
margin-right: auto;
}
.header .clock {
width: calc(175/1920*100%);
height: calc(80/100*100%);
display: flex;
align-items: center;
justify-content: center;
border:solid var(--idfm-white) 3px;
border-radius: calc(9/86*100%);
}
.header .clock svg {
aspect-ratio: 2.45;
height: calc(0.7*100%);
}
/* Idfm: 1860x892px (margin: 0px 30px) */
.panelsContainer {
width: calc(1860/1920*100%);
height: calc(892/1080*100%);
margin: 0 calc(30/1920*100%);
display: flex;
flex-direction: column;
background-color: white;
border-collapse:separate;
border:solid var(--idfm-black) 1px;
border-radius: calc(15/1920*100%);
}
.nextPassagesContainer {
height: 100%;
width: 100%;
display: none;
position: relative;
}
.nextPassagesContainer .line:last-child {
border-bottom: 0;
/* To make up for the bottom border deletion */
padding-bottom: calc(2px);
}
.displayed {
display: block;
}
/* Idfm: 1880x176px (margin: 0px 20px) */
.line {
width: calc(1880/1920*100%);
height: calc(100% / 5);
margin: 0 calc(20/1920*100%);
display: flex;
align-items: center;
/* TODO: compute the border weight according to the parent height */
/* TODO: Disable border-bottom for the last .line */
border-bottom: solid calc(2px);
}
.line svg {
font-family: IDFVoyageur-bold;
max-width: 100%;
max-height: 100%;
}
/* Idfm: 100x100px (margin: 0px 15px) */
.transportMode {
aspect-ratio : 1 / 1;
height: calc(100/176*100%);
margin: 0 calc(15/1920*100%);
}
.tramLinePicto {
aspect-ratio : 1 / 1;
height: calc(100/176*100%);
margin-right: calc(23/1920*100%);
}
.busLinePicto {
aspect-ratio : 2.25;
height: calc(70/176*100%);
margin-right: calc(23/1920*100%);
}
.destination {
height: calc(60/176*100%);
width: 50%;
font-family: IDFVoyageur-bold;
text-align: left;
}
.trafficStatus {
height: calc(50/176*100%);
aspect-ratio: 35/50;
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
}
.trafficStatus svg {
width: 100%;
}
.firstPassage {
height: calc(100/176*100%);
aspect-ratio: 2.5;
display: flex;
align-items: center;
justify-content: center;
padding-right: calc(30/1920*100%);
/* TODO: compute the border weight according to the parent width */
border-right: solid calc(5px);
}
.unavailableFirstPassage {
height: calc(100/176*100%);
aspect-ratio: calc(230/100);
margin-right: calc(30/1920*100%);
/* TODO: compute the border weight according to the parent width */
border-right: solid calc(5px);
}
.firstPassage svg {
aspect-ratio: 215/50;
height: calc(1/2*100%);
}
.secondPassage {
height: calc(45/176*100%);
aspect-ratio: calc(230/45);
margin-right: calc(30/1920*100%);
}
.secondPassage svg {
font-family: IDFVoyageur-regular;
}
.unavailableSecondPassage {
height: calc(100/176*100%);
aspect-ratio: calc(230/100);
margin-right: calc(30/1920*100%);
}
.unavailableSecondNextPassage svg {
font-family: IDFVoyageur-regular;
}
/* Idfm: 1800x54px (margin: 0px 50px) */
.footer {
width: calc(1820/1920*100%);
height: calc(54/1080*100%);
margin: 0 calc(50/1920*100%);
display: flex;
align-items: center;
justify-content: right;
}
.footer div {
aspect-ratio: 1;
height: 50%;
margin-left: calc(42/1920*100%);
}

View File

@@ -1,253 +0,0 @@
import { Component, createEffect, createSignal, useContext } from "solid-js";
import { createStore } from "solid-js/store";
import { createDateNow } from "@solid-primitives/date";
import { format } from "date-fns";
import { getTransportModeSrc } from "./types";
import { BusinessDataContext } from "./businessData";
import { NextPassagesPanel } from "./nextPassagesPanel";
import { SearchContext } from "./search";
import styles from "./nextPassagesDisplay.module.css";
export const NextPassagesDisplay: Component = () => {
const maxPassagePerPanel = 5;
const syncPeriodMsec = 20 * 1000;
const { passages, getLinePassages, addPassages, clearPassages, serverUrl } =
useContext(BusinessDataContext);
const { getDisplayedStop } = useContext(SearchContext);
const [panels, setPanels] = createStore([]);
const [displayedPanelId, setDisplayedPanelId] = createSignal<number>(0);
let _lines = new Map();
const [dateNow] = createDateNow(1000);
const panelSwapInterval = setInterval(() => {
let nextPanelId = displayedPanelId() + 1;
if (nextPanelId >= panels.length) {
nextPanelId = 0;
}
/* console.log(`Display panel #${nextPanelId}`); */
setDisplayedPanelId(nextPanelId);
}, 4000);
createEffect(() => {
console.log("######### onStopIdUpdate #########");
// Track local.stopIp to force dependency.
console.log("getDisplayedStop=", getDisplayedStop());
clearPassages();
});
createEffect(async () => {
console.log(`## OnPassageUpdate ${passages()} ##`);
/* console.log(passages()); */
await requestPassages();
});
async function _fetchLine(lineId: string) {
if (!_lines.has(lineId)) {
const data = await fetch(`${serverUrl()}/line/${lineId}`, {
headers: { "Content-Type": "application/json" },
});
const line = await data.json();
_lines.set(line.id, line);
}
}
async function requestPassages() {
console.log("### requestPassages ###");
/* TODO: Manage several displays (one by stop) */
const stops = getDisplayedStop();
if (stops.length == 0) {
return;
}
const stop = stops[0];
const httpOptions = { headers: { "Content-Type": "application/json" } };
if (stop !== undefined) {
const stopId = stop.id;
console.log(`Fetching data for ${stopId}`);
const url = `${serverUrl()}/stop/nextPassages/${stopId}`;
/* console.log(`url=${url}`); */
const data = await fetch(url, httpOptions);
const response = await data.json();
/* console.log(response); */
const byLineByDstPassages = response.passages;
/* console.log(byLineByDstPassages); */
const linePromises = [];
for (const lineId of Object.keys(byLineByDstPassages)) {
linePromises.push(_fetchLine(lineId));
}
await Promise.all(linePromises);
console.log("byLineByDstPassages=", byLineByDstPassages);
// console.log("before addPassages passages=", passages());
addPassages(byLineByDstPassages);
console.log("AFTER passages=", passages());
}
}
setInterval(
// const nextPassagesRequestsInterval = setTimeout(
async () => {
await requestPassages();
},
syncPeriodMsec
);
// TODO: Sort transport modes by weight
// TODO: Split this method to isolate the nextPassagesPanel part.
function _computeHeader(title: string): JSX.Element {
let transportModes = [];
transportModes = new Set(
Object.keys(passages()).map((lineId) => {
const line = _lines.get(lineId);
if (line !== undefined) {
return getTransportModeSrc(line.transportMode, false);
}
return null;
})
);
return (
<div class={styles.header}>
<For each={Array.from(transportModes)}>
{(transportMode) => {
return (
<div class={styles.transportMode}>
<img src={transportMode} />
</div>
);
}}
</For>
<div class={styles.title}>
<svg viewbox="0 0 1260 50">
<text
x="0"
y="50%"
dominant-baseline="middle"
font-size="50"
style="fill: #ffffff"
>
{title}
</text>
</svg>
</div>
<div class={styles.clock}>
<svg viewbox="0 0 115 43">
<text
x="50%"
y="55%"
dominant-baseline="middle"
text-anchor="middle"
font-size="43"
style="fill: #ffffff"
>
{format(dateNow(), "HH:mm")}
</text>
</svg>
</div>
</div>
);
}
function _computeFooter(): JSX.Element {
return (
<div class={styles.footer}>
<For each={panels}>
{(positioned) => {
const { position, panel } = positioned;
const circleStyle = {
fill: `var(--idfm-${position == displayedPanelId() ? "white" : "black"
})`,
};
return (
<div>
<svg viewBox="0 0 29 29">
<circle
cx="50%"
cy="50%"
r="13"
stroke="#ffffff"
stroke-width="3"
style={circleStyle}
/>
</svg>
</div>
);
}}
</For>
</div>
);
}
const mainDivClasses = `${styles.NextPassagesDisplay} ${styles.ar16x9}`;
return (
<div class={mainDivClasses}>
{_computeHeader("Prochains passages")}
<div class={styles.panelsContainer}>
{() => {
setPanels([]);
let newPanels = [];
let positioneds = [];
let index = 0;
let chunk = {};
let chunkSize = 0;
console.log("passages=", passages());
for (const lineId of Object.keys(passages())) {
console.log("lineId=", lineId);
const byLinePassages = getLinePassages(lineId);
console.log("byLinePassages=", byLinePassages);
const byLinePassagesKeys = Object.keys(byLinePassages);
console.log("byLinePassagesKeys=", byLinePassagesKeys);
if (byLinePassagesKeys.length <= maxPassagePerPanel - chunkSize) {
chunk[lineId] = byLinePassages;
chunkSize += byLinePassagesKeys.length;
} else {
console.log("chunk=", chunk);
const [store, setStore] = createStore(chunk);
const panelid = index++;
const panel = (
<NextPassagesPanel
show={panelid == displayedPanelId()}
nextPassages={store}
lines={_lines}
/>
);
newPanels.push(panel);
positioneds.push({ position: panelid, panel });
chunk = {};
chunk[lineId] = byLinePassages;
chunkSize = byLinePassagesKeys.length;
}
}
if (chunkSize) {
const panelId = index++;
const [store, setStore] = createStore(chunk);
const panel = (
<NextPassagesPanel
show={panelId == displayedPanelId()}
nextPassages={store}
lines={_lines}
/>
);
newPanels.push(panel);
positioneds.push({ position: panelId, panel });
}
setPanels(positioneds);
return newPanels;
}}
</div>
{_computeFooter()}
</div>
);
};

View File

@@ -1,121 +0,0 @@
import { Component } from 'solid-js';
import { createStore } from 'solid-js/store';
import { createDateNow, getTime } from '@solid-primitives/date';
import { Motion } from "@motionone/solid";
import { TrafficStatus } from './types';
import { renderLineTransportMode, renderLinePicto } from './utils';
import styles from './nextPassagesDisplay.module.css';
export const NextPassagesPanel: Component = (props) => {
/* TODO: Find where to get data to compute traffic status. */
const trafficStatusColor = new Map<TrafficStatus, string>([
[TrafficStatus.UNKNOWN, "#ffffff"],
[TrafficStatus.FLUID, "#00643c"],
[TrafficStatus.DISRUPTED, "#ffbe00"],
[TrafficStatus.VERY_DISRUPTED, "#ff5a00"],
[TrafficStatus.BYPASSED, "#ffffff"]
]);
const [dateNow] = createDateNow(5000);
function _computeTtwPassage(class_, passage, fontSize) {
const refTs = passage.expectedDepartTs !== null ? passage.expectedDepartTs : passage.expectedArrivalTs;
const ttwSec = refTs - (getTime(dateNow()) / 1000);
const isApproaching = ttwSec <= 60;
return (
<div class={class_}>
<svg viewBox={`0 0 215 ${fontSize}`}>
<Motion.text
x="100%" y="55%"
dominant-baseline="middle" text-anchor="end"
font-size={fontSize} style={{ fill: "#000000" }}
initial={isApproaching}
animate={{ opacity: [1, 0, 1] }}
transition={{ duration: 3, repeat: Infinity }}>
{Math.floor(ttwSec / 60)} min
</Motion.text>
</svg>
</div>
);
}
function _computeUnavailablePassage(class_) {
const textStyle = { fill: "#000000" };
return (
<div class={class_}>
<svg viewbox="0 0 230 110">
<text x="100%" y="26" font-size="25" text-anchor="end" style={textStyle}>Information</text>
<text x="100%" y="63" font-size="25" text-anchor="end" style={textStyle}>non</text>
<text x="100%" y="100" font-size="25" text-anchor="end" style={textStyle}>disponible</text>
</svg>
</div>
);
}
function _computeSecondPassage(passage): JSX.Element {
return (
<Show when={passage !== undefined} fallback={_computeUnavailablePassage(styles.unavailableSecondPassage)}>
{_computeTtwPassage(styles.secondPassage, passage, 45)}
</Show>
);
}
function _computeFirstPassage(passage): JSX.Element {
return (
<Show when={passage !== undefined} fallback={_computeUnavailablePassage(styles.unavailableFirstPassage)}>
{_computeTtwPassage(styles.firstPassage, passage, 50)}
</Show>
);
}
/* TODO: Manage end of service */
function _genNextPassages(nextPassages, line, destination) {
const nextPassagesLength = nextPassages.length;
const firstPassage = nextPassagesLength > 0 ? nextPassages[0] : undefined;
const secondPassage = nextPassagesLength > 1 ? nextPassages[1] : undefined;
const trafficStatusStyle = { fill: trafficStatusColor.get(line.trafficStatus) };
return (
<div class={styles.line}>
<div class={styles.transportMode}>
{renderLineTransportMode(line)}
</div>
{renderLinePicto(line, styles)}
<div class={styles.destination}>
<svg viewbox="0 0 600 40">
<text x="0" y="50%" dominant-baseline="middle" font-size="40" style={{ fill: "#000000" }}>
{destination}
</text>
</svg>
</div>
<div class={styles.trafficStatus}>
<svg viewBox="0 0 51 51">
<circle cx="50%" cy="50%" r="24" stroke="#231f20" stroke-width="3" style={trafficStatusStyle} />
</svg>
</div>
{firstPassage ? _computeFirstPassage(firstPassage) : null}
{secondPassage ? _computeSecondPassage(secondPassage) : null}
</div>
);
}
return (
<div classList={{ [styles.nextPassagesContainer]: true, [styles.displayed]: props.show }} style={{ "top": `${100 * props.position}%` }}>
{() => {
const ret = [];
for (const lineId of Object.keys(props.nextPassages)) {
const line = props.lines.get(lineId);
const byLineNextPassages = props.nextPassages[lineId];
for (const destination of Object.keys(byLineNextPassages)) {
const nextPassages = byLineNextPassages[destination];
ret.push(_genNextPassages(nextPassages, line, destination));
}
}
return ret;
}}
</div>
);
}

View File

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

View File

@@ -0,0 +1,298 @@
import { createContext, createEffect, createResource, createSignal, For, JSX, ParentComponent, Show, useContext, VoidComponent } from "solid-js";
import { createStore } from "solid-js/store";
import { createDateNow } from "@solid-primitives/date";
import { IconButton, Menu, MenuTrigger, MenuContent, MenuItem } from "@hope-ui/solid";
import { format } from "date-fns";
import { BusinessDataContext, BusinessDataStore } from "./businessData";
import { AppContextContext, AppContextStore } from "./appContext";
import { getTransportModeSrc, PositionedPanel } from "./utils";
import { PassagesPanel } from "./passagesPanel";
import { IconHamburgerMenu } from './extra/iconHamburgerMenu';
import "./passagesDisplay.scss";
interface PassagesDisplayStore {
isPassagesRefreshEnabled: () => boolean;
enablePassagesRefresh: () => void;
disablePassagesRefresh: () => void;
togglePassagesRefresh: () => void;
getPanels: () => PositionedPanel[];
setPanels: (panels: PositionedPanel[]) => void;
getDisplayedPanelId: () => number;
setDisplayedPanelId: (panelId: number) => void;
};
const PassagesDisplayContext = createContext<PassagesDisplayStore>();
function PassagesDisplayProvider(props: { children: JSX.Element }) {
type Store = {
refreshEnabled: boolean;
panels: PositionedPanel[];
displayedPanelId: number;
};
const [store, setStore] = createStore<Store>({ refreshEnabled: true, panels: [], displayedPanelId: 0 });
const isPassagesRefreshEnabled = (): boolean => {
return store.refreshEnabled;
}
const enablePassagesRefresh = (): void => {
setStore('refreshEnabled', true);
}
const disablePassagesRefresh = (): void => {
setStore('refreshEnabled', false);
}
const togglePassagesRefresh = (): void => {
setStore('refreshEnabled', !store.refreshEnabled);
}
const getPanels = (): PositionedPanel[] => {
return store.panels;
}
const setPanels = (panels: PositionedPanel[]): void => {
setStore('panels', panels);
}
const getDisplayedPanelId = (): number => {
return store.displayedPanelId;
}
const setDisplayedPanelId = (panelId: number): void => {
setStore('displayedPanelId', panelId);
}
return (
<PassagesDisplayContext.Provider value={{
isPassagesRefreshEnabled, enablePassagesRefresh,
disablePassagesRefresh, togglePassagesRefresh,
getPanels, setPanels,
getDisplayedPanelId, setDisplayedPanelId
}}>
{props.children}
</PassagesDisplayContext.Provider>
);
}
// TODO: Sort transport modes by weight
const Header: VoidComponent<{ title: string }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const passagesDisplayStore: PassagesDisplayStore | undefined = useContext(PassagesDisplayContext);
if (businessDataStore === undefined || passagesDisplayStore === undefined)
return <div />;
const { getLine, passages } = businessDataStore;
const { isPassagesRefreshEnabled, togglePassagesRefresh } = passagesDisplayStore;
const [dateNow] = createDateNow(1000);
const computeTransportModes = async (lineIds: string[]): Promise<string[]> => {
const lines = await Promise.all(lineIds.map((lineId) => getLine(lineId)));
const urls: Set<string> = new Set();
for (const line of lines) {
const src = getTransportModeSrc(line.transportMode, false);
if (src !== undefined) {
urls.add(src);
}
}
return Array.from(urls);
}
const [linesIds, setLinesIds] = createSignal<string[]>([]);
const [transportModeUrls] = createResource<string[], string[]>(linesIds, computeTransportModes);
createEffect(() => {
setLinesIds(Object.keys(passages()));
});
return (
<div class="header">
<Show when={transportModeUrls() !== undefined} >
<For each={transportModeUrls()}>
{(url) =>
<div class="transportMode">
<img src={url} />
</div>
}
</For>
</Show>
<div class="title">
<svg viewBox="0 0 1260 50">
<text x="0" y="50%" dominant-baseline="middle" font-size="50" style="fill: #ffffff">
{props.title}
</text>
</svg>
</div>
<div class="menu">
<Menu>
<MenuTrigger
as={IconButton}
icon=<IconHamburgerMenu />
/>
<MenuContent>
<MenuItem onSelect={() => togglePassagesRefresh()}>{isPassagesRefreshEnabled() ? "Disable" : "Enable"}</MenuItem>
</MenuContent>
</Menu>
</div>
<div class="clock">
<svg viewBox="0 0 115 43">
<text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle" font-size="43" style="fill: #ffffff">
{format(dateNow(), "HH:mm")}
</text>
</svg>
</div>
</div >
);
};
const Footer: VoidComponent<{}> = () => {
const passagesDisplayStore: PassagesDisplayStore | undefined = useContext(PassagesDisplayContext);
if (passagesDisplayStore === undefined)
return <div />;
const { getDisplayedPanelId, getPanels } = passagesDisplayStore;
return (
<div class="footer">
<For each={getPanels()}>
{(panel) => {
const position = panel.position;
return (
<div>
<svg viewBox="0 0 29 29">
<circle cx="50%" cy="50%" r="13" stroke="#ffffff" stroke-width="3"
style={{ fill: `var(--idfm-${position == getDisplayedPanelId() ? "white" : "black"})` }}
/>
</svg>
</div>
);
}}
</For>
</div>
);
}
const Body: ParentComponent<{ maxPassagesPerPanel: number, syncPeriodMsec: number, panelSwitchPeriodMsec: number }> = (props) => {
const businessDataStore: BusinessDataStore | undefined = useContext(BusinessDataContext);
const appContextStore: AppContextStore | undefined = useContext(AppContextContext);
const passagesDisplayStore: PassagesDisplayStore | undefined = useContext(PassagesDisplayContext);
if (businessDataStore === undefined || appContextStore === undefined || passagesDisplayStore === undefined) {
return <div />;
}
const { getLineDestinations, passages, getPassagesLineIds, clearPassages, refreshPassages } = businessDataStore;
const { isPassagesRefreshEnabled, getDisplayedPanelId, setDisplayedPanelId, getPanels, setPanels } = passagesDisplayStore;
const { getDisplayedStops } = appContextStore;
setInterval(() => {
let nextPanelId = getDisplayedPanelId() + 1;
if (nextPanelId >= getPanels().length) {
nextPanelId = 0;
}
setDisplayedPanelId(nextPanelId);
}, props.panelSwitchPeriodMsec);
setInterval(
async () => {
if (isPassagesRefreshEnabled()) {
const stops = getDisplayedStops();
if (stops.length > 0) {
refreshPassages(stops[0].id);
}
}
else {
console.log("Passages refresh disabled... skip it.");
}
},
props.syncPeriodMsec
);
createEffect(() => {
console.log("######### onStopIdUpdate #########");
// Track local.stopIp to force dependency.
console.log("getDisplayedStop=", getDisplayedStops());
clearPassages();
});
createEffect(async () => {
console.log(`## OnPassageUpdate ${passages()} ##`);
const stops = getDisplayedStops();
if (stops.length > 0) {
refreshPassages(stops[0].id);
}
});
return (
<div class="body">
{() => {
setPanels([]);
let newPanels = [];
let positioneds: PositionedPanel[] = [];
let index = 0;
let lineIds: string[] = [];
let destinationsNb = 0;
for (const lineId of getPassagesLineIds()) {
const lineDestinations = getLineDestinations(lineId);
if (lineDestinations.length <= props.maxPassagesPerPanel - destinationsNb) {
lineIds.push(lineId);
destinationsNb += lineDestinations.length;
}
else {
const panelid = index++;
const panel = <PassagesPanel stopId={getDisplayedStops()[0].id} lineIds={lineIds} show={panelid == getDisplayedPanelId()} />;
newPanels.push(panel);
positioneds.push({ position: panelid, panel: panel });
lineIds = [lineId];
destinationsNb = lineDestinations.length;
}
}
if (destinationsNb) {
const panelId = index++;
const panel = <PassagesPanel stopId={getDisplayedStops()[0].id} lineIds={lineIds} show={panelId == getDisplayedPanelId()} />;
newPanels.push(panel);
positioneds.push({ position: panelId, panel: panel });
}
setPanels(positioneds);
return newPanels;
}}
</div>
);
}
export const PassagesDisplay: ParentComponent = () => {
const MAX_PASSAGES_PER_PANEL = 5;
// TODO: Use props.
const syncPeriodMsec = 20 * 1000;
const panelSwitchPeriodMsec = 4 * 1000;
return (
<div class="passagesDisplay">
<PassagesDisplayProvider>
<Header title="Prochains passages" />
<Body maxPassagesPerPanel={MAX_PASSAGES_PER_PANEL} syncPeriodMsec={syncPeriodMsec} panelSwitchPeriodMsec={panelSwitchPeriodMsec} />
<Footer />
</PassagesDisplayProvider>
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,224 +0,0 @@
import { batch, Component, createEffect, createResource, createSignal, onMount, Show, useContext } from 'solid-js';
import {
Box, Button, Input, InputLeftAddon, InputGroup, HStack, List, ListItem, Progress,
ProgressIndicator, VStack
} from "@hope-ui/solid";
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
import { BusinessDataContext } from './businessData';
import { SearchContext } from './search';
import { renderLineTransportMode, renderLinePicto, TransportModeWeights } from './utils';
import styles from './stopManager.module.css';
const StopRepr: Component = (props) => {
const { getLine } = useContext(BusinessDataContext);
const [lineReprs] = createResource(props.stop.lines, fetchLinesRepr);
async function fetchLinesRepr(lineIds) {
const reprs = [];
for (const lineId of lineIds) {
const line = await getLine(lineId);
if (line !== undefined) {
reprs.push(<div class={styles.transportMode}>{renderLineTransportMode(line)}</div>);
reprs.push(renderLinePicto(line, styles));
}
}
return reprs;
}
return (
<HStack height="100%">
{props.stop.name}
<For each={lineReprs()}>{(line) => line}</For>
</HStack>
);
}
const StopAreaRepr: Component = (props) => {
const { getLine } = useContext(BusinessDataContext);
const [lineReprs] = createResource(props.stop, fetchLinesRepr);
async function fetchLinesRepr(stop) {
const lineIds = new Set(stop.lines);
const stops = stop.stops;
for (const stop of stops) {
stop.lines.forEach(lineIds.add, lineIds);
}
const byModeReprs = {};
for (const lineId of lineIds) {
const line = await getLine(lineId);
if (line !== undefined) {
if (!(line.transportMode in byModeReprs)) {
byModeReprs[line.transportMode] = {
mode: <div class={styles.transportMode}>{renderLineTransportMode(line)}</div>
};
}
byModeReprs[line.transportMode][line.shortName] = renderLinePicto(line, styles);
}
}
const reprs = [];
const sortedTransportModes = Object.keys(byModeReprs).sort((x, y) => TransportModeWeights[x] < TransportModeWeights[y]);
for (const transportMode of sortedTransportModes) {
const lines = byModeReprs[transportMode];
const repr = [lines.mode];
delete lines.mode;
for (const lineId of Object.keys(lines).sort((x, y) => x.localeCompare(y))) {
repr.push(lines[lineId]);
}
reprs.push(repr);
}
return reprs;
}
return (
<HStack height="100%">
{props.stop.name}
<For each={lineReprs()}>{(line) => line}</For>
</HStack>
);
}
const Map: Component = (props) => {
const mapCenter = [48.853, 2.35];
const { addMarkers, getStops } = useContext(SearchContext);
let mapDiv: any;
let map = null;
const stopsLayerGroup = L.featureGroup();
function buildMap(div: HTMLDivElement) {
map = L.map(div).setView(mapCenter, 11);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
stopsLayerGroup.addTo(map);
}
function setMarker(stop) {
const markers = [];
if (stop.lat !== undefined && stop.lon !== undefined) {
/* TODO: Add stop lines representation to popup. */
markers.push(L.marker([stop.lat, stop.lon]).bindPopup(`${stop.name}`).openPopup());
}
else {
for (const _stop of stop.stops) {
markers.push(...setMarker(_stop));
}
}
return markers;
}
onMount(() => buildMap(mapDiv));
const onStopUpdate = createEffect(() => {
/* TODO: Avoid to clear all layers... */
stopsLayerGroup.clearLayers();
for (const stop of Object.values(getStops())) {
const markers = setMarker(stop);
addMarkers(stop.id, markers);
for (const marker of markers) {
stopsLayerGroup.addLayer(marker);
}
}
const stopsBound = stopsLayerGroup.getBounds();
if (Object.keys(stopsBound).length) {
map.fitBounds(stopsBound);
}
});
return <div ref={mapDiv} id='main-map' style={{ width: "100%", height: "100%" }} />;
}
export const StopsManager: Component = (props) => {
const [minCharactersNb, setMinCharactersNb] = createSignal<int>(4);
const [_inProgress, _setInProgress] = createSignal<bool>(false);
const { serverUrl } = useContext(BusinessDataContext);
const { getStops, removeStops, setStops, setDisplayedStop } = useContext(SearchContext);
async function _fetchStopByName(name) {
const data = await fetch(`${serverUrl()}/stop/?name=${name}`, {
headers: { 'Content-Type': 'application/json' }
});
const stops = await data.json();
const stopIds = stops.map((stop) => stop.id);
const stopIdsToRemove = Object.keys(getStops()).filter(stopId => !(stopId in stopIds));
const byIdStops = {};
for (const stop of stops) {
byIdStops[stop.id] = stop;
}
batch(() => {
removeStops(stopIdsToRemove);
setStops(byIdStops);
});
}
async function _onStopNameInput(event) {
/* TODO: Add a tempo before fetching stop for giving time to user to finish his request */
const stopName = event.target.value;
if (stopName.length >= minCharactersNb()) {
console.log(`Fetching data for ${stopName}`);
_setInProgress(true);
await _fetchStopByName(stopName);
_setInProgress(false);
}
}
return (
<VStack h="100%">
<InputGroup w="50%" h="5%">
<InputLeftAddon>🚉 🚏</InputLeftAddon>
<Input onInput={_onStopNameInput} readOnly={_inProgress()} placeholder="Stop name..." />
</InputGroup>
<Progress size="xs" w="50%" indeterminate={_inProgress()}>
<ProgressIndicator striped animated />
</Progress>
<Box w="100%" h="40%" borderWidth="1px" borderColor="var(--idfm-black)" borderRadius="$lg" overflow="scroll" marginBottom="2px">
<List width="100%" height="100%">
{() => {
const items = [];
for (const stop of Object.values(getStops()).sort((x, y) => x.name.localeCompare(y.name))) {
items.push(
<ListItem h="10%" borderWidth="1px" mb="0px" color="var(--idfm-black)" borderRadius="$lg">
<Button fullWidth="true" color="var(--idfm-black)" bg="white" onClick={() => {
console.log(`${stop.id} clicked !!!`);
setDisplayedStop(stop);
}}>
<Box w="100%" h="100%">
<Show when={stop.stops !== undefined} fallback={<StopRepr stop={stop} />}>
<StopAreaRepr stop={stop} />
</Show>
</Box>
</Button>
</ListItem>);
}
return items;
}}
</List>
</Box>
<Box borderWidth="1px" borderColor="var(--idfm-black)" borderRadius="$lg" h="55%" w="100%" overflow="scroll">
<Map />
</Box>
</VStack>
);
};

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,215 @@
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);
if (foundStop.stops !== undefined) {
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

@@ -1,13 +1,3 @@
const validTransportModes = ["bus", "tram", "metro", "rer", "transilien", "funicular", "ter", "unknown"];
export function getTransportModeSrc(mode: string, color: bool = true): string {
let ret = null;
if (validTransportModes.includes(mode)) {
ret = `/public/symbole_${mode}_${color ? "" : "support_fonce_"}RVB.svg`;
}
return ret;
}
export enum TrafficStatus {
UNKNOWN = 0,
FLUID,
@@ -16,28 +6,111 @@ export enum TrafficStatus {
BYPASSED
}
export interface Passages { };
export interface Passage {
line: number,
operator: string,
destinations: Array<string>,
atStop: boolean,
aimedArrivalTs: number,
expectedArrivalTs: number,
arrivalPlatformName: string,
aimedDepartTs: number,
expectedDepartTs: number,
arrivalStatus: string,
departStatus: string,
export class Passage {
line: number;
operator: number;
destinations: string[];
atStop: boolean;
aimedArrivalTs: number;
expectedArrivalTs: number;
arrivalPlatformName: string;
aimedDepartTs: number;
expectedDepartTs: number;
arrivalStatus: string;
departStatus: string;
constructor(line: number, operator: number, destinations: string[], atStop: boolean, aimedArrivalTs: number,
expectedArrivalTs: number, arrivalPlatformName: string, aimedDepartTs: number, expectedDepartTs: number,
arrivalStatus: string, departStatus: string) {
this.line = line;
this.operator = operator;
this.destinations = destinations;
this.atStop = atStop;
this.aimedArrivalTs = aimedArrivalTs;
this.expectedArrivalTs = expectedArrivalTs;
this.arrivalPlatformName = arrivalPlatformName;
this.aimedDepartTs = aimedDepartTs;
this.expectedDepartTs = expectedDepartTs;
this.arrivalStatus = arrivalStatus;
this.departStatus = departStatus;
}
};
export type Passages = Record<string, Record<string, Passage[]>>;
export interface Stops { };
export interface Stop {
id: number,
name: string,
town: string,
lat: number,
lon: number,
lines: Array<string>
export class Stop {
id: number;
name: string;
town: string;
epsg3857_x: number;
epsg3857_y: number;
stops: Stop[];
lines: number[];
constructor(id: number, name: string, town: string, epsg3857_x: number, epsg3857_y: number, stops: Stop[], lines: number[]) {
this.id = id;
this.name = name;
this.town = town;
this.epsg3857_x = epsg3857_x;
this.epsg3857_y = epsg3857_y;
this.stops = stops;
this.lines = lines;
for (const stop of this.stops) {
this.lines.push(...stop.lines);
}
}
};
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 {
id: number;
shortName: string;
name: string;
status: string; // TODO: Use an enum
transportMode: string; // TODO: Use an enum
backColorHexa: string;
foreColorHexa: string;
operatorId: number;
accessibility: boolean;
visualSignsAvailable: string; // TODO: Use an enum
audibleSignsAvailable: string; // TODO: Use an enum
stopIds: number[];
constructor(id: number, shortName: string, name: string, status: string, transportMode: string, backColorHexa: string,
foreColorHexa: string, operatorId: number, accessibility: boolean, visualSignsAvailable: string,
audibleSignsAvailable: string, stopIds: number[]) {
this.id = id;
this.shortName = shortName;
this.name = name;
this.status = status;
this.transportMode = transportMode;
this.backColorHexa = backColorHexa;
this.foreColorHexa = foreColorHexa;
this.operatorId = operatorId;
this.accessibility = accessibility;
this.visualSignsAvailable = visualSignsAvailable;
this.audibleSignsAvailable = audibleSignsAvailable;
this.stopIds = stopIds;
}
};
export type Lines = Record<string, Line>;

View File

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

View File

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